Skip to content

Commit cf7026f

Browse files
feat: lazy-init + threshold calibration for semantic router
Lazy initialization: - SemanticRouter.TryInitialize() auto-initializes when embedding provider loads after startup (e.g., CLI pushes API key via POST /v1/settings/providers) - Router always passed to ServerDeps (never nil) — legacy classifier handles traffic until centroids are computed - IsInitialized() exposed in admin stats endpoint Threshold calibration: - POST /admin/routing/threshold {"route":"...", "threshold":0.62} hot-updates per-route similarity thresholds without restart - Tier 2 logs all route similarity scores at Debug level for observability - X-Route-Scores header emits top-3 scores on every embedding-routed response - RoutingDecision.Scores field carries per-route scores through the pipeline Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 9442294 commit cf7026f

7 files changed

Lines changed: 170 additions & 21 deletions

File tree

main.go

Lines changed: 7 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -337,27 +337,20 @@ func main() {
337337

338338
// Initialization embeds all route utterances — this requires a live provider
339339
// with embedding support. If the provider isn't loaded yet (keys pushed later
340-
// by CLI), initialization is deferred and the legacy classifier handles traffic
341-
// until the admin triggers re-init or the provider becomes available.
340+
// by CLI), initialization is deferred and TryInitialize will be called
341+
// automatically when a provider key is registered via POST /v1/settings/providers.
342342
if _, err := pluginManager.GetProvider(embeddingProvider); err == nil {
343343
initCtx, initCancel := context.WithTimeout(context.Background(), 60*time.Second)
344-
if err := semanticRouter.Initialize(initCtx); err != nil {
345-
slog.Warn("semantic router initialization failed — falling back to legacy classifier",
346-
"error", err,
347-
"embedding_provider", embeddingProvider)
348-
semanticRouter = nil
349-
} else {
344+
if ok, err := semanticRouter.TryInitialize(initCtx); err != nil {
345+
slog.Warn("semantic router initialization failed — will retry when provider loads",
346+
"error", err, "embedding_provider", embeddingProvider)
347+
} else if ok {
350348
slog.Info("semantic router initialized — embedding-based routing active")
351349
}
352350
initCancel()
353351
} else {
354-
slog.Info("semantic router deferred — embedding provider not yet loaded, using legacy classifier",
352+
slog.Info("semantic router deferred — embedding provider not yet loaded, will auto-init when available",
355353
"provider", embeddingProvider)
356-
// Keep semanticRouter non-nil but uninitialized. The chat handler's
357-
// fallback to the legacy classifier handles this safely because
358-
// tier2Embedding will fail and cascade through to tier3 or degrade.
359-
// Set to nil to use legacy classifier cleanly until provider is available.
360-
semanticRouter = nil
361354
}
362355

363356
// ─── Initialize Orchestration ────────────────────────────────────

server/agent/intent_types.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,5 +67,6 @@ type RoutingDecision struct {
6767
Confidence float64
6868
Category IntentCategory
6969
Reasoning []string
70-
SpecialistAgentID string // bridges category → specialist agent
70+
SpecialistAgentID string // bridges category → specialist agent
71+
Scores map[string]float64 `json:"scores,omitempty"` // per-route similarity scores from Tier 2; empty for Tier 1/3 decisions
7172
}

server/agent/semantic_router.go

Lines changed: 58 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -75,12 +75,15 @@ func (b *ProviderEmbeddingBackend) Embed(ctx context.Context, text string) ([]fl
7575
//
7676
// Thread-safety: route definitions are protected by a RWMutex; the routing
7777
// mode is stored as an atomic int32 so reads and writes are lock-free.
78+
// initialized is an atomic bool so IsInitialized and TryInitialize are safe
79+
// to call from any goroutine.
7880
type SemanticRouter struct {
79-
mu sync.RWMutex
80-
routes []RouteDefinition
81-
mode atomic.Int32 // stores RoutingMode
82-
embedder EmbeddingBackend
83-
llmRouter LLMRouter
81+
mu sync.RWMutex
82+
routes []RouteDefinition
83+
mode atomic.Int32 // stores RoutingMode
84+
initialized atomic.Bool
85+
embedder EmbeddingBackend
86+
llmRouter LLMRouter
8487
}
8588

8689
// NewSemanticRouter creates a SemanticRouter with the given EmbeddingBackend
@@ -125,6 +128,21 @@ func (sr *SemanticRouter) SetMode(mode RoutingMode) {
125128
slog.Info("semantic router: mode changed", "mode", mode.String())
126129
}
127130

131+
// SetRouteThreshold updates the similarity threshold for a named route.
132+
// Returns an error if no route with that name exists.
133+
func (sr *SemanticRouter) SetRouteThreshold(name string, threshold float64) error {
134+
sr.mu.Lock()
135+
defer sr.mu.Unlock()
136+
for i := range sr.routes {
137+
if sr.routes[i].Name == name {
138+
sr.routes[i].Threshold = threshold
139+
slog.Info("semantic router: threshold updated", "route", name, "threshold", threshold)
140+
return nil
141+
}
142+
}
143+
return fmt.Errorf("semantic router: route %q not found", name)
144+
}
145+
128146
// Initialize computes centroids for all registered routes by embedding each
129147
// utterance and averaging the resulting vectors. This must be called before
130148
// Route; it may also be called again after AddRoute to update centroids.
@@ -150,9 +168,33 @@ func (sr *SemanticRouter) Initialize(ctx context.Context) error {
150168
}
151169

152170
slog.Info("semantic router: initialization complete", "routes", len(sr.routes))
171+
sr.initialized.Store(true)
153172
return nil
154173
}
155174

175+
// IsInitialized reports whether centroids have been successfully computed.
176+
// Safe to call from any goroutine.
177+
func (sr *SemanticRouter) IsInitialized() bool {
178+
return sr.initialized.Load()
179+
}
180+
181+
// TryInitialize attempts to initialize the router if it has not been
182+
// initialized yet. Returns (true, nil) when initialization succeeds,
183+
// (false, nil) when the router was already initialized, and (false, err)
184+
// when initialization fails. Thread-safe: concurrent calls are safe because
185+
// Initialize holds the write lock, but only one caller will observe the
186+
// centroids being computed — subsequent callers after the first success
187+
// will return (false, nil).
188+
func (sr *SemanticRouter) TryInitialize(ctx context.Context) (bool, error) {
189+
if sr.initialized.Load() {
190+
return false, nil
191+
}
192+
if err := sr.Initialize(ctx); err != nil {
193+
return false, err
194+
}
195+
return true, nil
196+
}
197+
156198
// Route classifies query and returns a RoutingDecision. The pipeline executed
157199
// depends on the active RoutingMode:
158200
// - RoutingModeCascade: Tier1 → Tier2 → Tier3
@@ -269,13 +311,15 @@ func (sr *SemanticRouter) tier2Embedding(ctx context.Context, query string) (Rou
269311
return RoutingDecision{}, false, fmt.Errorf("tier2: embed query: %w", err)
270312
}
271313

314+
allScores := make(map[string]float64, len(routes))
272315
var best RouteMatch
273316
found := false
274317
for _, route := range routes {
275318
if len(route.Centroid) == 0 {
276319
continue
277320
}
278321
sim := cosineSimilarityLocal(vec, route.Centroid)
322+
allScores[route.Name] = sim
279323
if !found || sim > best.Similarity {
280324
best = RouteMatch{Route: route, Similarity: sim}
281325
found = true
@@ -291,8 +335,15 @@ func (sr *SemanticRouter) tier2Embedding(ctx context.Context, query string) (Rou
291335
threshold = best.Route.Threshold
292336
}
293337

338+
slog.Debug("semantic router: tier2 scores",
339+
"query_len", len(query),
340+
"best_route", best.Route.Name,
341+
"best_similarity", best.Similarity,
342+
"threshold", threshold,
343+
"matched", best.Similarity >= threshold,
344+
)
345+
294346
if best.Similarity < threshold {
295-
slog.Debug("semantic router: tier2 no match", "best_route", best.Route.Name, "similarity", best.Similarity, "threshold", threshold)
296347
return RoutingDecision{}, false, nil
297348
}
298349

@@ -303,6 +354,7 @@ func (sr *SemanticRouter) tier2Embedding(ctx context.Context, query string) (Rou
303354
Fallback: best.Route.Fallback,
304355
Confidence: best.Similarity,
305356
Category: category,
357+
Scores: allScores,
306358
Reasoning: []string{
307359
fmt.Sprintf("tier2: embedding match route=%s similarity=%.4f threshold=%.4f",
308360
best.Route.Name, best.Similarity, threshold),

server/handle_admin_routing.go

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,62 @@ func (s *Server) handleAdminSetRoutingMode(c *gin.Context) {
154154
})
155155
}
156156

157+
// handleAdminSetRouteThreshold updates the similarity threshold for a named route.
158+
//
159+
// POST /admin/routing/threshold
160+
//
161+
// Request body:
162+
//
163+
// {"route": "deep_inference", "threshold": 0.62}
164+
//
165+
// Response 200 — updated route summary list.
166+
// Response 400 — missing fields, unknown route, or threshold out of [0.0, 1.0].
167+
// Response 503 — semantic router not initialised.
168+
func (s *Server) handleAdminSetRouteThreshold(c *gin.Context) {
169+
if s.semanticRouter == nil {
170+
slog.Warn("handleAdminSetRouteThreshold: semanticRouter not initialised")
171+
c.JSON(http.StatusServiceUnavailable, gin.H{
172+
"error": "semantic router not initialised",
173+
})
174+
return
175+
}
176+
177+
var req struct {
178+
Route string `json:"route" binding:"required"`
179+
Threshold float64 `json:"threshold" binding:"required"`
180+
}
181+
if err := c.ShouldBindJSON(&req); err != nil {
182+
slog.Warn("handleAdminSetRouteThreshold: invalid request body", "error", err)
183+
c.JSON(http.StatusBadRequest, gin.H{
184+
"error": "request body must contain 'route' and 'threshold' fields",
185+
})
186+
return
187+
}
188+
189+
if req.Threshold < 0.0 || req.Threshold > 1.0 {
190+
c.JSON(http.StatusBadRequest, gin.H{
191+
"error": "threshold must be in [0.0, 1.0]",
192+
})
193+
return
194+
}
195+
196+
if err := s.semanticRouter.SetRouteThreshold(req.Route, req.Threshold); err != nil {
197+
slog.Warn("handleAdminSetRouteThreshold: route not found", "route", req.Route)
198+
c.JSON(http.StatusBadRequest, gin.H{
199+
"error": err.Error(),
200+
})
201+
return
202+
}
203+
204+
slog.Info("admin: route threshold updated", "route", req.Route, "threshold", req.Threshold)
205+
summaries := buildRouteSummaries(s.semanticRouter.GetRoutes())
206+
c.JSON(http.StatusOK, gin.H{
207+
"route": req.Route,
208+
"threshold": req.Threshold,
209+
"routes": summaries,
210+
})
211+
}
212+
157213
// handleAdminRoutingStats returns routing statistics and a route inventory.
158214
//
159215
// GET /admin/routing/stats
@@ -189,6 +245,7 @@ func (s *Server) handleAdminRoutingStats(c *gin.Context) {
189245
c.JSON(http.StatusOK, gin.H{
190246
"current_mode": currentMode.String(),
191247
"route_count": len(routes),
248+
"initialized": s.semanticRouter.IsInitialized(),
192249
"stats": nil,
193250
})
194251
}

server/handle_gateway.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1092,6 +1092,20 @@ func (s *Server) handleSetProviderKey(c *gin.Context) {
10921092
if s.pluginManager != nil {
10931093
s.hotRegisterProvider(req.Provider, req.Key)
10941094
}
1095+
1096+
// If the semantic router exists but hasn't been initialized yet,
1097+
// attempt lazy initialization now that a new provider is available.
1098+
if s.semanticRouter != nil && !s.semanticRouter.IsInitialized() {
1099+
go func() {
1100+
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
1101+
defer cancel()
1102+
if ok, err := s.semanticRouter.TryInitialize(ctx); err != nil {
1103+
slog.Warn("semantic router auto-init failed", "error", err)
1104+
} else if ok {
1105+
slog.Info("semantic router auto-initialized after provider registration")
1106+
}
1107+
}()
1108+
}
10951109
}
10961110

10971111
// Persist

server/handlers/chat.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"io"
88
"log/slog"
99
"net/http"
10+
"sort"
1011
"strings"
1112
"time"
1213

@@ -192,6 +193,11 @@ func (h *ChatHandler) Chat(c *gin.Context) {
192193
"reasoning", decision.Reasoning,
193194
)
194195

196+
// Emit per-route similarity scores as a debug header when Tier 2 was used.
197+
if len(decision.Scores) > 0 {
198+
c.Header("X-Route-Scores", formatRouteScores(decision.Scores))
199+
}
200+
195201
// Route based on decision
196202
switch decision.Handler {
197203
case "template":
@@ -731,3 +737,28 @@ func (h *ChatHandler) lookupUserTier(ctx context.Context, userID string) string
731737

732738
return ""
733739
}
740+
741+
// formatRouteScores produces a compact "route=score" string for the top-3
742+
// routes by descending similarity, e.g. "fast_inference=0.82,direct_response=0.71,deep_inference=0.65".
743+
// Intended for the X-Route-Scores debug header.
744+
func formatRouteScores(scores map[string]float64) string {
745+
type routeScore struct {
746+
name string
747+
score float64
748+
}
749+
pairs := make([]routeScore, 0, len(scores))
750+
for name, score := range scores {
751+
pairs = append(pairs, routeScore{name, score})
752+
}
753+
sort.Slice(pairs, func(i, j int) bool {
754+
return pairs[i].score > pairs[j].score
755+
})
756+
if len(pairs) > 3 {
757+
pairs = pairs[:3]
758+
}
759+
parts := make([]string, len(pairs))
760+
for i, p := range pairs {
761+
parts[i] = fmt.Sprintf("%s=%.4f", p.name, p.score)
762+
}
763+
return strings.Join(parts, ",")
764+
}

server/router.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -249,6 +249,7 @@ func (s *Server) setupRoutes() {
249249
admin.GET("/routing/mode", s.handleAdminRoutingMode)
250250
admin.POST("/routing/mode", s.handleAdminSetRoutingMode)
251251
admin.GET("/routing/stats", s.handleAdminRoutingStats)
252+
admin.POST("/routing/threshold", s.handleAdminSetRouteThreshold)
252253

253254
// User management (Wave 1)
254255
admin.GET("/users", s.handleAdminListUsers)

0 commit comments

Comments
 (0)