@@ -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.
7880type 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 ),
0 commit comments