Files
Cyrene_v2/internal/services/backthink/proactive_test.go
T
AskaEth 3dc1232bc2
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(comments): 注释中文化 · internal/runtime/model + internal/services/backthink(54 文件,纯替换)
- 只翻注释/文档字符串:`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
2026-10-04 16:27:54 +08:00

367 lines
12 KiB
Go

package backthink
import (
"context"
"strings"
"testing"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
)
func TestParseProactive_WithMarkers(t *testing.T) {
t.Parallel()
conclusion := "[我决定:push]\n[渠道:kook]\n我想提醒你明天有体检"
msg, hint, push := parseProactive(conclusion)
if !push {
t.Fatalf("push = false, want true (decision said push)")
}
if hint != "kook" {
t.Fatalf("channel hint = %q, want kook", hint)
}
if msg != "我想提醒你明天有体检" {
t.Fatalf("message = %q, want the clean body", msg)
}
}
func TestParseProactive_NoPushMarker(t *testing.T) {
t.Parallel()
conclusion := "[我决定:no_push]\n[渠道:telegram]\n暂时没有想说的"
msg, hint, push := parseProactive(conclusion)
if push {
t.Fatalf("push = true, want false (decision said no_push)")
}
if hint != "telegram" {
t.Fatalf("channel hint = %q, want telegram", hint)
}
if msg != "暂时没有想说的" {
t.Fatalf("message = %q, want the clean body", msg)
}
}
func TestParseProactive_ChannelColonWidth(t *testing.T) {
t.Parallel()
// 容忍全角冒号与尾随空格,以及大写渠道值。
conclusion := "[我决定:push] [渠道:WEB] 晚上早点睡"
msg, hint, push := parseProactive(conclusion)
if !push {
t.Fatalf("push = false, want true")
}
if hint != "web" {
t.Fatalf("channel hint = %q, want web (lowercased)", hint)
}
if msg != "晚上早点睡" {
t.Fatalf("message = %q, want the clean body", msg)
}
}
func TestParseProactive_NothingVariants(t *testing.T) {
t.Parallel()
for _, c := range []string{
"无",
"无。",
"nothing to report",
"Nothing to report.",
"nothing",
"暂无",
} {
if _, _, push := parseProactive(c); push {
t.Fatalf("parseProactive(%q) push = true, want false", c)
}
}
}
func TestParseProactive_NoMarkerDefaultPush(t *testing.T) {
t.Parallel()
conclusion := "洗衣机洗好了,衣服可以收了"
msg, hint, push := parseProactive(conclusion)
if !push {
t.Fatalf("push = false, want true (no marker, not a nothing phrase)")
}
if hint != "" {
t.Fatalf("channel hint = %q, want empty", hint)
}
if msg != conclusion {
t.Fatalf("message = %q, want the whole conclusion", msg)
}
}
func TestParseProactive_MalformedFailsSafe(t *testing.T) {
t.Parallel()
cases := []string{
"[我决定:maybe]", // decision value outside allow-list
"[我决定]", // bracket without a value
"[我决定:push", // unterminated bracket
"[渠道:web]", // channel marker alone, no decision → but no decision marker
"[我决定:push", // deliberate ambiguity
}
for _, c := range cases {
if _, _, push := parseProactive(c); push {
t.Fatalf("parseProactive(%q) push = true, want false (fail safe on ambiguity)", c)
}
}
}
func TestParseProactive_MarkerWithoutBody(t *testing.T) {
t.Parallel()
// 单独一个 push 标记、正文为空时绝不能推出空消息(失败安全)。
if _, _, push := parseProactive("[我决定:push]"); push {
t.Fatalf("push = true for a marker with no body, want false")
}
// push 标记的正文写着「无」是自相矛盾的 → 失败安全。
if _, _, push := parseProactive("[我决定:push]\n无"); push {
t.Fatalf("push = true for a push marker with nothing body, want false")
}
}
func TestThink_ParsesStructuredDecision_EndToEnd(t *testing.T) {
t.Parallel()
conclusion := "[我决定:push]\n[渠道:kook]\n我想提醒你明天有体检"
thinker := &mockThinker{text: conclusion}
active := &mockActive{n: 1}
im := &mockIMAdapter{}
b, _ := newBackthink(t, Config{}, Deps{
Thinker: thinker,
State: &mockState{state: UserState{}},
Active: active,
IM: im,
Gate: &mockGate{allow: true},
Now: func() time.Time { return dayAt() },
})
b.think(context.Background(), heartbeatTrigger())
// 渠道提示 "kook" 路由到 IM 桥(一个具体平台),而不是
// 活跃客户端。
if active.calls != 0 {
t.Fatalf("active calls = %d, want 0 (specific platform routes to IM)", active.calls)
}
if im.count() != 1 {
t.Fatalf("im calls = %d, want 1", im.count())
}
if im.last.Content != "我想提醒你明天有体检" {
t.Fatalf("im message content = %q, want the clean body", im.last.Content)
}
if im.last.Platform != "kook" {
t.Fatalf("im message platform = %q, want kook", im.last.Platform)
}
recs, err := b.List(context.Background(), 10)
if err != nil {
t.Fatalf("List: %v", err)
}
if len(recs) == 0 {
t.Fatal("no record persisted")
}
if !recs[0].ShouldPush {
t.Fatalf("ShouldPush = false, want true")
}
if recs[0].Conclusion != "我想提醒你明天有体检" {
t.Fatalf("record Conclusion = %q, want the clean body", recs[0].Conclusion)
}
if recs[0].PushResult.Route != RouteIMBridge {
t.Fatalf("record Route = %q, want %q", recs[0].PushResult.Route, RouteIMBridge)
}
if !strings.Contains(recs[0].PushResult.Detail, "channel_hint=kook") {
t.Fatalf("record Detail = %q, want it to include the kook hint", recs[0].PushResult.Detail)
}
if !recs[0].PushResult.Pushed {
t.Fatalf("record Pushed = false, want true")
}
}
func TestThink_NoMarkerDefaultRoute_IM(t *testing.T) {
t.Parallel()
// 无渠道标记 → auto → 单个 IM 渠道(由桥挑出她的选择 /
// 她最近的平台 / 白名单默认),而不是活跃 Web 客户端。
conclusion := "[我决定:push]\n洗衣机洗好了,衣服可以收了"
thinker := &mockThinker{text: conclusion}
active := &mockActive{n: 2}
im := &mockIMAdapter{}
b, _ := newBackthink(t, Config{}, Deps{
Thinker: thinker,
State: &mockState{state: UserState{}},
Active: active,
IM: im,
Gate: &mockGate{allow: true},
Now: func() time.Time { return dayAt() },
})
b.think(context.Background(), heartbeatTrigger())
if active.calls != 0 {
t.Fatalf("active calls = %d, want 0 (auto targets the IM bridge, not the web client)", active.calls)
}
if im.count() != 1 {
t.Fatalf("im calls = %d, want 1 (single IM target)", im.count())
}
recs, err := b.List(context.Background(), 10)
if err != nil {
t.Fatalf("List: %v", err)
}
if recs[0].PushResult.Route != RouteIMBridge {
t.Fatalf("record Route = %q, want %q", recs[0].PushResult.Route, RouteIMBridge)
}
if recs[0].PushResult.Pushed != true {
t.Fatalf("record Pushed = %v, want true", recs[0].PushResult.Pushed)
}
}
func TestThink_ConcludedNoPush_NotPushed(t *testing.T) {
t.Parallel()
conclusion := "[我决定:no_push]\n今晚就不打扰她了"
thinker := &mockThinker{text: conclusion}
active := &mockActive{n: 1}
im := &mockIMAdapter{}
b, _ := newBackthink(t, Config{}, Deps{
Thinker: thinker,
State: &mockState{state: UserState{}},
Active: active,
IM: im,
Gate: &mockGate{allow: true},
Now: func() time.Time { return dayAt() },
})
b.think(context.Background(), heartbeatTrigger())
if active.calls != 0 || im.count() != 0 {
t.Fatalf("push attempted despite no_push: active=%d im=%d", active.calls, im.count())
}
recs, err := b.List(context.Background(), 10)
if err != nil {
t.Fatalf("List: %v", err)
}
if recs[0].ShouldPush {
t.Fatalf("ShouldPush = true, want false")
}
if recs[0].PushResult.Route != RouteNone {
t.Fatalf("record Route = %q, want %q", recs[0].PushResult.Route, RouteNone)
}
}
func TestThink_DoNotDisturbBlocksPush(t *testing.T) {
t.Parallel()
// 她想推送,但用户正在睡觉 → 硬安全门拒绝,
// 与她的决策无关。
conclusion := "[我决定:push]\n想跟你说晚安"
thinker := &mockThinker{text: conclusion}
b, _ := newBackthink(t, Config{}, Deps{
Thinker: thinker,
State: &mockState{state: UserState{Sleeping: true}},
Pusher: &mockPusher{result: PushResult{Pushed: true, Route: RouteActiveClient}},
Now: func() time.Time { return nightAt() },
})
b.think(context.Background(), heartbeatTrigger())
recs, err := b.List(context.Background(), 10)
if err != nil {
t.Fatalf("List: %v", err)
}
if recs[0].ShouldPush {
t.Fatalf("ShouldPush = true, want false (asleep blocks push)")
}
if recs[0].PushResult.Route != RouteNone {
t.Fatalf("record Route = %q, want %q", recs[0].PushResult.Route, RouteNone)
}
}
func TestBuildProactiveMessage_DefaultPlatform(t *testing.T) {
t.Parallel()
b := &Backthink{now: dayAt}
msg := b.buildProactiveMessage("晚安", "")
if msg.Platform != defaultProactivePlatform {
t.Fatalf("Platform = %q, want default %q", msg.Platform, defaultProactivePlatform)
}
if msg.Content != "晚安" {
t.Fatalf("Content = %q, want 晚安", msg.Content)
}
if msg.ChannelType != adapter.ChannelDirect || msg.ContentType != adapter.ContentText {
t.Fatalf("message kind = %q/%q, want direct/text", msg.ChannelType, msg.ContentType)
}
}
func TestBuildProactiveMessage_SpecificPlatform(t *testing.T) {
t.Parallel()
b := &Backthink{now: dayAt}
msg := b.buildProactiveMessage("晚安", "telegram")
if msg.Platform != "telegram" {
t.Fatalf("Platform = %q, want telegram", msg.Platform)
}
}
func TestPusher_SpecificPlatform_NoIM_FallsBackToActive(t *testing.T) {
t.Parallel()
// 请求了具体平台但未接线 IM 槽时,推送器
// 降级到默认路由,好让主人仍能收到消息,并
// 记录下请求的提示。
active := &mockActive{n: 1}
p := newPusher(active, nil, &mockGate{allow: true}, discardLog())
res := p.Push(context.Background(), PushRequest{
Message: adapter.UnifiedMessage{},
ChannelHint: "kook",
})
if !res.Pushed || res.Route != RouteActiveClient {
t.Fatalf("push = %+v, want pushed active_client (fallback)", res)
}
if !strings.Contains(res.Detail, "channel_hint=kook") {
t.Fatalf("Detail = %q, want it to include the kook hint", res.Detail)
}
}
// TestStripInternalMarkers_CleanBody 验证内部决策/渠道
// 标记在模型可能发出的每种形状下(带括号、全角、裸写)
// 都被从主动正文中移除,因此标记永远无法到达面向用户的文本。
func TestStripInternalMarkers_CleanBody(t *testing.T) {
t.Parallel()
cases := []struct {
name, in, want string
}{
{name: "bracketed own lines", in: "[我决定:push]\n[渠道:kook]\n我想提醒你明天有体检", want: "我想提醒你明天有体检"},
{name: "inline bracketed", in: "[我决定:push] [渠道:WEB] 晚上早点睡", want: "晚上早点睡"},
{name: "bare markers", in: "我决定:push\n渠道:kook\n记得喝水哦", want: "记得喝水哦"},
{name: "full-width brackets", in: "【我决定:push】【渠道:telegram】早点休息", want: "早点休息"},
{name: "marker trailing body", in: "今天风很大[我决定:push][渠道:kook]", want: "今天风很大"},
{name: "no marker", in: "洗衣机洗好了,衣服可以收了", want: "洗衣机洗好了,衣服可以收了"},
{name: "empty", in: "", want: ""},
}
for _, tc := range cases {
tc := tc
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
if got := stripInternalMarkers(tc.in); got != tc.want {
t.Fatalf("stripInternalMarkers(%q) = %q, want %q", tc.in, got, tc.want)
}
})
}
}
// TestThink_BareMarkersStrippedFromBody 是 KOOK 泄漏的回归用例:
// 携带裸写(无括号)"我决定:push"/"渠道:..." 标记的结论仍须
// 投递干净的温暖正文,绝不是结构化的决策文本。
func TestThink_BareMarkersStrippedFromBody(t *testing.T) {
t.Parallel()
conclusion := "我决定:push\n渠道:kook\n记得喝水哦"
thinker := &mockThinker{text: conclusion}
im := &mockIMAdapter{}
b, _ := newBackthink(t, Config{}, Deps{
Thinker: thinker,
State: &mockState{state: UserState{}},
IM: im,
Gate: &mockGate{allow: true},
Now: func() time.Time { return dayAt() },
})
b.think(context.Background(), heartbeatTrigger())
if im.count() != 1 {
t.Fatalf("im calls = %d, want 1", im.count())
}
if im.last.Content != "记得喝水哦" {
t.Fatalf("im content = %q, want the clean body", im.last.Content)
}
if strings.Contains(im.last.Content, "我决定") || strings.Contains(im.last.Content, "渠道") {
t.Fatalf("internal marker leaked into the message body: %q", im.last.Content)
}
}