feat(model,capacity,vision,io): per-persona 路由 + 本地三级(默认关) + L3 分类 + OCR 独立模型 + image-crop 工具(裁决 03.4.1/03.4.3/14.4.1/14.4.4/14.4.5)
- 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 文件面)
This commit is contained in:
1 parent
63e5b70e3d
commit
fa33c79e11
24 files changed
+1941
-122
No files matched your search
@@ -0,0 +1,203 @@
|
||||
package capacity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// stubClassifier is a deterministic IntentClassifier: it records every text it
|
||||
// was asked about so a test can pin whether L3 ran at all (裁决 14.4.4 的
|
||||
// CAPACITY_L3_ENABLED 默认关).
|
||||
type stubClassifier struct {
|
||||
mu sync.Mutex
|
||||
seen []string
|
||||
verdict Judgment
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (s *stubClassifier) Classify(_ context.Context, text string) (Judgment, error) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.calls++
|
||||
s.seen = append(s.seen, text)
|
||||
if s.err != nil {
|
||||
return Judgment{}, s.err
|
||||
}
|
||||
return s.verdict, nil
|
||||
}
|
||||
|
||||
func (s *stubClassifier) callCount() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.calls
|
||||
}
|
||||
|
||||
func l3Config(c IntentClassifier, ttl time.Duration) Config {
|
||||
cfg := activeConfig()
|
||||
cfg.L3Enabled = true
|
||||
cfg.L3Classifier = c
|
||||
cfg.L3CacheTTL = ttl
|
||||
return cfg
|
||||
}
|
||||
|
||||
// TestDecide_L3Disabled_ClassifierNeverRuns pins the default-off red line: with
|
||||
// CAPACITY_L3_ENABLED false the classifier must never be consulted, even when one
|
||||
// is configured, so L1/L2/L4 behavior is unchanged.
|
||||
func TestDecide_L3Disabled_ClassifierNeverRuns(t *testing.T) {
|
||||
stub := &stubClassifier{verdict: Judgment{IsTask: true, Confidence: 0.9}}
|
||||
cfg := activeConfig()
|
||||
cfg.L3Classifier = stub // configured but not enabled
|
||||
cfg.L3CacheTTL = time.Minute
|
||||
|
||||
// A turn no heuristic can decide: L1 needs a task verb + object, L2 needs a
|
||||
// leaning window. "那件事后来如何" carries no signal at all.
|
||||
dec := New(cfg).Decide(context.Background(), in("那件事后来如何"))
|
||||
if stub.callCount() != 0 {
|
||||
t.Fatalf("classifier calls = %d, want 0 while L3 is disabled", stub.callCount())
|
||||
}
|
||||
if dec.State != StateQuiet {
|
||||
t.Fatalf("state = %s, want quiet (conservative default)", dec.State)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecide_L3AwakensUndecidedTurn pins that an enabled L3 layer turns an
|
||||
// otherwise undecided turn into a wake with the classifier's own confidence, and
|
||||
// advertises the awake tool set.
|
||||
func TestDecide_L3AwakensUndecidedTurn(t *testing.T) {
|
||||
stub := &stubClassifier{verdict: Judgment{IsTask: true, TaskKind: "query", Confidence: 0.82}}
|
||||
dec := New(l3Config(stub, time.Minute)).Decide(context.Background(), in("那件事后来如何"))
|
||||
|
||||
if stub.callCount() != 1 {
|
||||
t.Fatalf("classifier calls = %d, want 1", stub.callCount())
|
||||
}
|
||||
if dec.State != StateAwake {
|
||||
t.Fatalf("state = %s, want awake (L3 verdict)", dec.State)
|
||||
}
|
||||
if dec.Confidence != 0.82 {
|
||||
t.Fatalf("confidence = %v, want the classifier's 0.82", dec.Confidence)
|
||||
}
|
||||
if dec.Judgment.IsTask != true {
|
||||
t.Errorf("judgment = %+v, want the classifier's isTask=true written back for L4", dec.Judgment)
|
||||
}
|
||||
if !contains(dec.AllowedTools, "web-search") {
|
||||
t.Errorf("allowed = %v, want the awake set including web-search", dec.AllowedTools)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecide_L3ComplexKindWakesFull pins the quantified gate's upper branch: a
|
||||
// "complex" task kind maps to the full state rather than awake.
|
||||
func TestDecide_L3ComplexKindWakesFull(t *testing.T) {
|
||||
stub := &stubClassifier{verdict: Judgment{IsTask: true, TaskKind: taskKindComplex, Confidence: 0.9}}
|
||||
dec := New(l3Config(stub, time.Minute)).Decide(context.Background(), in("那件事后来如何"))
|
||||
if dec.State != StateFull {
|
||||
t.Fatalf("state = %s, want full for a complex task kind", dec.State)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecide_L3LowConfidenceIsUndecided pins the conservative gate: a verdict the
|
||||
// model is unsure about must not wake anything, and must not be written back as a
|
||||
// task either (the L4 write-back feeds the learning loop, so a hesitant verdict
|
||||
// recorded as a task would teach the wrong habit).
|
||||
func TestDecide_L3LowConfidenceIsUndecided(t *testing.T) {
|
||||
stub := &stubClassifier{verdict: Judgment{IsTask: true, Confidence: 0.3}}
|
||||
dec := New(l3Config(stub, time.Minute)).Decide(context.Background(), in("那件事后来如何"))
|
||||
if stub.callCount() != 1 {
|
||||
t.Fatalf("classifier calls = %d, want 1", stub.callCount())
|
||||
}
|
||||
if dec.State != StateQuiet {
|
||||
t.Fatalf("state = %s, want quiet (below-threshold verdict is undecided)", dec.State)
|
||||
}
|
||||
if dec.Judgment.IsTask {
|
||||
t.Fatalf("judgment = %+v, want isTask=false (a below-threshold verdict is not a task)", dec.Judgment)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecide_L3ClassifierErrorKeepsConservativeDefault pins that a model failure
|
||||
// is reported as "no verdict" rather than crashing or waking tools.
|
||||
func TestDecide_L3ClassifierErrorKeepsConservativeDefault(t *testing.T) {
|
||||
stub := &stubClassifier{err: errors.New("upstream down")}
|
||||
dec := New(l3Config(stub, time.Minute)).Decide(context.Background(), in("那件事后来如何"))
|
||||
if dec.State != StateQuiet {
|
||||
t.Fatalf("state = %s, want quiet after a classifier error", dec.State)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecide_L3CacheReusesVerdict pins the L3 output cache (裁决 14.4.4): the same
|
||||
// turn text costs one model call inside the TTL, and a different turn still costs
|
||||
// its own call.
|
||||
func TestDecide_L3CacheReusesVerdict(t *testing.T) {
|
||||
stub := &stubClassifier{verdict: Judgment{IsTask: true, Confidence: 0.9}}
|
||||
d := New(l3Config(stub, time.Minute))
|
||||
ctx := context.Background()
|
||||
|
||||
for i := 0; i < 3; i++ {
|
||||
if dec := d.Decide(ctx, in("那件事后来如何")); dec.State != StateAwake {
|
||||
t.Fatalf("call %d state = %s, want awake", i, dec.State)
|
||||
}
|
||||
}
|
||||
if got := stub.callCount(); got != 1 {
|
||||
t.Fatalf("classifier calls = %d, want 1 (cached verdict reused)", got)
|
||||
}
|
||||
d.Decide(ctx, in("那件事后来如何?"))
|
||||
if got := stub.callCount(); got != 2 {
|
||||
t.Fatalf("classifier calls = %d, want 2 (different text is not cached)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestL3Cache_ExpiredEntryIsReclassified pins the TTL bound itself.
|
||||
func TestL3Cache_ExpiredEntryIsReclassified(t *testing.T) {
|
||||
stub := &stubClassifier{verdict: Judgment{IsTask: true, Confidence: 0.9}}
|
||||
c := newL3Cache(stub, time.Minute)
|
||||
ctx := context.Background()
|
||||
if _, err := c.classify(ctx, "那件事后来如何"); err != nil {
|
||||
t.Fatalf("classify: %v", err)
|
||||
}
|
||||
// Backdate the stored entry past the TTL.
|
||||
c.mu.Lock()
|
||||
for k, e := range c.entries {
|
||||
e.at = time.Now().Add(-2 * time.Minute)
|
||||
c.entries[k] = e
|
||||
}
|
||||
c.mu.Unlock()
|
||||
if _, err := c.classify(ctx, "那件事后来如何"); err != nil {
|
||||
t.Fatalf("classify: %v", err)
|
||||
}
|
||||
if got := stub.callCount(); got != 2 {
|
||||
t.Fatalf("classifier calls = %d, want 2 (expired entry reclassified)", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestL3Cache_EmptyTextIsNotClassified pins that an empty turn is a sentinel
|
||||
// error rather than a model call.
|
||||
func TestL3Cache_EmptyTextIsNotClassified(t *testing.T) {
|
||||
stub := &stubClassifier{verdict: Judgment{IsTask: true, Confidence: 0.9}}
|
||||
c := newL3Cache(stub, time.Minute)
|
||||
if _, err := c.classify(context.Background(), " "); !errors.Is(err, ErrEmptyTurn) {
|
||||
t.Fatalf("err = %v, want ErrEmptyTurn", err)
|
||||
}
|
||||
if stub.callCount() != 0 {
|
||||
t.Fatalf("classifier calls = %d, want 0", stub.callCount())
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseL3Judgment pins the wire parsing: a fenced JSON reply is accepted, a
|
||||
// prose reply is an error, and a hallucinated confidence is clamped.
|
||||
func TestParseL3Judgment(t *testing.T) {
|
||||
ok, err := parseL3Judgment("```json\n{\"isTask\":true,\"taskKind\":\"query\",\"confidence\":0.7}\n```")
|
||||
if err != nil {
|
||||
t.Fatalf("parseL3Judgment: %v", err)
|
||||
}
|
||||
if !ok.IsTask || ok.TaskKind != "query" || ok.Confidence != 0.7 {
|
||||
t.Fatalf("parsed = %+v, want isTask=true taskKind=query confidence=0.7", ok)
|
||||
}
|
||||
if _, err := parseL3Judgment("我觉得这是一个任务"); err == nil {
|
||||
t.Fatal("prose reply parsed without error, want failure")
|
||||
}
|
||||
if got := clampUnit(7.5); got != 1 {
|
||||
t.Errorf("clampUnit(7.5) = %v, want 1", got)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user