Files
2026-07-13 13:00:08 +08:00

293 lines
7.2 KiB
Go

package capability
import (
"context"
"encoding/json"
"fmt"
"strings"
"sync"
"time"
"reasonix/internal/event"
"reasonix/internal/provider"
)
const (
semanticMaxCandidates = 12
semanticMaxResults = 3
semanticTimeout = 3 * time.Second
semanticCacheTTL = 5 * time.Minute
semanticMaxTokens = 256
)
// SemanticRouter calls a lightweight model when deterministic routing has no
// require/prefer hits. Failures fall back immediately to deterministic results.
type SemanticRouter struct {
Provider provider.Provider
Sink event.Sink
Model string
Effort string
// Pricing prices the router's own usage events; without it the routing
// cost always displays as zero.
Pricing *provider.Pricing
// Audit receives router token/cost/latency counters (RecordRouterUsage).
Audit *Audit
mu sync.Mutex
cache map[string]semanticCacheEntry
}
type semanticCacheEntry struct {
ids []string
expiresAt time.Time
}
// RouteSemantic may append up to 3 suggest candidates. It never overrides an
// existing require/prefer decision. On any failure it returns decision unchanged.
func (r *SemanticRouter) RouteSemantic(ctx context.Context, input string, catalog Catalog, decision RouteDecision) RouteDecision {
if r == nil || r.Provider == nil {
return decision
}
if hasStrongMatch(decision) {
return decision
}
input = normalize(input)
if input == "" {
return decision
}
candidates := semanticPool(input, catalog.Entries)
if len(candidates) == 0 {
return decision
}
cacheKey := input + "|" + catalog.Fingerprint
if ids, ok := r.cacheGet(cacheKey); ok {
return mergeSemanticIDs(decision, catalog, ids, "semantic cache hit")
}
ids, err := r.callModel(ctx, input, candidates)
if err != nil || len(ids) == 0 {
return decision
}
r.cachePut(cacheKey, ids)
return mergeSemanticIDs(decision, catalog, ids, "lightweight semantic match")
}
func hasStrongMatch(d RouteDecision) bool {
for _, c := range d.Candidates {
if c.Policy == AutoUseRequire || c.Policy == AutoUsePrefer {
return true
}
}
return false
}
func semanticPool(text string, entries []Entry) []Entry {
var scored []Entry
crossLanguageFallback := containsHan(text)
for _, e := range entries {
if e.Status == StatusDisabled || e.Status == StatusFailed {
continue
}
if e.Kind != KindSkill && e.Kind != KindMCPTool && e.Kind != KindMCPServer {
continue
}
if e.AutoUse == AutoUseOff {
continue
}
if negativeMatch(text, e.NegativeTriggers) {
continue
}
blob := normalize(e.Name + " " + e.Description + " " + strings.Join(e.Triggers, " "))
if blob == "" {
continue
}
// Prefer a cheap lexical match. For Han-script tasks, also admit the
// bounded built-in/high-policy Skill set so English metadata does not make
// the semantic router blind to Chinese requests.
matched := false
for _, tok := range strings.Fields(text) {
if len(tok) < 3 {
continue
}
if strings.Contains(blob, tok) {
matched = true
break
}
}
if !matched && !(crossLanguageFallback && e.Kind == KindSkill && (e.Source == "builtin" || e.AutoUse == AutoUsePrefer || e.AutoUse == AutoUseRequire)) {
continue
}
scored = append(scored, e)
}
if len(scored) > semanticMaxCandidates {
scored = scored[:semanticMaxCandidates]
}
return scored
}
func containsHan(text string) bool {
for _, r := range text {
if r >= '\u3400' && r <= '\u9fff' {
return true
}
}
return false
}
func (r *SemanticRouter) callModel(ctx context.Context, input string, candidates []Entry) ([]string, error) {
ctx, cancel := context.WithTimeout(ctx, semanticTimeout)
defer cancel()
var b strings.Builder
b.WriteString("Select up to 3 capability IDs relevant to the user task. ")
b.WriteString("Reply with ONLY a JSON array of strings, e.g. [\"skill:review\"]. ")
b.WriteString("If none fit, reply [].\n\nTask:\n")
b.WriteString(input)
b.WriteString("\n\nCandidates:\n")
for _, e := range candidates {
fmt.Fprintf(&b, "- %s (%s): %s\n", e.ID, e.Kind, truncate(e.Description, 120))
}
req := provider.Request{
Messages: []provider.Message{{
Role: provider.RoleUser,
Content: b.String(),
}},
Temperature: provider.TemperaturePtr(0),
MaxTokens: semanticMaxTokens,
}
if r.Model != "" {
// Model override is provider-specific; many providers ignore Request.Model
// and use the bound model. The Host wires a dedicated provider when configured.
_ = r.Model
}
start := time.Now()
ch, err := r.Provider.Stream(ctx, req)
if err != nil {
return nil, err
}
var text strings.Builder
var usage *provider.Usage
for chunk := range ch {
switch chunk.Type {
case provider.ChunkText:
text.WriteString(chunk.Text)
case provider.ChunkUsage:
if chunk.Usage != nil {
u := *chunk.Usage
usage = &u
}
case provider.ChunkError:
if chunk.Err != nil {
return nil, chunk.Err
}
}
}
if usage != nil {
if r.Audit != nil {
r.Audit.RecordRouterUsage(usage.PromptTokens, usage.CompletionTokens, r.Pricing.Cost(usage), time.Since(start).Milliseconds())
}
if r.Sink != nil {
r.Sink.Emit(event.Event{
Kind: event.Usage,
Usage: usage,
Pricing: r.Pricing,
UsageSource: event.UsageSourceCapabilityRouter,
})
}
}
return parseSemanticIDs(text.String())
}
func parseSemanticIDs(raw string) ([]string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil, fmt.Errorf("empty semantic response")
}
// Strip optional markdown fences.
if i := strings.Index(raw, "["); i >= 0 {
if j := strings.LastIndex(raw, "]"); j > i {
raw = raw[i : j+1]
}
}
var ids []string
if err := json.Unmarshal([]byte(raw), &ids); err != nil {
return nil, fmt.Errorf("invalid semantic JSON: %w", err)
}
out := make([]string, 0, semanticMaxResults)
seen := map[string]bool{}
for _, id := range ids {
id = strings.TrimSpace(id)
if id == "" || seen[id] {
continue
}
seen[id] = true
out = append(out, id)
if len(out) >= semanticMaxResults {
break
}
}
return out, nil
}
func mergeSemanticIDs(decision RouteDecision, catalog Catalog, ids []string, reason string) RouteDecision {
have := map[string]bool{}
for _, c := range decision.Candidates {
have[c.Entry.ID] = true
}
for _, id := range ids {
if have[id] {
continue
}
e, ok := catalog.Lookup(id)
if !ok {
continue
}
decision.Candidates = append(decision.Candidates, RouteCandidate{
Entry: e,
Policy: AutoUseSuggest,
Reason: reason,
})
have[id] = true
}
if len(decision.Candidates) > 5 {
decision.Candidates = decision.Candidates[:5]
}
return decision
}
func (r *SemanticRouter) cacheGet(key string) ([]string, bool) {
r.mu.Lock()
defer r.mu.Unlock()
if r.cache == nil {
return nil, false
}
e, ok := r.cache[key]
if !ok || time.Now().After(e.expiresAt) {
return nil, false
}
return append([]string(nil), e.ids...), true
}
func (r *SemanticRouter) cachePut(key string, ids []string) {
r.mu.Lock()
defer r.mu.Unlock()
if r.cache == nil {
r.cache = map[string]semanticCacheEntry{}
}
r.cache[key] = semanticCacheEntry{
ids: append([]string(nil), ids...),
expiresAt: time.Now().Add(semanticCacheTTL),
}
}
func truncate(s string, n int) string {
s = strings.TrimSpace(s)
if len(s) <= n {
return s
}
return s[:n] + "…"
}