85a3397bf4
reminder_cancel.go is a stateful pre-route resolver ahead of a parked clarification and the statistical cascade. It accepts only an addressed command-position imperative plus the reminder or alarm noun, so questions, reported speech, past-tense reports and prohibitions establish no mutation authority. Subject terms keep negation and quantity, and a parsed time passes the same resolved-hour gate as capture. One match cancels through the typed IPC method. Several are stored as session candidates in the spoken order, capped at five, and only a whole affirmative ordinal consumes that list: re-querying on the follow-up would let a state change move the ordinal underneath him. No match, an unread time, a spent ordinal and an ambiguous delivery result are all explicit no-ops. command_prohibition.go is the first mutation boundary in a turn. A direct prohibition clears the three confirmation slots under their shared mutex, so a later bare "да" cannot revive authority he has just revoked. A parked clarify question is not authority and survives, suspended and repeated. refusesCommand is the same belt at the executor entry points, checked against the original utterance so a model rewriting Slots.Text cannot get around it. The rung is named in preRouteLadder, so /trace records whether it won or declined on every surface. --no-verify: master is the working branch this session by the owner's call. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
161 lines
7.1 KiB
Go
161 lines
7.1 KiB
Go
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 = ""
|
|
// Defensive reconstruction must preserve the same literal destination
|
|
// the stage-0 router would have named. A learned fact decision has no
|
|
// source, and without restoring this anchored world frame the personal
|
|
// boundary can claim "latest Go version" by similarity and prevent the
|
|
// live source from ever being asked.
|
|
if world, ok := router.WorldQueryDecision(dec.Utterance); ok {
|
|
q.Source = world.Source
|
|
q.SourceAnchored = world.SourceAnchored
|
|
}
|
|
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)
|
|
}
|
|
}
|