Files
Cyrene_v2/internal/services/backthink/proactive.go
T
AskaEth 3dc1232bc2
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(comments): 注释中文化 · internal/runtime/model + internal/services/backthink(54 文件,纯替换)
- 只翻注释/文档字符串:`go/scanner` 剥注释后按 token 序列+literal 逐 token 比对,**54/54 文件 SAME**;
  改动文件总行数与 HEAD 漂移 = 0(严格 1:1,无合并/拆行)
- 裁决编号与 design 节号逐字保留(03.4.1/03.4.2/03.4.3/14.4.1/14.4.5/07.4.2-4、design/04 §3.1/§3.2 等);
  `chat_template_kwargs` / `enable_thinking` / `reasoning_effort` / `thinking:{type}` 字段名与形状说明未动
- 存疑译法(已登记):`persona` 在叙事语境用「角色」、标识符语境保留 `persona`(仓库既有模块本身混用);
  `rung`→「级」;`provider` 存在「提供商/提供者」两种落笔(可按需一键统一)
- 复核:build/vet 绿 + 两包测试绿;gofmt 干净;残留英文注释 0 条
- 既有存量(未修,只报告):`model/anthropic_test.go` 612 行 >500;`applyAnthropicEvent` 115 行 >80
2026-10-04 16:27:54 +08:00

174 lines
6.4 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"
)
// 自主思考被引导(context.go 的 guidancePrompt)以如下紧凑、
// 机器可解析的决策收尾,好让服务不必再从触发的重要度
// 单独推断是否推送(design/20 §2 P2):
//
// [我决定:push] (或 no_push)
// [渠道:web|im|kook|telegram|...|auto]
// <温暖的正文内容>
//
// 这些标记按纯文本解析,因此模型稍有不同地
// 格式化它们时仍能得出可用的决策;格式错乱/含糊的决策
// 标记失败安全为「不推送」。
var (
// decideMarker 匹配 push/no_push 决策词,容忍两种宽度的
// 冒号与可选空格。它用于恢复决策,所以对
// 带括号的形式保持严格:裸的 "我决定:push" 被当作散文,
// 而不是决策(失败安全)。
decideMarker = regexp.MustCompile(`(?i)\[\s*我决定\s*[::]\s*(no_push|push)\s*\]`)
// channelMarker 匹配决策行上的目标渠道词。
channelMarker = regexp.MustCompile(`(?i)\[\s*渠道\s*[::]\s*([a-z0-9_\-]+)\s*\]`)
// decidePrefixes 是「尝试了决策但无法解析」的标记形状,
// 这类轮次被视为含糊(失败安全)。它
// 刻意只含带括号形式,因此仅仅在散文中出现
// "我决定:"(中文里很常见)的消息不会被误读为格式错乱的标记。
decidePrefixes = []string{"[我决定"}
// stripDecideRe / stripChannelRe 把决策/渠道标记识别为
// 需要从用户可见正文中删除的词,按它们预期的带括号形式
// 与全角括号形式处理,因此格式良好的决策永不泄漏。
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 是没有标记时表示「无可汇报」的结论。
// 正文在比较前先归一化(折叠大小写、去掉标点/空格),
// 因此 "无"、"无。" 与 "Nothing to report." 都收敛到
// 同一个词。
var nonePhrases = []string{
"无", "没什么", "没有", "无物可报", "暂无", "没有值得说的",
"nothing", "none", "nothingtoreport", "nothingworthreporting", "nothingnew",
}
// parseProactive 从思考结论中恢复推送决策、目标渠道提示与
// 干净的正文消息。它是纯函数:不读状态、不做 I/O,
// 因此可安全并发调用、测试成本低。
//
// 语义(design/20 §2 P2):
// - 格式良好的 [我决定:push|no_push] 标记具有权威性。
// - 格式错乱/含糊的决策标记 → 不推送(失败安全)。
// - 无标记:正文读作 "无"/"nothing to report" → 不推送;
// 否则正文被当作主动消息(push=true),与
// P2 之前的默认一致。
// - 渠道提示是 [渠道:...] 词,转小写;为空/auto 则保持
// 默认的活跃客户端优先、IM 降级路由。
// - 决策为推送但正文为空或仍读作「无」 → 不推送
// (失败安全),因此单独一个标记绝不会推出空消息。
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 返回匹配到的决策词(已转小写)以及
// 是否存在格式良好的决策标记。
func matchDecide(text string) (string, bool) {
m := decideMarker.FindStringSubmatch(text)
if m == nil {
return "", false
}
return strings.ToLower(m[1]), true
}
// hasAmbiguousDecide 报告是否尝试了决策标记但
// 无法解析,调用方把它当作「不推送」(失败安全)。
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 返回已转小写的目标渠道词;结论未携带
// (或携带格式错乱的)渠道标记时返回 ""。格式错乱的渠道
// 标记降级为默认路由,而不是让整轮失败。
func matchChannel(text string) string {
m := channelMarker.FindStringSubmatch(text)
if m == nil {
return ""
}
return strings.ToLower(m[1])
}
// stripInternalMarkers 移除结构化的决策/渠道标记,因此
// 主动消息正文绝不会把它们带给用户。它处理预期的
// 带括号形式([我决定:push][渠道:kook])以及模型可能发出的漂移形式——
// 裸的 "我决定:push" / "渠道:kook"(无括号)与全角 【我决定:push】——然后
// 丢弃因此变空的行并压缩剩余部分。它既在
// parseProactive 内部应用(使消息与记录干净),又作为
// buildProactiveMessage 里的最后一道守护,因此无论标记如何
// 被格式化,都无法存活到出站正文里。只有我们的日志/记录可以携带
// 该标记;面向用户的消息必须是纯温暖内容。
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 报告正文是否表示「没什么值得说的」。它既用于
// 驱动无标记时的默认,也用于对矛盾的 push 标记失败安全。
func looksNone(text string) bool {
t := normalizeNone(text)
if t == "" {
return false
}
for _, p := range nonePhrases {
if t == p {
return true
}
}
return false
}
// normalizeNone 把正文转小写并去掉空格与标点,好让
// nonePhrases 在 "无"、"无。"、"Nothing to report." 之类之间干净比较。
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()
}