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

184 lines
7.0 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 (
"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)
}
}