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) }