Files
fathom/internal/insights/embed.go
zzg bf955e5ca4 Use a local model for insights when one is already running
You asked whether insights could run against a local model, and the answer
changes the calculus. I had assessed the Anthropic API, which needs a
credential and sends data off the machine, and embedding a model in the
binary, which costs ~90MB and a dependency. An already-running local server
has none of those: no credential, no per-call cost, nothing leaving the
machine, and nothing beyond the standard library talking to localhost. My
"no LLM" conclusion was right for the options I had in view and stated too
broadly.

Discovers Ollama, LM Studio, or llama.cpp by probing OLLAMA_HOST,
FATHOM_LOCAL_MODEL_URL, then the three default ports, with a short timeout.
Finding nothing is the ordinary case, not an error: there is no warning, no
configuration required, and the tab behaves exactly as it did.

Used for two things:

- Grouping prompts by meaning rather than shared vocabulary. MinHash has a
  provable blind spot -- "diagnose the intermittent failure" and "fix the
  flaky test" mean the same thing and share no words -- and embeddings close
  it. This does affect what gets found, so the method used is shown on every
  clustered finding and MinHash is the fallback on any failure.
- Naming a group, so a finding reads "Flaky test triage - asked 10 times"
  rather than "You asked for much the same thing 10 times". This was the one
  gap I identified as needing semantics and dismissed as not worth a
  dependency; at zero cost it is worth it.

The line the model does not cross: it may write a phrase, never a number.
Detection stays entirely heuristic, so occurrence counts, costs, and the
evidence behind them are identical with or without it. A model that could
invent an occurrence count would poison the only thing making this tab worth
trusting. Tests assert the numbers survive labelling, that a failed or
rambling label leaves the heuristic title in place, and that sequence
findings are never sent for naming since their steps already name them.

cleanLabel absorbs what small models actually do -- preambles, reasoning
blocks, quotes, "Common theme:" prefixes -- and rejects anything
sentence-shaped, because a missing label costs polish while a rambling one
would be shown as fact.

Verified end to end against a stand-in server: findings went from "You asked
for much the same thing 10 times" to "Endpoint pagination work - asked 10
times", with the counts unchanged. Verified the toggle reverts detection to
lexical rather than only changing the wording, and that killing the model
server leaves the tab working with no warning.
2026-09-15 22:45:23 -07:00

170 lines
4.5 KiB
Go

package insights
import (
"context"
"math"
"sort"
)
// Embedder produces one vector per input string.
//
// An interface rather than a concrete client so the detectors stay pure
// functions of their input: tests supply a deterministic fake, production
// supplies a local model, and a nil Embedder is the ordinary case where
// clustering falls back to MinHash.
type Embedder interface {
Embed(ctx context.Context, texts []string) ([][]float32, error)
}
// ClusterMethod records how a cluster was actually formed.
//
// It is reported rather than hidden because it changes what the finding means.
// MinHash clusters share vocabulary; embedding clusters may share only meaning.
// A reader comparing two machines should be able to see why they disagree.
type ClusterMethod string
const (
MethodMinHash ClusterMethod = "lexical"
MethodEmbedding ClusterMethod = "semantic"
)
// embedSimilarity is the cosine threshold for embedding clusters.
//
// Higher than the MinHash threshold because cosine similarity between short
// task descriptions runs high generally — unrelated developer prompts share
// enough register to sit around 0.6, so a threshold tuned for MinHash's
// distribution would group everything.
const embedSimilarity = 0.82
// clusterPrompts groups related prompts, preferring semantics when a model is
// available and falling back to vocabulary overlap when it is not.
//
// Any failure — no model, a timeout, a wrong-sized response — returns the
// MinHash result. The fallback is silent by design: a local model going away
// should quietly cost some clustering quality, not produce an error in a
// background analysis.
func clusterPrompts(ctx context.Context, emb Embedder, prompts []string, minhashThreshold float64) ([][]int, ClusterMethod) {
lexical := func() [][]int {
sigs := make([]signature, len(prompts))
for i, p := range prompts {
sigs[i] = minhash(shingle(canonPrompt(p), shingleSize))
}
return clusterBySimilarity(sigs, minhashThreshold)
}
if emb == nil {
return lexical(), MethodMinHash
}
// Only non-empty prompts are sent. Blank ones cannot cluster meaningfully
// and would otherwise all embed to the same vector and group together.
idx := make([]int, 0, len(prompts))
texts := make([]string, 0, len(prompts))
for i, p := range prompts {
if len(canonPrompt(p)) == 0 {
continue
}
idx = append(idx, i)
texts = append(texts, p)
}
if len(texts) < 2 {
return lexical(), MethodMinHash
}
vecs, err := emb.Embed(ctx, texts)
if err != nil || len(vecs) != len(texts) {
return lexical(), MethodMinHash
}
for _, v := range vecs {
if len(v) == 0 {
return lexical(), MethodMinHash
}
}
groups := clusterByCosine(vecs, embedSimilarity)
// Map back to the caller's indices.
out := make([][]int, 0, len(groups))
for _, g := range groups {
members := make([]int, 0, len(g))
for _, m := range g {
members = append(members, idx[m])
}
sort.Ints(members)
out = append(out, members)
}
sort.SliceStable(out, func(i, j int) bool { return len(out[i]) > len(out[j]) })
return out, MethodEmbedding
}
// clusterByCosine mirrors clusterBySimilarity: greedy assignment against
// cluster seeds, so a cluster keeps a representative and the cost stays linear
// in the number of clusters rather than quadratic in the number of items.
func clusterByCosine(vecs [][]float32, threshold float64) [][]int {
normed := make([][]float32, len(vecs))
for i, v := range vecs {
normed[i] = normalize(v)
}
type group struct {
seed []float32
members []int
}
var groups []group
for i, v := range normed {
if v == nil {
continue
}
placed := false
for g := range groups {
if dot(groups[g].seed, v) >= threshold {
groups[g].members = append(groups[g].members, i)
placed = true
break
}
}
if !placed {
groups = append(groups, group{seed: v, members: []int{i}})
}
}
out := make([][]int, 0, len(groups))
for _, g := range groups {
if len(g.members) < 2 {
continue
}
out = append(out, g.members)
}
return out
}
// normalize returns a unit vector, or nil for a zero vector — which would
// otherwise have cosine similarity 1 with everything and collapse the clusters.
func normalize(v []float32) []float32 {
var sum float64
for _, x := range v {
sum += float64(x) * float64(x)
}
if sum == 0 {
return nil
}
n := float32(1 / math.Sqrt(sum))
out := make([]float32, len(v))
for i, x := range v {
out[i] = x * n
}
return out
}
func dot(a, b []float32) float64 {
if len(a) != len(b) {
return 0
}
var sum float64
for i := range a {
sum += float64(a[i]) * float64(b[i])
}
return sum
}