package capacity import ( "context" "encoding/json" "fmt" "log/slog" "strings" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" "git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model" ) // l3MaxTokens caps the classifier's reply: it is a tiny JSON object, and an // uncapped reply would burn budget on prose the parser then rejects. const l3MaxTokens = 128 // l3SystemPrompt instructs the model to answer with a strict JSON judgment only. // It deliberately repeats "JSON only": a prose answer is an error, not a verdict. const l3SystemPrompt = `你是意图分类器。判断用户这一轮是在「办事」(有明确任务动作/对象),还是在「陪伴/闲聊」。 只输出一个 JSON 对象,不要任何解释或代码块标记,格式: {"isTask": true|false, "taskKind": "complex"|"code"|"query"|"ops", "confidence": 0.0-1.0} taskKind 只在 isTask 为 true 时有意义;拿不准时把 confidence 给低(<0.6)。` // l3WireJudgment is the wire shape the classifier parses. It is separate from // Judgment so an unexpected field can never leak into the decision engine. type l3WireJudgment struct { IsTask bool `json:"isTask"` TaskKind string `json:"taskKind"` Confidence float64 `json:"confidence"` } // LLMClassifier is the model-assisted intent classifier behind the L3 layer // (design/14 §2.2 L3; 裁决 14.4.4). It asks the adapter for a strict JSON // judgment and returns it; an unparsable or empty reply is an error, never a // guess, so the caller keeps its conservative default. It is safe for concurrent // use. type LLMClassifier struct { adapter model.Adapter log *slog.Logger } // NewLLMClassifier builds the classifier over the runtime's LLM stack. A nil // adapter is allowed and makes Classify report ErrNotConfigured. func NewLLMClassifier(adapter model.Adapter, log *slog.Logger) *LLMClassifier { if log == nil { log = slog.New(slog.NewTextHandler(discard{}, nil)) } return &LLMClassifier{adapter: adapter, log: logger.WithSource(log, "runtime/capacity")} } // ErrNotConfigured is returned when the classifier has no LLM stack to ask. var ErrNotConfigured = fmt.Errorf("capacity: L3 classifier has no model configured") // Classify asks the model for one judgment of text. It returns the raw verdict; // callers apply the confidence threshold (see Decider.l3). func (c *LLMClassifier) Classify(ctx context.Context, text string) (Judgment, error) { if strings.TrimSpace(text) == "" { return Judgment{}, ErrEmptyTurn } if c.adapter == nil { return Judgment{}, ErrNotConfigured } resp, err := c.adapter.Chat(ctx, model.Request{ Messages: []model.Message{ model.TextMessage(model.RoleSystem, l3SystemPrompt), model.TextMessage(model.RoleUser, text), }, MaxTokens: l3MaxTokens, Purpose: "capacity-l3", }, model.Options{}) if err != nil { return Judgment{}, fmt.Errorf("capacity: L3 classify: %w", err) } wire, err := parseL3Judgment(resp.Content) if err != nil { return Judgment{}, err } c.log.Debug("capacity L3 verdict", "is_task", wire.IsTask, "task_kind", strings.TrimSpace(wire.TaskKind), "confidence", wire.Confidence) return Judgment{ IsTask: wire.IsTask, TaskKind: strings.TrimSpace(wire.TaskKind), Confidence: clampUnit(wire.Confidence), }, nil } // parseL3Judgment extracts the JSON object from a model reply. A reply wrapped in // a ```json fence is tolerated (models add one routinely), but prose around the // object is not: the object must be the whole answer. func parseL3Judgment(content string) (l3WireJudgment, error) { raw := stripCodeFence(strings.TrimSpace(content)) if raw == "" { return l3WireJudgment{}, fmt.Errorf("capacity: L3 classify: %w: empty reply", model.ErrBadRequest) } var wire l3WireJudgment if err := json.Unmarshal([]byte(raw), &wire); err != nil { return l3WireJudgment{}, fmt.Errorf("capacity: L3 classify: %w: parse reply: %v", model.ErrBadRequest, err) } return wire, nil } // stripCodeFence removes a single ```/```json fence around the payload when the // whole reply is fenced. func stripCodeFence(s string) string { if !strings.HasPrefix(s, "```") { return s } body := strings.TrimPrefix(s, "```") if i := strings.IndexByte(body, '\n'); i >= 0 { body = body[i+1:] } body = strings.TrimSuffix(body, "```") return strings.TrimSpace(body) } // clampUnit keeps a model-reported confidence inside [0,1], so a hallucinated // 7.5 can never outrank the threshold arithmetic. func clampUnit(v float64) float64 { switch { case v < 0: return 0 case v > 1: return 1 default: return v } }