- proactive.go(新): parseProactive 从结论解析 [我决定:push|no_push][渠道:...]<正文>;
兼容大小写/全半角冒号; 无标记回退(无/nothing=不推); 畸形/孤标记 fail-safe 不推
- context.go: guidancePrompt 引导结构化结尾+保留P1温暖引导
- push.go: PushRequest{Message,ChannelHint}; Pusher.Push 路由(auto→active优先/IM兜底,
web→仅active, im→仅IM, 具体平台→IM+platform打标+缺IM回退默认记detail); buildProactiveMessage
- backthink.go: think() 用 parseProactive→ShouldPush=push&&disturbAllowed(不再被tr.Import卡死)
- decide.go: disturbAllowed(asleep/working/quiet 硬性不打扰门) 取代旧import门(保留旧Decide兼容)
- 测试: parseProactive 6组+think端到端(真实pusher:kook→IM/无标记→active/no_push→不推/睡眠→门拦截)
+buildProactiveMessage+具体平台无IM回退; 后端 build/vet/test 全绿
- 注: 生产 Active/IM sink 未注入 wire.go backthink.Deps(另一步); 多平台IM受单IMAdapter接口限制
155 lines
5.3 KiB
Go
155 lines
5.3 KiB
Go
package backthink
|
||
|
||
import (
|
||
"regexp"
|
||
"strings"
|
||
"unicode"
|
||
)
|
||
|
||
// The autonomous think is guided (guidancePrompt in context.go) to conclude with
|
||
// a compact, machine-parseable decision so the service no longer has to infer
|
||
// whether to push from the trigger's importance alone (design/20 §2 P2):
|
||
//
|
||
// [我决定:push] (or no_push)
|
||
// [渠道:web|im|kook|telegram|...|auto]
|
||
// <the warm message body>
|
||
//
|
||
// The markers are parsed as plain text so a model that formats them slightly
|
||
// differently still yields a usable decision; a malformed/ambiguous decision
|
||
// marker fails safe to "no push".
|
||
var (
|
||
// decideMarker matches the push/no_push decision token, tolerating a colon in
|
||
// either width and optional spaces.
|
||
decideMarker = regexp.MustCompile(`(?i)\[\s*我决定\s*[::]\s*(no_push|push)\s*\]`)
|
||
// channelMarker matches the target-channel token.
|
||
channelMarker = regexp.MustCompile(`(?i)\[\s*渠道\s*[::]\s*([a-z0-9_\-]+)\s*\]`)
|
||
// decidePrefixes are the marker shapes that signal a decision was attempted
|
||
// but could not be parsed, so the pass is treated as ambiguous (fail safe). It
|
||
// is deliberately the bracketed form only, so a message that merely contains
|
||
// "我决定:" in prose (common in Chinese) is not misread as a malformed marker.
|
||
decidePrefixes = []string{"[我决定"}
|
||
)
|
||
|
||
// nonePhrases are conclusions that mean "nothing to report" when no marker is
|
||
// present. The body is normalized (case-folded, punctuation/spaces stripped)
|
||
// before comparison so "无", "无。" and "Nothing to report." all collapse to the
|
||
// same token.
|
||
var nonePhrases = []string{
|
||
"无", "没什么", "没有", "无物可报", "暂无", "没有值得说的",
|
||
"nothing", "none", "nothingtoreport", "nothingworthreporting", "nothingnew",
|
||
}
|
||
|
||
// parseProactive recovers the push decision, the target-channel hint and the
|
||
// clean message body from a think conclusion. It is a pure function: it reads no
|
||
// state and performs no I/O, so it is safe to call concurrently and cheap to test.
|
||
//
|
||
// Semantics (design/20 §2 P2):
|
||
// - A well-formed [我决定:push|no_push] marker is authoritative.
|
||
// - A malformed/ambiguous decision marker → no push (fail safe).
|
||
// - No marker: a body that reads as "无"/"nothing to report" → no push;
|
||
// otherwise the body is treated as a proactive message (push=true), matching
|
||
// the pre-P2 default.
|
||
// - The channel hint is the [渠道:...] token, lowercased; empty/auto keeps the
|
||
// default active-client-first, IM-fallback route.
|
||
// - A push decision whose body is empty or still reads as "nothing" → no push
|
||
// (fail safe), so a lone marker never pushes an empty message.
|
||
func parseProactive(conclusion string) (message, channelHint string, push bool) {
|
||
text := strings.TrimSpace(conclusion)
|
||
|
||
if val, ok := matchDecide(text); ok {
|
||
push = val == "push"
|
||
} else if hasAmbiguousDecide(text) {
|
||
push = false
|
||
} else {
|
||
push = !looksNone(text)
|
||
}
|
||
|
||
channelHint = matchChannel(text)
|
||
message = stripMarkers(text)
|
||
|
||
if push && (message == "" || looksNone(message)) {
|
||
push = false
|
||
}
|
||
return message, channelHint, push
|
||
}
|
||
|
||
// matchDecide returns the matched decision token (lowercased) and whether a
|
||
// well-formed decision marker was present.
|
||
func matchDecide(text string) (string, bool) {
|
||
m := decideMarker.FindStringSubmatch(text)
|
||
if m == nil {
|
||
return "", false
|
||
}
|
||
return strings.ToLower(m[1]), true
|
||
}
|
||
|
||
// hasAmbiguousDecide reports whether a decision marker was attempted but could
|
||
// not be parsed, which the caller treats as "do not push" (fail safe).
|
||
func hasAmbiguousDecide(text string) bool {
|
||
if decideMarker.MatchString(text) {
|
||
return false
|
||
}
|
||
for _, p := range decidePrefixes {
|
||
if strings.Contains(text, p) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// matchChannel returns the lowercased target-channel token, or "" when the
|
||
// conclusion carries no (or a malformed) channel marker. A malformed channel
|
||
// marker degrades to the default route rather than failing the whole pass.
|
||
func matchChannel(text string) string {
|
||
m := channelMarker.FindStringSubmatch(text)
|
||
if m == nil {
|
||
return ""
|
||
}
|
||
return strings.ToLower(m[1])
|
||
}
|
||
|
||
// stripMarkers removes the structured decision/channel markers and any lines that
|
||
// become empty, leaving the clean message body. It tolerates the markers on their
|
||
// own lines or inline with the body.
|
||
func stripMarkers(text string) string {
|
||
lines := strings.Split(text, "\n")
|
||
out := make([]string, 0, len(lines))
|
||
for _, ln := range lines {
|
||
ln = strings.TrimSpace(decideMarker.ReplaceAllString(ln, ""))
|
||
ln = strings.TrimSpace(channelMarker.ReplaceAllString(ln, ""))
|
||
if ln == "" {
|
||
continue
|
||
}
|
||
out = append(out, ln)
|
||
}
|
||
return strings.Join(out, "\n")
|
||
}
|
||
|
||
// looksNone reports whether the body says "nothing worth saying". It is used both
|
||
// to drive the no-marker default and to fail-safe a contradictory push marker.
|
||
func looksNone(text string) bool {
|
||
t := normalizeNone(text)
|
||
if t == "" {
|
||
return false
|
||
}
|
||
for _, p := range nonePhrases {
|
||
if t == p {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// normalizeNone lowercases the body and strips spaces and punctuation so the
|
||
// nonePhrases compare cleanly across "无", "无。", "Nothing to report." and similar.
|
||
func normalizeNone(text string) string {
|
||
var b strings.Builder
|
||
for _, r := range strings.ToLower(strings.TrimSpace(text)) {
|
||
if unicode.IsSpace(r) || unicode.IsPunct(r) {
|
||
continue
|
||
}
|
||
b.WriteRune(r)
|
||
}
|
||
return b.String()
|
||
}
|