| 1 | package capability |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "encoding/json" |
| 6 | "fmt" |
| 7 | "strings" |
| 8 | "sync" |
| 9 | "time" |
| 10 | |
| 11 | "reasonix/internal/event" |
| 12 | "reasonix/internal/provider" |
| 13 | ) |
| 14 | |
| 15 | const ( |
| 16 | semanticMaxCandidates = 12 |
| 17 | semanticMaxResults = 3 |
| 18 | semanticTimeout = 3 * time.Second |
| 19 | semanticCacheTTL = 5 * time.Minute |
| 20 | semanticMaxTokens = 256 |
| 21 | ) |
| 22 | |
| 23 | // SemanticRouter calls a lightweight model when deterministic routing has no |
| 24 | // require/prefer hits. Failures fall back immediately to deterministic results. |
| 25 | type SemanticRouter struct { |
| 26 | Provider provider.Provider |
| 27 | Sink event.Sink |
| 28 | Model string |
| 29 | Effort string |
| 30 | // Pricing prices the router's own usage events; without it the routing |
| 31 | // cost always displays as zero. |
| 32 | Pricing *provider.Pricing |
| 33 | // Audit receives router token/cost/latency counters (RecordRouterUsage). |
| 34 | Audit *Audit |
| 35 | |
| 36 | mu sync.Mutex |
| 37 | cache map[string]semanticCacheEntry |
| 38 | } |
| 39 | |
| 40 | type semanticCacheEntry struct { |
| 41 | ids []string |
| 42 | expiresAt time.Time |
| 43 | } |
| 44 | |
| 45 | // RouteSemantic may append up to 3 suggest candidates. It never overrides an |
| 46 | // existing require/prefer decision. On any failure it returns decision unchanged. |
| 47 | func (r *SemanticRouter) RouteSemantic(ctx context.Context, input string, catalog Catalog, decision RouteDecision) RouteDecision { |
| 48 | if r == nil || r.Provider == nil { |
| 49 | return decision |
| 50 | } |
| 51 | if hasStrongMatch(decision) { |
| 52 | return decision |
| 53 | } |
| 54 | input = normalize(input) |
| 55 | if input == "" { |
| 56 | return decision |
| 57 | } |
| 58 | candidates := semanticPool(input, catalog.Entries) |
| 59 | if len(candidates) == 0 { |
| 60 | return decision |
| 61 | } |
| 62 | |
| 63 | cacheKey := input + "|" + catalog.Fingerprint |
| 64 | if ids, ok := r.cacheGet(cacheKey); ok { |
| 65 | return mergeSemanticIDs(decision, catalog, ids, "semantic cache hit") |
| 66 | } |
| 67 | |
| 68 | ids, err := r.callModel(ctx, input, candidates) |
| 69 | if err != nil || len(ids) == 0 { |
| 70 | return decision |
| 71 | } |
| 72 | r.cachePut(cacheKey, ids) |
| 73 | return mergeSemanticIDs(decision, catalog, ids, "lightweight semantic match") |
| 74 | } |
| 75 | |
| 76 | func hasStrongMatch(d RouteDecision) bool { |
| 77 | for _, c := range d.Candidates { |
| 78 | if c.Policy == AutoUseRequire || c.Policy == AutoUsePrefer { |
| 79 | return true |
| 80 | } |
| 81 | } |
| 82 | return false |
| 83 | } |
| 84 | |
| 85 | func semanticPool(text string, entries []Entry) []Entry { |
| 86 | var scored []Entry |
| 87 | crossLanguageFallback := containsHan(text) |
| 88 | for _, e := range entries { |
| 89 | if e.Status == StatusDisabled || e.Status == StatusFailed { |
| 90 | continue |
| 91 | } |
| 92 | if e.Kind != KindSkill && e.Kind != KindMCPTool && e.Kind != KindMCPServer { |
| 93 | continue |
| 94 | } |
| 95 | if e.AutoUse == AutoUseOff { |
| 96 | continue |
| 97 | } |
| 98 | if negativeMatch(text, e.NegativeTriggers) { |
| 99 | continue |
| 100 | } |
| 101 | blob := normalize(e.Name + " " + e.Description + " " + strings.Join(e.Triggers, " ")) |
| 102 | if blob == "" { |
| 103 | continue |
| 104 | } |
| 105 | // Prefer a cheap lexical match. For Han-script tasks, also admit the |
| 106 | // bounded built-in/high-policy Skill set so English metadata does not make |
| 107 | // the semantic router blind to Chinese requests. |
| 108 | matched := false |
| 109 | for _, tok := range strings.Fields(text) { |
| 110 | if len(tok) < 3 { |
| 111 | continue |
| 112 | } |
| 113 | if strings.Contains(blob, tok) { |
| 114 | matched = true |
| 115 | break |
| 116 | } |
| 117 | } |
| 118 | if !matched && !(crossLanguageFallback && e.Kind == KindSkill && (e.Source == "builtin" || e.AutoUse == AutoUsePrefer || e.AutoUse == AutoUseRequire)) { |
| 119 | continue |
| 120 | } |
| 121 | scored = append(scored, e) |
| 122 | } |
| 123 | if len(scored) > semanticMaxCandidates { |
| 124 | scored = scored[:semanticMaxCandidates] |
| 125 | } |
| 126 | return scored |
| 127 | } |
| 128 | |
| 129 | func containsHan(text string) bool { |
| 130 | for _, r := range text { |
| 131 | if r >= '\u3400' && r <= '\u9fff' { |
| 132 | return true |
| 133 | } |
| 134 | } |
| 135 | return false |
| 136 | } |
| 137 | |
| 138 | func (r *SemanticRouter) callModel(ctx context.Context, input string, candidates []Entry) ([]string, error) { |
| 139 | ctx, cancel := context.WithTimeout(ctx, semanticTimeout) |
| 140 | defer cancel() |
| 141 | ctx = provider.WithRequestAttemptCounter(ctx) |
| 142 | |
| 143 | var b strings.Builder |
| 144 | b.WriteString("Select up to 3 capability IDs relevant to the user task. ") |
| 145 | b.WriteString("Reply with ONLY a JSON array of strings, e.g. [\"skill:review\"]. ") |
| 146 | b.WriteString("If none fit, reply [].\n\nTask:\n") |
| 147 | b.WriteString(input) |
| 148 | b.WriteString("\n\nCandidates:\n") |
| 149 | for _, e := range candidates { |
| 150 | fmt.Fprintf(&b, "- %s (%s): %s\n", e.ID, e.Kind, truncate(e.Description, 120)) |
| 151 | } |
| 152 | |
| 153 | req := provider.Request{ |
| 154 | Messages: []provider.Message{{ |
| 155 | Role: provider.RoleUser, |
| 156 | Content: b.String(), |
| 157 | }}, |
| 158 | Temperature: provider.TemperaturePtr(0), |
| 159 | MaxTokens: semanticMaxTokens, |
| 160 | } |
| 161 | if r.Model != "" { |
| 162 | // Model override is provider-specific; many providers ignore Request.Model |
| 163 | // and use the bound model. The Host wires a dedicated provider when configured. |
| 164 | _ = r.Model |
| 165 | } |
| 166 | |
| 167 | start := time.Now() |
| 168 | var usage *provider.Usage |
| 169 | defer func() { |
| 170 | usage = provider.UsageWithRequestAttemptCount(ctx, usage) |
| 171 | if usage == nil { |
| 172 | return |
| 173 | } |
| 174 | if (usage.PromptTokens > 0 || usage.CompletionTokens > 0) && r.Audit != nil { |
| 175 | r.Audit.RecordRouterUsage(usage.PromptTokens, usage.CompletionTokens, r.Pricing.Cost(usage), time.Since(start).Milliseconds()) |
| 176 | } |
| 177 | if r.Sink != nil { |
| 178 | r.Sink.Emit(event.Event{ |
| 179 | Kind: event.Usage, |
| 180 | ModelRef: strings.TrimSpace(r.Model), |
| 181 | Usage: usage, |
| 182 | Pricing: r.Pricing, |
| 183 | UsageSource: event.UsageSourceCapabilityRouter, |
| 184 | }) |
| 185 | } |
| 186 | }() |
| 187 | ch, err := r.Provider.Stream(ctx, req) |
| 188 | if err != nil { |
| 189 | return nil, err |
| 190 | } |
| 191 | var text strings.Builder |
| 192 | for chunk := range ch { |
| 193 | switch chunk.Type { |
| 194 | case provider.ChunkText: |
| 195 | text.WriteString(chunk.Text) |
| 196 | case provider.ChunkUsage: |
| 197 | if chunk.Usage != nil { |
| 198 | u := *chunk.Usage |
| 199 | usage = &u |
| 200 | } |
| 201 | case provider.ChunkError: |
| 202 | if chunk.Err != nil { |
| 203 | return nil, chunk.Err |
| 204 | } |
| 205 | } |
| 206 | } |
| 207 | return parseSemanticIDs(text.String()) |
| 208 | } |
| 209 | |
| 210 | func parseSemanticIDs(raw string) ([]string, error) { |
| 211 | raw = strings.TrimSpace(raw) |
| 212 | if raw == "" { |
| 213 | return nil, fmt.Errorf("empty semantic response") |
| 214 | } |
| 215 | // Strip optional markdown fences. |
| 216 | if i := strings.Index(raw, "["); i >= 0 { |
| 217 | if j := strings.LastIndex(raw, "]"); j > i { |
| 218 | raw = raw[i : j+1] |
| 219 | } |
| 220 | } |
| 221 | var ids []string |
| 222 | if err := json.Unmarshal([]byte(raw), &ids); err != nil { |
| 223 | return nil, fmt.Errorf("invalid semantic JSON: %w", err) |
| 224 | } |
| 225 | out := make([]string, 0, semanticMaxResults) |
| 226 | seen := map[string]bool{} |
| 227 | for _, id := range ids { |
| 228 | id = strings.TrimSpace(id) |
| 229 | if id == "" || seen[id] { |
| 230 | continue |
| 231 | } |
| 232 | seen[id] = true |
| 233 | out = append(out, id) |
| 234 | if len(out) >= semanticMaxResults { |
| 235 | break |
| 236 | } |
| 237 | } |
| 238 | return out, nil |
| 239 | } |
| 240 | |
| 241 | func mergeSemanticIDs(decision RouteDecision, catalog Catalog, ids []string, reason string) RouteDecision { |
| 242 | have := map[string]bool{} |
| 243 | for _, c := range decision.Candidates { |
| 244 | have[c.Entry.ID] = true |
| 245 | } |
| 246 | for _, id := range ids { |
| 247 | if have[id] { |
| 248 | continue |
| 249 | } |
| 250 | e, ok := catalog.Lookup(id) |
| 251 | if !ok { |
| 252 | continue |
| 253 | } |
| 254 | decision.Candidates = append(decision.Candidates, RouteCandidate{ |
| 255 | Entry: e, |
| 256 | Policy: AutoUseSuggest, |
| 257 | Reason: reason, |
| 258 | }) |
| 259 | have[id] = true |
| 260 | } |
| 261 | if len(decision.Candidates) > 5 { |
| 262 | decision.Candidates = decision.Candidates[:5] |
| 263 | } |
| 264 | return decision |
| 265 | } |
| 266 | |
| 267 | func (r *SemanticRouter) cacheGet(key string) ([]string, bool) { |
| 268 | r.mu.Lock() |
| 269 | defer r.mu.Unlock() |
| 270 | if r.cache == nil { |
| 271 | return nil, false |
| 272 | } |
| 273 | e, ok := r.cache[key] |
| 274 | if !ok || time.Now().After(e.expiresAt) { |
| 275 | return nil, false |
| 276 | } |
| 277 | return append([]string(nil), e.ids...), true |
| 278 | } |
| 279 | |
| 280 | func (r *SemanticRouter) cachePut(key string, ids []string) { |
| 281 | r.mu.Lock() |
| 282 | defer r.mu.Unlock() |
| 283 | if r.cache == nil { |
| 284 | r.cache = map[string]semanticCacheEntry{} |
| 285 | } |
| 286 | r.cache[key] = semanticCacheEntry{ |
| 287 | ids: append([]string(nil), ids...), |
| 288 | expiresAt: time.Now().Add(semanticCacheTTL), |
| 289 | } |
| 290 | } |
| 291 | |
| 292 | func truncate(s string, n int) string { |
| 293 | s = strings.TrimSpace(s) |
| 294 | if len(s) <= n { |
| 295 | return s |
| 296 | } |
| 297 | return s[:n] + "…" |
| 298 | } |
| 299 |