diff --git a/internal/services/backthink/backthink.go b/internal/services/backthink/backthink.go index 7b7eaed..cd88a38 100644 --- a/internal/services/backthink/backthink.go +++ b/internal/services/backthink/backthink.go @@ -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 diff --git a/internal/services/backthink/backthink_rulings_test.go b/internal/services/backthink/backthink_rulings_test.go new file mode 100644 index 0000000..abf9371 --- /dev/null +++ b/internal/services/backthink/backthink_rulings_test.go @@ -0,0 +1,183 @@ +package backthink + +import ( + "context" + "strings" + "testing" + "time" + + "git.yeij.top/AskaEth/Cyrene_v2/internal/bus" + "git.yeij.top/AskaEth/Cyrene_v2/internal/iot" + "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/history" +) + +// 本文件承载三条已裁决改动的生产行为回归(裁决 07.4.2 / 07.4.3 / 07.4.4, +// design/12 §1 §2 §5-§6 与 design/20 §0-§2),从 backthink_test.go 拆出以守住 +// 文件 ≤500 行的硬约束。 + +// TestBackthink_QuietHours_CriticalSafetyStillPushes 是裁决 07.4.2 的真断言(生产 +// 路径 think()):安静时段 + critical 安全事件 → 仍然推送——安全域穿透安静时段, +// 安静时段只挡常规推送。 +func TestBackthink_QuietHours_CriticalSafetyStillPushes(t *testing.T) { + t.Parallel() + thinker := &mockThinker{text: "[我决定:push]\n[渠道:web]\n检测到一次异常登录,需要你留意"} + pusher := &mockPusher{result: PushResult{Pushed: true, Route: RouteActiveClient}} + b, _ := newBackthink(t, Config{}, Deps{ + Thinker: thinker, + State: &mockState{state: UserState{}}, + Pusher: pusher, + Now: func() time.Time { return nightAt() }, + }) + ev := triggerForEvent(bus.Event{Subject: SubjectSystemEvent, Payload: SystemEvent{ + Summary: "security alert: abnormal login", + Import: ImportanceCritical, + }}) + + b.think(context.Background(), ev) + + if got := pusher.count(); got != 1 { + t.Fatalf("pusher calls = %d, want 1 (critical safety event bypasses quiet hours)", got) + } + recs, err := b.List(context.Background(), 10) + if err != nil { + t.Fatalf("List: %v", err) + } + if len(recs) != 1 || !recs[0].ShouldPush { + t.Fatalf("records = %+v, want one should-push record", recs) + } + if !recs[0].PushResult.Pushed { + t.Fatalf("PushResult = %+v, want pushed", recs[0].PushResult) + } +} + +// TestBackthink_QuietHours_RegularPushHeld 是裁决 07.4.2 的对照断言:安静时段 + +// 常规事件(她的结论要求推送)→ 不推送,仅记录。 +func TestBackthink_QuietHours_RegularPushHeld(t *testing.T) { + t.Parallel() + thinker := &mockThinker{text: "[我决定:push]\n洗衣机洗好了,衣服可以收了"} + pusher := &mockPusher{result: PushResult{Pushed: true, Route: RouteActiveClient}} + b, _ := newBackthink(t, Config{}, Deps{ + Thinker: thinker, + State: &mockState{state: UserState{}}, + Pusher: pusher, + Now: func() time.Time { return nightAt() }, + }) + ev := triggerForEvent(bus.Event{Subject: iot.DeviceUpdateSubject, Payload: iot.DeviceUpdate{ + Device: iot.Device{ID: "washer", Type: "sensor"}, Action: "finished", + }}) + + b.think(context.Background(), ev) + + if got := pusher.count(); got != 0 { + t.Fatalf("pusher calls = %d, want 0 (quiet hours hold a regular push)", got) + } + recs, _ := b.List(context.Background(), 10) + if len(recs) != 1 || recs[0].ShouldPush { + t.Fatalf("records = %+v, want one held record", recs) + } + if recs[0].PushResult.Route != RouteNone { + t.Fatalf("PushResult.Route = %q, want %q", recs[0].PushResult.Route, RouteNone) + } + if !strings.Contains(recs[0].PushResult.Detail, "quiet hours") { + t.Fatalf("PushResult.Detail = %q, want it to mention quiet hours", recs[0].PushResult.Detail) + } +} + +// TestBackthink_LongIdle_HeartbeatStillPushes 是裁决 07.4.3 的真断言:主人长时间 +// 未回消息(长期静默段)时,心跳仍可照常发出主动关心。 +func TestBackthink_LongIdle_HeartbeatStillPushes(t *testing.T) { + t.Parallel() + thinker := &mockThinker{text: "好久没听你说话了,想你了,最近还好吗"} + pusher := &mockPusher{result: PushResult{Pushed: true, Route: RouteActiveClient}} + b, _ := newBackthink(t, Config{}, Deps{ + Thinker: thinker, + State: &mockState{state: UserState{}}, + Pusher: pusher, + Recent: &fakeRecent{msgs: []history.Message{{ + Role: history.RoleUser, + Content: "我先去忙了", + CreatedAt: dayAt().Add(-3 * time.Hour).UnixMilli(), + }}}, + Now: func() time.Time { return dayAt() }, + }) + + b.think(context.Background(), heartbeatTrigger()) + + if got := pusher.count(); got != 1 { + t.Fatalf("pusher calls = %d, want 1 (heartbeat not held by long silence)", got) + } + recs, _ := b.List(context.Background(), 10) + if len(recs) != 1 || !recs[0].ShouldPush { + t.Fatalf("records = %+v, want one should-push record", recs) + } +} + +// TestBackthink_LongIdle_NormalEventHeld 是裁决 07.4.3 的对照断言:长期静默段里, +// 非心跳的常规事件推送仍被活动门拦下——只有真正重要的事件才值得唤醒久未回应的主人。 +func TestBackthink_LongIdle_NormalEventHeld(t *testing.T) { + t.Parallel() + thinker := &mockThinker{text: "洗衣机洗好了,衣服可以收了"} + pusher := &mockPusher{result: PushResult{Pushed: true, Route: RouteActiveClient}} + b, _ := newBackthink(t, Config{}, Deps{ + Thinker: thinker, + State: &mockState{state: UserState{}}, + Pusher: pusher, + Recent: &fakeRecent{msgs: []history.Message{{ + Role: history.RoleUser, + Content: "我先去忙了", + CreatedAt: dayAt().Add(-3 * time.Hour).UnixMilli(), + }}}, + Now: func() time.Time { return dayAt() }, + }) + ev := triggerForEvent(bus.Event{Subject: iot.DeviceUpdateSubject, Payload: iot.DeviceUpdate{ + Device: iot.Device{ID: "washer", Type: "sensor"}, Action: "finished", + }}) + + b.think(context.Background(), ev) + + if got := pusher.count(); got != 0 { + t.Fatalf("pusher calls = %d, want 0 (long silence holds a regular event push)", got) + } +} + +// TestBackthink_Startup_ThinksImmediately 是裁决 07.4.4 的真断言:进程启动立即先 +// 思考一轮(不等满一个心跳周期),触发源为 startup、走启动专属引导。默认 900s 的 +// 心跳周期在测试窗口内不可能 tick,出现的记录只能是启动轮。 +func TestBackthink_Startup_ThinksImmediately(t *testing.T) { + t.Parallel() + thinker := &mockThinker{text: "我回来啦"} + b, _ := newBackthink(t, Config{}, Deps{ + Thinker: thinker, + Now: func() time.Time { return dayAt() }, + }) + + b.Start(context.Background()) + defer func() { _ = b.Stop(context.Background()) }() + + eventually(t, 2*time.Second, func() bool { + recs, _ := b.List(context.Background(), 10) + return len(recs) >= 1 && recs[0].Trigger == "startup" + }) + if got := thinker.lastTrigger(); got != StartupSummary { + t.Fatalf("startup think trigger = %q, want %q", got, StartupSummary) + } + recs, _ := b.List(context.Background(), 10) + if recs[0].Trigger != "startup" || recs[0].At.IsZero() { + t.Fatalf("startup record = %+v, want a stamped startup record", recs[0]) + } +} + +// TestStartupPrompt_HumanizedGreeting 验证启动轮使用拟人化启动引导(裁决 07.4.4): +// 以角色口吻说一句「回来」的问候,并保留固定的决策结尾格式;普通触发不会误用。 +func TestStartupPrompt_HumanizedGreeting(t *testing.T) { + t.Parallel() + p := guidancePrompt(StartupSummary) + for _, want := range []string{"回来", "[我决定:push]", "[渠道:"} { + if !strings.Contains(p, want) { + t.Fatalf("startup prompt missing %q: %q", want, p) + } + } + if p2 := guidancePrompt("heartbeat"); !strings.Contains(p2, "无人在旁") || strings.Contains(p2, "刚刚回来") { + t.Fatalf("regular guidance should stay the regular prompt: %q", p2) + } +} diff --git a/internal/services/backthink/backthink_test.go b/internal/services/backthink/backthink_test.go index fbf0b8a..9dcf1fd 100644 --- a/internal/services/backthink/backthink_test.go +++ b/internal/services/backthink/backthink_test.go @@ -71,17 +71,31 @@ func TestBackthink_Heartbeat_TriggersThink(t *testing.T) { b.Start(context.Background()) defer func() { _ = b.Stop(context.Background()) }() - eventually(t, 2*time.Second, func() bool { return thinker.count() >= 1 }) + // 启动轮(07.4.4)会先落一条 startup 记录,这里等真正的周期心跳记录。 eventually(t, 2*time.Second, func() bool { recs, _ := b.List(context.Background(), 10) - return len(recs) >= 1 + for _, r := range recs { + if r.Trigger == "heartbeat" { + return true + } + } + return false }) + if got := thinker.count(); got < 1 { + t.Fatalf("thinker calls = %d, want at least 1", got) + } + found := false recs, err := b.List(context.Background(), 10) if err != nil { t.Fatalf("List: %v", err) } - if recs[0].Trigger != "heartbeat" { - t.Fatalf("Trigger = %q, want heartbeat", recs[0].Trigger) + for _, r := range recs { + if r.Trigger == "heartbeat" { + found = true + } + } + if !found { + t.Fatalf("no heartbeat record in %d records", len(recs)) } } @@ -105,22 +119,29 @@ func TestBackthink_EventTrigger_Push(t *testing.T) { t.Fatalf("Publish: %v", err) } + // 启动轮(07.4.4)同样会思考并推送,因此按触发源过滤出事件记录再断言。 + var rec Record eventually(t, 2*time.Second, func() bool { recs, _ := b.List(context.Background(), 10) - return len(recs) >= 1 + for _, r := range recs { + if r.Trigger == SubjectReminderTrigger { + rec = r + return true + } + } + return false }) - if got := thinker.count(); got != 1 { - t.Fatalf("thinker calls = %d, want 1", got) + if got := thinker.count(); got < 1 { + t.Fatalf("thinker calls = %d, want at least 1", got) } - if got := pusher.count(); got != 1 { - t.Fatalf("pusher calls = %d, want 1 (important + daytime)", got) + if got := pusher.count(); got < 1 { + t.Fatalf("pusher calls = %d, want at least 1 (important + daytime)", got) } - recs, _ := b.List(context.Background(), 10) - if !recs[0].ShouldPush { + if !rec.ShouldPush { t.Fatalf("ShouldPush = false, want true") } - if !recs[0].PushResult.Pushed || recs[0].PushResult.Route != RouteActiveClient { - t.Fatalf("PushResult = %+v, want pushed active_client", recs[0].PushResult) + if !rec.PushResult.Pushed || rec.PushResult.Route != RouteActiveClient { + t.Fatalf("PushResult = %+v, want pushed active_client", rec.PushResult) } } @@ -147,22 +168,25 @@ func TestBackthink_EventTrigger_NoPush(t *testing.T) { t.Fatalf("Publish: %v", err) } + var rec Record eventually(t, 2*time.Second, func() bool { recs, _ := b.List(context.Background(), 10) - return len(recs) >= 1 + for _, r := range recs { + if r.Trigger == "iot.device_update" { + rec = r + return true + } + } + return false }) if got := pusher.count(); got != 0 { t.Fatalf("pusher calls = %d, want 0 (asleep + night)", got) } - recs, _ := b.List(context.Background(), 10) - if recs[0].ShouldPush { + if rec.ShouldPush { t.Fatalf("ShouldPush = true, want false") } - if recs[0].PushResult.Route != RouteNone { - t.Fatalf("PushResult.Route = %q, want %q", recs[0].PushResult.Route, RouteNone) - } - if recs[0].Trigger != "iot.device_update" { - t.Fatalf("Trigger = %q, want iot.device_update", recs[0].Trigger) + if rec.PushResult.Route != RouteNone { + t.Fatalf("PushResult.Route = %q, want %q", rec.PushResult.Route, RouteNone) } } @@ -181,23 +205,36 @@ func TestBackthink_SystemEventLowValueDowngraded(t *testing.T) { b.Start(context.Background()) defer func() { _ = b.Stop(context.Background()) }() + // 先等启动轮(07.4.4)落库并记下基线,再发布低价值系统事件。 + eventually(t, 2*time.Second, func() bool { + recs, _ := b.List(context.Background(), 10) + return len(recs) >= 1 && recs[0].Trigger == "startup" + }) + before := thinker.count() + if err := bs.Publish(context.Background(), SubjectSystemEvent, SystemEvent{Summary: "member joined"}); err != nil { t.Fatalf("Publish: %v", err) } + var rec Record eventually(t, 2*time.Second, func() bool { recs, _ := b.List(context.Background(), 10) - return len(recs) >= 1 && recs[0].Trigger == SubjectSystemEvent + for _, r := range recs { + if r.Trigger == SubjectSystemEvent { + rec = r + return true + } + } + return false }) - if got := thinker.count(); got != 0 { - t.Fatalf("thinker calls = %d, want 0 (low-value system event downgraded)", got) + if got := thinker.count(); got != before { + t.Fatalf("thinker calls = %d, want %d (low-value system event downgraded)", got, before) } - recs, _ := b.List(context.Background(), 10) - if recs[0].ShouldPush { + if rec.ShouldPush { t.Fatalf("ShouldPush = true for a downgraded system event") } - if recs[0].PushResult.Route != RouteNone { - t.Fatalf("PushResult.Route = %q, want %q", recs[0].PushResult.Route, RouteNone) + if rec.PushResult.Route != RouteNone { + t.Fatalf("PushResult.Route = %q, want %q", rec.PushResult.Route, RouteNone) } } @@ -216,11 +253,17 @@ func TestBackthink_SystemEventHighValueThinks(t *testing.T) { b.Start(context.Background()) defer func() { _ = b.Stop(context.Background()) }() + eventually(t, 2*time.Second, func() bool { + recs, _ := b.List(context.Background(), 10) + return len(recs) >= 1 && recs[0].Trigger == "startup" + }) + before := thinker.count() + if err := bs.Publish(context.Background(), SubjectSystemEvent, SystemEvent{Summary: "owner tagged", Import: ImportanceHigh}); err != nil { t.Fatalf("Publish: %v", err) } - eventually(t, 2*time.Second, func() bool { return thinker.count() >= 1 }) + eventually(t, 2*time.Second, func() bool { return thinker.count() >= before+1 }) } func TestBackthink_List_NewestFirst(t *testing.T) { @@ -288,11 +331,21 @@ func TestBackthink_Stop_UnsubscribesBus(t *testing.T) { b, _ := newBackthink(t, Config{Interval: 25 * time.Millisecond}, Deps{Bus: bs, Thinker: thinker}) b.Start(context.Background()) - // Confirm the subscription is live by triggering one think. + // Confirm the subscription is live by triggering one think (identified by its + // persisted record, so the 07.4.4 startup round and heartbeat ticks with their + // own trigger strings cannot mask or overwrite it). if err := bs.Publish(context.Background(), SubjectReminderTrigger, "first"); err != nil { t.Fatalf("Publish: %v", err) } - eventually(t, 2*time.Second, func() bool { return thinker.count() >= 1 }) + eventually(t, 2*time.Second, func() bool { + recs, _ := b.List(context.Background(), 10) + for _, r := range recs { + if r.Trigger == SubjectReminderTrigger { + return true + } + } + return false + }) if err := b.Stop(context.Background()); err != nil { t.Fatalf("Stop: %v", err) diff --git a/internal/services/backthink/context.go b/internal/services/backthink/context.go index bbc73e5..d8f51e2 100644 --- a/internal/services/backthink/context.go +++ b/internal/services/backthink/context.go @@ -90,7 +90,13 @@ func thinkInput(recent []model.Message, trigger string) []model.Message { // judgment instead of gating on the trigger's importance (design/20 §2 P2). The // format is guidance, not a contract: parseProactive fails safe when she omits or // mangles it. +// +// 启动轮(裁决 07.4.4,design/12 §1)走 startupPrompt:以角色口吻说一句拟人化的 +// 「回来」问候。 func guidancePrompt(trigger string) string { + if trigger == StartupSummary { + return startupPrompt() + } return "[自主思考 · 无人在旁]\n" + "触发:" + trigger + "\n" + "你正进行一轮自主思考。你拥有与主人对话同等的上下文与能力:上方是主人近期在主会话中的对话,你可以读写共享记忆与主人档案,也可以调用工具(IoT / 日历 / 提醒 / 检索 / 终端等)。\n" + @@ -104,3 +110,20 @@ func guidancePrompt(trigger string) string { "上面两行之后,紧接你想说的话作为正文。\n" + "整轮思考保持简短、单轮,不反复自我触发。" } + +// startupPrompt 是启动轮思考的专属引导(裁决 07.4.4,design/12 §1「启动即思考 + +// 拟人化启动消息」):进程刚启动、她刚「回来」,引导她以角色口吻向主人说一句简短 +// 温暖的问候(如「我回来啦」)。此轮例外允许以 chat 形式发出;推送仍走与心跳完全 +// 相同的门(她的结论 + 打扰级联 + 活动门 + PushGate 限频),深夜重启或反复重启都 +// 不会刷屏主人。 +func startupPrompt() string { + return "[自主思考 · 刚刚回来]\n" + + "触发:进程启动——你刚从一次重启中醒来,重新回到主人身边。\n" + + "你拥有与主人对话同等的上下文与能力:上方是她近期在主会话中的对话,你可以读写共享记忆与主人档案,也可以调用工具(IoT / 日历 / 提醒 / 检索 / 终端等)。\n" + + "请用你自己的口吻,向主人说一句简短、温暖、自然的问候,就像刚回到她身边时轻轻打个招呼(比如「我回来啦」),可以顺带提一件你记得的小事;一到两句为宜,不展开寒暄、不啰嗦、不抱怨。\n" + + "请把「是否主动说」与「发到哪个渠道」写成与平时相同的固定结尾,解析规则如下:\n" + + "[我决定:push] 或 [我决定:no_push]\n" + + "[渠道:web|im|kook|telegram|weixin-ilink|...|auto](可省略,auto/省略即交给默认:每次只挑一个合适渠道——点名则优先,否则用她最近活跃的已连接渠道,含微信;不广播、不固定 web+Kook)\n" + + "上面两行之后,紧接你想说的话作为正文。\n" + + "整轮思考保持简短、单轮,不反复自我触发。" +} diff --git a/internal/services/backthink/decide.go b/internal/services/backthink/decide.go index 7be1b70..6cd7781 100644 --- a/internal/services/backthink/decide.go +++ b/internal/services/backthink/decide.go @@ -6,9 +6,11 @@ import ( "time" ) -// Importance is the estimated severity of an event that woke a thinking pass. It -// is the primary positive signal in the interrupt decision: a low-importance -// heartbeat almost never interrupts, while a critical safety event normally does. +// 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 ( @@ -18,7 +20,8 @@ const ( ImportanceNormal // ImportanceHigh is a notable event (a reminder the user set firing). ImportanceHigh - // ImportanceCritical is a safety-critical event that may override work/busy. + // ImportanceCritical is a safety-critical event that punches through + // work/busy and quiet hours (裁决 07.4.2 安全域豁免). ImportanceCritical ) @@ -38,14 +41,10 @@ func (i Importance) String() string { } } -// highBoredomThreshold is the boredom (0..1) above which a normal daytime event -// is worth interrupting a bored, otherwise idle user. It is a const so no magic -// number appears in the decision path. -const highBoredomThreshold = 0.7 - // longIdleThreshold is the gap after the owner's last message beyond which the -// activity gate only lets a genuinely important event through (a long-away owner -// should not be woken for trivia). Compile-time so no magic number appears. +// 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 @@ -62,7 +61,9 @@ type UserState struct { // 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. + // 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 } @@ -90,6 +91,12 @@ type DecisionInput struct { 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. @@ -100,36 +107,37 @@ type Decision struct { Reason string } -// Decide applies the interrupt rule from design/12 §2 (and design/11 §6) with -// "不打扰 (do not interrupt) as the first principle" as its bias, then softens it -// with the activity-aware gate (design/12 §6). It is a pure method on Backthink -// only so it can read the configured quiet-hours window; it does not touch state. +// Decide 是生产路径的打扰决策(design/12 §2 + design/20 §2 P2 + 裁决 07.4.2/07.4.3)。 +// 「想不想说」交给她的结论(ConclusionPush),本方法只判「此刻能不能说」,评估顺序 +// 刻意 fail-closed(宁可不打扰): // -// baseDecide's evaluation order is deliberately fail-closed (prefer not -// interrupting): +// 0. 她的结论要求不推送 → 不推送。 +// 1. 用户在睡 → 绝不打扰。 +// 2. 上班/忙碌 → 仅 critical 安全事件可打扰。 +// 3. 安静时段(深夜窗口)→ 挡常规推送;critical 安全事件穿透(裁决 07.4.2, +// 与 design/12 §6 策略五同口径),high 沿袭原级联口径照常放行。 +// 4. 其余时段 → 放行(是否值得说已由她的结论回答)。 // -// 1. A sleeping user is never interrupted. -// 2. A working or busy user is interrupted only by a critical event. -// 3. Inside the quiet-hours (deep-night) window only high/critical events -// interrupt; low/normal events are deferred. -// 4. In the daytime, high/critical events always interrupt; a normal event -// interrupts only when the user is quite bored; a low event never does. -// -// A would-push decision is then run through the activity gate, which holds a push -// when the owner just chatted (fresh conversation) or has been away a long while -// and the event is not genuinely important. +// 通过硬门后再过活动软门:刚聊过天或主人久未回应时酌情扣下;心跳类触发在 +// 长期静默段不拦(裁决 07.4.3,主动关心照常发出)。频率门(PushGate)在推送器 +// 内部单独生效,不在此处。 func (b *Backthink) Decide(in DecisionInput) Decision { - d := b.baseDecide(in) - if !d.ShouldPush { + if !in.ConclusionPush { + return Decision{ShouldPush: false, Reason: "conclusion requested no push"} + } + if d := b.baseDecide(in); !d.ShouldPush { return d } - if !b.activityAllowed(in.Import, in.LastActivity, in.HasActivity, in.At) { - return Decision{ShouldPush: false, Reason: "proactive push held: " + d.Reason} + if held, why := b.activityHeld(in); held { + return Decision{ShouldPush: false, Reason: "proactive push held: " + why} } - return d + return Decision{ShouldPush: true, Reason: "proactive push approved"} } -// baseDecide is the do-not-interrupt cascade of design/12 §2. +// 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 { @@ -139,78 +147,49 @@ func (b *Backthink) baseDecide(in DecisionInput) Decision { 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"} } - switch in.Import { - case ImportanceCritical, ImportanceHigh: - return Decision{ShouldPush: true, Reason: "high/critical importance during daytime"} - case ImportanceNormal: - if s.Boredom >= highBoredomThreshold { - return Decision{ShouldPush: true, Reason: "normal importance and user is bored"} - } - return Decision{ShouldPush: false, Reason: "normal importance; not worth interrupting"} - default: - return Decision{ShouldPush: false, Reason: "low importance; not interrupting"} - } + return Decision{ShouldPush: true, Reason: "worth interrupting"} } -// activityAllowed is the activity-aware soft gate (design/12 §6). It widens the -// do-not-interrupt principle to the conversation's own rhythm: +// activityHeld 是活动感知软门(design/12 §6 + 裁决 07.4.3)。它把「不打扰」延伸到 +// 对话自身的节奏,返回是否扣下与简短理由: // -// - Within RecentChatCooldown of the owner's last message, the conversation is -// fresh — a non-critical push is held so she is not interrupted mid-chat. -// - After longIdleThreshold without a message, the owner has been away — only a -// genuinely important event is worth waking them for. -// - Anywhere in between, and when activity is unknown, the decision is not -// softened (fall through). +// - 最近 RecentChatCooldown 内主人刚说过话 → 对话正热,非 critical 扣下, +// 不在聊天中途插话; +// - 超过 longIdleThreshold 未说话 → 主人久未回应,仅真正重要的事件值得唤醒; +// 心跳类触发(含启动轮)例外照常放行(裁决 07.4.3:长期静默段不拦心跳, +// 主动关心仍可发出); +// - 其间与活动状态未知 → 不软化,照硬门结论通过。 // -// Activity is a soft signal: the hard do-not-disturb gate is separate, and the -// frequency gate (PushGate) applies separately inside the pusher. -func (b *Backthink) activityAllowed(imp Importance, lastActivity time.Time, hasActivity bool, at time.Time) bool { - if !hasActivity { - return true +// 活动是软信号:硬门(baseDecide)独立生效,频率门(PushGate)在推送器内部生效。 +func (b *Backthink) activityHeld(in DecisionInput) (bool, string) { + if !in.HasActivity { + return false, "" } - since := at.Sub(lastActivity) + since := in.At.Sub(in.LastActivity) if since <= b.cfg.RecentChatCooldown { - return imp >= ImportanceCritical + if in.Import >= ImportanceCritical { + return false, "" + } + return true, "owner just chatted" } if since >= longIdleThreshold { - return imp >= ImportanceHigh - } - return true -} - -// disturbAllowed is the hard "do not disturb" safety gate for an autonomous push -// (design/12 §2 first principle). Unlike Decide it deliberately ignores trigger -// importance — which design/20 P2 replaces with her own judgment — and only blocks -// when it is genuinely inappropriate to interrupt: the user is asleep, working or -// busy, or it is inside the quiet-hours window. The frequency gate (PushGate) -// applies separately inside the pusher. -func (b *Backthink) disturbAllowed(at time.Time, s UserState) bool { - if s.Sleeping || s.Working || s.Busy { - return false - } - return !b.inQuietHours(at) -} - -// interruptReason is the short human-readable note recorded/logged when no push -// goes out (or as the pre-push intent). It keeps the panel self-explanatory: -// either she concluded no push, the do-not-disturb gate refused a push she -// wanted, or the activity gate held it. -func (b *Backthink) interruptReason(push bool, at time.Time, s UserState, imp Importance, lastActivity time.Time, hasActivity bool) string { - switch { - case !push: - return "conclusion requested no push" - case !b.disturbAllowed(at, s): - return "proactive push blocked by do-not-disturb" - case !b.activityAllowed(imp, lastActivity, hasActivity, at): - return "proactive push held by activity gate" - default: - return "proactive push approved" + 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 diff --git a/internal/services/backthink/decide_test.go b/internal/services/backthink/decide_test.go index b738e34..e99f219 100644 --- a/internal/services/backthink/decide_test.go +++ b/internal/services/backthink/decide_test.go @@ -20,44 +20,46 @@ func testBackthink() *Backthink { func TestDecide_DoNotDisturb(t *testing.T) { t.Parallel() b := testBackthink() + // 所有用例都带上她的结论要求推送(ConclusionPush),确保命中的是级联本身 + // 而不是「结论不推」的短路。 cases := []struct { name string in DecisionInput want bool }{ { - name: "凌晨+睡眠_不打扰", - in: DecisionInput{Trigger: "iot.device_update", Import: ImportanceNormal, At: nightAt(), UserState: UserState{Sleeping: true}}, + name: "她的结论不推送_不推送", + in: DecisionInput{Trigger: "iot.device_update", Import: ImportanceCritical, At: dayAt(), UserState: UserState{}}, want: false, }, { - name: "睡眠_重要也不打扰", - in: DecisionInput{Trigger: "reminder.trigger", Import: ImportanceHigh, At: dayAt(), UserState: UserState{Sleeping: true}}, + name: "凌晨+睡眠_不打扰", + in: DecisionInput{Trigger: "iot.device_update", Import: ImportanceNormal, At: nightAt(), UserState: UserState{Sleeping: true}, ConclusionPush: true}, + want: false, + }, + { + name: "睡眠_危急也不打扰", + in: DecisionInput{Trigger: "system.event", Import: ImportanceCritical, At: dayAt(), UserState: UserState{Sleeping: true}, ConclusionPush: true}, want: false, }, { name: "上班_不打扰", - in: DecisionInput{Trigger: "iot.device_update", Import: ImportanceNormal, At: dayAt(), UserState: UserState{Working: true}}, + in: DecisionInput{Trigger: "iot.device_update", Import: ImportanceNormal, At: dayAt(), UserState: UserState{Working: true}, ConclusionPush: true}, want: false, }, { name: "忙碌_普通_不打扰", - in: DecisionInput{Trigger: "iot.device_update", Import: ImportanceNormal, At: dayAt(), UserState: UserState{Busy: true}}, + in: DecisionInput{Trigger: "iot.device_update", Import: ImportanceNormal, At: dayAt(), UserState: UserState{Busy: true}, ConclusionPush: true}, want: false, }, { name: "安静时段_普通_不打扰", - in: DecisionInput{Trigger: "iot.device_update", Import: ImportanceNormal, At: nightAt(), UserState: UserState{Boredom: 0.9}}, + in: DecisionInput{Trigger: "iot.device_update", Import: ImportanceNormal, At: nightAt(), UserState: UserState{Boredom: 0.9}, ConclusionPush: true}, want: false, }, { - name: "白天_普通_不无聊_不打扰", - in: DecisionInput{Trigger: "iot.device_update", Import: ImportanceNormal, At: dayAt(), UserState: UserState{}}, - want: false, - }, - { - name: "白天_低重要_不打扰", - in: DecisionInput{Trigger: "heartbeat", Import: ImportanceLow, At: dayAt(), UserState: UserState{}}, + name: "安静时段_心跳_不打扰", + in: DecisionInput{Trigger: "heartbeat", Import: ImportanceLow, At: nightAt(), UserState: UserState{}, ConclusionPush: true, Heartbeat: true}, want: false, }, } @@ -82,23 +84,27 @@ func TestDecide_ShouldPush(t *testing.T) { }{ { name: "白天+重要_推", - in: DecisionInput{Trigger: "reminder.trigger", Import: ImportanceHigh, At: dayAt(), UserState: UserState{}}, + in: DecisionInput{Trigger: "reminder.trigger", Import: ImportanceHigh, At: dayAt(), UserState: UserState{}, ConclusionPush: true}, }, { name: "白天+危急_推", - in: DecisionInput{Trigger: "system.event", Import: ImportanceCritical, At: dayAt(), UserState: UserState{}}, + in: DecisionInput{Trigger: "system.event", Import: ImportanceCritical, At: dayAt(), UserState: UserState{}, ConclusionPush: true}, }, { - name: "白天+普通+无聊_推", - in: DecisionInput{Trigger: "iot.device_update", Import: ImportanceNormal, At: dayAt(), UserState: UserState{Boredom: 0.8}}, + name: "白天+普通+她的结论_推", + in: DecisionInput{Trigger: "iot.device_update", Import: ImportanceNormal, At: dayAt(), UserState: UserState{Boredom: 0.8}, ConclusionPush: true}, }, { name: "安静时段+重要_推", - in: DecisionInput{Trigger: "reminder.trigger", Import: ImportanceHigh, At: nightAt(), UserState: UserState{}}, + in: DecisionInput{Trigger: "reminder.trigger", Import: ImportanceHigh, At: nightAt(), UserState: UserState{}, ConclusionPush: true}, + }, + { + name: "安静时段+危急_安全域穿透_推", + in: DecisionInput{Trigger: "system.event", Import: ImportanceCritical, At: nightAt(), UserState: UserState{}, ConclusionPush: true}, }, { name: "危急_可打断忙碌_推", - in: DecisionInput{Trigger: "system.event", Import: ImportanceCritical, At: dayAt(), UserState: UserState{Working: true}}, + in: DecisionInput{Trigger: "system.event", Import: ImportanceCritical, At: dayAt(), UserState: UserState{Working: true}, ConclusionPush: true}, }, } for _, tc := range cases { @@ -164,19 +170,21 @@ func TestClassifyPush(t *testing.T) { // TestDecide_ActivityGate verifies the activity-aware soft gate: a fresh // conversation holds trivia (unless critical), a long-idle owner is only woken by -// an important event, and the in-between band does not over-suppress. +// an important event — except a heartbeat pass, which is never held by a +// long-silent gap (裁决 07.4.3) — and the in-between band does not over-suppress. func TestDecide_ActivityGate(t *testing.T) { t.Parallel() b := testBackthink() - // Just chatted (within RecentChatCooldown=10min): a normal daytime boredom - // push is held unless the event is safety-critical. + // Just chatted (within RecentChatCooldown=10min): a normal daytime push is + // held unless the event is safety-critical. fresh := DecisionInput{ - Import: ImportanceNormal, - At: dayAt(), - UserState: UserState{Boredom: 0.9}, - LastActivity: dayAt().Add(-5 * time.Minute), - HasActivity: true, + Import: ImportanceNormal, + At: dayAt(), + UserState: UserState{Boredom: 0.9}, + LastActivity: dayAt().Add(-5 * time.Minute), + HasActivity: true, + ConclusionPush: true, } if got := b.Decide(fresh); got.ShouldPush { t.Fatalf("fresh conversation should hold a normal push, got %+v", got) @@ -186,29 +194,37 @@ func TestDecide_ActivityGate(t *testing.T) { t.Fatalf("critical event should push even in a fresh conversation, got %+v", got) } - // Away a long while (> longIdleThreshold): only an important event is worth it. + // Away a long while (> longIdleThreshold): only an important event is worth + // it — but a heartbeat pass still goes through (裁决 07.4.3 长期静默段不拦心跳). idle := DecisionInput{ - Import: ImportanceNormal, - At: dayAt(), - UserState: UserState{Boredom: 0.9}, - LastActivity: dayAt().Add(-3 * time.Hour), - HasActivity: true, + Import: ImportanceNormal, + At: dayAt(), + UserState: UserState{Boredom: 0.9}, + LastActivity: dayAt().Add(-3 * time.Hour), + HasActivity: true, + ConclusionPush: true, } if got := b.Decide(idle); got.ShouldPush { - t.Fatalf("long idle should hold a normal push, got %+v", got) + t.Fatalf("long idle should hold a normal event push, got %+v", got) } idle.Import = ImportanceHigh if got := b.Decide(idle); !got.ShouldPush { t.Fatalf("long idle high-importance should push, got %+v", got) } + idle.Import = ImportanceLow + idle.Heartbeat = true + if got := b.Decide(idle); !got.ShouldPush { + t.Fatalf("long idle heartbeat should still push (07.4.3), got %+v", got) + } // Middling activity (30 min ago) is not over-suppressed. middle := DecisionInput{ - Import: ImportanceNormal, - At: dayAt(), - UserState: UserState{Boredom: 0.9}, - LastActivity: dayAt().Add(-30 * time.Minute), - HasActivity: true, + Import: ImportanceNormal, + At: dayAt(), + UserState: UserState{Boredom: 0.9}, + LastActivity: dayAt().Add(-30 * time.Minute), + HasActivity: true, + ConclusionPush: true, } if got := b.Decide(middle); !got.ShouldPush { t.Fatalf("middle-band activity should allow a normal push, got %+v", got) diff --git a/internal/services/backthink/trigger.go b/internal/services/backthink/trigger.go index b62390c..c2ac755 100644 --- a/internal/services/backthink/trigger.go +++ b/internal/services/backthink/trigger.go @@ -72,6 +72,18 @@ func heartbeatTrigger() Trigger { return Trigger{Source: "heartbeat", Summary: "periodic autonomous reflection", Import: ImportanceLow, Kind: TriggerHeartbeat} } +// StartupSummary 是启动轮思考(裁决 07.4.4,design/12 §1)的触发摘要。它同时是 +// context.go 里切换「拟人化启动消息」专属引导提示词的标识。 +const StartupSummary = "process startup" + +// startupTrigger 是进程启动后的第一轮思考(裁决 07.4.4):不等满一个心跳周期, +// 启动立即触发,并引导她以角色口吻产出一条拟人化启动问候(如「我回来啦」)。 +// Kind 仍为 TriggerHeartbeat:启动轮的推送与心跳走完全相同的门与限频 +// (PushGate / 打扰级联 / 活动门),进程反复重启也不会刷屏主人。 +func startupTrigger() Trigger { + return Trigger{Source: "startup", Summary: StartupSummary, Import: ImportanceLow, Kind: TriggerHeartbeat} +} + // triggerForEvent maps a bus event to a Trigger. An IoT device change is treated // as normal importance, a reminder firing as high importance, and a generic // system event as normal importance. Every event is tagged TriggerEvent so its