Files
Cyrene_v2/internal/services/backthink/proactive.go
T
Aska 2283383b9e
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(backthink): 自主思考 P2——结构化推送决策+渠道选择(design/20)
- 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接口限制
2026-09-07 19:34:51 +08:00

155 lines
5.3 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()
}