diff --git a/cmd/mavend/actions_query.go b/cmd/mavend/actions_query.go index c8d8514..48fb523 100644 --- a/cmd/mavend/actions_query.go +++ b/cmd/mavend/actions_query.go @@ -434,6 +434,14 @@ func (h *reactiveHandler) queryMemory(ctx context.Context, t *queryTurn) (string return "", false } text := hit.Meta["text"] + // The score cleared the gate and the topic still has to match (#470). A + // note about his slow network scored high enough to answer "почему небо + // синее?", because the right-note and must-be-silent score ranges overlap + // and no threshold sits between them. + if !memory.RecallAllowed(t.dec.Utterance, text) { + log.Printf("voice: recall %q rejected for %q: a world question and no shared topic word", text, t.dec.Utterance) + return "", false + } // A note is phrased in Maven's voice; a fact is read back as it was // stored. if hit.Meta["type"] == "note" { @@ -469,6 +477,12 @@ func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string, if !memory.ConfidentScores(noteScores, h.queryMinScore, h.queryMinMargin) { return "", false } + // Same topic veto as queryMemory above: the best note must be about what + // he asked, not merely the nearest vector in the index. + if !memory.RecallAllowed(t.dec.Utterance, notes[0].Text) { + log.Printf("voice: note %q rejected for %q: a world question and no shared topic word", notes[0].Text, t.dec.Utterance) + return "", false + } texts := make([]string, len(notes)) for i, n := range notes { texts[i] = n.Text diff --git a/internal/memory/recalleval/recalleval.go b/internal/memory/recalleval/recalleval.go index fe310e1..1691b49 100644 --- a/internal/memory/recalleval/recalleval.go +++ b/internal/memory/recalleval/recalleval.go @@ -378,7 +378,7 @@ func scoreCase(ctx context.Context, emb router.Embedder, newStore NewStore, minS if len(hits) > 1 { o.Margin = hits[0].Score - hits[1].Score } - o.Recalled = bestRecall(hits, minScore, minMargin) + o.Recalled = bestRecall(c.Query, hits, minScore, minMargin) } for i, h := range hits { if h.ID != c.Want { @@ -424,11 +424,19 @@ func rankNote(inTop3 bool) string { // is not importable; recalleval_test.go asserts the two agree in behaviour. // The daemon returns the whole hit (a note and a fact are said differently); // the harness only scores what came back, so it keeps returning the text. -func bestRecall(results []memory.Result, minScore, minMargin float64) string { +// bestRecall mirrors the daemon's gate in cmd/mavend/recall.go, including the +// topic veto added for #470: a score that clears the gate still has to be +// about what he asked. Keep the two in step — a fixture that measures a +// weaker gate than the daemon runs flatters it. +func bestRecall(query string, results []memory.Result, minScore, minMargin float64) string { if !memory.Confident(results, minScore, minMargin) { return "" } - return results[0].Meta["text"] + text := results[0].Meta["text"] + if !memory.RecallAllowed(query, text) { + return "" + } + return text } func bump(m map[string]TagStat, key string, pass bool) { diff --git a/internal/memory/recalleval/recalleval_test.go b/internal/memory/recalleval/recalleval_test.go index 95e7449..e9cea1c 100644 --- a/internal/memory/recalleval/recalleval_test.go +++ b/internal/memory/recalleval/recalleval_test.go @@ -140,21 +140,22 @@ func words(s string) []string { // TestBestRecallMatchesDaemon — the harness duplicates bestRecall from // cmd/mavend/recall.go (package main is not importable). This pins the copy to -// the original's three rules: no hits, below the gate, or no text ⇒ silence. +// the original's rules: no hits, below the gate, no text, or no shared topic +// word ⇒ silence. func TestBestRecallMatchesDaemon(t *testing.T) { - if got := bestRecall(nil, 0.55, 0); got != "" { + if got := bestRecall("чай", nil, 0.55, 0); got != "" { t.Errorf("no hits: got %q, want silence", got) } low := []memory.Result{{ID: "a", Score: 0.4, Meta: map[string]string{"text": "чай"}}} - if got := bestRecall(low, 0.55, 0); got != "" { + if got := bestRecall("чай", low, 0.55, 0); got != "" { t.Errorf("below gate: got %q, want silence", got) } noText := []memory.Result{{ID: "a", Score: 0.9, Meta: map[string]string{}}} - if got := bestRecall(noText, 0.55, 0); got != "" { + if got := bestRecall("чай", noText, 0.55, 0); got != "" { t.Errorf("no text: got %q, want silence", got) } ok := []memory.Result{{ID: "a", Score: 0.9, Meta: map[string]string{"text": "чай"}}} - if got := bestRecall(ok, 0.55, 0); got != "чай" { + if got := bestRecall("чай", ok, 0.55, 0); got != "чай" { t.Errorf("above gate: got %q, want %q", got, "чай") } // Margin: a close runner-up means the embedder cannot tell the two apart, @@ -163,17 +164,23 @@ func TestBestRecallMatchesDaemon(t *testing.T) { {ID: "a", Score: 0.86, Meta: map[string]string{"text": "чай"}}, {ID: "b", Score: 0.85, Meta: map[string]string{"text": "кофе"}}, } - if got := bestRecall(close, 0.55, 0.03); got != "" { + if got := bestRecall("чай", close, 0.55, 0.03); got != "" { t.Errorf("thin margin: got %q, want silence", got) } - if got := bestRecall(close, 0.55, 0); got != "чай" { + if got := bestRecall("чай", close, 0.55, 0); got != "чай" { t.Errorf("margin off: got %q, want %q", got, "чай") } + // The topic veto (#470): the score is fine and the note is about + // something else. + offTopic := []memory.Result{{ID: "a", Score: 0.9, Meta: map[string]string{"text": "сеть какая-то медленная"}}} + if got := bestRecall("почему небо синее", offTopic, 0.55, 0); got != "" { + t.Errorf("off topic: got %q, want silence", got) + } clear := []memory.Result{ {ID: "a", Score: 0.86, Meta: map[string]string{"text": "чай"}}, {ID: "b", Score: 0.70, Meta: map[string]string{"text": "кофе"}}, } - if got := bestRecall(clear, 0.55, 0.03); got != "чай" { + if got := bestRecall("чай", clear, 0.55, 0.03); got != "чай" { t.Errorf("wide margin: got %q, want %q", got, "чай") } }