Files
Cyrene_v2/internal/services/backthink/decide.go
T
AskaEth afef75f640
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
feat(backthink): 安全域穿透安静时段 + 长静默不拦心跳 + 启动即思考(裁决 1.8/07.4.2/07.4.3/07.4.4/07.4.5)
执行方案阶段 2 · P2-2(`DevDocs/audit/_plan.md`)。四条裁决:
- 1.8/07.4.2:`think()` 不再走忽略 importance 的 `disturbAllowed`(已删),**接回 `Decide` 级联**;
  安静时段只挡常规推送、**critical 安全域穿透**;`DecisionInput` 增 `ConclusionPush`/`Heartbeat`。
  睡眠仍绝对不打扰(裁决只豁免安静时段,此点已在设计与报告写明)。
- 07.4.3:长静默(>2h)分支对**心跳类**放行(常规事件仍需 ≥high),刚聊过天 10min 内扣留不变。
- 07.4.4:`tickLoop` **启动立即先思考一轮** + 拟人化启动消息;启动轮与心跳**同门同限频**
  (重启风暴不会刷屏),ctx 已取消则跳过。
- 07.4.5:核对一致(`THINK_INTERVAL_SEC` 默认 900s 与 design/20 §0 权威口径一致)⇒ **代码零改动**。

验证:go build 绿;backthink 包 vet/测试绿;`-count=20`(抗 flake)+ `-race` 绿;
linux/amd64 + darwin/arm64 交叉编译绿;gofmt 干净;文件 ≤500 行、无 >80 行函数。
变异验证(三条,均带还原证明):① 安静时段改回"全拦" → 安全域穿透断言 FAIL(首次只删 critical
分支时仍绿——`critical >= high` 会从 high 分支漏过,故真实变异必须是"全拦");
② 删心跳豁免 → 长静默心跳断言 FAIL;③ 删启动思考 → 启动即思考断言 FAIL。

未验证:无真实模型凭据 ⇒ 启动轮问候与 `[我决定:…]` 解析仅经 mock + 真 loop 验证,端到端 LLM 未验。
2026-10-04 11:21:44 +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 is the estimated severity of an event that woke a thinking pass.
// Under design/20 §2 P2 it no longer decides *whether* she wants to speak (her
// conclusion does); it decides the hard-gate exemptions: a critical safety event
// punches through work/busy and quiet hours (裁决 07.4.2), and a high event still
// passes quiet hours (the pre-existing cascade semantics).
type Importance int
const (
// ImportanceLow is a routine trigger (e.g. the periodic heartbeat).
ImportanceLow Importance = iota
// ImportanceNormal is an ordinary event worth reflecting on (device change).
ImportanceNormal
// ImportanceHigh is a notable event (a reminder the user set firing).
ImportanceHigh
// ImportanceCritical is a safety-critical event that punches through
// work/busy and quiet hours (裁决 07.4.2 安全域豁免).
ImportanceCritical
)
// String returns a stable label for logs and records.
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 is the gap after the owner's last message beyond which the
// activity gate only lets a genuinely important event through — except heartbeat
// passes, which are never held by a long-silent gap (裁决 07.4.3). Compile-time
// so no magic number appears.
const longIdleThreshold = 2 * time.Hour
// UserState describes the user's current condition as observed from device
// telemetry (heart rate, sleep) and schedule (work, busy). It is the negative
// signal in the interrupt decision: a sleeping, working or busy user is left
// alone. Boredom (0..1) is the soft positive dial that widens interruption.
type UserState struct {
// HeartRateBPM is the current heart rate, 0 when unknown.
HeartRateBPM int
// Sleeping reports whether the user is currently asleep from sleep tracking.
Sleeping bool
// Working reports whether the user is in a work/scheduled block.
Working bool
// Busy reports whether the user is marked busy (calendar / do-not-disturb).
Busy bool
// Boredom ranges 0..1. Higher boredom means the user is idle and more open
// to a gentle interruption. Under design/20 §2 P2 the push intent is her own
// conclusion, so this dial no longer gates the decision; it is kept as a
// telemetry signal her judgment already absorbs.
Boredom float64
}
// String returns a concise human-readable summary for logs/records.
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 is the set of signals Decide weighs: the event's importance, the
// current wall-clock time, the user's current state and their boredom.
type DecisionInput struct {
// Trigger is a human-readable source label, recorded for traceability.
Trigger string
// Import is the event's estimated importance.
Import Importance
// At is the current wall-clock time, in the local timezone used for the
// do-not-disturb window.
At time.Time
// UserState carries the user's physiological/schedule condition.
UserState UserState
// LastActivity is the wall-clock time of the owner's most recent message.
// HasActivity reports whether a user message was found; when false the
// activity gate falls through without guessing.
LastActivity time.Time
// HasActivity reports whether LastActivity is meaningful.
HasActivity bool
// ConclusionPush 是她自己思考结论的推送意愿(design/20 §2 P2):「想不想说」
// 由她的结论决定,本决策只回答「此刻能不能说」。
ConclusionPush bool
// Heartbeat 标记本轮是否为心跳类触发(周期心跳或启动轮)。裁决 07.4.3:
// 长期静默段不拦心跳——主人久未回应时,主动关心仍可照常发出。
Heartbeat bool
}
// Decision is the outcome of Decide.
type Decision struct {
// ShouldPush reports whether the user should be interrupted.
ShouldPush bool
// Reason is a short human-readable justification, surfaced on the panel.
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 reports whether at falls inside the configured do-not-disturb
// window. The window may wrap midnight (e.g. 23:00-07:00); two equal non-zero
// hours disable it.
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 classifies a proactive message into one of a few flavors so the
// frequency gate can limit each flavor within a window, avoiding same-style spam
// (design/12 §6 策略二). It is a soft tag derived from the message body's
// keywords — it affects pacing, never identity or permission.
type PushKind int
const (
// KindGreeting is a warm hello / good-morning / good-night opener.
KindGreeting PushKind = iota
// KindReminder is a nudge about a schedule, task or deadline.
KindReminder
// KindShare is a discovery, observation or interesting find.
KindShare
// KindCare is a warm, caring check-in about the owner's wellbeing.
KindCare
)
// String returns a stable label for logs and records.
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 assigns a PushKind to a proactive message from its keyword
// content. A message that matches no flavor defaults to KindCare (the warm,
// generic intent of an unsolicited proactive note), which is the least
// spam-prone grouping. Priority is reminder > care > greeting > share so the
// dominant intent wins when several keywords co-occur.
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
}
}
// keyword sets for classifyPush. They are package vars (not consts) so the
// classification stays flexible and testable, but they are immutable by
// convention and never mutated at run time.
var (
kindReminderKeywords = []string{"提醒", "记得", "别忘了", "该去", "日程", "会议", "体检", "吃药", "喝药", "到点", "几点", "还有", "迟到", "明天", "马上", "最后", "截止"}
kindCareKeywords = []string{"关心", "最近", "辛苦", "注意身体", "照顾好", "好好休息", "心情", "还好", "想你了", "想我", "担心", "保重", "你还好", "陪你", "累"}
kindGreetingKeywords = []string{"早安", "晚安", "早上好", "中午好", "晚上好", "午安", "回来", "吃饭", "睡了"}
kindShareKeywords = []string{"发现", "分享", "看到", "听说", "注意到", "告诉你", "有个", "想到", "有意思", "有趣", "你知道吗"}
)
// hasAnyKeyword reports whether s contains any of the given substrings.
func hasAnyKeyword(s string, subs ...string) bool {
for _, sub := range subs {
if strings.Contains(s, sub) {
return true
}
}
return false
}