package channelbridge import ( "context" "encoding/json" "errors" "fmt" "log/slog" "strings" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) // This file holds the production LightJudge used by the participation engine // (design/18 §3). It is deliberately separate from flowgate's Reviewer: the Reviewer // prunes queued messages from a stack; this judge decides, once, at a cost-controlled // entrance gate, whether a triggered group/stranger message is worth participating in. // It only ever decides 值不值得回 — never who the sender is (a project red line). const ( // engageMaxTokens bounds the judge output so a noisy group context never produces a // giant completion (design/18 §3 cost guard). engageMaxTokens = 200 // engageSnippetLen is how many bytes of each recent message the context renderer // shows the judge — enough to weigh the topic without paging the whole group. engageSnippetLen = 80 ) // NewLLMLightJudge wraps a model.Adapter into a LightJudge (design/18 §3). It asks the // LLM, via a single cheap turn, whether the triggered message is worth participating // in, and routes the structured answer back to the engage engine. A nil adapter returns // a judge that always fails closed (so the engine stays silent rather than panicking). func NewLLMLightJudge(llm model.Adapter, log *slog.Logger) LightJudge { if log == nil { log = slog.Default() } return &llmLightJudge{llm: llm, log: log} } type llmLightJudge struct { llm model.Adapter log *slog.Logger } // Judge performs one cost-controlled light-turn on a triggered participation message // and returns the structured verdict. Any error (upstream, parse) is returned so the // engage engine fails closed to silence. It never reads sender identity — name is // passed only as a label, identity was already resolved structurally. func (j *llmLightJudge) Judge(ctx context.Context, in LightInput) (LightDecision, error) { if j.llm == nil { return LightDecision{}, errors.New("channelbridge: light judge: nil model adapter") } resp, err := j.llm.Chat(ctx, model.Request{ Messages: []model.Message{ model.TextMessage(model.RoleSystem, engageJudgeSystemPrompt), model.TextMessage(model.RoleUser, buildEngageUserContent(in)), }, Purpose: "engage_judge", MaxTokens: engageMaxTokens, }, model.Options{}) if err != nil { return LightDecision{}, fmt.Errorf("channelbridge: light judge: %w", err) } d, err := parseEngageDecision(resp.Content) if err != nil { j.log.Warn("light judge output unparsable, fail-closed to silence", "error", err) return LightDecision{}, err } // Map the participation verdict onto the legacy design/12 §6 Respond field so the // same decision drives both the engage engine (WorthReply/Interesting) and any // legacy consumer that only reads Respond. d.Respond = d.WorthReply || d.Interesting return d, nil } // engageJudgeSystemPrompt instructs the judge to weigh only participation value, never // identity (design/18 §0 red line), and to answer with a tight JSON object. const engageJudgeSystemPrompt = "你是群聊参与度轻思考器。只判断「她值不值得参与这条群聊消息」," + "绝不判断消息发送者是谁(谁说的已由系统元数据决定)。" + "只输出一个 JSON 对象,字段为:" + "{\"interested\": bool(她对这话题感兴趣吗), " + "\"worthReply\": bool(值得回吗), " + "\"avoidTopic\": bool(该话题她应回避吗,如敏感/隐私/成人红线), " + "\"reason\": string(一句话理由)}。" + "不要输出其它文字。" // buildEngageUserContent renders the persona prompt, the recent group context and the // triggering message for the judge. The persona block carries no owner secret; the // context is bounded by the engage renderer. When Engage is false (a legacy design/12 // §6 call) only the message and sender label are present, so the judge degrades to a // plain value judgement. func buildEngageUserContent(in LightInput) string { var b strings.Builder if in.Engage && in.PersonaPrompt != "" { b.WriteString("【人格设定】\n" + in.PersonaPrompt + "\n\n") } if in.Engage && in.TriggerWords != nil { b.WriteString("【角色提醒词】" + strings.Join(in.TriggerWords, " / ") + "\n\n") } if in.Engage && in.RecentContext != "" { b.WriteString("【群聊最近上下文】\n" + in.RecentContext + "\n\n") } b.WriteString("【本轮触发消息】\n") if in.SenderName != "" { b.WriteString(in.SenderName + ": ") } b.WriteString(in.Msg.Content) b.WriteString("\n\n请只输出 JSON 对象,不要其它文字。") return b.String() } // engageDecisionJSON mirrors the judge's structured verdict for decoding. type engageDecisionJSON struct { Interested bool `json:"interested"` WorthReply bool `json:"worthReply"` AvoidTopic bool `json:"avoidTopic"` Reason string `json:"reason"` } // parseEngageDecision decodes the judge's JSON object, tolerating a fenced/code-block // wrapper. A malformed answer returns an error so the caller fails closed to silence. func parseEngageDecision(s string) (LightDecision, error) { trimmed := strings.TrimSpace(s) if strings.HasPrefix(trimmed, "```") { trimmed = strings.TrimPrefix(trimmed, "```") if i := strings.IndexByte(trimmed, '\n'); i >= 0 { trimmed = trimmed[i+1:] } trimmed = strings.TrimSuffix(strings.TrimSpace(trimmed), "```") } // The model may wrap the object with explanatory prose; fall back to the first // balanced JSON object if the whole payload is not pure JSON. var raw engageDecisionJSON if err := json.Unmarshal([]byte(trimmed), &raw); err != nil { if obj := firstJSONObject(trimmed); obj != "" { if err2 := json.Unmarshal([]byte(obj), &raw); err2 == nil { return toEngageDecision(raw), nil } } return LightDecision{}, fmt.Errorf("channelbridge: parse engage judge output: %w", err) } return toEngageDecision(raw), nil } func toEngageDecision(raw engageDecisionJSON) LightDecision { return LightDecision{ Interesting: raw.Interested, WorthReply: raw.WorthReply, AvoidTopic: raw.AvoidTopic, Reason: raw.Reason, } } // firstJSONObject returns the first balanced {...} region of s, or "" when none exists. // It is a cheap tolerance for a model that prepends/appends prose to the JSON answer. func firstJSONObject(s string) string { start := strings.IndexByte(s, '{') if start < 0 { return "" } depth := 0 for i := start; i < len(s); i++ { switch s[i] { case '{': depth++ case '}': depth-- if depth == 0 { return s[start : i+1] } } } return "" }