feat(backthink): 自主思考 P2——结构化推送决策+渠道选择(design/20)
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

- proactive.go(新): parseProactive 从结论解析 [我决定:push|no_push][渠道:...]<正文>;
  兼容大小写/全半角冒号; 无标记回退(无/nothing=不推); 畸形/孤标记 fail-safe 不推
- context.go: guidancePrompt 引导结构化结尾+保留P1温暖引导
- push.go: PushRequest{Message,ChannelHint}; Pusher.Push 路由(auto→active优先/IM兜底,
  web→仅active, im→仅IM, 具体平台→IM+platform打标+缺IM回退默认记detail); buildProactiveMessage
- backthink.go: think() 用 parseProactive→ShouldPush=push&&disturbAllowed(不再被tr.Import卡死)
- decide.go: disturbAllowed(asleep/working/quiet 硬性不打扰门) 取代旧import门(保留旧Decide兼容)
- 测试: parseProactive 6组+think端到端(真实pusher:kook→IM/无标记→active/no_push→不推/睡眠→门拦截)
   +buildProactiveMessage+具体平台无IM回退; 后端 build/vet/test 全绿
- 注: 生产 Active/IM sink 未注入 wire.go backthink.Deps(另一步); 多平台IM受单IMAdapter接口限制
This commit is contained in:
Aska committed 2026-09-07 19:34:51 +08:00
1 parent 93aff2fea5
commit 2283383b9e
8 files changed
+688 -47

No files matched your search

