Compare commits
18 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 453919db20 | |||
| ad60e10e95 | |||
| 1528697287 | |||
| dbdab2d570 | |||
| b9371dcac6 | |||
| 62c2e92ec0 | |||
| aec94eb2e8 | |||
| 4dfe106fe3 | |||
| 2e0e2fd0bb | |||
| f3fa6b353a | |||
| 6645f64c3e | |||
| f10e0068dd | |||
| 9b124d9194 | |||
| 12530c8a95 | |||
| 51256c4c9a | |||
| 76481c2736 | |||
| bcc2305cd0 | |||
| 0ceeac8df4 |
@@ -36,6 +36,13 @@ stays on homesrv permanently, because it backs that floor. Read `docs/offload.md
|
||||
touching a daemon seam or adding a model caller. Vikunja #483 is the umbrella, #484 to #487
|
||||
are the work.
|
||||
|
||||
Both halves are wired as of 2026-08-03. Routing and replies prefer the workstation silently
|
||||
through `modelSeam`; nudge and reminder phrasing prefer it silently inside the phraser. A
|
||||
world question goes through `LLMPhraser.PhraseWorld` and names the gap when the card is not
|
||||
free — `worldGap` in `cmd/mavend/worldmodel.go`, which he hears instead of an invented
|
||||
answer. A box with no `workstation` block behaves exactly as it did before the seam: naming
|
||||
a gap requires a gap. The offload table in `docs/offload.md` says which caller is which.
|
||||
|
||||
## Build & test
|
||||
|
||||
CGO daemons (`mavend`, `mavsttd`, `mavttsd`, `mavenclient`) need the vendored toolchain
|
||||
|
||||
+50
-14
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
// actionFact handles router.IntentFact: persist a tapped self-fact, index
|
||||
@@ -15,14 +16,41 @@ func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) s
|
||||
if !dec.Slots.HasKey {
|
||||
return "не разобрала, что записать — попробуй иначе."
|
||||
}
|
||||
// A question is never a fact about him (#470). "какая последняя версия
|
||||
// языка Go?" used to land here, and the value stored was whatever the
|
||||
// model invented for it, at confidence 1.00, indexed for recall under the
|
||||
// question's own text. Two such rows then claimed seven unrelated world
|
||||
// questions through recall and silently disabled world answering.
|
||||
//
|
||||
// The routing error itself is not fixed here — the answer is to answer.
|
||||
// Sending the turn down the query chain is what he asked for anyway, and
|
||||
// it costs a mis-routed capture nothing: an explicit "запиши ..." is not
|
||||
// question-shaped, so it never takes this branch.
|
||||
if router.IsQuestionShaped(dec.Utterance) {
|
||||
log.Printf("voice: fact write refused, utterance is a question: %q (key %q) — answering as a query",
|
||||
dec.Utterance, dec.Slots.Key)
|
||||
q := dec
|
||||
q.Intent = router.IntentQuery
|
||||
// The key the model extracted is its guess at what to store, not a
|
||||
// fact he has. Left in place, queryFactByKey would read it back and
|
||||
// claim the turn before any real source ran.
|
||||
q.Slots.Key, q.Slots.HasKey = "", false
|
||||
q.Slots.Value = ""
|
||||
return h.actionQuery(ctx, q)
|
||||
}
|
||||
now := h.now()
|
||||
req := ipc.WriteFactReq{
|
||||
Ts: now,
|
||||
Kind: "self",
|
||||
Key: dec.Slots.Key,
|
||||
Value: dec.Slots.Value,
|
||||
Source: "tap:voice",
|
||||
Confidence: 1.0,
|
||||
Ts: now,
|
||||
Kind: "self",
|
||||
Key: dec.Slots.Key,
|
||||
Value: dec.Slots.Value,
|
||||
Source: "tap:voice",
|
||||
// Not 1.00 unconditionally any more (#470). A value he said is
|
||||
// evidence; a value the model supplied for words he never said is a
|
||||
// guess, and writing a guess at full confidence is the same mistake
|
||||
// the act path already refuses under "LLM output is not
|
||||
// authorization".
|
||||
Confidence: factConfidence(dec.Utterance, dec.Slots.Value),
|
||||
// Subject: the key doubles as the entity-resolution candidate —
|
||||
// a voice-tapped fact's key is usually the thing/person it's
|
||||
// about ("espresso_machine", "kate"), so queueing it for Nexus
|
||||
@@ -36,17 +64,25 @@ func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) s
|
||||
log.Printf("voice: write fact: %v", err)
|
||||
return "не получилось сохранить факт."
|
||||
}
|
||||
// Index the fact utterance in long-term memory (best-effort, must not
|
||||
// fail the fact write). Facts aren't in the notes table, so this is the
|
||||
// only recall path for them — "когда я пил воду?" reads back from here.
|
||||
// Index the fact in long-term memory (best-effort, must not fail the fact
|
||||
// write). Facts aren't in the notes table, so this is the only recall path
|
||||
// for them — "когда я пил воду?" reads back from here.
|
||||
//
|
||||
// The indexed text is the fact, not the utterance (#493). queryMemory
|
||||
// returns a fact's stored text verbatim, so what goes in here is what he
|
||||
// hears; storing the utterance meant recall answered with his own sentence
|
||||
// rather than the value. The utterance stays alongside as provenance —
|
||||
// readable on /trace, never the answer and never embedded.
|
||||
if h.memStore != nil {
|
||||
if vec, err := router.EmbedPassage(ctx, h.embedder, dec.Utterance); err != nil {
|
||||
text := store.FactRecallText(dec.Slots.Key, dec.Slots.Value)
|
||||
if vec, err := router.EmbedPassage(ctx, h.embedder, text); err != nil {
|
||||
log.Printf("voice: embed fact for memory: %v", err)
|
||||
} else if err := h.memStore.Insert(ctx, "fact:"+dec.Slots.Key+":"+strconv.FormatInt(now.Unix(), 10), vec, map[string]string{
|
||||
"source": "voice",
|
||||
"type": "fact",
|
||||
"text": dec.Utterance,
|
||||
"ts": strconv.FormatInt(now.Unix(), 10),
|
||||
"source": "voice",
|
||||
"type": "fact",
|
||||
"text": text,
|
||||
"utterance": dec.Utterance,
|
||||
"ts": strconv.FormatInt(now.Unix(), 10),
|
||||
}); err != nil {
|
||||
log.Printf("voice: memory insert fact: %v", err)
|
||||
}
|
||||
|
||||
+39
-24
@@ -13,6 +13,7 @@ import (
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/memory"
|
||||
"github.com/kami/maven/internal/morning"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/rss"
|
||||
"github.com/kami/maven/internal/store"
|
||||
@@ -433,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" {
|
||||
@@ -468,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
|
||||
@@ -523,10 +538,7 @@ func (h *reactiveHandler) queryWeb(ctx context.Context, t *queryTurn) (string, b
|
||||
// the question he actually asked. She answers the question, she does not
|
||||
// recite the page.
|
||||
snippet := page.Title + "\n" + crawl.TrimRunes(page.Text, webPageContextRunes)
|
||||
reply, perr := h.phraser.PhraseQuery(ctx, t.dec.Utterance, []string{snippet})
|
||||
if perr != nil {
|
||||
log.Printf("voice: web: phrase: %v", perr)
|
||||
}
|
||||
reply := h.phraseSource(ctx, "web", t.dec.Utterance, []string{snippet})
|
||||
if reply == "" {
|
||||
// No phraser (or it failed): read back the top of the page rather than
|
||||
// pretend the fetch did not happen.
|
||||
@@ -592,14 +604,7 @@ func (h *reactiveHandler) querySearch(ctx context.Context, t *queryTurn) (string
|
||||
// question he asked, not something to recite. The trim is one budget over the
|
||||
// joined block, so a long first snippet cannot crowd out the rest.
|
||||
evidence := crawl.TrimRunes(strings.Join(resp.Snippets(), "\n"), h.search.runes)
|
||||
var reply string
|
||||
if h.phraser != nil {
|
||||
var perr error
|
||||
reply, perr = h.phraser.PhraseQuery(ctx, t.dec.Utterance, []string{evidence})
|
||||
if perr != nil {
|
||||
log.Printf("voice: search: phrase: %v", perr)
|
||||
}
|
||||
}
|
||||
reply := h.phraseSource(ctx, "search", t.dec.Utterance, []string{evidence})
|
||||
if reply == "" {
|
||||
// No phraser, or it failed. Read back the best evidence rather than
|
||||
// pretend the search did not happen.
|
||||
@@ -680,14 +685,7 @@ func (h *reactiveHandler) queryKiwix(ctx context.Context, t *queryTurn) (string,
|
||||
// Handed over the same way a note or a page is: context for the question he
|
||||
// asked, not something to recite.
|
||||
snippet := top.Title + "\n" + crawl.TrimRunes(page.Text, h.kiwix.runes)
|
||||
var reply string
|
||||
if h.phraser != nil {
|
||||
var perr error
|
||||
reply, perr = h.phraser.PhraseQuery(ctx, t.dec.Utterance, []string{snippet})
|
||||
if perr != nil {
|
||||
log.Printf("voice: kiwix: phrase: %v", perr)
|
||||
}
|
||||
}
|
||||
reply := h.phraseSource(ctx, "kiwix", t.dec.Utterance, []string{snippet})
|
||||
if reply == "" {
|
||||
// No phraser, or it failed. Read back the best hit rather than pretend
|
||||
// the search did not happen.
|
||||
@@ -755,11 +753,28 @@ func isPersonalQuery(utterance string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// queryGeneral — general knowledge from the phraser, the last source before
|
||||
// giving up. It always claims: either the model answers or Maven says she
|
||||
// doesn't know.
|
||||
// queryGeneral — general knowledge, the last source before giving up. It always
|
||||
// claims: either a model answers, or Maven names the gap, or she says she does
|
||||
// not know.
|
||||
//
|
||||
// This is the sharpest case for the naming half. Nothing has been fetched, so
|
||||
// there is no passage to fall back on and no floor under the answer except the
|
||||
// model's weights — and a 1.7B's weights are where the invented answers come
|
||||
// from. With a workstation configured and asleep he is told that, rather than
|
||||
// told something false in a confident voice. With no workstation configured at
|
||||
// all the resident model answers exactly as it does today: naming a gap requires
|
||||
// a gap, and on that box the 1.7B is the whole product.
|
||||
func (h *reactiveHandler) queryGeneral(ctx context.Context, t *queryTurn) (string, bool) {
|
||||
reply, err := h.phraser.PhraseQuery(ctx, t.dec.Utterance, nil)
|
||||
if h.phraser == nil {
|
||||
// No model of any size. That is not the workstation being asleep, so it
|
||||
// is not that gap: it is simply not knowing.
|
||||
return "не знаю.", true
|
||||
}
|
||||
reply, err := h.phraseWorld(ctx, t.dec.Utterance, nil)
|
||||
if errors.Is(err, phraser.ErrNoWorldModel) {
|
||||
log.Printf("voice: %q needs the world model and it is not available", t.dec.Utterance)
|
||||
return worldGap, true
|
||||
}
|
||||
if err != nil || reply == "" {
|
||||
return "не знаю.", true
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"log"
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// ungroundedConfidence — what a self fact is worth when its value appears
|
||||
// nowhere in what he said. Below `query_min_score` is not the point (recall
|
||||
// gates on vector distance, not on this number); the point is that
|
||||
// `/history` and every future reader can tell a value he said from a value
|
||||
// the model supplied.
|
||||
const ungroundedConfidence = 0.6
|
||||
|
||||
// factConfidence scores a self fact by whether its value is grounded in the
|
||||
// utterance it came from. Grounded stays 1.00, which is what a tapped fact
|
||||
// has always been worth. Ungrounded drops, and says so in the log.
|
||||
//
|
||||
// An empty value is grounded by definition: the key alone carries the fact
|
||||
// ("поужинал"), and there is nothing for the model to have invented.
|
||||
func factConfidence(utterance, value string) float64 {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return 1.0
|
||||
}
|
||||
if valueGrounded(utterance, value) {
|
||||
return 1.0
|
||||
}
|
||||
log.Printf("voice: fact value %q is not in %q — writing at confidence %.2f",
|
||||
value, utterance, ungroundedConfidence)
|
||||
return ungroundedConfidence
|
||||
}
|
||||
|
||||
// valueGrounded reports whether every word of value traces back to a word he
|
||||
// actually said. The comparison is on a 4-rune prefix, so the model's
|
||||
// normalization survives ("пил воду" → "вода") while an invented value
|
||||
// ("1.20" for a question about Go) does not.
|
||||
func valueGrounded(utterance, value string) bool {
|
||||
said := factTokens(utterance)
|
||||
words := factTokens(value)
|
||||
if len(words) == 0 {
|
||||
return true
|
||||
}
|
||||
for _, w := range words {
|
||||
if !anyTokenMatches(said, w) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func anyTokenMatches(said []string, w string) bool {
|
||||
for _, s := range said {
|
||||
if s == w || sameStem(s, w) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// sameStem is inflection tolerance and nothing more: it compares all but the
|
||||
// last rune of the shorter word, and never fewer than three. Russian marks
|
||||
// case on the ending, so "пил воду" and the stored "вода" are the same word he
|
||||
// said, while "1.20" and "версия" are not. A word of three runes or fewer must
|
||||
// match outright, where a shorter prefix would match half the language.
|
||||
func sameStem(a, b string) bool {
|
||||
ar, br := []rune(a), []rune(b)
|
||||
shorter := min(len(ar), len(br))
|
||||
n := shorter - 1
|
||||
if n < 3 || len(ar) < n || len(br) < n {
|
||||
return false
|
||||
}
|
||||
return string(ar[:n]) == string(br[:n])
|
||||
}
|
||||
|
||||
// factTokens lowercases and splits on everything that is not a letter or a
|
||||
// digit, the same shape planTokens uses in the router.
|
||||
func factTokens(s string) []string {
|
||||
return strings.FieldsFunc(strings.ToLower(s), func(r rune) bool {
|
||||
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/memory"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/tool"
|
||||
"github.com/kami/maven/internal/voice"
|
||||
)
|
||||
|
||||
func newFactGateHandler(t *testing.T, now time.Time) (*reactiveHandler, ipc.CoreAPI) {
|
||||
t.Helper()
|
||||
st := newTestStore(t)
|
||||
api := ipc.NewStoreAPI(st)
|
||||
emb := router.NewHashEmbedder(1024)
|
||||
h := &reactiveHandler{
|
||||
api: api,
|
||||
embedder: emb,
|
||||
router: buildRouter(emb, tool.NewMatcher(api), 0.55, nil),
|
||||
replier: voice.NewStubReplier(),
|
||||
now: func() time.Time { return now },
|
||||
memStore: memory.NewInMemoryStore(),
|
||||
dataStore: st,
|
||||
}
|
||||
return h, api
|
||||
}
|
||||
|
||||
// The write half of #470: a question routed to IntentFact must not become a
|
||||
// fact about him, and must not leave a vector behind for recall to serve.
|
||||
func TestActionFact_QuestionIsNotWritten(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, api := newFactGateHandler(t, time.Now())
|
||||
|
||||
reply := h.actionFact(ctx, router.Decision{
|
||||
Intent: router.IntentFact,
|
||||
Utterance: "какая последняя версия языка Go?",
|
||||
Slots: router.Slots{Key: "go_version", HasKey: true, Value: `"1.20"`},
|
||||
})
|
||||
|
||||
if _, err := api.LatestFact(ctx, "go_version"); err == nil {
|
||||
t.Fatal("a question was stored as a fact about him")
|
||||
}
|
||||
hits, err := h.memStore.Search(ctx, mustEmbedPassage(t, h, "какая последняя версия языка Go?"), 3)
|
||||
if err != nil {
|
||||
t.Fatalf("memory search: %v", err)
|
||||
}
|
||||
if len(hits) != 0 {
|
||||
t.Fatalf("the question was indexed for recall: %+v", hits)
|
||||
}
|
||||
// It went down the query chain instead. Nothing is configured to answer a
|
||||
// world question in this harness, so "не знаю." is the honest outcome —
|
||||
// what matters is that the turn was answered, not stored.
|
||||
if reply == "" {
|
||||
t.Fatal("the turn was neither stored nor answered")
|
||||
}
|
||||
}
|
||||
|
||||
// The capture that must survive the gate: an explicit instruction to record,
|
||||
// even though it contains an interrogative.
|
||||
func TestActionFact_ExplicitCaptureStillWrites(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
h, api := newFactGateHandler(t, time.Now())
|
||||
|
||||
h.actionFact(ctx, router.Decision{
|
||||
Intent: router.IntentFact,
|
||||
Utterance: "запиши что я пил воду",
|
||||
Slots: router.Slots{Key: "water", HasKey: true, Value: `"вода"`},
|
||||
})
|
||||
|
||||
f, err := api.LatestFact(ctx, "water")
|
||||
if err != nil {
|
||||
t.Fatalf("an explicit capture was refused: %v", err)
|
||||
}
|
||||
if f.Confidence != 1.0 {
|
||||
t.Errorf("confidence = %v, want 1.0 for a value he said", f.Confidence)
|
||||
}
|
||||
// #493: what recall reads back is the fact, not the sentence he said.
|
||||
// queryMemory returns a fact's text verbatim, so the utterance sitting here
|
||||
// meant "запиши что я пил воду" was the answer to "когда я пил воду?".
|
||||
hits, err := h.memStore.Search(ctx, mustEmbedPassage(t, h, "вода"), 3)
|
||||
if err != nil {
|
||||
t.Fatalf("memory search: %v", err)
|
||||
}
|
||||
if len(hits) != 1 {
|
||||
t.Fatalf("the fact was not indexed once: %+v", hits)
|
||||
}
|
||||
if got := hits[0].Meta["text"]; got != "water — вода" {
|
||||
t.Errorf("indexed text = %q, want the fact", got)
|
||||
}
|
||||
if got := hits[0].Meta["utterance"]; got != "запиши что я пил воду" {
|
||||
t.Errorf("utterance provenance = %q, want it kept alongside", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFactConfidence(t *testing.T) {
|
||||
cases := []struct {
|
||||
utterance, value string
|
||||
want float64
|
||||
}{
|
||||
{"запиши что я пил воду", `"вода"`, 1.0},
|
||||
{"я выпил кофе", `"кофе"`, 1.0},
|
||||
{"поужинал", "", 1.0},
|
||||
{"отметь что я полил кактус", `"полил кактус"`, 1.0},
|
||||
{"какая последняя версия языка Go", `"1.20"`, ungroundedConfidence},
|
||||
{"кто премьер Японии", `"Тонио Озаки"`, ungroundedConfidence},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := factConfidence(c.utterance, c.value); got != c.want {
|
||||
t.Errorf("factConfidence(%q, %q) = %v, want %v", c.utterance, c.value, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func mustEmbedPassage(t *testing.T, h *reactiveHandler, text string) []float32 {
|
||||
t.Helper()
|
||||
vec, err := router.EmbedQuery(context.Background(), h.embedder, text)
|
||||
if err != nil {
|
||||
t.Fatalf("embed %q: %v", text, err)
|
||||
}
|
||||
return vec
|
||||
}
|
||||
@@ -146,6 +146,7 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
||||
emb = router.NewHashEmbedder(1024)
|
||||
}
|
||||
w.embedder = emb
|
||||
repairFactVectors(dataStore, emb)
|
||||
checkStoredEmbedder(dataStore, emb)
|
||||
|
||||
// ----- tool executor (the enabled act allowlist, store-backed) -----
|
||||
@@ -200,6 +201,13 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
|
||||
// either the pair, or the resident client alone, or nothing at all.
|
||||
hot, pair := modelSeam(cfg, llmClient)
|
||||
w.pair = pair
|
||||
// The phraser gets the same pair, which is what carries the workstation model
|
||||
// into the paths that do not go through `hot`: world questions (the naming
|
||||
// half), and the digestion worker's nudge and reminder phrasing (the silent
|
||||
// half). Wiring, so it happens once and before the voice server listens.
|
||||
if lp, ok := phr.(*phraser.LLMPhraser); ok && pair != nil {
|
||||
lp.UseRemote(pair)
|
||||
}
|
||||
// ----- router (the cascade; floor examples seed the classifier) -----
|
||||
// The act matcher's allowlist is exactly the enabled tool names — the
|
||||
// router only matches acts the executor can run (one source of truth).
|
||||
@@ -466,6 +474,36 @@ func seedTools(api ipc.CoreAPI, tools []config.ToolConfig) {
|
||||
log.Printf("voice: seeded %d act tools from config", n)
|
||||
}
|
||||
|
||||
// repairFactVectors brings stored fact vectors in line with the facts they name
|
||||
// (#493), once per box, before the embedder marker is even looked at.
|
||||
//
|
||||
// Automatic and not a flag, unlike -reembed: only voice-tapped facts are in
|
||||
// this index, so the work is tens of embeddings rather than the thousands of
|
||||
// notes that made the backfill a deliberate act. And the box that needs it is
|
||||
// broken in a way nobody can see — recall answers with the wrong text and
|
||||
// nothing logs an error — so waiting for an operator to know to run it is how
|
||||
// the defect survived four restarts in the first place.
|
||||
func repairFactVectors(dataStore *store.Store, emb router.Embedder) {
|
||||
if dataStore == nil {
|
||||
return
|
||||
}
|
||||
res, err := dataStore.RepairFactVectors(context.Background(),
|
||||
// EmbedPassage, the stored side, same as every other writer of these
|
||||
// vectors.
|
||||
func(ctx context.Context, text string) ([]float32, error) {
|
||||
return router.EmbedPassage(ctx, emb, text)
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("voice: fact vector repair failed, no marker written and nothing half-done — retried next start: %v", err)
|
||||
return
|
||||
}
|
||||
if res.Skipped || res.Rewritten+res.Dropped == 0 {
|
||||
return
|
||||
}
|
||||
log.Printf("voice: fact vector repair — %d re-embedded from the fact they name, %d dropped as voided or superseded, %d already right, took %s (#493)",
|
||||
res.Rewritten, res.Dropped, res.Kept, res.Took.Round(time.Millisecond))
|
||||
}
|
||||
|
||||
// reembedOnStart is the -reembed flag (set in run()). Opt-in on purpose: see
|
||||
// runReembed.
|
||||
var reembedOnStart bool
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
)
|
||||
|
||||
// worldPhraser — the naming half of the degradation rule (docs/offload.md), as
|
||||
// the query sources see it. Only *phraser.LLMPhraser implements it, so the
|
||||
// Stub and every test double stay exactly as they are.
|
||||
type worldPhraser interface {
|
||||
PhraseWorld(ctx context.Context, utterance string, sources []string) (string, error)
|
||||
}
|
||||
|
||||
// worldGap — what he hears when the question is about the world, the workstation
|
||||
// model is the one configured to answer it, and that machine is not answering.
|
||||
//
|
||||
// It says the true thing. The resident 1.7B is not a worse answer here, it is an
|
||||
// invented one: "Война и мир" came back with Левитан as its author, and a
|
||||
// question about his meeting came back as a swimming competition in Nottingham.
|
||||
// Naming the gap is the rule CLAUDE.md already applies to a sibling service
|
||||
// being down.
|
||||
const worldGap = "сейчас не могу ответить — большая модель недоступна, а придумывать не хочу."
|
||||
|
||||
// phraseWorld asks the world model, or reports the gap.
|
||||
//
|
||||
// The three outcomes come straight from LLMPhraser.PhraseWorld: no workstation
|
||||
// configured means the resident model answers as it always has, a workstation
|
||||
// that is up answers, and a workstation that is down returns
|
||||
// phraser.ErrNoWorldModel. A phraser that has no world seam at all — the Stub,
|
||||
// and the doubles in the tests — is the first of those three.
|
||||
func (h *reactiveHandler) phraseWorld(ctx context.Context, utterance string, sources []string) (string, error) {
|
||||
if h.phraser == nil {
|
||||
return "", phraser.ErrNoWorldModel
|
||||
}
|
||||
if w, ok := h.phraser.(worldPhraser); ok {
|
||||
return w.PhraseWorld(ctx, utterance, sources)
|
||||
}
|
||||
return h.phraser.PhraseQuery(ctx, utterance, sources)
|
||||
}
|
||||
|
||||
// phraseSource asks the world model to answer from a passage someone already
|
||||
// fetched — a live search result, a ZIM article, a page he named. It returns ""
|
||||
// rather than the gap phrase, because these callers hold something better than a
|
||||
// gap: the passage itself, which their own floor reads back to him. Nothing is
|
||||
// invented either way, and a real quote beats "не могу сейчас".
|
||||
func (h *reactiveHandler) phraseSource(ctx context.Context, name, utterance string, sources []string) string {
|
||||
reply, err := h.phraseWorld(ctx, utterance, sources)
|
||||
switch {
|
||||
case errors.Is(err, phraser.ErrNoWorldModel):
|
||||
log.Printf("voice: %s: no world model, reading the source back instead", name)
|
||||
return ""
|
||||
case err != nil:
|
||||
log.Printf("voice: %s: phrase: %v", name, err)
|
||||
}
|
||||
return reply
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// gapPhraser — a phraser whose world model is configured and asleep, which is
|
||||
// the state the naming half exists for.
|
||||
type gapPhraser struct {
|
||||
*phraser.Stub
|
||||
worldCalls int
|
||||
}
|
||||
|
||||
func (g *gapPhraser) PhraseWorld(context.Context, string, []string) (string, error) {
|
||||
g.worldCalls++
|
||||
return "", phraser.ErrNoWorldModel
|
||||
}
|
||||
|
||||
func worldTurn(utterance string) *queryTurn {
|
||||
return &queryTurn{dec: router.Decision{Intent: router.IntentQuery, Utterance: utterance}}
|
||||
}
|
||||
|
||||
// A world question with the workstation asleep says so. The resident model is
|
||||
// not asked, because what it produces here is an invention with no signal that
|
||||
// it is one.
|
||||
func TestQueryGeneralNamesTheGap(t *testing.T) {
|
||||
g := &gapPhraser{Stub: phraser.NewStub()}
|
||||
h := &reactiveHandler{phraser: g}
|
||||
reply, ok := h.queryGeneral(context.Background(), worldTurn("почему небо голубое"))
|
||||
if !ok {
|
||||
t.Fatal("queryGeneral passed on the last source in the chain")
|
||||
}
|
||||
if reply != worldGap {
|
||||
t.Fatalf("reply = %q, want the named gap", reply)
|
||||
}
|
||||
if g.worldCalls != 1 {
|
||||
t.Fatalf("PhraseWorld called %d times, want 1", g.worldCalls)
|
||||
}
|
||||
}
|
||||
|
||||
// A phraser with no world seam at all — the Stub, and every box with no
|
||||
// `workstation` block — answers exactly as it did before this seam existed.
|
||||
func TestQueryGeneralWithoutAWorldModelIsUnchanged(t *testing.T) {
|
||||
h := &reactiveHandler{phraser: phraser.NewStub()}
|
||||
reply, ok := h.queryGeneral(context.Background(), worldTurn("почему небо голубое"))
|
||||
if !ok {
|
||||
t.Fatal("queryGeneral passed on the last source in the chain")
|
||||
}
|
||||
if reply != "не знаю." {
|
||||
t.Fatalf("reply = %q, want the Stub's answer", reply)
|
||||
}
|
||||
}
|
||||
|
||||
// The gap is spoken aloud by a Russian voice, so it is Russian, feminine and
|
||||
// informal. "не хочу" and "не могу" are her own verbs; there is no "вы" and no
|
||||
// English in it.
|
||||
func TestWorldGapIsInPersona(t *testing.T) {
|
||||
for _, bad := range []string{"вы", "ваш", "рад ", "дорогой", "милый"} {
|
||||
if strings.Contains(worldGap, bad) {
|
||||
t.Errorf("the gap phrase contains %q: %s", bad, worldGap)
|
||||
}
|
||||
}
|
||||
if strings.ContainsAny(worldGap, "abcdefghijklmnopqrstuvwxyz") {
|
||||
t.Errorf("the gap phrase has Latin letters in it: %s", worldGap)
|
||||
}
|
||||
}
|
||||
|
||||
// The sources that hold a passage read it back rather than name a gap. He gets a
|
||||
// real quote instead of "не могу сейчас", and nothing is invented either way.
|
||||
func TestASourceWithAPassageReadsItBackInsteadOfNamingTheGap(t *testing.T) {
|
||||
g := &gapPhraser{Stub: phraser.NewStub()}
|
||||
h := &reactiveHandler{phraser: g}
|
||||
if got := h.phraseSource(context.Background(), "search", "почему небо голубое",
|
||||
[]string{"Рэлеевское рассеяние."}); got != "" {
|
||||
t.Fatalf("phraseSource = %q, want \"\" so the caller's own floor reads the passage back", got)
|
||||
}
|
||||
if g.worldCalls != 1 {
|
||||
t.Fatalf("PhraseWorld called %d times, want 1", g.worldCalls)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -40,7 +40,7 @@
|
||||
},
|
||||
|
||||
"//workstation": [
|
||||
"The big model on the desk PC (bugmachine, 7900 GRE 16GB), fronted by",
|
||||
"The big model on the desk PC (workpc, 7900 GRE 16GB), fronted by",
|
||||
"mavgpud on port 8080. It runs gemma-4-12b and it is preferred over the",
|
||||
"resident Qwen3-1.7B for routing and replies whenever the card is free.",
|
||||
"The machine is never assumed up: it sleeps, and the card is often held by",
|
||||
|
||||
+45
-16
@@ -1,6 +1,6 @@
|
||||
# Offloading model work to the workstation
|
||||
|
||||
*Last verified: 2026-08-02 @ 5c05163. Living doc: correct it in place, do not append.*
|
||||
*Last verified: 2026-08-03 @ 12530c8. Living doc: correct it in place, do not append.*
|
||||
|
||||
Owner's call, 2026-08-02. Vikunja #483 is the umbrella. Tasks #484 to #487 are the
|
||||
work, and this file holds the shape and the rules all four must obey.
|
||||
@@ -47,6 +47,24 @@ service being down.
|
||||
|
||||
Nothing in between. A turn never breaks on the workstation being asleep.
|
||||
|
||||
Both halves are wired, 03-08-2026. `LLMPhraser.PhraseWorld`
|
||||
(`internal/phraser/world.go`) is the naming half and has three outcomes, not two:
|
||||
|
||||
| State | What he hears |
|
||||
|---|---|
|
||||
| no `workstation` block | the resident model answers, exactly as before the seam existed |
|
||||
| configured, card free | the workstation answers |
|
||||
| configured, asleep or busy | the gap, `worldGap` in `cmd/mavend/worldmodel.go` |
|
||||
|
||||
The first row is the one worth stating. Naming a gap requires a gap. On a box with
|
||||
no second model the 1.7B is the whole product. Refusing every world question there
|
||||
would remove a capability the owner has today.
|
||||
|
||||
A source holding a passage is on the naming half too: a live search, a ZIM
|
||||
article, a page he named. None of them says "не могу сейчас". They read the
|
||||
passage back, which is what `phraseSource` returning `""` selects. A real quote
|
||||
beats a gap, and neither path invents.
|
||||
|
||||
## Admission control, not a scheduler
|
||||
|
||||
There is no GPU arbiter. That is a service with its own failure modes, and nothing
|
||||
@@ -104,17 +122,29 @@ it buys nothing. Four callers:
|
||||
|
||||
## Inventory: what runs a model on homesrv today
|
||||
|
||||
The **resident model** is one llama-server with seven callers:
|
||||
The **resident model** is one llama-server with seven callers, and 03-08-2026 is
|
||||
the date each of them stopped or did not stop being resident-only:
|
||||
|
||||
| Caller | What for |
|
||||
|---|---|
|
||||
| `cmd/mavend/voicewire.go` | routing |
|
||||
| `cmd/mavend/replier_llm.go` | replies |
|
||||
| `cmd/mavend/tick.go` | digestion worker: `PhraseNudge`, `PhraseReminder` |
|
||||
| `cmd/mavend/capture.go` | capture summarisation (unreachable, see #480) |
|
||||
| `cmd/mavend/mail.go` | mail extraction (off, no IMAP) |
|
||||
| `cmd/mavend/kiwixwire.go` | answering from a Kiwix, search or crawl passage |
|
||||
| `memoryeval.go`, `modelswap.go` | admin and evals |
|
||||
| Caller | What for | Offloaded |
|
||||
|---|---|---|
|
||||
| `cmd/mavend/voicewire.go` | routing | silently, through `hot` |
|
||||
| `cmd/mavend/replier_llm.go` | replies | silently, through `hot` |
|
||||
| `cmd/mavend/tick.go` | digestion worker: `PhraseNudge`, `PhraseReminder` | silently, inside the phraser |
|
||||
| `cmd/mavend/actions_query.go` | world questions, and any fetched passage | names the gap |
|
||||
| `cmd/mavend/capture.go` | capture summarisation (unreachable, see #480) | no, holds its own client |
|
||||
| `cmd/mavend/mail.go` | mail extraction (off, no IMAP) | no, holds its own client |
|
||||
| `memoryeval.go`, `modelswap.go` | admin and evals | no, and deliberately |
|
||||
|
||||
The last three rows are resident-only on purpose. `memoryeval.go` and
|
||||
`modelswap.go` measure and swap the resident model, so sending their work
|
||||
elsewhere would measure the wrong thing. `capture.go` and `mail.go` are
|
||||
background jobs that hold a gated background client (`llmBackgroundClientFor`),
|
||||
and that priority has no equivalent on the remote yet. Both are also unreachable
|
||||
on this deploy, so wiring them would ship an untestable path.
|
||||
|
||||
The `tick.go` row needs one caveat. `phraser.llm_nudges` is `false` in deploy, so
|
||||
nudges come from templates and the seam under them changes nothing until that
|
||||
flips. It is wired anyway: `PhraseReminder` is on the same transport and is on.
|
||||
|
||||
Then the embedder above, **whisper.cpp** in `mavsttd`, and **piper** in `mavttsd`.
|
||||
`mavwaked` uses no model at all: an energy-threshold VAD over 30ms frames.
|
||||
@@ -125,11 +155,10 @@ Then the embedder above, **whisper.cpp** in `mavsttd`, and **piper** in `mavttsd
|
||||
`internal/netaddr` landed in PR #92. A seam address now carries its own scheme,
|
||||
and a scheme-less one is still unix. A tcp seam requires a shared token, because
|
||||
the filesystem permission that authenticated the unix socket is gone.
|
||||
2. **The resident model** (#485). Half wired, 02-08-2026. A `workstation` block
|
||||
builds an `llm.Pair` in `modelSeam` (`cmd/mavend/voicewire.go`), and routing
|
||||
and replies complete through it. Both are the silent half of the rule. The
|
||||
naming half is not wired. A world question still goes to the resident model
|
||||
through `PhraseQuery`. That, and the four callers 485 did not reach, are #490.
|
||||
2. **The resident model** (#485, #490). Wired. A `workstation` block builds an
|
||||
`llm.Pair` in `modelSeam` (`cmd/mavend/voicewire.go`), routing and replies
|
||||
complete through it, and the phraser holds the same pair (`UseRemote`). Both
|
||||
halves of the rule are live: see the table above for which caller gets which.
|
||||
Measured, `docs/evals/2026-08-02-workstation-gemma4-12b.md`: gemma-4-12b
|
||||
through the cascade scores 84.4% full accuracy at p50 329ms. The resident
|
||||
model scores 72.7% at p50 0.80-1.04s. On the talk fixture it is 25/27
|
||||
|
||||
+15
-6
@@ -498,12 +498,21 @@ rejects `https://api.openai.com`, and forget really deletes
|
||||
(`internal/store/memory.go:145` is a real `DELETE`, not a tombstone). Vision is
|
||||
19/19, speaker 22/22, media 16/16.
|
||||
|
||||
**470 got worse.** Both poisoned facts show `voided` on `/history`, and the
|
||||
defect survives. Re-measured at 15:42, after four restarts: `почему небо синее?`
|
||||
still answers `какая последняя версия языка Go?` with no `search:` line. What
|
||||
comes back is the question he typed, not the value the fact held. So the poison
|
||||
is a vector in the memory index, and `revert` does not remove it. There is
|
||||
currently no documented way to repair a poisoned box.
|
||||
**470 got worse, then closed.** Both poisoned facts showed `voided` on
|
||||
`/history` and the defect survived. Re-measured at 15:42, after four restarts:
|
||||
`почему небо синее?` still answered `какая последняя версия языка Go?` with no
|
||||
`search:` line. What came back was the question he typed, not the value the fact
|
||||
held. So the poison was a vector in the memory index, and `revert` did not
|
||||
remove it.
|
||||
|
||||
Repaired in two parts. 470 stopped the writes: a question is never a fact, and a
|
||||
void drops the key's vectors. 493 fixed what the index holds. A fact is indexed
|
||||
as the fact and not as the utterance, and a correction drops its superseded
|
||||
vector too.
|
||||
|
||||
A poisoned box now repairs itself on the next start. `RepairFactVectors`
|
||||
re-embeds every fact vector from the fact it names, and deletes the voided and
|
||||
superseded ones. It runs once, guarded by a marker, and logs what it did.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1110,7 +1110,7 @@ const (
|
||||
DefaultKiwixSnippetRunes = 1500
|
||||
)
|
||||
|
||||
// WorkstationConfig — the big model on the owner's desktop (bugmachine, a
|
||||
// WorkstationConfig — the big model on the owner's desktop (workpc, a
|
||||
// 7900 GRE with 16GB), fronted by mavgpud.
|
||||
//
|
||||
// homesrv cannot grow a GPU, so the resident Qwen3-1.7B is the floor and this
|
||||
|
||||
@@ -129,6 +129,11 @@ type Req struct {
|
||||
RepeatPenalty float64
|
||||
// Stop — sequences that end generation early (e.g. newline for a one-liner).
|
||||
Stop []string
|
||||
// Temperature — 0 (the zero value) is greedy decoding, and greedy is what
|
||||
// every caller here wanted before this field existed. It is set only by the
|
||||
// phraser, whose own transport has always sampled at 0.7: routing a phrasing
|
||||
// call through this client must not quietly change how it decodes.
|
||||
Temperature float64
|
||||
}
|
||||
|
||||
type msg struct {
|
||||
@@ -176,7 +181,7 @@ func (c *Client) Complete(ctx context.Context, r Req) (string, error) {
|
||||
Messages: []msg{{Role: "system", Content: r.System}, {Role: "user", Content: r.User}},
|
||||
MaxTokens: r.MaxTokens,
|
||||
Grammar: r.Grammar,
|
||||
Temp: 0,
|
||||
Temp: r.Temperature,
|
||||
RepeatPenalty: r.RepeatPenalty,
|
||||
Stop: r.Stop,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// stopwords — words that carry no topic. A question and a note that share only
|
||||
// these share nothing: "почему небо синее" and "сеть какая-то медленная" both
|
||||
// contain "какая"-shaped filler and are about different worlds.
|
||||
var stopwords = map[string]bool{
|
||||
// interrogatives and demonstratives
|
||||
"что": true, "чего": true, "какой": true, "какая": true, "какое": true,
|
||||
"какие": true, "каких": true, "кто": true, "кого": true, "кому": true,
|
||||
"почему": true, "зачем": true, "где": true, "куда": true, "откуда": true,
|
||||
"когда": true, "сколько": true, "как": true, "то": true, "это": true,
|
||||
"этот": true, "тот": true, "там": true, "тут": true, "такой": true,
|
||||
// pronouns — every sentence he says is about him, so "я" is not a topic
|
||||
"я": true, "меня": true, "мне": true, "мой": true, "моя": true, "мои": true,
|
||||
"ты": true, "тебя": true, "тебе": true, "твой": true, "он": true, "она": true,
|
||||
"они": true, "мы": true, "себя": true, "свой": true,
|
||||
// prepositions, conjunctions, particles, copulas
|
||||
"в": true, "во": true, "на": true, "с": true, "со": true, "у": true,
|
||||
"о": true, "об": true, "про": true, "за": true, "из": true, "по": true,
|
||||
"до": true, "от": true, "для": true, "над": true, "под": true, "при": true,
|
||||
"и": true, "а": true, "но": true, "или": true, "же": true, "ли": true,
|
||||
"не": true, "ни": true, "бы": true, "был": true, "была": true, "было": true,
|
||||
"быть": true, "есть": true, "был-ли": true, "уже": true, "ещё": true,
|
||||
"еще": true, "так": true, "вот": true, "там-же": true,
|
||||
// English filler, for the mixed utterances he does say
|
||||
"the": true, "a": true, "an": true, "is": true, "are": true, "was": true,
|
||||
"were": true, "be": true, "of": true, "in": true, "on": true, "at": true,
|
||||
"to": true, "for": true, "about": true, "and": true, "or": true, "not": true,
|
||||
"what": true, "who": true, "why": true, "when": true, "where": true,
|
||||
"which": true, "how": true, "i": true, "my": true, "me": true, "it": true,
|
||||
"this": true, "that": true,
|
||||
}
|
||||
|
||||
// firstPerson — the words that make an utterance a question about his own
|
||||
// life. Not possession only: "как я восстановил конфиги" owns nothing and is
|
||||
// still about him.
|
||||
var firstPerson = map[string]bool{
|
||||
"я": true, "меня": true, "мне": true, "мной": true, "мой": true,
|
||||
"моя": true, "моё": true, "мое": true, "мои": true, "моего": true,
|
||||
"моей": true, "моих": true, "моим": true, "себя": true, "свой": true,
|
||||
"своя": true, "свои": true, "своего": true, "мною": true,
|
||||
"i": true, "me": true, "my": true, "mine": true, "myself": true,
|
||||
}
|
||||
|
||||
// RecallAllowed is the second half of the recall gate (#470). A hit that
|
||||
// cleared the score and margin gate may still be about something else
|
||||
// entirely: the held-out fixture puts the right note at 0.791-0.890 and the
|
||||
// must-be-silent cases at 0.795-0.835, so no threshold sits between them, and
|
||||
// a note about his slow network answered "почему небо синее?".
|
||||
//
|
||||
// The veto applies only to a question that mentions nothing of his. That
|
||||
// restriction is what keeps the fix from costing more than it saves: recall
|
||||
// exists to find the note whose words he no longer remembers, and demanding a
|
||||
// shared word of every recall silenced four true recalls on the fixture to
|
||||
// kill one false one. A question about his own life keeps the embedder alone
|
||||
// as its judge. A question about the world has to name something the memory
|
||||
// actually mentions.
|
||||
func RecallAllowed(query, text string) bool {
|
||||
if mentionsHim(query) {
|
||||
return true
|
||||
}
|
||||
return SharesContentWord(query, text)
|
||||
}
|
||||
|
||||
func mentionsHim(query string) bool {
|
||||
for _, w := range strings.FieldsFunc(strings.ToLower(query), func(r rune) bool {
|
||||
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
|
||||
}) {
|
||||
if firstPerson[w] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// SharesContentWord reports whether query and text have at least one topic
|
||||
// word in common, after dropping the words that carry no topic. Stems are
|
||||
// compared, so the note and the question do not have to inflect alike.
|
||||
func SharesContentWord(query, text string) bool {
|
||||
q := contentWords(query)
|
||||
if len(q) == 0 {
|
||||
// Nothing to compare — a question made entirely of filler. The score
|
||||
// gate is then the only judge it can have.
|
||||
return true
|
||||
}
|
||||
t := contentWords(text)
|
||||
for _, a := range q {
|
||||
for _, b := range t {
|
||||
if a == b || sameStem(a, b) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func contentWords(s string) []string {
|
||||
var out []string
|
||||
for _, w := range strings.FieldsFunc(strings.ToLower(s), func(r rune) bool {
|
||||
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
|
||||
}) {
|
||||
if !stopwords[w] {
|
||||
out = append(out, w)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// sameStem is inflection and derivation tolerance: Russian marks case and
|
||||
// tense on the ending, and the note and the question rarely use the same form.
|
||||
// "воду" and "вода" are the same water, "кормить" and "корм" the same feeding.
|
||||
// All but the last rune of the shorter word must match, and never fewer than
|
||||
// three, which is what keeps "сеть" clear of "сеанс".
|
||||
func sameStem(a, b string) bool {
|
||||
ar, br := []rune(a), []rune(b)
|
||||
n := min(len(ar), len(br)) - 1
|
||||
if n < 3 {
|
||||
return false
|
||||
}
|
||||
return string(ar[:n]) == string(br[:n])
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
package memory
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRecallAllowed(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
query, text string
|
||||
want bool
|
||||
}{
|
||||
// The #470 shape: a world question and a note about his box.
|
||||
{"world question, unrelated note", "почему небо синее", "сеть какая-то медленная", false},
|
||||
{"world question, unrelated fact", "какая столица Франции", "какая последняя версия языка Go", false},
|
||||
{"silent fixture case", "во сколько отходит поезд", "бэкап запускается в три ночи", false},
|
||||
|
||||
// A world question that does name the topic keeps its answer.
|
||||
{"world question, same topic", "какой поезд идёт в Минск", "поезда в Минск ходят утром", true},
|
||||
|
||||
// A question about his own life is judged by the embedder alone,
|
||||
// because recall exists for words he no longer remembers.
|
||||
{"about him, no shared word", "во сколько я обычно засыпаю", "ложусь около одиннадцати", true},
|
||||
{"about him, english", "which colour scheme do i like", "тёмная тема везде", true},
|
||||
|
||||
// Inflection must not break a match.
|
||||
{"inflected", "чем кормить кота", "корм для кота в шкафу", true},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := RecallAllowed(c.query, c.text); got != c.want {
|
||||
t.Errorf("%s: RecallAllowed(%q, %q) = %v, want %v", c.name, c.query, c.text, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A question made only of filler has no topic word to match on, and the score
|
||||
// gate is then the only judge it can have.
|
||||
func TestRecallAllowedFallsBackWhenNothingToCompare(t *testing.T) {
|
||||
if !RecallAllowed("что это", "сеть какая-то медленная") {
|
||||
t.Error("a question with no content word must not be vetoed")
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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, "чай")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,12 @@ type LLMPhraser struct {
|
||||
launch func(ctx context.Context, cfg Config) (backend, error)
|
||||
probe func(ctx context.Context, base string) (string, error)
|
||||
|
||||
// remote — the workstation model, when one is configured. Set once at wiring
|
||||
// time by UseRemote and read on every phrasing call. nil ⇒ every call goes to
|
||||
// the resident llama-server this phraser owns, which is the whole deploy
|
||||
// before a `workstation` block exists. See world.go.
|
||||
remote Remote
|
||||
|
||||
// swapMu — single-flight around Swap. Held for the whole swap, including the
|
||||
// model load, so two concurrent swap requests can never both be loading.
|
||||
swapMu sync.Mutex
|
||||
@@ -363,10 +369,7 @@ func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes []
|
||||
// prompt guaranteed to make a small model fill the gap from memory.
|
||||
notes = nonEmpty(notes)
|
||||
if len(notes) == 0 {
|
||||
// General knowledge — no notes to ground the answer. The system
|
||||
// prompt is the single tested source in router.KnowledgePrompt.
|
||||
sys := persona.Prepend(p.cfg.ContextBlock, router.KnowledgePrompt())
|
||||
prompt := fmt.Sprintf("Пользователь спрашивает: \"%s\".", utterance)
|
||||
sys, prompt := p.knowledgePrompt(utterance)
|
||||
resp, err := p.chatWithSystem(ctx, sys, prompt, 768)
|
||||
if err != nil || resp == "" {
|
||||
return "не знаю.", nil
|
||||
@@ -381,11 +384,7 @@ func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes []
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
sys := p.querySystemPrompt()
|
||||
prompt := fmt.Sprintf(
|
||||
"Он спрашивает: \"%s\"\n\nИсточники:\n%s\nОтветь ему коротко и своими словами, опираясь только на эти источники. Если ответа в них нет — так и скажи.",
|
||||
utterance, evidenceBlock(notes),
|
||||
)
|
||||
sys, prompt := p.evidencePrompt(utterance, notes)
|
||||
resp, err := p.chatWithSystem(ctx, sys, prompt, 768)
|
||||
text, _, perr := parseResponseMood(resp)
|
||||
if err != nil || perr != nil {
|
||||
@@ -481,6 +480,17 @@ func chatSystemPrompt(block func() string) string {
|
||||
// the LLM completion endpoint. Like chatWithSystem but for an arbitrary message
|
||||
// slice — the caller owns the system prompt placement.
|
||||
func (p *LLMPhraser) chatWithMessages(ctx context.Context, msgs []chatMsg, maxTokens int) (string, error) {
|
||||
// Same silent preference as chatWithSystem, when the array is the shape
|
||||
// llm.Req can carry: one system turn and one user turn. PhraseChat already
|
||||
// folds the history into a single user message (some chat templates reject
|
||||
// consecutive user turns), so today that is every call. A longer array goes
|
||||
// to the resident model rather than get flattened here, because flattening a
|
||||
// conversation is a decision its owner should make.
|
||||
if len(msgs) == 2 && msgs[0].Role == "system" && msgs[1].Role == "user" {
|
||||
if out, ok := p.remoteChat(ctx, msgs[0].Content, msgs[1].Content, maxTokens); ok {
|
||||
return out, nil
|
||||
}
|
||||
}
|
||||
base, release, err := p.acquire()
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -640,6 +650,12 @@ func (p *LLMPhraser) chat(ctx context.Context, userPrompt string) (string, error
|
||||
}
|
||||
|
||||
func (p *LLMPhraser) chatWithSystem(ctx context.Context, system, user string, maxTokens int) (string, error) {
|
||||
// The workstation model first when it will take work, and silently: every
|
||||
// caller of this helper is on the silent half of the degradation rule. It
|
||||
// answering is not news, and it being asleep is not news either.
|
||||
if out, ok := p.remoteChat(ctx, system, user, maxTokens); ok {
|
||||
return out, nil
|
||||
}
|
||||
base, release, err := p.acquire()
|
||||
if err != nil {
|
||||
return "", err
|
||||
@@ -733,6 +749,27 @@ func (p *LLMPhraser) systemPrompt() string {
|
||||
return persona.Prepend(p.cfg.ContextBlock, nudgeSystem)
|
||||
}
|
||||
|
||||
// knowledgePrompt — the no-sources branch: a world question, answered from
|
||||
// weights alone. The system prompt is the single tested source in
|
||||
// router.KnowledgePrompt.
|
||||
//
|
||||
// Split out of PhraseQuery so PhraseWorld sends the workstation model the same
|
||||
// bytes the resident model gets. Prompt parity across two models is a stated
|
||||
// constraint (CLAUDE.md), and two copies of a prompt is how it stops holding.
|
||||
func (p *LLMPhraser) knowledgePrompt(utterance string) (sys, user string) {
|
||||
return persona.Prepend(p.cfg.ContextBlock, router.KnowledgePrompt()),
|
||||
fmt.Sprintf("Пользователь спрашивает: \"%s\".", utterance)
|
||||
}
|
||||
|
||||
// evidencePrompt — the sources branch: read these, add nothing. Shared with
|
||||
// PhraseWorld for the same reason as knowledgePrompt.
|
||||
func (p *LLMPhraser) evidencePrompt(utterance string, notes []string) (sys, user string) {
|
||||
return p.querySystemPrompt(), fmt.Sprintf(
|
||||
"Он спрашивает: \"%s\"\n\nИсточники:\n%s\nОтветь ему коротко и своими словами, опираясь только на эти источники. Если ответа в них нет — так и скажи.",
|
||||
utterance, evidenceBlock(notes),
|
||||
)
|
||||
}
|
||||
|
||||
// querySystemPrompt returns the system prompt for the evidence branch of
|
||||
// PhraseQuery. Prepends the configured persona when set.
|
||||
//
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
package phraser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
|
||||
"github.com/kami/maven/internal/llm"
|
||||
)
|
||||
|
||||
// Remote — the workstation model, seen from the phraser. `*llm.Pair` satisfies
|
||||
// it, and a test fake satisfies it in three lines.
|
||||
//
|
||||
// Only the refusing half of Pair is here on purpose. Pair.Complete falls back to
|
||||
// its own floor client, and the phraser already owns a floor: the llama-server it
|
||||
// spawned. Two floors under one call is one too many, so the phraser asks whether
|
||||
// the remote will take work, uses it when it will, and otherwise does exactly
|
||||
// what it did before this file existed.
|
||||
type Remote interface {
|
||||
// Available is an atomic read of a cached probe, so it is free to call per
|
||||
// turn. See llm.Pair.
|
||||
Available() bool
|
||||
// CompleteRemote runs on the workstation or returns ErrRemoteUnavailable. It
|
||||
// never falls back.
|
||||
CompleteRemote(ctx context.Context, r llm.Req) (string, error)
|
||||
}
|
||||
|
||||
// ErrNoWorldModel — a world question was asked, a workstation model is
|
||||
// configured to answer it, and that machine is not answering. The caller turns
|
||||
// this into a gap he is told about ("не могу сейчас"), never into an answer from
|
||||
// the resident model.
|
||||
//
|
||||
// This is the naming half of the degradation rule in docs/offload.md. The
|
||||
// resident Qwen3-1.7B does not answer a world question worse than the 12B, it
|
||||
// invents: measured, the workstation model scores knowledge 9/9 on the talk
|
||||
// fixture against the resident model's confabulations
|
||||
// (docs/evals/2026-08-02-workstation-gemma4-12b.md).
|
||||
var ErrNoWorldModel = errors.New("phraser: no world model available")
|
||||
|
||||
// chatTemperature — what the phraser's own transport has always sampled at.
|
||||
// Named so the remote path cannot drift from it silently. Whether 0.7 is right
|
||||
// at all is Vikunja #402, and answering that here would hide a phrasing change
|
||||
// inside a routing change.
|
||||
const chatTemperature = 0.7
|
||||
|
||||
// UseRemote points the phraser at the workstation model. Wiring time only, once,
|
||||
// before anything phrases: the field is read without a lock on every call
|
||||
// because a per-turn lock to answer a question that changes at deploy time is
|
||||
// not worth paying for.
|
||||
//
|
||||
// A nil remote is the normal state of a box with no `workstation` block, and it
|
||||
// must behave exactly as the box behaved before this seam existed.
|
||||
func (p *LLMPhraser) UseRemote(r Remote) {
|
||||
p.remote = r
|
||||
}
|
||||
|
||||
// PhraseWorld answers a question about the world — either from the model's own
|
||||
// knowledge (no sources) or from a passage someone fetched (a live search, a ZIM
|
||||
// article, a page he named). Three outcomes, and the middle one is the point:
|
||||
//
|
||||
// - No workstation configured. The resident model answers, exactly as it does
|
||||
// today. Naming a gap needs a gap: on a box that never had a second model,
|
||||
// refusing every world question would remove a capability he has now.
|
||||
// - Workstation configured and taking work. It answers.
|
||||
// - Workstation configured and down. ErrNoWorldModel, and the caller says so.
|
||||
//
|
||||
// The prompts are the ones PhraseQuery uses, built by the same two functions, so
|
||||
// the two models are asked the same question in the same words.
|
||||
func (p *LLMPhraser) PhraseWorld(ctx context.Context, utterance string, sources []string) (string, error) {
|
||||
sources = nonEmpty(sources)
|
||||
if p.remote == nil {
|
||||
return p.PhraseQuery(ctx, utterance, sources)
|
||||
}
|
||||
var sys, user string
|
||||
if len(sources) == 0 {
|
||||
sys, user = p.knowledgePrompt(utterance)
|
||||
} else {
|
||||
sys, user = p.evidencePrompt(utterance, sources)
|
||||
}
|
||||
if !p.remote.Available() {
|
||||
return "", ErrNoWorldModel
|
||||
}
|
||||
resp, err := p.remote.CompleteRemote(ctx, llm.Req{
|
||||
System: sys,
|
||||
User: user,
|
||||
Grammar: p.grammar(),
|
||||
MaxTokens: 768,
|
||||
Temperature: chatTemperature,
|
||||
})
|
||||
if err != nil {
|
||||
// The cached probe was one interval stale, or the card went away
|
||||
// mid-request. Either way this is the gap, not an error to log and
|
||||
// paper over with the smaller model.
|
||||
log.Printf("phraser: world model: %v", err)
|
||||
return "", errors.Join(ErrNoWorldModel, err)
|
||||
}
|
||||
resp = stripThink(resp)
|
||||
text, _, perr := parseResponseMood(resp)
|
||||
if perr != nil {
|
||||
log.Printf("phraser: PhraseWorld: %v", perr)
|
||||
return "", errors.Join(ErrNoWorldModel, perr)
|
||||
}
|
||||
if text != "" {
|
||||
return text, nil
|
||||
}
|
||||
if resp == "" {
|
||||
return "", ErrNoWorldModel
|
||||
}
|
||||
return resp, nil
|
||||
}
|
||||
|
||||
// remoteChat is the silent half, for the phrasing paths where the workstation
|
||||
// model is only better: a nudge, a reminder, a reply, a question answered from
|
||||
// his own notes. It reports whether it answered; it never reports why not,
|
||||
// because the caller's next move is the resident model either way.
|
||||
//
|
||||
// He is not told which of the two models phrased his reply. That is the rule.
|
||||
func (p *LLMPhraser) remoteChat(ctx context.Context, system, user string, maxTokens int) (string, bool) {
|
||||
if p.remote == nil || !p.remote.Available() {
|
||||
return "", false
|
||||
}
|
||||
out, err := p.remote.CompleteRemote(ctx, llm.Req{
|
||||
System: system,
|
||||
User: user,
|
||||
Grammar: p.grammar(),
|
||||
MaxTokens: maxTokens,
|
||||
Temperature: chatTemperature,
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("phraser: workstation model declined, phrasing here instead: %v", err)
|
||||
return "", false
|
||||
}
|
||||
if out = stripThink(out); out == "" {
|
||||
return "", false
|
||||
}
|
||||
return out, true
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
package phraser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/llm"
|
||||
"github.com/kami/maven/internal/loop"
|
||||
)
|
||||
|
||||
// fakeRemote — a workstation model that is up or down on command, and records
|
||||
// what it was asked.
|
||||
type fakeRemote struct {
|
||||
up bool
|
||||
reply string
|
||||
err error
|
||||
got []llm.Req
|
||||
}
|
||||
|
||||
func (f *fakeRemote) Available() bool { return f.up }
|
||||
|
||||
func (f *fakeRemote) CompleteRemote(_ context.Context, r llm.Req) (string, error) {
|
||||
f.got = append(f.got, r)
|
||||
if f.err != nil {
|
||||
return "", f.err
|
||||
}
|
||||
return f.reply, nil
|
||||
}
|
||||
|
||||
// The three outcomes of the naming half, in one place. The middle one is the
|
||||
// whole task: a gap he is told about, not an answer from the smaller model.
|
||||
func TestPhraseWorldNamesTheGapOnlyWhenThereIsOne(t *testing.T) {
|
||||
answer := `{"response": "Небо голубое из-за рэлеевского рассеяния.", "mood": "neutral"}`
|
||||
|
||||
t.Run("no workstation configured: the resident model answers as today", func(t *testing.T) {
|
||||
spy := newPromptSpy(t)
|
||||
p := NewLLMPhraserAt(spy.srv.URL, Config{})
|
||||
got, err := p.PhraseWorld(context.Background(), "почему небо голубое", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("PhraseWorld: %v", err)
|
||||
}
|
||||
if got == "" {
|
||||
t.Fatal("no reply from the resident model")
|
||||
}
|
||||
if len(spy.user) != 1 {
|
||||
t.Fatalf("resident model saw %d requests, want 1", len(spy.user))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("workstation up: it answers and the resident model is not asked", func(t *testing.T) {
|
||||
spy := newPromptSpy(t)
|
||||
p := NewLLMPhraserAt(spy.srv.URL, Config{})
|
||||
remote := &fakeRemote{up: true, reply: answer}
|
||||
p.UseRemote(remote)
|
||||
got, err := p.PhraseWorld(context.Background(), "почему небо голубое", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("PhraseWorld: %v", err)
|
||||
}
|
||||
if !strings.Contains(got, "рассеяния") {
|
||||
t.Errorf("reply is not the workstation's: %q", got)
|
||||
}
|
||||
if len(spy.user) != 0 {
|
||||
t.Errorf("the resident model was asked %d times, want 0", len(spy.user))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("workstation down: the gap, and nothing invented", func(t *testing.T) {
|
||||
spy := newPromptSpy(t)
|
||||
p := NewLLMPhraserAt(spy.srv.URL, Config{})
|
||||
p.UseRemote(&fakeRemote{up: false})
|
||||
got, err := p.PhraseWorld(context.Background(), "почему небо голубое", nil)
|
||||
if !errors.Is(err, ErrNoWorldModel) {
|
||||
t.Fatalf("err = %v, want ErrNoWorldModel", err)
|
||||
}
|
||||
if got != "" {
|
||||
t.Errorf("got a reply %q with no world model", got)
|
||||
}
|
||||
if len(spy.user) != 0 {
|
||||
t.Errorf("the resident model answered a world question %d times, want 0", len(spy.user))
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("workstation errors mid-request: still the gap", func(t *testing.T) {
|
||||
spy := newPromptSpy(t)
|
||||
p := NewLLMPhraserAt(spy.srv.URL, Config{})
|
||||
p.UseRemote(&fakeRemote{up: true, err: errors.New("connection refused")})
|
||||
if _, err := p.PhraseWorld(context.Background(), "почему небо голубое", nil); !errors.Is(err, ErrNoWorldModel) {
|
||||
t.Fatalf("err = %v, want ErrNoWorldModel", err)
|
||||
}
|
||||
if len(spy.user) != 0 {
|
||||
t.Errorf("the resident model answered a world question %d times, want 0", len(spy.user))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Prompt parity: the workstation model is asked the same question in the same
|
||||
// words, or the fixtures measure one thing and the daemon ships another.
|
||||
func TestPhraseWorldSendsTheSamePromptsAsPhraseQuery(t *testing.T) {
|
||||
spy := newPromptSpy(t)
|
||||
resident := NewLLMPhraserAt(spy.srv.URL, Config{})
|
||||
if _, err := resident.PhraseQuery(context.Background(), "кто написал войну и мир", []string{"Лев Толстой"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
remote := &fakeRemote{up: true, reply: `{"response": "Толстой.", "mood": "neutral"}`}
|
||||
offloaded := NewLLMPhraserAt(spy.srv.URL, Config{})
|
||||
offloaded.UseRemote(remote)
|
||||
if _, err := offloaded.PhraseWorld(context.Background(), "кто написал войну и мир", []string{"Лев Толстой"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if len(remote.got) != 1 {
|
||||
t.Fatalf("the workstation saw %d requests, want 1", len(remote.got))
|
||||
}
|
||||
if remote.got[0].System != spy.system[0] {
|
||||
t.Errorf("system prompts differ:\nremote: %q\nresident: %q", remote.got[0].System, spy.system[0])
|
||||
}
|
||||
if remote.got[0].User != spy.user[0] {
|
||||
t.Errorf("user prompts differ:\nremote: %q\nresident: %q", remote.got[0].User, spy.user[0])
|
||||
}
|
||||
}
|
||||
|
||||
// The silent half. A nudge phrased on the workstation is not news, and one
|
||||
// phrased here because the card is busy is not news either — but it must be
|
||||
// sampled the same way, or the workstation quietly changes how she sounds.
|
||||
func TestNudgePhrasingPrefersTheWorkstationSilently(t *testing.T) {
|
||||
spy := newPromptSpy(t)
|
||||
p := NewLLMPhraserAt(spy.srv.URL, Config{LLMNudges: true})
|
||||
remote := &fakeRemote{up: true, reply: `{"response": "Выпей воды.", "mood": "neutral"}`}
|
||||
p.UseRemote(remote)
|
||||
|
||||
pn, err := p.PhraseNudge(context.Background(), loop.Candidate{Rule: loop.WaterRule(), Severity: loop.Sev1})
|
||||
if err != nil {
|
||||
t.Fatalf("PhraseNudge: %v", err)
|
||||
}
|
||||
if pn.Body != "Выпей воды." {
|
||||
t.Errorf("body = %q, want the workstation's wording", pn.Body)
|
||||
}
|
||||
if len(remote.got) != 1 {
|
||||
t.Fatalf("the workstation saw %d requests, want 1", len(remote.got))
|
||||
}
|
||||
if remote.got[0].Temperature != chatTemperature {
|
||||
t.Errorf("temperature = %v, want %v (what the resident transport samples at)",
|
||||
remote.got[0].Temperature, chatTemperature)
|
||||
}
|
||||
if len(spy.user) != 0 {
|
||||
t.Errorf("the resident model phrased %d nudges, want 0", len(spy.user))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNudgePhrasingFallsBackWhenTheCardIsBusy(t *testing.T) {
|
||||
spy := newPromptSpy(t)
|
||||
p := NewLLMPhraserAt(spy.srv.URL, Config{LLMNudges: true})
|
||||
p.UseRemote(&fakeRemote{up: false})
|
||||
|
||||
if _, err := p.PhraseNudge(context.Background(), loop.Candidate{Rule: loop.WaterRule(), Severity: loop.Sev1}); err != nil {
|
||||
t.Fatalf("PhraseNudge: %v", err)
|
||||
}
|
||||
if len(spy.user) != 1 {
|
||||
t.Fatalf("the resident model phrased %d nudges, want 1", len(spy.user))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package router
|
||||
|
||||
import "strings"
|
||||
|
||||
// interrogatives — the question words that mark an utterance as asking rather
|
||||
// than telling. Tokenized, never substring: "что" inside "чтобы" and "как"
|
||||
// inside "какао" are not questions.
|
||||
var interrogatives = []string{
|
||||
"что", "чего", "какой", "какая", "какое", "какие", "каких",
|
||||
"кто", "кого", "кому", "чей", "почему", "зачем", "отчего",
|
||||
"где", "куда", "откуда", "когда", "сколько", "как",
|
||||
"what", "who", "whom", "why", "when", "where", "which", "how",
|
||||
}
|
||||
|
||||
// narrativeRequests — "tell me about X" asks for knowledge Maven does not
|
||||
// hold about him. It carries no question mark and no interrogative, which is
|
||||
// how "расскажи про битву при Ватерлоо" reached the fact store (#470).
|
||||
var narrativeRequests = []string{
|
||||
"расскажи", "объясни", "опиши", "перечисли",
|
||||
"tell", "explain", "describe",
|
||||
}
|
||||
|
||||
// captureVerbs — an explicit instruction to record something. These win over
|
||||
// every test below, because "запиши что я пил воду" contains an interrogative
|
||||
// and is still a capture: the word he said is "запиши".
|
||||
var captureVerbs = []string{
|
||||
"запиши", "запомни", "отметь", "заметь", "добавь", "сохрани",
|
||||
"note", "remember", "log", "save",
|
||||
}
|
||||
|
||||
// IsQuestionShaped reports whether text asks for something rather than
|
||||
// records it. It is a deterministic offline test over tokens, so it costs
|
||||
// nothing and never depends on the model that produced the routing decision.
|
||||
//
|
||||
// It exists because a mis-routed question used to be persisted as a fact
|
||||
// about the owner, with the model's invented answer as the value (#470). The
|
||||
// predicate is deliberately blunt: refusing to store a question is cheap and
|
||||
// reversible, storing an invented fact about him is neither.
|
||||
func IsQuestionShaped(text string) bool {
|
||||
t := strings.TrimSpace(text)
|
||||
if t == "" {
|
||||
return false
|
||||
}
|
||||
toks := planTokens(t)
|
||||
for _, v := range captureVerbs {
|
||||
if hasTok(toks, v) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if strings.HasSuffix(t, "?") {
|
||||
return true
|
||||
}
|
||||
for _, w := range interrogatives {
|
||||
if hasTok(toks, w) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for _, w := range narrativeRequests {
|
||||
if hasTok(toks, w) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package router
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsQuestionShaped(t *testing.T) {
|
||||
// The seven utterances #470 recorded, plus the captures that must keep
|
||||
// working. A capture misread as a question loses a fact; a question
|
||||
// misread as a capture poisons recall, so the captures are the ones worth
|
||||
// pinning here.
|
||||
cases := []struct {
|
||||
text string
|
||||
want bool
|
||||
}{
|
||||
{"какая последняя версия языка Go?", true},
|
||||
{"что дальше?", true},
|
||||
{"расскажи про битву при Ватерлоо", true},
|
||||
{"почему небо синее?", true},
|
||||
{"какая столица Австралии?", true},
|
||||
{"кто такой Никола Тесла?", true},
|
||||
{"сколько стоит доллар", true},
|
||||
{"who is the premier of Japan", true},
|
||||
{"объясни линии Фраунгофера", true},
|
||||
|
||||
{"запиши что я пил воду", false},
|
||||
{"запомни какая у меня машина", false},
|
||||
{"отметь что я поужинал", false},
|
||||
{"поужинал", false},
|
||||
{"я выпил кофе", false},
|
||||
{"вода", false},
|
||||
{"привет", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := IsQuestionShaped(c.text); got != c.want {
|
||||
t.Errorf("IsQuestionShaped(%q) = %v, want %v", c.text, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Substring matching is what made the day-plan predicates wrong before, and
|
||||
// this predicate gates a write, so it gets the same guard.
|
||||
func TestIsQuestionShapedIsTokenized(t *testing.T) {
|
||||
for _, text := range []string{"чтобы не забыть, я полил кактус", "какао выпил"} {
|
||||
if IsQuestionShaped(text) {
|
||||
t.Errorf("IsQuestionShaped(%q) = true; a question word inside a longer word is not a question", text)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -46,6 +47,39 @@ func (s *Store) WriteFact(ctx context.Context, ts time.Time, kind FactKind, key,
|
||||
return id, nil
|
||||
}
|
||||
|
||||
// FactRecallText is the text a fact is indexed under and read back as (#493).
|
||||
//
|
||||
// It used to be the utterance that wrote the fact, so recall of ANY
|
||||
// voice-tapped fact answered with the sentence he said instead of the value
|
||||
// stored: `go_version = 1.20` was indexed as "какая последняя версия языка
|
||||
// Go?", and that question is what came back. The poisoned rows made the defect
|
||||
// visible; the shape was wrong for legitimate facts too.
|
||||
//
|
||||
// The key is spoken with its underscores dropped, because a key is written for
|
||||
// the store and this string is read out loud.
|
||||
func FactRecallText(key, value string) string {
|
||||
spoken := strings.TrimSpace(strings.ReplaceAll(key, "_", " "))
|
||||
v := strings.TrimSpace(DecodeFactValue(value))
|
||||
switch {
|
||||
case v == "":
|
||||
return spoken
|
||||
case spoken == "":
|
||||
return v
|
||||
}
|
||||
return spoken + " — " + v
|
||||
}
|
||||
|
||||
// DecodeFactValue unwraps a stored value for reading. The column holds raw json
|
||||
// when the writer serialized one (SetValue, CorrectValue) and a plain string
|
||||
// when it did not (a voice tap), so a reader that wants the text handles both.
|
||||
func DecodeFactValue(value string) string {
|
||||
var s string
|
||||
if err := json.Unmarshal([]byte(value), &s); err == nil {
|
||||
return s
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// LatestFact returns the latest non-voided fact for key, or ErrNoFact.
|
||||
// "Non-voided" = no later row has voids_id pointing at it. We resolve this by
|
||||
// taking the newest row whose id is not referenced by any voids_id.
|
||||
@@ -290,6 +324,20 @@ func (s *Store) CorrectValue(ctx context.Context, key, source string, value any,
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("last insert id: %w", err)
|
||||
}
|
||||
// The same repair a void needs, for the same reason (#493). A correction
|
||||
// supersedes the value, and the vector still holds the old one, so recall
|
||||
// kept answering with the value he had just corrected. Dropping it costs
|
||||
// the key its recall vector until the fact is tapped again: this layer has
|
||||
// no embedder, and a missing vector loses a question while a stale one
|
||||
// answers it wrongly.
|
||||
//
|
||||
// Best-effort: the corrected row is committed, and a correction that lands
|
||||
// beats one that fails on cleanup.
|
||||
if n, derr := s.VectorMemory().DeletePrefix(ctx, "fact:"+key+":"); derr != nil {
|
||||
log.Printf("store: correct %q: memory vectors survive: %v", key, derr)
|
||||
} else if n > 0 {
|
||||
log.Printf("store: correct %q: dropped %d superseded memory vector(s)", key, n)
|
||||
}
|
||||
return newID, nil
|
||||
}
|
||||
|
||||
@@ -332,6 +380,20 @@ func (s *Store) VoidLatestFact(ctx context.Context, key, source string, ts time.
|
||||
if err != nil {
|
||||
return 0, 0, fmt.Errorf("void: last insert id: %w", err)
|
||||
}
|
||||
// The other half of the repair (#470). A fact reaches recall through a
|
||||
// vector keyed `fact:<key>:<unix>`, holding the utterance that wrote it.
|
||||
// Voiding the row alone left that vector answering questions, so revert
|
||||
// reported success on a box that stayed broken. Deleting every vector for
|
||||
// the key covers the earlier rows too: their values are superseded, and a
|
||||
// superseded value has no business claiming a turn.
|
||||
//
|
||||
// Best-effort by design: the audit trail is already committed, and a fact
|
||||
// that is voided but still recallable is better than a void that failed.
|
||||
if n, derr := s.VectorMemory().DeletePrefix(ctx, "fact:"+key+":"); derr != nil {
|
||||
log.Printf("store: void %q: memory vectors survive: %v", key, derr)
|
||||
} else if n > 0 {
|
||||
log.Printf("store: void %q: dropped %d memory vector(s)", key, n)
|
||||
}
|
||||
return oldID, newID, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,177 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// metaKeyFactVectorShape names the shape the stored fact vectors were written
|
||||
// in. It exists so the repair below runs once per box instead of on every
|
||||
// start: the rows it fixes were written by a code path that no longer exists,
|
||||
// and once fixed nothing writes that shape again.
|
||||
const metaKeyFactVectorShape = "fact_vector_shape"
|
||||
|
||||
// factVectorShapeFact is the shape FactRecallText produces. Anything else in
|
||||
// the marker (including nothing, which is every box written before #493) means
|
||||
// the fact vectors still hold utterances.
|
||||
const factVectorShapeFact = "fact-text (#493)"
|
||||
|
||||
// FactVectorRepair is what one repair run did, for logging.
|
||||
type FactVectorRepair struct {
|
||||
Skipped bool // marker already matched — nothing to do
|
||||
Rewritten int // rows re-embedded from the fact they name
|
||||
Dropped int // rows deleted: voided, superseded, or naming no fact at all
|
||||
Kept int // rows already holding the right text
|
||||
Took time.Duration
|
||||
}
|
||||
|
||||
// RepairFactVectors brings the fact rows of memory_vectors in line with the
|
||||
// facts they name, and is the operator recovery a poisoned box had no path to
|
||||
// (#470 point 4, #493).
|
||||
//
|
||||
// Three defects put wrong text in that index, and all three are write-path
|
||||
// fixes that do nothing for rows already stored:
|
||||
//
|
||||
// - the indexed text was the utterance, so every fact row reads back a
|
||||
// sentence rather than a value;
|
||||
// - a void left its vector behind, so retracted junk kept answering;
|
||||
// - a correction left its vector behind, so the superseded value did.
|
||||
//
|
||||
// So each fact row is resolved against the fact store and one of three things
|
||||
// happens. It is dropped when the key has no fact, when the newest row for the
|
||||
// key is a void marker, or when a newer vector for the same key exists — a
|
||||
// superseded value has no business claiming a turn. It is re-embedded when its
|
||||
// text is not what FactRecallText says the fact is. Otherwise it is left alone.
|
||||
//
|
||||
// Idempotent, and safe to interrupt: every step compares before writing and the
|
||||
// marker is written last, so a run that dies partway is simply redone.
|
||||
func (s *Store) RepairFactVectors(ctx context.Context, embed EmbedFunc) (FactVectorRepair, error) {
|
||||
start := time.Now()
|
||||
var res FactVectorRepair
|
||||
|
||||
shape, err := s.Meta(ctx, metaKeyFactVectorShape)
|
||||
if err != nil {
|
||||
return res, err
|
||||
}
|
||||
if shape == factVectorShapeFact {
|
||||
res.Skipped = true
|
||||
res.Took = time.Since(start)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
rows, err := s.db.QueryContext(ctx, `SELECT id, meta FROM memory_vectors`)
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("repair fact vectors: read: %w", err)
|
||||
}
|
||||
type factVec struct {
|
||||
id, key string
|
||||
meta map[string]string
|
||||
ts int64
|
||||
}
|
||||
var vecs []factVec
|
||||
newest := map[string]int64{} // key → newest ts seen for it
|
||||
for rows.Next() {
|
||||
var id, metaJSON string
|
||||
if err := rows.Scan(&id, &metaJSON); err != nil {
|
||||
rows.Close()
|
||||
return res, fmt.Errorf("repair fact vectors: row: %w", err)
|
||||
}
|
||||
meta := map[string]string{}
|
||||
if err := json.Unmarshal([]byte(metaJSON), &meta); err != nil {
|
||||
rows.Close()
|
||||
return res, fmt.Errorf("repair fact vectors: meta for %q: %w", id, err)
|
||||
}
|
||||
if meta["type"] != "fact" {
|
||||
continue
|
||||
}
|
||||
key, ts, ok := splitFactVectorID(id)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
vecs = append(vecs, factVec{id: id, key: key, meta: meta, ts: ts})
|
||||
if ts > newest[key] {
|
||||
newest[key] = ts
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
if err := rows.Err(); err != nil {
|
||||
return res, fmt.Errorf("repair fact vectors: rows: %w", err)
|
||||
}
|
||||
|
||||
for _, v := range vecs {
|
||||
drop := v.ts < newest[v.key]
|
||||
var want string
|
||||
if !drop {
|
||||
f, ferr := s.LatestFact(ctx, v.key)
|
||||
switch {
|
||||
case errors.Is(ferr, ErrNoFact):
|
||||
drop = true
|
||||
case ferr != nil:
|
||||
return res, fmt.Errorf("repair fact vectors: fact %q: %w", v.key, ferr)
|
||||
case DecodeFactValue(f.Value) == "voided":
|
||||
drop = true
|
||||
default:
|
||||
want = FactRecallText(v.key, f.Value)
|
||||
}
|
||||
}
|
||||
if drop {
|
||||
if err := s.VectorMemory().Delete(ctx, v.id); err != nil {
|
||||
return res, err
|
||||
}
|
||||
res.Dropped++
|
||||
continue
|
||||
}
|
||||
if v.meta["text"] == want {
|
||||
res.Kept++
|
||||
continue
|
||||
}
|
||||
vec, err := embed(ctx, want)
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("repair fact vectors: embed %q: %w", v.id, err)
|
||||
}
|
||||
// The whole meta blob is rewritten in Go rather than patched in SQL,
|
||||
// because json_set needs the JSON1 extension and this store is opened
|
||||
// through sqlcipher.
|
||||
v.meta["text"] = want
|
||||
metaJSON, err := json.Marshal(v.meta)
|
||||
if err != nil {
|
||||
return res, fmt.Errorf("repair fact vectors: meta %q: %w", v.id, err)
|
||||
}
|
||||
if _, err := s.db.ExecContext(ctx,
|
||||
`UPDATE memory_vectors SET vec = ?, meta = ? WHERE id = ?`,
|
||||
encodeVec(vec), string(metaJSON), v.id); err != nil {
|
||||
return res, fmt.Errorf("repair fact vectors: write %q: %w", v.id, err)
|
||||
}
|
||||
res.Rewritten++
|
||||
}
|
||||
|
||||
if err := s.SetMeta(ctx, metaKeyFactVectorShape, factVectorShapeFact); err != nil {
|
||||
return res, err
|
||||
}
|
||||
res.Took = time.Since(start)
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// splitFactVectorID reads the key and write time back out of a fact vector's
|
||||
// id, which the write path builds as `fact:<key>:<unix>`. A key may hold a
|
||||
// colon, the timestamp may not, so the split is from the right.
|
||||
func splitFactVectorID(id string) (key string, ts int64, ok bool) {
|
||||
rest, found := strings.CutPrefix(id, "fact:")
|
||||
if !found {
|
||||
return "", 0, false
|
||||
}
|
||||
cut := strings.LastIndex(rest, ":")
|
||||
if cut <= 0 {
|
||||
return "", 0, false
|
||||
}
|
||||
ts, err := strconv.ParseInt(rest[cut+1:], 10, 64)
|
||||
if err != nil {
|
||||
return "", 0, false
|
||||
}
|
||||
return rest[:cut], ts, true
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// The write-path half of #493: recall of a fact must read back the fact, not
|
||||
// the sentence he happened to say.
|
||||
func TestFactRecallText(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name, key, value, want string
|
||||
}{
|
||||
{"json value", "go_version", `"1.20"`, "go version — 1.20"},
|
||||
{"plain value", "water", "выпил", "water — выпил"},
|
||||
{"no value", "shower", "", "shower"},
|
||||
{"underscores are spoken as spaces", "espresso_machine", `"чистая"`, "espresso machine — чистая"},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
if got := FactRecallText(tc.key, tc.value); got != tc.want {
|
||||
t.Fatalf("FactRecallText(%q, %q) = %q; want %q", tc.key, tc.value, got, tc.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A correction left the superseded value in the index, so recall answered with
|
||||
// the value he had just corrected (#493).
|
||||
func TestCorrectValueDropsMemoryVectors(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
now := time.Now()
|
||||
mem := s.VectorMemory()
|
||||
|
||||
if _, err := s.WriteFact(ctx, now, KindSelf, "go_version", `"1.20"`, "tap:voice", 1.0, sql.NullInt64{}); err != nil {
|
||||
t.Fatalf("WriteFact: %v", err)
|
||||
}
|
||||
if err := mem.Insert(ctx, "fact:go_version:1", []float32{1, 0, 0}, map[string]string{
|
||||
"type": "fact", "text": "go version — 1.20",
|
||||
}); err != nil {
|
||||
t.Fatalf("Insert: %v", err)
|
||||
}
|
||||
if _, err := s.CorrectValue(ctx, "go_version", "feedback", "1.25", now.Add(time.Minute)); err != nil {
|
||||
t.Fatalf("CorrectValue: %v", err)
|
||||
}
|
||||
got, err := mem.ByPrefix(ctx, "fact:")
|
||||
if err != nil {
|
||||
t.Fatalf("ByPrefix: %v", err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("after the correction the index still holds %+v; the superseded value must not answer", got)
|
||||
}
|
||||
}
|
||||
|
||||
// The recovery path a poisoned box had none of (#470 point 4, #493): rows
|
||||
// written before the fix hold utterances, voided junk and superseded values,
|
||||
// and no write-path change reaches any of them.
|
||||
func TestRepairFactVectors(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
now := time.Now()
|
||||
mem := s.VectorMemory()
|
||||
embed := func(ctx context.Context, text string) ([]float32, error) {
|
||||
return []float32{float32(len(text)), 1, 0}, nil
|
||||
}
|
||||
|
||||
// A live fact indexed under the question that wrote it — the defect.
|
||||
if _, err := s.WriteFact(ctx, now, KindSelf, "water", `"выпил"`, "tap:voice", 1.0, sql.NullInt64{}); err != nil {
|
||||
t.Fatalf("WriteFact water: %v", err)
|
||||
}
|
||||
if err := mem.Insert(ctx, "fact:water:100", []float32{9, 9, 9}, map[string]string{
|
||||
"type": "fact", "source": "voice", "text": "запиши что я пил воду",
|
||||
}); err != nil {
|
||||
t.Fatalf("Insert water: %v", err)
|
||||
}
|
||||
// A voided fact whose vector survived the void.
|
||||
if _, err := s.WriteFact(ctx, now, KindSelf, "go_version", `"1.20"`, "tap:voice", 1.0, sql.NullInt64{}); err != nil {
|
||||
t.Fatalf("WriteFact go_version: %v", err)
|
||||
}
|
||||
if _, _, err := s.VoidLatestFact(ctx, "go_version", "feedback", now.Add(time.Minute)); err != nil {
|
||||
t.Fatalf("VoidLatestFact: %v", err)
|
||||
}
|
||||
if err := mem.Insert(ctx, "fact:go_version:100", []float32{9, 9, 9}, map[string]string{
|
||||
"type": "fact", "text": "какая последняя версия языка Go?",
|
||||
}); err != nil {
|
||||
t.Fatalf("Insert go_version: %v", err)
|
||||
}
|
||||
// A key with two vectors: only the newest may answer.
|
||||
if _, err := s.WriteFact(ctx, now, KindSelf, "mood", `"устал"`, "tap:voice", 1.0, sql.NullInt64{}); err != nil {
|
||||
t.Fatalf("WriteFact mood: %v", err)
|
||||
}
|
||||
for _, ts := range []string{"100", "200"} {
|
||||
if err := mem.Insert(ctx, "fact:mood:"+ts, []float32{9, 9, 9}, map[string]string{
|
||||
"type": "fact", "text": "мне грустно",
|
||||
}); err != nil {
|
||||
t.Fatalf("Insert mood %s: %v", ts, err)
|
||||
}
|
||||
}
|
||||
// A note must be left entirely alone.
|
||||
if err := mem.Insert(ctx, "note:7", []float32{5, 5, 5}, map[string]string{
|
||||
"type": "note", "text": "сеть тормозит по вечерам",
|
||||
}); err != nil {
|
||||
t.Fatalf("Insert note: %v", err)
|
||||
}
|
||||
|
||||
res, err := s.RepairFactVectors(ctx, embed)
|
||||
if err != nil {
|
||||
t.Fatalf("RepairFactVectors: %v", err)
|
||||
}
|
||||
if res.Rewritten != 2 || res.Dropped != 2 {
|
||||
t.Fatalf("repair reported %+v; want 2 rewritten (water, newest mood) and 2 dropped (voided go_version, superseded mood)", res)
|
||||
}
|
||||
|
||||
got, err := mem.ByPrefix(ctx, "fact:")
|
||||
if err != nil {
|
||||
t.Fatalf("ByPrefix: %v", err)
|
||||
}
|
||||
texts := map[string]string{}
|
||||
for _, r := range got {
|
||||
texts[r.ID] = r.Meta["text"]
|
||||
}
|
||||
if len(texts) != 2 {
|
||||
t.Fatalf("the index holds %+v; want only fact:water:100 and fact:mood:200", texts)
|
||||
}
|
||||
if texts["fact:water:100"] != "water — выпил" {
|
||||
t.Fatalf("water reads back %q; want the fact, not the utterance", texts["fact:water:100"])
|
||||
}
|
||||
if texts["fact:mood:200"] != "mood — устал" {
|
||||
t.Fatalf("mood reads back %q", texts["fact:mood:200"])
|
||||
}
|
||||
// Provenance the row already carried must survive the rewrite.
|
||||
for _, r := range got {
|
||||
if r.ID == "fact:water:100" && r.Meta["source"] != "voice" {
|
||||
t.Fatalf("water lost its source meta: %+v", r.Meta)
|
||||
}
|
||||
}
|
||||
if notes, err := mem.ByPrefix(ctx, "note:"); err != nil || len(notes) != 1 {
|
||||
t.Fatalf("the note row was touched: %+v (err %v)", notes, err)
|
||||
}
|
||||
|
||||
// Marker written, so a second run is free and changes nothing.
|
||||
again, err := s.RepairFactVectors(ctx, embed)
|
||||
if err != nil {
|
||||
t.Fatalf("second RepairFactVectors: %v", err)
|
||||
}
|
||||
if !again.Skipped {
|
||||
t.Fatalf("second run did work: %+v; the marker must make it a no-op", again)
|
||||
}
|
||||
}
|
||||
@@ -148,6 +148,27 @@ func (m *MemoryStore) Delete(ctx context.Context, id string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeletePrefix removes every vector whose id starts with prefix and returns
|
||||
// how many went. Same escaping as ByPrefix, so a key containing % or _ cannot
|
||||
// widen the delete.
|
||||
//
|
||||
// It exists for the repair half of a revert (#470). Voiding a fact row left
|
||||
// its vector in the index, so recall kept serving the voided fact's utterance
|
||||
// and the documented repair did not repair.
|
||||
func (m *MemoryStore) DeletePrefix(ctx context.Context, prefix string) (int64, error) {
|
||||
pattern := escapeLike(prefix) + "%"
|
||||
res, err := m.db.ExecContext(ctx,
|
||||
`DELETE FROM memory_vectors WHERE id LIKE ? ESCAPE '\'`, pattern)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("memory: delete prefix %q: %w", prefix, err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("memory: delete prefix %q: rows affected: %w", prefix, err)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// escapeLike neutralises the LIKE wildcards in a literal prefix.
|
||||
func escapeLike(s string) string {
|
||||
r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Stage 3 of #470: reverting a fact reported success and left the vector that
|
||||
// was answering questions, so the documented repair did not repair.
|
||||
func TestVoidLatestFactDropsMemoryVectors(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
now := time.Now()
|
||||
mem := s.VectorMemory()
|
||||
|
||||
if _, err := s.WriteFact(ctx, now, KindSelf, "go_version", `"1.20"`, "tap:voice", 1.0, sql.NullInt64{}); err != nil {
|
||||
t.Fatalf("WriteFact: %v", err)
|
||||
}
|
||||
// The id shape actionFact writes: fact:<key>:<unix>.
|
||||
if err := mem.Insert(ctx, "fact:go_version:1", []float32{1, 0, 0}, map[string]string{
|
||||
"type": "fact", "text": "какая последняя версия языка Go?",
|
||||
}); err != nil {
|
||||
t.Fatalf("Insert: %v", err)
|
||||
}
|
||||
// A vector for another key must survive the void.
|
||||
if err := mem.Insert(ctx, "fact:water:1", []float32{0, 1, 0}, map[string]string{
|
||||
"type": "fact", "text": "запиши что я пил воду",
|
||||
}); err != nil {
|
||||
t.Fatalf("Insert: %v", err)
|
||||
}
|
||||
|
||||
if _, _, err := s.VoidLatestFact(ctx, "go_version", "feedback", now.Add(time.Minute)); err != nil {
|
||||
t.Fatalf("VoidLatestFact: %v", err)
|
||||
}
|
||||
|
||||
got, err := mem.ByPrefix(ctx, "fact:")
|
||||
if err != nil {
|
||||
t.Fatalf("ByPrefix: %v", err)
|
||||
}
|
||||
if len(got) != 1 || got[0].ID != "fact:water:1" {
|
||||
t.Fatalf("after the void the index holds %+v; want only fact:water:1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeletePrefixDoesNotWidenOnWildcards(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
s := newTestStore(t)
|
||||
mem := s.VectorMemory()
|
||||
|
||||
for _, id := range []string{"fact:a_b:1", "fact:axb:1"} {
|
||||
if err := mem.Insert(ctx, id, []float32{1, 0}, map[string]string{"type": "fact"}); err != nil {
|
||||
t.Fatalf("Insert %q: %v", id, err)
|
||||
}
|
||||
}
|
||||
n, err := mem.DeletePrefix(ctx, "fact:a_b:")
|
||||
if err != nil {
|
||||
t.Fatalf("DeletePrefix: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("deleted %d rows; the _ in the key must not match x", n)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user