执行方案阶段 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 未验。
184 lines
7.0 KiB
Go
184 lines
7.0 KiB
Go
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)
|
||
}
|
||
}
|