+145 -22
View File
@@ -4,6 +4,7 @@ import (
"context"
"fmt"
"log/slog"
"strings"
"time"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
@@ -25,6 +26,19 @@ const (
RouteIMBridge Route = "im_bridge"
)
// Channel-hint tokens recognized by the push router. They come from the
// conclusion's [渠道:...] marker (parseProactive) and select where a proactive
// message is delivered. An empty/unknown hint is treated as auto. A concrete
// platform id (kook/telegram/...) is a value other than these three.
const (
channelAuto = "auto"
channelIM = "im"
channelWeb = "web"
// defaultProactivePlatform is the Platform the outbound proactive message
// carries when the conclusion names no specific channel.
defaultProactivePlatform = "cyrene-backthink"
)
// String returns the route as a plain label.
func (r Route) String() string { return string(r) }
@@ -75,16 +89,30 @@ type PushGate interface {
Allow(ctx context.Context) bool
}
// PushRequest carries one proactive push attempt: the outbound message plus the
// channel target the autonomous conclusion asked for (design/20 §2 P2).
// ChannelHint is the lowercased [渠道:...] token: ""/auto keep the default
// active-client-first, IM-fallback route; "web" targets the active client; "im"
// targets the IM bridge; a concrete platform id (kook/telegram/...) targets the IM
// bridge with the message tagged for that platform.
type PushRequest struct {
// Message is the normalized outbound proactive message.
Message adapter.UnifiedMessage
// ChannelHint is the requested target channel, "" when she did not choose one.
ChannelHint string
}
// Pusher delivers a proactive message under the active-client-first, IM-fallback
// policy (design/12 §3), bounded by the rate gate.
// policy (design/12 §3), bounded by the rate gate, honoring the channel hint the
// autonomous conclusion selected.
type Pusher interface {
// Push delivers msg, preferring the active client and falling back to the IM
// bridge. It never blocks on a slow target.
Push(ctx context.Context, msg adapter.UnifiedMessage) PushResult
// Push delivers req preferring the active client and falling back to the IM
// bridge, per the channel hint. It never blocks on a slow target.
Push(ctx context.Context, req PushRequest) PushResult
}
// pusher is the concrete Pusher: it consults the gate, then tries the active
// client and falls back to IM.
// client and falls back to IM, honoring the requested channel hint.
type pusher struct {
active ActiveClient
im IMAdapter
@@ -99,8 +127,11 @@ func newPusher(active ActiveClient, im IMAdapter, gate PushGate, log *slog.Logge
return &pusher{active: active, im: im, gate: gate, log: log}
}
// Push implements Pusher.
func (p *pusher) Push(ctx context.Context, msg adapter.UnifiedMessage) PushResult {
// Push implements Pusher: it applies the rate gate, then resolves the channel
// hint to a route. A specific platform targets the IM bridge (tagged on the
// message) and falls back to the default route when no IM sink is wired, so the
// owner still receives the message rather than having it dropped.
func (p *pusher) Push(ctx context.Context, req PushRequest) PushResult {
if err := ctx.Err(); err != nil {
return PushResult{Pushed: false, Route: RouteNone, Err: err.Error()}
}
@@ -109,7 +140,36 @@ func (p *pusher) Push(ctx context.Context, msg adapter.UnifiedMessage) PushResul
return PushResult{Pushed: false, Route: RouteRateLimited, Detail: "proactive push rate limited"}
}
// Active client first.
hint := normalizeChannelHint(req.ChannelHint)
msg := req.Message
// A specific platform names the requested channel on the message so a
// platform-aware IM sink can honor it; auto/web/im keep the default platform.
if isSpecificChannel(hint) {
msg.Platform = hint
}
var res PushResult
switch hint {
case channelWeb:
res = p.pushActive(ctx, msg)
case channelIM:
res = p.pushIM(ctx, msg)
case channelAuto:
res = p.pushDefault(ctx, msg)
default:
// A specific platform targets the IM bridge for that channel. With no IM
// sink wired (the backthink IM seam is single-sink), fall back to the
// default route so the owner is still reached; the hint is recorded.
res = p.pushIM(ctx, msg)
if !res.Pushed {
res = p.pushDefault(ctx, msg)
}
}
return annotateHint(res, req.ChannelHint)
}
// pushDefault tries the active client first and falls back to the IM bridge.
func (p *pusher) pushDefault(ctx context.Context, msg adapter.UnifiedMessage) PushResult {
if p.active != nil {
n, err := p.active.SendProactive(ctx, msg)
if err != nil {
@@ -118,17 +178,74 @@ func (p *pusher) Push(ctx context.Context, msg adapter.UnifiedMessage) PushResul
return PushResult{Pushed: true, Route: RouteActiveClient, Detail: fmt.Sprintf("delivered to %d active client(s)", n)}
}
}
// IM fallback.
if p.im != nil {
if err := p.im.SendProactive(ctx, msg); err != nil {
p.log.Warn("backthink IM push failed", "error", err)
return PushResult{Pushed: false, Route: RouteIMBridge, Err: err.Error()}
}
return PushResult{Pushed: true, Route: RouteIMBridge, Detail: "delivered via IM bridge"}
if p.im == nil {
return PushResult{Pushed: false, Route: RouteNone, Detail: "no active client or IM target"}
}
return p.pushIM(ctx, msg)
}
return PushResult{Pushed: false, Route: RouteNone, Detail: "no active client or IM target"}
// pushActive delivers only to the active web/native client.
func (p *pusher) pushActive(ctx context.Context, msg adapter.UnifiedMessage) PushResult {
if p.active != nil {
n, err := p.active.SendProactive(ctx, msg)
if err != nil {
p.log.Warn("backthink active client push failed", "error", err)
} else if n > 0 {
return PushResult{Pushed: true, Route: RouteActiveClient, Detail: fmt.Sprintf("delivered to %d active client(s)", n)}
}
}
return PushResult{Pushed: false, Route: RouteNone, Detail: "no active client"}
}
// pushIM delivers only to the IM bridge.
func (p *pusher) pushIM(ctx context.Context, msg adapter.UnifiedMessage) PushResult {
if p.im == nil {
return PushResult{Pushed: false, Route: RouteNone, Detail: "no IM target"}
}
if err := p.im.SendProactive(ctx, msg); err != nil {
p.log.Warn("backthink IM push failed", "error", err)
return PushResult{Pushed: false, Route: RouteIMBridge, Err: err.Error()}
}
return PushResult{Pushed: true, Route: RouteIMBridge, Detail: "delivered via IM bridge"}
}
// normalizeChannelHint lowercases and trims a channel hint, mapping synonyms to
// the canonical route tokens (auto/im/web); anything else is a concrete platform
// id (kook/telegram/...). Unknown/blank maps to auto.
func normalizeChannelHint(hint string) string {
switch strings.ToLower(strings.TrimSpace(hint)) {
case "", "auto":
return channelAuto
case "im", "bridge":
return channelIM
case "web", "frontend", "active":
return channelWeb
default:
return strings.ToLower(strings.TrimSpace(hint))
}
}
// isSpecificChannel reports whether hint names a concrete platform rather than a
// routing keyword (auto/im/web).
func isSpecificChannel(hint string) bool {
return hint != channelAuto && hint != channelIM && hint != channelWeb
}
// annotateHint appends the requested channel hint to the push result detail so
// the record/panel surfaces which channel she chose, even when the route fell
// back. Blank/auto hints add nothing.
func annotateHint(res PushResult, hint string) PushResult {
h := strings.ToLower(strings.TrimSpace(hint))
if h == "" || h == channelAuto {
return res
}
tag := "channel_hint=" + h
if res.Detail == "" {
res.Detail = tag
} else {
res.Detail = res.Detail + ", " + tag
}
return res
}
// rateGate is the production PushGate: a sliding-window counter over
@@ -153,13 +270,19 @@ func (g *rateGate) Allow(ctx context.Context) bool {
}
// buildProactiveMessage assembles the normalized outbound message carrying a
// think conclusion. It is intentionally minimal: the concrete active-client and
// IM adapters apply their own routing/targeting to the owner.
func (b *Backthink) buildProactiveMessage(conclusion string) adapter.UnifiedMessage {
// think conclusion's clean body. It tags the platform with the requested channel
// when the conclusion names a concrete platform, otherwise the default backthink
// platform. The concrete active-client and IM adapters apply their own
// routing/targeting to the owner (red line: owner only).
func (b *Backthink) buildProactiveMessage(message, channelHint string) adapter.UnifiedMessage {
platform := defaultProactivePlatform
if h := normalizeChannelHint(channelHint); isSpecificChannel(h) {
platform = h
}
return adapter.UnifiedMessage{
Platform: "cyrene-backthink",
Platform: platform,
ChannelType: adapter.ChannelDirect,
Content: conclusion,
Content: message,
ContentType: adapter.ContentText,
Sender: adapter.Actor{Role: adapter.RoleSystem, DisplayName: "Cyrene"},
Timestamp: b.now(),