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