feat(backthink): 安全域穿透安静时段 + 长静默不拦心跳 + 启动即思考(裁决 1.8/07.4.2/07.4.3/07.4.4/07.4.5)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

执行方案阶段 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 未验。
This commit is contained in:
AskaEth committed 2026-10-04 11:21:44 +08:00
1 parent a35bcb8aa9
commit afef75f640
7 files changed
+468 -176

No files matched your search

+39 -13
View File
@@ -9,9 +9,13 @@
// - Asynchronous events on the in-process bus (iot.device_update,
// reminder.trigger, system.event) that wake a thinking pass.
//
// Each pass produces a conclusion, then consults Decide to judge whether the user
// should be interrupted at all (event importance / time-of-day / user state such
// as heart rate, sleep, work, busy / boredom). When it is worth interrupting the
// Each pass produces a conclusion whose structured tail decides whether she
// wants to speak at all (design/20 §2 P2), then consults Decide — the importance
// cascade restored as the production decision path — to judge whether now is an
// acceptable moment (event importance / time-of-day / user state such as heart
// rate, sleep, work, busy). A critical safety event punches through work/busy
// and quiet hours (裁决 07.4.2), and a heartbeat pass is never held by a
// long-silent activity gap (裁决 07.4.3). When it is worth interrupting the
// push goes to the active client first and falls back to the IM bridge. The
// proactive-push rate gate of DevDocs/design/11 §6 governs the periodic heartbeat
// only; an event-driven push bypasses it (owner mandate: "该发就发") subject to
@@ -308,10 +312,21 @@ func (b *Backthink) shouldThink(tr Trigger) bool {
return true
}
// tickLoop is the heartbeat goroutine. It fires one thinking pass per tick and
// exits when ctx is cancelled.
// tickLoop is the heartbeat goroutine. Per 裁决 07.4.4 (design/12 §1) it thinks
// once immediately on process start — producing a humanized startup greeting —
// before settling into one thinking pass per tick; it exits when ctx is
// cancelled. The accepted side effect is that every process restart wakes one
// immediate thinking round.
func (b *Backthink) tickLoop(ctx context.Context) {
defer b.wg.Done()
// 启动即思考:不等满一个心跳周期。已取消的上下文直接退出,不落一条失败的
// 启动记录。
select {
case <-ctx.Done():
return
default:
}
b.think(ctx, startupTrigger())
ticker := time.NewTicker(b.cfg.Interval)
defer ticker.Stop()
for {
@@ -348,24 +363,35 @@ func (b *Backthink) think(parent context.Context, tr Trigger) {
return
}
// P2 (design/20 §2): her own conclusion decides whether to push and to which
// channel — not the trigger's importance. parseProactive recovers the decision
// robustly and fails safe (malformed/ambiguous → no push).
// P2 (design/20 §2): her own conclusion decides whether she wants to speak
// and to which channel. parseProactive recovers the decision robustly and
// fails safe (malformed/ambiguous → no push).
message, channelHint, push := parseProactive(conclusion)
state := b.currentState(cctx)
lastActivity, hasActivity := b.lastUserActivity(cctx)
// The "do not disturb" gate (asleep/working/busy/quiet) still applies as a hard
// safety; the activity gate (fresh conversation / long idle) softens it; the
// frequency gate (PushGate) is applied inside the pusher.
shouldPush := push && b.disturbAllowed(at, state) && b.activityAllowed(tr.Import, lastActivity, hasActivity, at)
// 裁决 07.4.2:importance 级联恢复为生产决策路径——她的结论回答「想不想说」,
// Decide 只判「此刻能不能说」:critical 安全事件穿透工作/忙碌/安静时段,安静
// 时段只挡常规推送;心跳类触发(含启动轮)在长期静默段不拦(裁决 07.4.3)。
// 频率门(PushGate)仍在推送器内部单独生效。
decision := b.Decide(DecisionInput{
Trigger: tr.Source,
Import: tr.Import,
At: at,
UserState: state,
LastActivity: lastActivity,
HasActivity: hasActivity,
ConclusionPush: push,
Heartbeat: tr.Kind == TriggerHeartbeat,
})
shouldPush := decision.ShouldPush
recordConclusion := message
if recordConclusion == "" {
recordConclusion = conclusion
}
pushRes := PushResult{Route: RouteNone, Detail: b.interruptReason(push, at, state, tr.Import, lastActivity, hasActivity)}
pushRes := PushResult{Route: RouteNone, Detail: decision.Reason}
if shouldPush {
// Same-event dedup: a repeating identical event is suppressed after the
// first push (防刷), while a genuinely new event still goes out. Heartbeats