主动推送目标不再取第一个白名单渠道(kook),也不再双目标 web+kook;改为每次只发一个平台,范围=全部已连接白名单渠道(含微信 weixin-ilink)。
backthink/push.go: pusher 单目标路由——web hint 一次 pushActive;im/auto/实体平台 hint 一次性 pushIM (msgs 置哨兵/实体平台),无 IM sink 时诚实降级 pushActive;保留 PushGate 降频与成功 RecordPush。
channelbridge/proactive.go: proactiveTarget 三级选择——①决策 [渠道:xxx] 实体平台且白名单+已连接优先;②最近活跃(主会话最近20条 newest-first 找白名单+已连接平台,web 会话自动跳过);③回退 NextConnectedAccount 用旋转游标避免永远 kook。微信不再排除、可被选中;决策 hint 指定非白名单渠道回退(不报错)。Bridge 新增 proactiveCursor 字段(mu 保护)。
context.go: guidancePrompt 描述改为每次挑一个合适渠道(含微信)。
测试: 决策优先/最近活跃/微信可选中/无信号回退旋转/单目标 等,go build/vet/test 全绿 + windows/darwin 交叉编译。stash@{0} 保留被取代的'web+kook双目标'WIP以备回滚。
367 lines
12 KiB
Go
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()
|
|
// Tolerate a full-width colon and trailing spaces, and uppercase channel value.
|
|
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()
|
|
// A lone push marker with no body must not push an empty message (fail safe).
|
|
if _, _, push := parseProactive("[我决定:push]"); push {
|
|
t.Fatalf("push = true for a marker with no body, want false")
|
|
}
|
|
// A push marker whose body says "nothing" is contradictory → fail safe.
|
|
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())
|
|
|
|
// The channel hint "kook" routes to the IM bridge (a specific platform), not the
|
|
// active client.
|
|
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()
|
|
// No channel marker → auto → a single IM channel (the bridge picks her choice /
|
|
// her recent platform / the whitelist default), NOT the active web client.
|
|
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()
|
|
// She wants to push, but the user is asleep → the hard safety gate refuses,
|
|
// regardless of her decision.
|
|
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()
|
|
// When a specific platform is requested but no IM sink is wired, the pusher
|
|
// falls back to the default route so the owner still receives the message, and
|
|
// records the requested hint.
|
|
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 verifies the internal decision/channel
|
|
// markers are removed from a proactive body in every shape a model may emit
|
|
// (bracketed, full-width, bare), so a marker can never reach the user-facing text.
|
|
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 is the regression for the KOOK leak: a
|
|
// conclusion carrying bare (bracket-less) "我决定:push"/"渠道:..." markers must
|
|
// still deliver a clean warm body, never the structured decision text.
|
|
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)
|
|
}
|
|
}
|