Files
Cyrene_v2/internal/services/channelbridge/flowgate.go
T
AskaEth 093f38d986
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled
docs(comments): 注释中文化 · internal/services/channelbridge(纯替换)
- 只翻注释/文档字符串:剥掉注释后代码行逐字节不变(机械校验只剩允许的行尾注释形态)
- 术语对齐既有译法(owner→主人、persona→角色、engage→参与度判定、quiet hours→安静时段、backoff→退避)
- 复核:go build 绿 + channelbridge 包测试绿;gofmt 干净
2026-10-04 13:33:06 +08:00

181 lines
5.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package channelbridge
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"sort"
"strings"
"git.yeij.top/AskaEth/Cyrene_v2/internal/channel/adapter"
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
)
// gateFor 返回适配器的按账号流控闸门,首次使用时依据适配器的
// Config 快照惰性构建。闸门以适配器名称为键,使每个账号
// (platform:confId)保有自己、可跨适配器重连存活的闸门;
// **闸门内部的节流计数器按会话隔离**
// (adapter.ConversationKey:platform+channelID+groupID/peerID),因此两个群绝不会
// 相互挤占(design/12 §6 策略三/四,裁决 06.4.3)。
func (b *Bridge) gateFor(a adapter.PlatformAdapter) *adapter.FlowGate {
name := a.Name()
b.gateMu.Lock()
defer b.gateMu.Unlock()
if g, ok := b.gates[name]; ok {
return g
}
g := adapter.NewFlowGate(b.gateOptions(a))
b.gates[name] = g
return g
}
// gateOptions 把适配器 Config 快照中的按账号流控限制解析进
// FlowGate。键缺失(单账号环境变量适配器)时限制关闭,
// 既有渠道行为不变;键存在且值为 0 时采用保护性默认值
// (每群每分钟 6 条、上限 50),负值则关闭限制——
// 这是对 CHANNELS_JSON 账号的向后兼容约定(design/12 §6 实现要点)。
func (b *Bridge) gateOptions(a adapter.PlatformAdapter) adapter.FlowGateOptions {
opts := adapter.FlowGateOptions{
Review: b.review,
OnPruned: b.onRejected,
Now: b.now,
}
cfg := a.Config()
if grp, ok := cfgInt(cfg, "groupMsgPerMin"); ok {
opts.GroupMsgPerMin = flowOrDefault(grp, adapter.FlowLimitDefault)
}
if tot, ok := cfgInt(cfg, "msgPerMin"); ok && tot > 0 {
opts.MsgPerMin = tot
}
if max, ok := cfgInt(cfg, "maxMessages"); ok {
opts.MaxMessages = flowOrDefault(max, adapter.FlowCapDefault)
}
return opts
}
// flowOrDefault 把配置的限制值映射为实际生效的限制:负值
// 关闭限制(0),零采用保护性默认值,正值则
// 固定为该值。这就是「没有该字段的旧账号保持原有行为或
// 取安全默认值」的约定。
func flowOrDefault(v, def int) int {
switch {
case v < 0:
return 0
case v == 0:
return def
default:
return v
}
}
// cfgInt 从适配器配置 map 读取整数字段,键缺失或类型不符时
// 返回 (0,false)(因此缺失的字段保持「关闭」)。
func cfgInt(m map[string]any, key string) (int, bool) {
if m == nil {
return 0, false
}
switch v := m[key].(type) {
case int:
return v, true
case int64:
return int(v), true
case float64:
return int(v), true
default:
return 0, false
}
}
const (
// reviewMaxTokens 限定审查模型的输出,使冗长的队列清单绝不会
// 产出超长补全(design/12 §6 策略四 成本护栏)。
reviewMaxTokens = 256
// reviewSnippetLen 是展示给审查者的每条消息内容字节数,
// 足以判断重要性而不泄露整个队列。
reviewSnippetLen = 80
// reviewPrompt 指示审查模型挑选哪些队列消息重要。
// 它只判断噪声/重要性——绝不判断发送者是谁(项目
// 红线:身份来自 actor 元数据,而非 LLM)。
reviewPrompt = "你是群聊消息去噪助手。下面列出若干进入队列的消息(每条带编号)。" +
"请判断哪些消息没有保留价值(灌水、无关、低信息量),只返回你认为【有必要保留】的编号 JSON 数组。" +
"只判断信息价值,不判断发送者身份;不要编造编号;只输出 JSON 数组。"
)
// NewLLMReviewer 把 model.Adapter 包装成 adapter.Reviewer(design/12 §6 策略四)。
// 它用一次轻量单轮提示问 LLM 哪些队列消息值得保留,
// 并把答案回送给闸门。闸门只在待处理集合接近上限时
// 调用它,因此绝不会按消息调用 LLM。
func NewLLMReviewer(llm model.Adapter, log *slog.Logger) adapter.Reviewer {
if log == nil {
log = slog.Default()
}
return func(ctx context.Context, msgs []adapter.UnifiedMessage) ([]int, error) {
resp, err := llm.Chat(ctx, model.Request{
Messages: []model.Message{
model.TextMessage(model.RoleSystem, reviewPrompt),
model.TextMessage(model.RoleUser, buildReviewContent(msgs)),
},
Purpose: "noise_review",
MaxTokens: reviewMaxTokens,
}, model.Options{})
if err != nil {
return nil, fmt.Errorf("flowgate: review model: %w", err)
}
keep, err := parseKeepIndices(resp.Content, len(msgs))
if err != nil {
log.Warn("flow gate review output unparsable, falling back to FIFO",
"error", err)
return nil, err
}
return keep, nil
}
}
// buildReviewContent 把队列消息渲染为带编号的截断清单,
// 便于审查者低成本判断重要性。这里不做身份判定或 LLM 判断
// ——只生成紧凑、带索引标签的文本摘要。
func buildReviewContent(msgs []adapter.UnifiedMessage) string {
var b strings.Builder
for i, m := range msgs {
name := senderDisplayName(m, m.Sender)
if name == "" {
name = "(未标注)"
}
fmt.Fprintf(&b, "[%d] %s: %s\n", i, name, truncateText(m.Content, reviewSnippetLen))
}
return b.String()
}
// truncateText 为展示把 s 截短到至多 n 字节(按 rune 计数)。
func truncateText(s string, n int) string {
if n <= 0 {
return ""
}
runes := []rune(s)
if len(runes) <= n {
return s
}
return string(runes[:n]) + "…"
}
// parseKeepIndices 解码审查模型返回的 JSON 索引数组,并按升序
// 只保留范围内、去重后的值。越界或重复的
// 索引被丢弃,因此模型幻觉出的数字绝不会淘汰真实消息。
func parseKeepIndices(s string, bound int) ([]int, error) {
var idx []int
if err := json.Unmarshal([]byte(s), &idx); err != nil {
return nil, err
}
seen := make(map[int]bool, len(idx))
out := make([]int, 0, len(idx))
for _, i := range idx {
if i >= 0 && i < bound && !seen[i] {
seen[i] = true
out = append(out, i)
}
}
sort.Ints(out)
return out, nil
}