161 lines
5.0 KiB
Go
161 lines
5.0 KiB
Go
package router
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"math"
|
|
"sort"
|
|
)
|
|
|
|
// ErrNoIntents — the classifier has no seeded examples; cannot classify.
|
|
// The daemon seeds ~10 examples/intent at bootstrap (per spec). Until then
|
|
// every free-form utterance routes to clarify-or-ask, never to a guess.
|
|
var ErrNoIntents = errors.New("router: no intents seeded")
|
|
|
|
// Example — one labeled utterance + its embedding. The classifier is
|
|
// append-only: misroute correction = AddExample for the corrected intent
|
|
// (per spec — "append-only, grows the classifier as used. more reliable over
|
|
// time, no retrain"). The note stays as provenance; the router never silently
|
|
// rewires a label.
|
|
type Example struct {
|
|
Text string
|
|
Vec []float32
|
|
}
|
|
|
|
// Result — one scored intent from Classify. Score is cosine similarity in
|
|
// [-1,1]; higher = closer to that intent's centroid.
|
|
type Result struct {
|
|
Intent Intent
|
|
Score float64
|
|
}
|
|
|
|
// Classifier — nearest-centroid over labeled intents. One forward pass (the
|
|
// embed) yields a vector; cosine similarity to each intent's centroid (mean
|
|
// of its examples) gives a score; max wins. ~10 examples/intent is the spec's
|
|
// bootstrap target.
|
|
//
|
|
// Pure given the Embedder: the only I/O is the embed call itself. Centroid
|
|
// math is deterministic and unit-testable with a fake embedder. The cascade
|
|
// (router.go) applies the confidence threshold; the classifier just scores.
|
|
type Classifier struct {
|
|
embedder Embedder
|
|
examples map[Intent][]Example
|
|
centroids map[Intent][]float32
|
|
dim int
|
|
}
|
|
|
|
func NewClassifier(e Embedder) *Classifier {
|
|
return &Classifier{
|
|
embedder: e,
|
|
examples: make(map[Intent][]Example),
|
|
centroids: make(map[Intent][]float32),
|
|
dim: e.Dim(),
|
|
}
|
|
}
|
|
|
|
// AddExample — appends a labeled example and recomputes that intent's centroid.
|
|
// Misroute correction calls this with the corrected intent. Production path.
|
|
func (c *Classifier) AddExample(ctx context.Context, intent Intent, text string) error {
|
|
vec, err := c.embedder.Embed(ctx, text)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
c.addVec(intent, text, vec)
|
|
return nil
|
|
}
|
|
|
|
// AddExampleVec — for tests that want to skip the embedder (inject vectors
|
|
// directly). Keeps the classifier pure under a fake embedder without re-running
|
|
// the hash. Not used by the production cascade.
|
|
func (c *Classifier) AddExampleVec(intent Intent, text string, vec []float32) {
|
|
c.addVec(intent, text, vec)
|
|
}
|
|
|
|
func (c *Classifier) addVec(intent Intent, text string, vec []float32) {
|
|
c.examples[intent] = append(c.examples[intent], Example{Text: text, Vec: vec})
|
|
c.centroids[intent] = meanVec(c.examples[intent])
|
|
}
|
|
|
|
// Examples — read-only view of seeded examples per intent. Introspectable: the
|
|
// daemon surfaces "what maven has been taught" through an authed surface.
|
|
func (c *Classifier) Examples(intent Intent) []Example {
|
|
return c.examples[intent]
|
|
}
|
|
|
|
// Intents — the set of intents with at least one seeded example.
|
|
func (c *Classifier) Intents() []Intent {
|
|
out := make([]Intent, 0, len(c.centroids))
|
|
for k := range c.centroids {
|
|
out = append(out, k)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
|
|
return out
|
|
}
|
|
|
|
// Classify — embeds the utterance and returns all intents scored by cosine
|
|
// similarity to their centroid, sorted best-first (ties broken by intent asc
|
|
// for determinism). The caller applies the confidence threshold (stage 3).
|
|
// Returns ErrNoIntents if no examples have been seeded.
|
|
func (c *Classifier) Classify(ctx context.Context, utterance string) ([]Result, error) {
|
|
if len(c.centroids) == 0 {
|
|
return nil, ErrNoIntents
|
|
}
|
|
vec, err := c.embedder.Embed(ctx, utterance)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
results := make([]Result, 0, len(c.centroids))
|
|
for intent, centroid := range c.centroids {
|
|
results = append(results, Result{Intent: intent, Score: cosine(vec, centroid)})
|
|
}
|
|
sort.Slice(results, func(i, j int) bool {
|
|
if results[i].Score != results[j].Score {
|
|
return results[i].Score > results[j].Score
|
|
}
|
|
return results[i].Intent < results[j].Intent
|
|
})
|
|
return results, nil
|
|
}
|
|
|
|
// meanVec — L2-normalized mean of a set of example vectors. Normalizing the
|
|
// centroid keeps cosine = dot product against normalized query vectors, and
|
|
// stops high-example-count intents from dominating purely by magnitude.
|
|
func meanVec(exs []Example) []float32 {
|
|
if len(exs) == 0 {
|
|
return nil
|
|
}
|
|
m := make([]float32, len(exs[0].Vec))
|
|
for _, e := range exs {
|
|
for i, x := range e.Vec {
|
|
m[i] += x
|
|
}
|
|
}
|
|
var sum float64
|
|
for i := range m {
|
|
m[i] /= float32(len(exs))
|
|
sum += float64(m[i]) * float64(m[i])
|
|
}
|
|
if sum == 0 {
|
|
return m
|
|
}
|
|
inv := float32(1.0 / math.Sqrt(sum))
|
|
for i := range m {
|
|
m[i] *= inv
|
|
}
|
|
return m
|
|
}
|
|
|
|
// cosine — both inputs are L2-normalized ⇒ dot product == cosine similarity.
|
|
// Mismatched/empty dimensions return 0 (no signal), which the threshold gate
|
|
// turns into clarify — never into a confident wrong write.
|
|
func cosine(a, b []float32) float64 {
|
|
if len(a) != len(b) || len(a) == 0 {
|
|
return 0
|
|
}
|
|
var dot float64
|
|
for i := range a {
|
|
dot += float64(a[i]) * float64(b[i])
|
|
}
|
|
return dot
|
|
}
|