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 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() }