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)。自排轮也不享这条豁免 // (design/12 §N C.4):要求她声明 ≥ warning 才放行。编译期 // 常量,因此不出现魔法数字。 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 是事件的估计重要度,已叠上她自己在结论里声明的紧急度 // (见 Backthink.escalatedImport)。 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 // Kind 是本轮的触发类别(周期心跳 / 启动轮 / 真实事件 / 自我唤醒),取代了 // 原来的布尔 Heartbeat —— 布尔表达不了三种触发(design/12 §N C.4)。判定侧只 // 需要区分「周期性心跳(含启动轮,享长期静默豁免)」与「她自己排起的一轮 // (要求她说得出理由)」,其余类别按事件处理。 // // ⚠️ TriggerKind 的**零值是 TriggerUnknown**(G-A 修法):它是**最紧**的一档, // 不享任何豁免(activityHeld 让它落在扣下上)。生产调用方仍**必须**显式传 // tr.Kind(backthink.go 的 think 是唯一调用点,已显式传)—— 零值安全只是最后一道 // 兜底,不是「可以不填」的许可。 Kind TriggerKind } // 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,主动关心照常发出),自排轮则要求她说得出理由 // (design/12 §N C.4)。频率门(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:长期静默段不拦心跳, // 主动关心仍可发出);她自己排起的一轮**不享**这条豁免(design/12 §N C.4) // ——要求她说得出理由(Import ≥ High,即声明 ≥ warning),否则扣下; // - 其间与活动状态未知 → 不软化,照硬门结论通过。 // // 未知触发(TriggerUnknown,即漏填 Kind 的零值)同样**不享**豁免:它掉到最后那条 // 扣下上(G-A 修法要的就是这个方向——未知 ≠ 最宽)。 // // 活动是软信号:硬门(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.Kind == TriggerHeartbeat { return false, "" // 07.4.3 原样:例行心跳(含启动轮)不拦 } if in.Import >= ImportanceHigh { return false, "" } if in.Kind == TriggerSelfWake { return true, "self-initiated wake held: owner away and no declared urgency" } return true, "owner away a long while" // 其余(含 TriggerUnknown)一并扣下 } 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 }