174 lines
6.7 KiB
Go
174 lines
6.7 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. It is used to recover the decision, so it
|
||
// stays strict on the bracketed form: a bare "我决定:push" is treated as prose,
|
||
// not as a decision (fail safe).
|
||
decideMarker = regexp.MustCompile(`(?i)\[\s*我决定\s*[::]\s*(no_push|push)\s*\]`)
|
||
// channelMarker matches the target-channel token on the decision line.
|
||
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{"[我决定"}
|
||
|
||
// stripDecideRe / stripChannelRe recognize the decision/channel markers as a
|
||
// token to delete from the user-facing body, in their intended bracketed form
|
||
// and with full-width brackets, so a well-formatted decision never leaks.
|
||
stripDecideRe = regexp.MustCompile(`(?i)[【\[]\s*我决定\s*[::]\s*(no_push|push)\s*[】\]]`)
|
||
stripChannelRe = regexp.MustCompile(`(?i)[【\[]\s*渠道\s*[::]\s*[a-z0-9_\-]+\s*[】\]]`)
|
||
bareDecideRe = regexp.MustCompile(`(?i)我决定\s*[::]\s*(no_push|push)`)
|
||
bareChannelRe = regexp.MustCompile(`(?i)渠道\s*[::]\s*[a-z0-9_\-]+`)
|
||
)
|
||
|
||
// 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 = stripInternalMarkers(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])
|
||
}
|
||
|
||
// stripInternalMarkers removes the structured decision/channel markers so a
|
||
// proactive message body never carries them to the user. It handles the intended
|
||
// bracketed form ([我决定:push][渠道:kook]) plus the drift forms a model may emit —
|
||
// bare "我决定:push" / "渠道:kook" (no brackets) and full-width 【我决定:push】 — then
|
||
// drops any line that becomes empty and collapses the remainder. It is applied
|
||
// both inside parseProactive (so the message and record are clean) and again as a
|
||
// final guard in buildProactiveMessage, so a marker cannot survive into the
|
||
// outbound body no matter how it was formatted. Only our logs/records may carry
|
||
// the marker; the user-facing message must be pure warm content.
|
||
func stripInternalMarkers(text string) string {
|
||
lines := strings.Split(text, "\n")
|
||
out := make([]string, 0, len(lines))
|
||
for _, ln := range lines {
|
||
ln = stripDecideRe.ReplaceAllString(ln, "")
|
||
ln = stripChannelRe.ReplaceAllString(ln, "")
|
||
ln = bareDecideRe.ReplaceAllString(ln, "")
|
||
ln = bareChannelRe.ReplaceAllString(ln, "")
|
||
ln = strings.TrimSpace(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()
|
||
}
|