Files
Cyrene_v2/internal/services/backthink/decide.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

279 lines
11 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 (
"fmt"
"strings"
"time"
)
// Importance 是唤醒一轮思考的事件估计严重度。
// 在 design/20 §2 P2 之下,它不再决定她*想不想*说话(那由她的
// 结论决定);它决定硬门豁免:critical 安全事件
// 穿透工作/忙碌与安静时段(裁决 07.4.2),而 high 事件仍
// 通过安静时段(既有的级联语义)。
type Importance int
const (
// ImportanceLow 是例行触发(如周期性心跳)。
ImportanceLow Importance = iota
// ImportanceNormal 是值得反思的普通事件(设备变化)。
ImportanceNormal
// ImportanceHigh 是值得注意的事件(用户设置的提醒触发)。
ImportanceHigh
// ImportanceCritical 是穿透
// 工作/忙碌与安静时段的安全关键事件(裁决 07.4.2 安全域豁免)。
ImportanceCritical
)
// String 返回用于日志与记录的稳定标签。
func (i Importance) String() string {
switch i {
case ImportanceLow:
return "low"
case ImportanceNormal:
return "normal"
case ImportanceHigh:
return "high"
case ImportanceCritical:
return "critical"
default:
return fmt.Sprintf("importance(%d)", int(i))
}
}
// longIdleThreshold 是主人最后一条消息之后的间隔,超过它活动门
// 只放行真正重要的事件——但心跳轮除外,
// 它们永远不会被长期静默的间隔拦下(裁决 07.4.3)。编译期
// 常量,因此不出现魔法数字。
const longIdleThreshold = 2 * time.Hour
// UserState 描述由设备遥测(心率、睡眠)与日程(工作、忙碌)
// 观察到的用户当前状况。它是打扰决策里的负向
// 信号:睡眠、工作或忙碌中的用户不被打扰。
// 无聊度(0..1)是放宽打扰的软性正向旋钮。
type UserState struct {
// HeartRateBPM 是当前心率,未知时为 0。
HeartRateBPM int
// Sleeping 报告睡眠追踪显示用户当前是否睡着。
Sleeping bool
// Working 报告用户是否处于工作/日程时段。
Working bool
// Busy 报告用户是否被标记为忙碌(日历 / 免打扰)。
Busy bool
// Boredom 取值 0..1。无聊度越高表示用户越空闲、越愿意
// 被温和地打扰。在 design/20 §2 P2 之下推送意图来自她自己的
// 结论,所以这个旋钮不再为决策把关;它作为一个
// 她的判断本就会吸收的遥测信号保留下来。
Boredom float64
}
// String 返回用于日志/记录的简洁可读摘要。
func (s UserState) String() string {
return fmt.Sprintf("bpm=%d sleeping=%v working=%v busy=%v boredom=%.2f",
s.HeartRateBPM, s.Sleeping, s.Working, s.Busy, s.Boredom)
}
// DecisionInput 是 Decide 所称量的信号集合:事件重要度、
// 当前墙钟时间、用户当前状态及其无聊度。
type DecisionInput struct {
// Trigger 是可读的来源标签,为可追溯而记录。
Trigger string
// Import 是事件的估计重要度。
Import Importance
// At 是当前墙钟时间,处于免打扰窗口所用的
// 本地时区。
At time.Time
// UserState 携带用户的生理/日程状况。
UserState UserState
// LastActivity 是主人最近一条消息的墙钟时间。
// HasActivity 报告是否找到了用户消息;为 false 时
// 活动门直接落空、不靠猜。
LastActivity time.Time
// HasActivity 报告 LastActivity 是否有意义。
HasActivity bool
// ConclusionPush 是她自己思考结论的推送意愿(design/20 §2 P2):「想不想说」
// 由她的结论决定,本决策只回答「此刻能不能说」。
ConclusionPush bool
// Heartbeat 标记本轮是否为心跳类触发(周期心跳或启动轮)。裁决 07.4.3:
// 长期静默段不拦心跳——主人久未回应时,主动关心仍可照常发出。
Heartbeat bool
}
// Decision 是 Decide 的结果。
type Decision struct {
// ShouldPush 报告是否应打扰用户。
ShouldPush bool
// Reason 是简短可读的理由,会在面板上呈现。
Reason string
}
// Decide 是生产路径的打扰决策(design/12 §2 + design/20 §2 P2 + 裁决 07.4.2/07.4.3)。
// 「想不想说」交给她的结论(ConclusionPush),本方法只判「此刻能不能说」,评估顺序
// 刻意 fail-closed(宁可不打扰):
//
// 0. 她的结论要求不推送 → 不推送。
// 1. 用户在睡 → 绝不打扰。
// 2. 上班/忙碌 → 仅 critical 安全事件可打扰。
// 3. 安静时段(深夜窗口)→ 挡常规推送;critical 安全事件穿透(裁决 07.4.2,
// 与 design/12 §6 策略五同口径),high 沿袭原级联口径照常放行。
// 4. 其余时段 → 放行(是否值得说已由她的结论回答)。
//
// 通过硬门后再过活动软门:刚聊过天或主人久未回应时酌情扣下;心跳类触发在
// 长期静默段不拦(裁决 07.4.3,主动关心照常发出)。频率门(PushGate)在推送器
// 内部单独生效,不在此处。
func (b *Backthink) Decide(in DecisionInput) Decision {
if !in.ConclusionPush {
return Decision{ShouldPush: false, Reason: "conclusion requested no push"}
}
if d := b.baseDecide(in); !d.ShouldPush {
return d
}
if held, why := b.activityHeld(in); held {
return Decision{ShouldPush: false, Reason: "proactive push held: " + why}
}
return Decision{ShouldPush: true, Reason: "proactive push approved"}
}
// baseDecide 是不打扰硬门级联(design/12 §2)。critical 安全事件走安全域分支:
// 工作忙碌与安静时段都放行(裁决 07.4.2 安全域豁免);睡眠仍是绝对不打扰。
// 白天是否「值得说」不再由重要性/无聊度判定——那是她的结论(design/20 §2 P2)
// 已经回答的问题。
func (b *Backthink) baseDecide(in DecisionInput) Decision {
s := in.UserState
if s.Sleeping {
return Decision{ShouldPush: false, Reason: "user is asleep; not interrupting"}
}
if (s.Working || s.Busy) && in.Import < ImportanceCritical {
return Decision{ShouldPush: false, Reason: "user is working/busy; not interrupting"}
}
if b.inQuietHours(in.At) {
if in.Import >= ImportanceCritical {
return Decision{ShouldPush: true, Reason: "quiet hours but critical safety event bypasses"}
}
if in.Import >= ImportanceHigh {
return Decision{ShouldPush: true, Reason: "quiet hours but event is important"}
}
return Decision{ShouldPush: false, Reason: "quiet hours; not interrupting"}
}
return Decision{ShouldPush: true, Reason: "worth interrupting"}
}
// activityHeld 是活动感知软门(design/12 §6 + 裁决 07.4.3)。它把「不打扰」延伸到
// 对话自身的节奏,返回是否扣下与简短理由:
//
// - 最近 RecentChatCooldown 内主人刚说过话 → 对话正热,非 critical 扣下,
// 不在聊天中途插话;
// - 超过 longIdleThreshold 未说话 → 主人久未回应,仅真正重要的事件值得唤醒;
// 心跳类触发(含启动轮)例外照常放行(裁决 07.4.3:长期静默段不拦心跳,
// 主动关心仍可发出);
// - 其间与活动状态未知 → 不软化,照硬门结论通过。
//
// 活动是软信号:硬门(baseDecide)独立生效,频率门(PushGate)在推送器内部生效。
func (b *Backthink) activityHeld(in DecisionInput) (bool, string) {
if !in.HasActivity {
return false, ""
}
since := in.At.Sub(in.LastActivity)
if since <= b.cfg.RecentChatCooldown {
if in.Import >= ImportanceCritical {
return false, ""
}
return true, "owner just chatted"
}
if since >= longIdleThreshold {
if in.Heartbeat {
return false, ""
}
if in.Import >= ImportanceHigh {
return false, ""
}
return true, "owner away a long while"
}
return false, ""
}
// inQuietHours 报告 at 是否落在已配置的免打扰
// 窗口内。窗口可以跨午夜(如 23:00-07:00);两个相等的非零
// 钟点会禁用它。
func (b *Backthink) inQuietHours(at time.Time) bool {
h := at.Hour()
start, end := b.cfg.QuietStartHour, b.cfg.QuietEndHour
if start <= end {
return h >= start && h < end
}
return h >= start || h < end
}
// PushKind 把主动消息归类成几种风格,好让
// 频率门能在窗口内限制每种风格,避免同一风格刷屏
// (design/12 §6 策略二)。它是由消息正文关键词导出的软标签
// ——它只影响节奏,绝不影响身份或权限。
type PushKind int
const (
// KindGreeting 是温暖的打招呼 / 早安 / 晚安开场。
KindGreeting PushKind = iota
// KindReminder 是关于日程、任务或截止时间的提醒。
KindReminder
// KindShare 是发现、观察或有意思的见闻。
KindShare
// KindCare 是对主人身心状况的温暖关心问候。
KindCare
)
// String 返回用于日志与记录的稳定标签。
func (k PushKind) String() string {
switch k {
case KindGreeting:
return "greeting"
case KindReminder:
return "reminder"
case KindShare:
return "share"
case KindCare:
return "care"
default:
return fmt.Sprintf("pushkind(%d)", int(k))
}
}
// classifyPush 按关键词内容给主动消息指派 PushKind。
// 不匹配任何风格的消息默认为 KindCare(一条主动问候的
// 温暖、泛化意图),那是最不容易
// 刷屏的分组。优先级为 reminder > care > greeting > share,
// 因此多个关键词共现时主流意图获胜。
func classifyPush(message string) PushKind {
switch {
case hasAnyKeyword(message, kindReminderKeywords...):
return KindReminder
case hasAnyKeyword(message, kindCareKeywords...):
return KindCare
case hasAnyKeyword(message, kindGreetingKeywords...):
return KindGreeting
case hasAnyKeyword(message, kindShareKeywords...):
return KindShare
default:
return KindCare
}
}
// classifyPush 所用的关键词集合。它们是包级变量(非常量)以便
// 分类保持灵活可测,但按约定不可变,
// 从不在运行时改写。
var (
kindReminderKeywords = []string{"提醒", "记得", "别忘了", "该去", "日程", "会议", "体检", "吃药", "喝药", "到点", "几点", "还有", "迟到", "明天", "马上", "最后", "截止"}
kindCareKeywords = []string{"关心", "最近", "辛苦", "注意身体", "照顾好", "好好休息", "心情", "还好", "想你了", "想我", "担心", "保重", "你还好", "陪你", "累"}
kindGreetingKeywords = []string{"早安", "晚安", "早上好", "中午好", "晚上好", "午安", "回来", "吃饭", "睡了"}
kindShareKeywords = []string{"发现", "分享", "看到", "听说", "注意到", "告诉你", "有个", "想到", "有意思", "有趣", "你知道吗"}
)
// hasAnyKeyword 报告 s 是否包含给定子串中的任意一个。
func hasAnyKeyword(s string, subs ...string) bool {
for _, sub := range subs {
if strings.Contains(s, sub) {
return true
}
}
return false
}