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
@@ -110,6 +110,17 @@ type Config struct {
|
||||
// Awakening carries the persona-level override (design §3). Zero = no
|
||||
// override.
|
||||
Awakening Awakening
|
||||
// L3Enabled turns on the model-assisted intent layer (design/14 §2.2 L3;
|
||||
// 裁决 14.4.4). It is off by default: the layer costs one model call per
|
||||
// undecided turn, so it must be asked for explicitly. When false the
|
||||
// classifier is never consulted, whatever L3Classifier holds.
|
||||
L3Enabled bool
|
||||
// L3Classifier is the model-assisted classifier the L3 layer consults when
|
||||
// L3Enabled is true. Nil keeps the layer silent.
|
||||
L3Classifier IntentClassifier
|
||||
// L3CacheTTL is how long a verdict for identical turn text is reused (裁决
|
||||
// 14.4.4 的 L3 输出缓存). Zero or negative disables reuse.
|
||||
L3CacheTTL time.Duration
|
||||
}
|
||||
|
||||
// Input is everything Decide needs to classify one round (design §4).
|
||||
@@ -140,10 +151,13 @@ type Input struct {
|
||||
type Option func(*Decider)
|
||||
|
||||
// Decider is the awakened-capacity decision engine. It is immutable after New
|
||||
// and safe for concurrent Decide calls.
|
||||
// and safe for concurrent Decide calls (the L3 cache is mutex-guarded).
|
||||
type Decider struct {
|
||||
cfg Config
|
||||
log *slog.Logger
|
||||
// l3cache is the model-assisted intent layer's classifier + verdict cache.
|
||||
// Nil when the layer is off, which is the default (裁决 14.4.4).
|
||||
l3cache *l3Cache
|
||||
}
|
||||
|
||||
// sourcedLog returns a logger tagged with the module source (project rule §10),
|
||||
@@ -175,6 +189,9 @@ func New(cfg Config, opts ...Option) *Decider {
|
||||
}
|
||||
d := &Decider{cfg: cfg}
|
||||
d.log = sourcedLog(nil)
|
||||
if cfg.L3Enabled {
|
||||
d.l3cache = newL3Cache(cfg.L3Classifier, cfg.L3CacheTTL)
|
||||
}
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(d)
|
||||
@@ -196,7 +213,7 @@ func (d *Decider) Decide(ctx context.Context, in Input) Decision {
|
||||
if !d.cfg.Enabled {
|
||||
return Decision{State: in.CurrentState, AllowedTools: append([]string(nil), in.AllToolNames...)}
|
||||
}
|
||||
state, conf, tool := d.intent(in)
|
||||
state, conf, tool := d.intent(ctx, in)
|
||||
j := judgmentFor(in, state, conf, tool)
|
||||
state, conf = d.applyAwakening(state, conf, in)
|
||||
allowed := d.allowedTools(state, in.AllToolNames)
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
package capacity
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// IntentClassifier is the model-assisted intent classifier behind the L3 layer
|
||||
// (design/14 §2.2 L3; 裁决 14.4.4). It is an interface so the decision engine
|
||||
// stays testable without a model: the wiring supplies the LLM-backed
|
||||
// implementation (NewLLMClassifier), a test supplies a stub.
|
||||
//
|
||||
// Classify returns the raw judgment for one turn's text — {isTask, taskKind,
|
||||
// confidence} plus nothing else. An error means "no verdict": the caller keeps
|
||||
// the conservative default rather than guessing.
|
||||
type IntentClassifier interface {
|
||||
Classify(ctx context.Context, text string) (Judgment, error)
|
||||
}
|
||||
|
||||
// maxL3CacheEntries bounds the L3 verdict cache so a long-lived process cannot
|
||||
// grow it without limit. Past the bound, expired entries are dropped first and
|
||||
// the cache is cleared if every entry is still fresh.
|
||||
const maxL3CacheEntries = 256
|
||||
|
||||
// ErrEmptyTurn is returned when the L3 classifier is asked to judge empty turn
|
||||
// text. It is a sentinel so callers (and tests) can test the category without
|
||||
// matching on the message text.
|
||||
var ErrEmptyTurn = errors.New("capacity: empty turn text for L3 classification")
|
||||
|
||||
// DefaultL3CacheTTL is the default reuse window for an L3 verdict — the landing
|
||||
// suggestion of 裁决 14.4.4 (5m): long enough that a repeated phrase inside one
|
||||
// conversation costs one model call, short enough that a changed habit re-learns.
|
||||
const DefaultL3CacheTTL = 5 * time.Minute
|
||||
|
||||
// l3Cache memoizes classifier verdicts per turn text for a bounded TTL, so the
|
||||
// same phrase does not cost a model call on every round. A zero TTL disables
|
||||
// reuse. The classifier call happens outside the lock: two concurrent turns must
|
||||
// not serialize on each other's model call.
|
||||
type l3Cache struct {
|
||||
classifier IntentClassifier
|
||||
ttl time.Duration
|
||||
|
||||
mu sync.Mutex
|
||||
entries map[string]l3Entry
|
||||
}
|
||||
|
||||
// l3Entry is one cached verdict with the time it was produced.
|
||||
type l3Entry struct {
|
||||
judgment Judgment
|
||||
at time.Time
|
||||
}
|
||||
|
||||
// newL3Cache builds the cache. A nil classifier yields nil, which the decider
|
||||
// treats as "L3 not available".
|
||||
func newL3Cache(classifier IntentClassifier, ttl time.Duration) *l3Cache {
|
||||
if classifier == nil {
|
||||
return nil
|
||||
}
|
||||
return &l3Cache{classifier: classifier, ttl: ttl, entries: map[string]l3Entry{}}
|
||||
}
|
||||
|
||||
// classify returns the verdict for text, reusing a fresh cached one when
|
||||
// possible. The bool reports whether a verdict was produced; an error is returned
|
||||
// to the caller for reporting instead of being swallowed here.
|
||||
func (c *l3Cache) classify(ctx context.Context, text string) (Judgment, error) {
|
||||
key := l3CacheKey(text)
|
||||
if key == "" {
|
||||
return Judgment{}, ErrEmptyTurn
|
||||
}
|
||||
if j, ok := c.lookup(key, time.Now()); ok {
|
||||
return j, nil
|
||||
}
|
||||
j, err := c.classifier.Classify(ctx, text)
|
||||
if err != nil {
|
||||
return Judgment{}, err
|
||||
}
|
||||
c.store(key, j, time.Now())
|
||||
return j, nil
|
||||
}
|
||||
|
||||
// lookup returns a fresh cached verdict, if any.
|
||||
func (c *l3Cache) lookup(key string, now time.Time) (Judgment, bool) {
|
||||
if c.ttl <= 0 {
|
||||
return Judgment{}, false
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
e, ok := c.entries[key]
|
||||
if !ok {
|
||||
return Judgment{}, false
|
||||
}
|
||||
if now.Sub(e.at) > c.ttl {
|
||||
return Judgment{}, false
|
||||
}
|
||||
return e.judgment, true
|
||||
}
|
||||
|
||||
// store records a verdict, evicting expired entries (and, if the bound is still
|
||||
// exceeded, the whole map) so the cache cannot grow without limit.
|
||||
func (c *l3Cache) store(key string, j Judgment, now time.Time) {
|
||||
if c.ttl <= 0 {
|
||||
return
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if len(c.entries) >= maxL3CacheEntries {
|
||||
c.evictExpired(now)
|
||||
}
|
||||
c.entries[key] = l3Entry{judgment: j, at: now}
|
||||
}
|
||||
|
||||
// evictExpired drops entries past their TTL; when none expired, it clears the
|
||||
// map so a burst of distinct turns cannot pin the memory.
|
||||
func (c *l3Cache) evictExpired(now time.Time) {
|
||||
dropped := 0
|
||||
for k, e := range c.entries {
|
||||
if now.Sub(e.at) > c.ttl {
|
||||
delete(c.entries, k)
|
||||
dropped++
|
||||
}
|
||||
}
|
||||
if dropped == 0 {
|
||||
c.entries = map[string]l3Entry{}
|
||||
}
|
||||
}
|
||||
|
||||
// l3CacheKey normalizes a turn's text into the cache key. Empty text (nothing to
|
||||
// classify) yields "".
|
||||
func l3CacheKey(text string) string {
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,11 @@
|
||||
// signals.go holds the intent layer vocabulary and the L1/L2/L3 layers; the
|
||||
// package doc lives in capacity.go.
|
||||
package capacity
|
||||
|
||||
import "strings"
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// defaultQuietTools is the companion-safe face (design §1): terminal + file edit
|
||||
// + memory/emotion companion. It is a function building the slice fresh so no
|
||||
@@ -16,12 +21,13 @@ func defaultAwakeTools() []string {
|
||||
}
|
||||
|
||||
// intent classifies the round. It runs L1 on the current turn, falls to L2 on
|
||||
// the recent context trend, and folds in the L4 learned-habit verdict (design §2.2)
|
||||
// at each layer: L4 may correct a decided result (boost / quiet→awake) or fill in
|
||||
// when L1/L2 are undecided. With no matching habit L4 is silent, so a turn with no
|
||||
// recall behaves exactly as before; otherwise the result stays conservative (低置信
|
||||
// still rests via applyAwakening).
|
||||
func (d *Decider) intent(in Input) (State, float64, string) {
|
||||
// the recent context trend, then to the model-assisted L3 layer when it is
|
||||
// enabled, and folds in the L4 learned-habit verdict (design §2.2) at each layer:
|
||||
// L4 may correct a decided result (boost / quiet→awake) or fill in when L1/L2/L3
|
||||
// are undecided. With no matching habit L4 is silent, so a turn with no recall
|
||||
// behaves exactly as before; otherwise the result stays conservative (低置信 still
|
||||
// rests via applyAwakening).
|
||||
func (d *Decider) intent(ctx context.Context, in Input) (State, float64, string) {
|
||||
if len(in.Messages) == 0 {
|
||||
return "", 0, ""
|
||||
}
|
||||
@@ -33,12 +39,44 @@ func (d *Decider) intent(in Input) (State, float64, string) {
|
||||
if s, c, t, ok := l2(in.Messages); ok {
|
||||
return d.mergeL4(s, c, t, l4s, l4c, l4t, l4ok)
|
||||
}
|
||||
if s, c, t, ok := d.l3(ctx, current); ok {
|
||||
return d.mergeL4(s, c, t, l4s, l4c, l4t, l4ok)
|
||||
}
|
||||
if l4ok {
|
||||
return l4s, l4c, l4t
|
||||
}
|
||||
return "", 0.3, "" // 拿不准 → conservative fallback
|
||||
}
|
||||
|
||||
// l3 runs the model-assisted intent layer (design/14 §2.2 L3; 裁决 14.4.4): the
|
||||
// configured classifier judges the current turn and its verdict is trusted only
|
||||
// at or above the wake threshold — a low-confidence verdict is treated as
|
||||
// undecided, so a hesitant model can never wake tools on its own. A classifier
|
||||
// error is reported and treated the same way. With the layer off this returns
|
||||
// ok=false immediately, leaving L1/L2/L4 behavior byte-identical.
|
||||
func (d *Decider) l3(ctx context.Context, current string) (State, float64, string, bool) {
|
||||
if d.l3cache == nil {
|
||||
return "", 0, "", false
|
||||
}
|
||||
j, err := d.l3cache.classify(ctx, current)
|
||||
if err != nil {
|
||||
d.log.Warn("capacity L3 classifier unavailable, keeping the conservative default", "error", err)
|
||||
return "", 0, "", false
|
||||
}
|
||||
if j.Confidence < d.cfg.MinConfidence {
|
||||
d.log.Debug("capacity L3 verdict below threshold, treated as undecided",
|
||||
"confidence", j.Confidence, "threshold", d.cfg.MinConfidence, "is_task", j.IsTask)
|
||||
return "", 0, "", false
|
||||
}
|
||||
if !j.IsTask {
|
||||
return StateQuiet, j.Confidence, "", true
|
||||
}
|
||||
if j.TaskKind == taskKindComplex {
|
||||
return StateFull, j.Confidence, inferTool(current), true
|
||||
}
|
||||
return StateAwake, j.Confidence, inferTool(current), true
|
||||
}
|
||||
|
||||
// l1 is the fast heuristic on the current turn (design §2.2 L1): a strong
|
||||
// action signal wakes (awake, or full for complex work); a clear companion
|
||||
// signal (weak + no task object) stays quiet; otherwise it is undecided and the
|
||||
|
||||
Reference in new issue
Block a user