Files
Cyrene_v2/internal/services/backthink/proactive.go
T
AskaEth f91a83ff81
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(urgency): 通用紧急度通道(Declarer/Level + 只抬不压折算)+ IoT 首个生产者 + 她自己的结论走结构化标记穿透安静时段
2026-10-05 13:11:04 +08:00

212 lines
8.6 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"
"git.yeij.top/AskaEth/Cyrene_v2/internal/urgency"
)
// 自主思考被引导(context.go 的 guidancePrompt)以如下紧凑、
// 机器可解析的决策收尾,好让服务不必再从触发的重要度
// 单独推断是否推送(design/20 §2 P2):
//
// [我决定:push] (或 no_push)
// [渠道:web|im|kook|telegram|...|auto]
// [紧急:routine|warning|critical] (可省略;见 parseProactiveUrgency)
// <温暖的正文内容>
//
// 这些标记按纯文本解析,因此模型稍有不同地
// 格式化它们时仍能得出可用的决策;格式错乱/含糊的决策
// 标记失败安全为「不推送」。
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*\]`)
// urgencyMarker 匹配她自报的紧急度档位词。它是**结构化**通道:只认固定枚举,
// 绝不从散文里解读意图(项目红线);解析严格(必须带括号)=失败安全。
urgencyMarker = regexp.MustCompile(`(?i)\[\s*紧急\s*[::]\s*(routine|warning|critical)\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*[】\]]`)
// stripUrgencyRe 删掉带括号的紧急度标记,档位值不限枚举:她写成枚举外的词时
// 解析侧不上抬(保守),面向用户的正文里也绝不能留着这段内部标记。
stripUrgencyRe = regexp.MustCompile(`(?i)[【\[]\s*紧急\s*[::]\s*[^】\]\s]+\s*[】\]]`)
bareDecideRe = regexp.MustCompile(`(?i)我决定\s*[::]\s*(no_push|push)`)
bareChannelRe = regexp.MustCompile(`(?i)渠道\s*[::]\s*[a-z0-9_\-]+`)
// bareUrgencyRe 只认枚举档位:散文里「紧急:」这类词很常见,
// 不该因为恰好紧跟一个英文词就被删掉。
bareUrgencyRe = regexp.MustCompile(`(?i)紧急\s*[::]\s*(routine|warning|critical)`)
)
// 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 降级路由。
// - 她自报的紧急度由 parseProactiveUrgency 单独恢复(固定枚举的
// 结构化标记,绝不从散文解读)。
// - 决策为推送但正文为空或仍读作「无」 → 不推送
// (失败安全),因此单独一个标记绝不会推出空消息。
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
}
// parseProactiveUrgency 从结论的结构化尾段恢复她自报的紧急度 —— 这是通用紧急度通道
// 的一种生产者形式(「她自己」):主动思考轮可以读外围插件数据、调工具,因此她要能
// 声明「我认为这条紧急、要突破安静时段发出去」。
//
// 它只认固定枚举 [紧急:routine|warning|critical],绝不从散文/自由文本里解读意图
// (项目红线:判断要结构化)。没写、格式错乱或写了枚举外的档位一律得到零值
// LevelRoutine(即不上抬)—— 保守默认优于猜测,误判只可能少打扰、不会多打扰。
func parseProactiveUrgency(conclusion string) urgency.Level {
m := urgencyMarker.FindStringSubmatch(conclusion)
if m == nil {
return urgency.LevelRoutine
}
switch strings.ToLower(m[1]) {
case "critical":
return urgency.LevelCritical
case "warning":
return urgency.LevelWarning
default:
return urgency.LevelRoutine
}
}
// 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][紧急:critical])以及模型可能发出的漂移形式——
// 裸的 "我决定:push" / "渠道:kook" / "紧急:critical"(无括号)与全角 【我决定: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 = stripUrgencyRe.ReplaceAllString(ln, "")
ln = bareDecideRe.ReplaceAllString(ln, "")
ln = bareChannelRe.ReplaceAllString(ln, "")
ln = bareUrgencyRe.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()
}