返回 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 // 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
299 lines GO