package main import ( "context" "log" "strconv" "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/memory" "github.com/kami/maven/internal/phraser" "github.com/kami/maven/internal/router" "github.com/kami/maven/internal/store" ) // actionFact handles router.IntentFact: persist a tapped self-fact, index // it for recall, and let pattern detection propose a routine. func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) string { if !dec.Slots.HasKey { return phraser.Ack(phraser.FailFactUnparsed, nil) } // 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) } // A complaint is not a fact either (#481). "сеть какая-то медленная" and // "интернет не работает" were stored as `self` rows at confidence 1.00, and // recall reads a self row back later as if it were still true — the same // class of row that outranked live search in #470. The sentence describes a // moment, so she answers it and stores nothing. An explicit "запомни ..." // and anything about him are both left alone by the test. if router.IsTransientComplaint(dec.Utterance) { log.Printf("voice: fact write refused, utterance is a passing complaint: %q (key %q) — answering as chat", dec.Utterance, dec.Slots.Key) c := dec c.Intent = router.IntentChat c.Slots.Key, c.Slots.HasKey = "", false c.Slots.Value = "" return h.actionChat(ctx, c) } now := h.now() req := ipc.WriteFactReq{ 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 // resolution costs one async lookup and is a no-op (not_found) // for the abstract self-state keys (mood, water) that aren't // entities at all. Subject: dec.Slots.Key, } factID, err := h.api.WriteFact(ctx, req) if err != nil { log.Printf("voice: write fact: %v", err) return phraser.Ack(phraser.FailFact, nil) } // 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. // // The vector id carries a timestamp, so a second tap of the same key adds a // row rather than replacing one, and recall then scores the superseded // value against the current one. CorrectValue and VoidLatestFact already // drop the key's vectors; an ordinary re-tap is the third way a value is // superseded and it did not (#493). Dropping first keeps exactly one vector // per key, which is what "recall answers with the current value" means. if h.recall.memStore != nil { pruneFactVectors(ctx, h.recall.memStore, dec.Slots.Key) text := store.FactRecallText(dec.Slots.Key, dec.Slots.Value) if vec, err := router.EmbedPassage(ctx, h.recall.embedder, text); err != nil { log.Printf("voice: embed fact for memory: %v", err) } else if err := h.recall.memStore.Insert(ctx, "fact:"+dec.Slots.Key+":"+strconv.FormatInt(now.Unix(), 10), vec, map[string]string{ "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) } } // Event extraction + pattern detection (best-effort, must not fail the // fact write). If the fact describes a recognizable action, it becomes a // normalized event; if ≥3 events for the same action+object show stable // intervals, a proposed routine is created and parked for confirmation. if h.dataStore != nil { if phrase := h.detectPattern(ctx, factID, dec.Slots.Key, dec.Slots.Value, now); phrase != "" { return phrase // "ты заправляешь ... напоминать?" } } return "" // replier phrases the success reply } // vectorPruner — the part of the vector index this file needs and memory.Store // does not carry. store.MemoryStore implements it; the in-memory test double // may not, and a double that cannot prune is not a reason to fail a fact write. type vectorPruner interface { DeletePrefix(ctx context.Context, prefix string) (int64, error) } // pruneFactVectors drops every vector for one fact key, so the insert that // follows is the only one left. Best-effort and silent on a store that cannot // prune: the fact row is the truth, and a stale vector costs a wrong recall, // not a lost fact. func pruneFactVectors(ctx context.Context, ms memory.Store, key string) { p, ok := ms.(vectorPruner) if !ok { return } n, err := p.DeletePrefix(ctx, "fact:"+key+":") if err != nil { log.Printf("voice: prune memory vectors for %q: %v", key, err) return } if n > 0 { log.Printf("voice: %q superseded, dropped %d stale memory vector(s)", key, n) } }