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 }