- 只翻注释/文档字符串:`go/scanner` 剥注释后按 token 序列+literal 逐 token 比对,**54/54 文件 SAME**;
改动文件总行数与 HEAD 漂移 = 0(严格 1:1,无合并/拆行)
- 裁决编号与 design 节号逐字保留(03.4.1/03.4.2/03.4.3/14.4.1/14.4.5/07.4.2-4、design/04 §3.1/§3.2 等);
`chat_template_kwargs` / `enable_thinking` / `reasoning_effort` / `thinking:{type}` 字段名与形状说明未动
- 存疑译法(已登记):`persona` 在叙事语境用「角色」、标识符语境保留 `persona`(仓库既有模块本身混用);
`rung`→「级」;`provider` 存在「提供商/提供者」两种落笔(可按需一键统一)
- 复核:build/vet 绿 + 两包测试绿;gofmt 干净;残留英文注释 0 条
- 既有存量(未修,只报告):`model/anthropic_test.go` 612 行 >500;`applyAnthropicEvent` 115 行 >80
428 lines
13 KiB
Go
428 lines
13 KiB
Go
package backthink
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/bus"
|
||
"git.yeij.top/AskaEth/Cyrene_v2/internal/iot"
|
||
)
|
||
|
||
// newBackthink 用一份新的内存 DB 与给定 deps 构建 Backthink,
|
||
// 并返回填好 DB 句柄的 deps,好让测试能检查 deps。
|
||
func newBackthink(t *testing.T, cfg Config, deps Deps) (*Backthink, Deps) {
|
||
t.Helper()
|
||
deps.DB = newTestDB(t)
|
||
b, err := New(cfg, deps)
|
||
if err != nil {
|
||
t.Fatalf("New: %v", err)
|
||
}
|
||
t.Cleanup(func() { _ = b.Stop(context.Background()) })
|
||
return b, deps
|
||
}
|
||
|
||
func TestNew_Validation(t *testing.T) {
|
||
t.Parallel()
|
||
if _, err := New(Config{}, Deps{DB: newTestDB(t)}); !errors.Is(err, ErrNoThinker) {
|
||
t.Fatalf("missing thinker: err = %v, want ErrNoThinker", err)
|
||
}
|
||
if _, err := New(Config{}, Deps{Thinker: &mockThinker{}}); !errors.Is(err, ErrNilDB) {
|
||
t.Fatalf("missing db: err = %v, want ErrNilDB", err)
|
||
}
|
||
}
|
||
|
||
// TestConfig_Defaults 验证收紧后的主动推送默认值:15 分钟
|
||
// 心跳、每 60 分钟最多 1 次推送、推送后 30 分钟冷却、
|
||
// 最近对话 10 分钟冷却,以及按风格 2 小时的窗口。
|
||
func TestConfig_Defaults(t *testing.T) {
|
||
t.Parallel()
|
||
cfg := normalize(Config{})
|
||
if cfg.IntervalSec != 900 {
|
||
t.Fatalf("IntervalSec = %d, want 900", cfg.IntervalSec)
|
||
}
|
||
if cfg.Interval != 900*time.Second {
|
||
t.Fatalf("Interval = %v, want 900s", cfg.Interval)
|
||
}
|
||
if cfg.PushLimit != 1 {
|
||
t.Fatalf("PushLimit = %d, want 1", cfg.PushLimit)
|
||
}
|
||
if cfg.PushWindow != 60*time.Minute {
|
||
t.Fatalf("PushWindow = %v, want 60m", cfg.PushWindow)
|
||
}
|
||
if cfg.PushCooldown != 30*time.Minute {
|
||
t.Fatalf("PushCooldown = %v, want 30m", cfg.PushCooldown)
|
||
}
|
||
if cfg.RecentChatCooldown != 10*time.Minute {
|
||
t.Fatalf("RecentChatCooldown = %v, want 10m", cfg.RecentChatCooldown)
|
||
}
|
||
if cfg.KindWindow != 2*time.Hour {
|
||
t.Fatalf("KindWindow = %v, want 2h", cfg.KindWindow)
|
||
}
|
||
}
|
||
|
||
func TestBackthink_Heartbeat_TriggersThink(t *testing.T) {
|
||
t.Parallel()
|
||
thinker := &mockThinker{text: "nothing to report"}
|
||
b, _ := newBackthink(t, Config{Interval: 20 * time.Millisecond}, Deps{Thinker: thinker})
|
||
|
||
b.Start(context.Background())
|
||
defer func() { _ = b.Stop(context.Background()) }()
|
||
|
||
// 启动轮(07.4.4)会先落一条 startup 记录,这里等真正的周期心跳记录。
|
||
eventually(t, 2*time.Second, func() bool {
|
||
recs, _ := b.List(context.Background(), 10)
|
||
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)
|
||
}
|
||
for _, r := range recs {
|
||
if r.Trigger == "heartbeat" {
|
||
found = true
|
||
}
|
||
}
|
||
if !found {
|
||
t.Fatalf("no heartbeat record in %d records", len(recs))
|
||
}
|
||
}
|
||
|
||
func TestBackthink_EventTrigger_Push(t *testing.T) {
|
||
t.Parallel()
|
||
thinker := &mockThinker{text: "washing finished, clothes ready"}
|
||
pusher := &mockPusher{result: PushResult{Pushed: true, Route: RouteActiveClient}}
|
||
bs := bus.New()
|
||
b, _ := newBackthink(t, Config{}, Deps{
|
||
Bus: bs,
|
||
Thinker: thinker,
|
||
State: &mockState{state: UserState{}},
|
||
Pusher: pusher,
|
||
Now: func() time.Time { return dayAt() },
|
||
})
|
||
|
||
b.Start(context.Background())
|
||
defer func() { _ = b.Stop(context.Background()) }()
|
||
|
||
if err := bs.Publish(context.Background(), SubjectReminderTrigger, "reminder due"); err != nil {
|
||
t.Fatalf("Publish: %v", err)
|
||
}
|
||
|
||
// 启动轮(07.4.4)同样会思考并推送,因此按触发源过滤出事件记录再断言。
|
||
var rec Record
|
||
eventually(t, 2*time.Second, func() bool {
|
||
recs, _ := b.List(context.Background(), 10)
|
||
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 at least 1", got)
|
||
}
|
||
if got := pusher.count(); got < 1 {
|
||
t.Fatalf("pusher calls = %d, want at least 1 (important + daytime)", got)
|
||
}
|
||
if !rec.ShouldPush {
|
||
t.Fatalf("ShouldPush = false, want true")
|
||
}
|
||
if !rec.PushResult.Pushed || rec.PushResult.Route != RouteActiveClient {
|
||
t.Fatalf("PushResult = %+v, want pushed active_client", rec.PushResult)
|
||
}
|
||
}
|
||
|
||
func TestBackthink_EventTrigger_NoPush(t *testing.T) {
|
||
t.Parallel()
|
||
thinker := &mockThinker{text: "washing finished, clothes ready"}
|
||
pusher := &mockPusher{result: PushResult{Pushed: true, Route: RouteActiveClient}}
|
||
bs := bus.New()
|
||
b, _ := newBackthink(t, Config{}, Deps{
|
||
Bus: bs,
|
||
Thinker: thinker,
|
||
State: &mockState{state: UserState{Sleeping: true}},
|
||
Pusher: pusher,
|
||
Now: func() time.Time { return nightAt() },
|
||
})
|
||
|
||
b.Start(context.Background())
|
||
defer func() { _ = b.Stop(context.Background()) }()
|
||
|
||
if err := bs.Publish(context.Background(), iot.DeviceUpdateSubject, iot.DeviceUpdate{
|
||
Device: iot.Device{ID: "washer", Type: "sensor"},
|
||
Action: "finished",
|
||
}); err != nil {
|
||
t.Fatalf("Publish: %v", err)
|
||
}
|
||
|
||
var rec Record
|
||
eventually(t, 2*time.Second, func() bool {
|
||
recs, _ := b.List(context.Background(), 10)
|
||
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)
|
||
}
|
||
if rec.ShouldPush {
|
||
t.Fatalf("ShouldPush = true, want false")
|
||
}
|
||
if rec.PushResult.Route != RouteNone {
|
||
t.Fatalf("PushResult.Route = %q, want %q", rec.PushResult.Route, RouteNone)
|
||
}
|
||
}
|
||
|
||
func TestBackthink_SystemEventLowValueDowngraded(t *testing.T) {
|
||
t.Parallel()
|
||
// design/12 §6 策略一:公开平台上的低价值系统事件不会
|
||
// 唤醒一整轮思考——它被降级为仅登记一条记录。
|
||
thinker := &mockThinker{text: "should not be called"}
|
||
bs := bus.New()
|
||
b, _ := newBackthink(t, Config{}, Deps{
|
||
Bus: bs,
|
||
Thinker: thinker,
|
||
Now: func() time.Time { return dayAt() },
|
||
})
|
||
|
||
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)
|
||
for _, r := range recs {
|
||
if r.Trigger == SubjectSystemEvent {
|
||
rec = r
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
})
|
||
if got := thinker.count(); got != before {
|
||
t.Fatalf("thinker calls = %d, want %d (low-value system event downgraded)", got, before)
|
||
}
|
||
if rec.ShouldPush {
|
||
t.Fatalf("ShouldPush = true for a downgraded system event")
|
||
}
|
||
if rec.PushResult.Route != RouteNone {
|
||
t.Fatalf("PushResult.Route = %q, want %q", rec.PushResult.Route, RouteNone)
|
||
}
|
||
}
|
||
|
||
func TestBackthink_SystemEventHighValueThinks(t *testing.T) {
|
||
t.Parallel()
|
||
// 高重要度(与主人相关)的系统事件仍会唤醒一轮思考——
|
||
// 降噪门只降级低价值噪声(design/12 §6 策略一)。
|
||
thinker := &mockThinker{text: "important notice"}
|
||
bs := bus.New()
|
||
b, _ := newBackthink(t, Config{}, Deps{
|
||
Bus: bs,
|
||
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"
|
||
})
|
||
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() >= before+1 })
|
||
}
|
||
|
||
func TestBackthink_List_NewestFirst(t *testing.T) {
|
||
t.Parallel()
|
||
thinker := &mockThinker{text: "nothing to report"}
|
||
b, _ := newBackthink(t, Config{Interval: 15 * time.Millisecond}, Deps{Thinker: thinker})
|
||
|
||
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) >= 2
|
||
})
|
||
top, err := b.List(context.Background(), 1)
|
||
if err != nil {
|
||
t.Fatalf("List: %v", err)
|
||
}
|
||
if len(top) != 1 {
|
||
t.Fatalf("len = %d, want 1", len(top))
|
||
}
|
||
all, err := b.List(context.Background(), 10)
|
||
if err != nil {
|
||
t.Fatalf("List: %v", err)
|
||
}
|
||
// 单行列表里的那一条必须与完整列表的头部是同一条最新
|
||
// 记录,且完整列表必须是严格的由新到旧。
|
||
if top[0].ID != all[0].ID {
|
||
t.Fatalf("List(1)[0].ID = %d, want %d (== newest)", top[0].ID, all[0].ID)
|
||
}
|
||
for i := 1; i < len(all); i++ {
|
||
if all[i].ID >= all[i-1].ID {
|
||
t.Fatalf("records not newest-first: %d before %d", all[i-1].ID, all[i].ID)
|
||
}
|
||
}
|
||
}
|
||
|
||
func TestBackthink_Stop_NoLeak(t *testing.T) {
|
||
t.Parallel()
|
||
thinker := &mockThinker{text: "nothing to report"}
|
||
b, _ := newBackthink(t, Config{Interval: 10 * time.Millisecond}, Deps{Thinker: thinker})
|
||
|
||
b.Start(context.Background())
|
||
eventually(t, 2*time.Second, func() bool { return thinker.count() >= 1 })
|
||
|
||
if err := b.Stop(context.Background()); err != nil {
|
||
t.Fatalf("Stop: %v", err)
|
||
}
|
||
// 幂等。
|
||
if err := b.Stop(context.Background()); err != nil {
|
||
t.Fatalf("second Stop: %v", err)
|
||
}
|
||
|
||
before := thinker.count()
|
||
time.Sleep(40 * time.Millisecond)
|
||
if got := thinker.count(); got != before {
|
||
t.Fatalf("thinker calls grew after stop: %d -> %d", before, got)
|
||
}
|
||
}
|
||
|
||
func TestBackthink_Stop_UnsubscribesBus(t *testing.T) {
|
||
t.Parallel()
|
||
thinker := &mockThinker{text: "nothing to report"}
|
||
bs := bus.New()
|
||
b, _ := newBackthink(t, Config{Interval: 25 * time.Millisecond}, Deps{Bus: bs, Thinker: thinker})
|
||
|
||
b.Start(context.Background())
|
||
// 通过触发一次思考来确认订阅是活的(以它持久化的
|
||
// 记录为准,这样 07.4.4 的启动轮与心跳 tick 各自的
|
||
// 触发字符串不会掩盖或覆盖它)。
|
||
if err := bs.Publish(context.Background(), SubjectReminderTrigger, "first"); err != nil {
|
||
t.Fatalf("Publish: %v", err)
|
||
}
|
||
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)
|
||
}
|
||
|
||
before := thinker.count()
|
||
if err := bs.Publish(context.Background(), iot.DeviceUpdateSubject, iot.DeviceUpdate{
|
||
Device: iot.Device{ID: "washer", Type: "sensor"}, Action: "finished",
|
||
}); err != nil {
|
||
t.Fatalf("Publish after stop: %v", err)
|
||
}
|
||
time.Sleep(40 * time.Millisecond)
|
||
if got := thinker.count(); got != before {
|
||
t.Fatalf("thinker calls grew after stop (bus not unsubscribed): %d -> %d", before, got)
|
||
}
|
||
}
|
||
|
||
// TestBackthink_EventPush_DedupSameEvent 验证同一事件去重门:
|
||
// 重复的相同事件(同源 + 同摘要)在首次推送后
|
||
// 被抑制(防刷),而真正不同的事件仍会发出。重复的那条
|
||
// 被记为已抑制(route none),而不是投递出去。
|
||
func TestBackthink_EventPush_DedupSameEvent(t *testing.T) {
|
||
t.Parallel()
|
||
thinker := &mockThinker{text: "washing finished, clothes ready"}
|
||
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 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) // 第一次:推送
|
||
b.think(context.Background(), ev) // 第二次:同一事件 -> 被抑制
|
||
b.think(context.Background(), ev) // 第三次:同一事件 -> 被抑制
|
||
|
||
if got := pusher.count(); got != 1 {
|
||
t.Fatalf("pusher calls = %d, want 1 (repeating same event deduped)", got)
|
||
}
|
||
recs, err := b.List(context.Background(), 10)
|
||
if err != nil {
|
||
t.Fatalf("List: %v", err)
|
||
}
|
||
if len(recs) < 3 {
|
||
t.Fatalf("len(recs) = %d, want at least 3 (one per think)", len(recs))
|
||
}
|
||
// 最新的在前:最近的这两条就是被抑制的重复。
|
||
for i := 0; i < 2; i++ {
|
||
if recs[i].PushResult.Pushed || recs[i].PushResult.Route != RouteNone {
|
||
t.Fatalf("recs[%d] = %+v, want suppressed duplicate", i, recs[i].PushResult)
|
||
}
|
||
if !strings.Contains(recs[i].PushResult.Detail, "duplicate") {
|
||
t.Fatalf("recs[%d].Detail = %q, want it to mention duplicate", i, recs[i].PushResult.Detail)
|
||
}
|
||
}
|
||
// 第三新的那条是首次(已投递的)推送。
|
||
if !recs[2].PushResult.Pushed || recs[2].PushResult.Route != RouteActiveClient {
|
||
t.Fatalf("recs[2] = %+v, want the first delivered push", recs[2].PushResult)
|
||
}
|
||
|
||
// 真正不同的事件会重置该链并发出。
|
||
ev2 := triggerForEvent(bus.Event{Subject: iot.DeviceUpdateSubject, Payload: iot.DeviceUpdate{
|
||
Device: iot.Device{ID: "dryer", Type: "sensor"}, Action: "finished",
|
||
}})
|
||
b.think(context.Background(), ev2)
|
||
if got := pusher.count(); got != 2 {
|
||
t.Fatalf("pusher calls after a different event = %d, want 2", got)
|
||
}
|
||
// 在出现不同事件之后,原先那个相同事件不再是
|
||
// 连续重复,因此它会再次推送——该门只去重连续重复。
|
||
b.think(context.Background(), ev)
|
||
if got := pusher.count(); got != 3 {
|
||
t.Fatalf("pusher calls after an intervening different event = %d, want 3", got)
|
||
}
|
||
}
|