- 03.4.1:per-persona 路由载体选 models.json(组内新增 personaModels/personaProviders + 校验 + PersonaRoute()), Router/RuntimeLLM 快照绑定、loop 流式路径按该 turn 的 persona 路由;主步用路由结果、降级步钉自己的模型 - 03.4.3:本地第三级降级链(LLM_LOCAL_*,Configured() 含 Enabled ⇒ **默认关**),warn 标注来源 - 14.4.4:capacity 接入 L3(分类器接口 + TTL 缓存 + 严格 JSON 解析),CAPACITY_L3_ENABLED **默认 false** - 14.4.1:语义修正 —— group.multimodal 才是「主模型能否收图」(env 路径不再臆断),LLM_VISION_MODEL 只配视觉工具; vision/OCR 可各用不同模型;runtime 不再因有图抢路由(vision.Router 是唯一分层权威) - 14.4.5:新增 image-crop 工具(工具数 5→6,策略默认 owner-only),schema 支持按区域裁切 - 组合根:wire_runtime 读 group.Multimodal;capacity 装配拆出 wire_capacity.go(守 500 行) - 复核:`go build`/`go vet`/各包 `go test -count=1` 全绿;**6 条变异反证**先红后还原(含作者自查出的 2 条假阳性变异) - 未落:按 provider 的 thinking 开关(需 openai_encode.go/openai.go 文件面)、persona awakening(需 loader 文件面)
130 lines
4.5 KiB
Go
130 lines
4.5 KiB
Go
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
|
|
}
|
|
}
|