Files
Cyrene_v2/internal/services/backthink/proactive.go
T
Aska 12031bd13c
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
fix(backthink): 主动推送降频(15min心跳+60min≤1次+推后30min冷却+刚聊过10min冷避) + 情绪约束(分类限频/活跃感知) + 剥离'我决定:push'内部标记
2026-09-08 13:08:57 +08:00

174 lines
6.7 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. 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()
}