From 85a3397bf4dc817198233900a64c538423145ac3 Mon Sep 17 00:00:00 2001 From: claude Date: Sat, 15 Aug 2026 17:19:25 +0400 Subject: [PATCH] Cancel a reminder by voice, and honour a refusal (V-719) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- cmd/mavend/actions_act.go | 10 +- cmd/mavend/actions_fact.go | 9 + cmd/mavend/actions_note.go | 19 +- cmd/mavend/actions_reminder.go | 6 + cmd/mavend/actions_task_test.go | 30 ++ cmd/mavend/command_prohibition.go | 49 ++ cmd/mavend/decisiontrace.go | 4 +- cmd/mavend/ecosystem_acts.go | 13 +- cmd/mavend/entityname_test.go | 42 ++ cmd/mavend/eval_scenarios_test.go | 7 +- cmd/mavend/factgate_test.go | 20 +- cmd/mavend/ordinal.go | 59 +++ cmd/mavend/reminder_cancel.go | 383 ++++++++++++++++ cmd/mavend/reminder_cancel_test.go | 425 ++++++++++++++++++ cmd/mavend/replier_llm.go | 7 + cmd/mavend/replier_llm_test.go | 32 +- cmd/mavend/self.go | 11 + cmd/mavend/self_test.go | 30 ++ cmd/mavend/simulator_test.go | 352 ++++++++++++++- .../testdata/scenarios/assistant_workday.json | 219 +++++++++ cmd/mavend/turnrole.go | 3 +- cmd/mavend/turnroute.go | 3 + cmd/mavend/voice.go | 24 +- internal/dialogue/session.go | 6 + internal/dialogue/session_test.go | 17 + internal/phraser/ack_ru_v1.json | 3 +- 26 files changed, 1746 insertions(+), 37 deletions(-) create mode 100644 cmd/mavend/command_prohibition.go create mode 100644 cmd/mavend/reminder_cancel.go create mode 100644 cmd/mavend/reminder_cancel_test.go create mode 100644 cmd/mavend/testdata/scenarios/assistant_workday.json diff --git a/cmd/mavend/actions_act.go b/cmd/mavend/actions_act.go index ed9321c..a30902b 100644 --- a/cmd/mavend/actions_act.go +++ b/cmd/mavend/actions_act.go @@ -15,6 +15,14 @@ import ( // it to the ecosystems first, and run it behind the confirm gate and the // allowlist. proposeGap and the confirm gate itself live in confirm.go. func (h *reactiveHandler) actionAct(ctx context.Context, dec router.Decision) string { + // An allowlist or a model route is evidence about WHAT could run, never + // authority to run it. Keep the user's negative command at the execution + // boundary too: actionAct is also reached by rebuilt decisions outside the + // ordinary pre-route ladder. + if refusesCommand(dec) { + return commandProhibitionReply + } + // tool executor: run the matched fn against the enabled allowlist. // HasFn=false ⇒ try the matcher (for LLM-routed acts where the verb // didn't go through the stage-0 act grammar). @@ -41,7 +49,7 @@ func (h *reactiveHandler) actionAct(ctx context.Context, dec router.Decision) st // Hexis ecosystem action: if ecosystem is configured and we have a verb // + entity text, try to resolve the entity and execute via Hexis. - if h.ecosystem != nil && h.ecosystem.hexis != nil && dec.Slots.Text != "" { + if h.ecosystem != nil && h.ecosystem.hexis != nil && router.ActHasEntityTarget(dec) { if reply := h.handleHexisAct(ctx, dec); reply != "" { return reply } diff --git a/cmd/mavend/actions_fact.go b/cmd/mavend/actions_fact.go index 9cf5cf0..d96c189 100644 --- a/cmd/mavend/actions_fact.go +++ b/cmd/mavend/actions_fact.go @@ -38,6 +38,15 @@ func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) s // 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 diff --git a/cmd/mavend/actions_note.go b/cmd/mavend/actions_note.go index b1d7964..0fc3f3a 100644 --- a/cmd/mavend/actions_note.go +++ b/cmd/mavend/actions_note.go @@ -16,10 +16,11 @@ const nothingToCorrectReply = "не поняла, что поправить. с // actionNote handles router.IntentNote: embed the note, persist it, and // index it for recall. // -// The stored body is dec.Utterance and nothing else (V-576). It is not -// Slots.Text, not phraser output and not any other model string: a note is -// durable, the embedder indexes it, and it comes back later as recall in his -// own words. Phrasing belongs in the spoken confirmation. +// The stored body comes only from dec.Utterance (V-576/V-721). An explicit +// leading capture frame is structurally removed; an unmarked note is otherwise +// byte-for-byte his utterance. It is never Slots.Text, phraser output or any +// other model string: a note is durable, the embedder indexes it, and it comes +// back later as recall in his own words. Phrasing belongs in the confirmation. func (h *reactiveHandler) actionNote(ctx context.Context, dec router.Decision) string { // A correction with no referent. Everything that could own one has already // run by here: clarify, confirm and repair are all resolved before routing, @@ -39,16 +40,20 @@ func (h *reactiveHandler) actionNote(ctx context.Context, dec router.Decision) s if reply, ok := h.captureListFromNote(ctx, dec); ok { return reply } + noteText := dec.Utterance + if body, explicit := router.ParseNoteCapture(dec.Utterance); explicit { + noteText = body + } // embed the note text with the same model the classifier uses, persist // via CoreAPI (source=tap:voice). Semantic recall lives in `notes`, not // facts — no predicate reads it (spec's two-memory split). - vec, err := router.EmbedPassage(ctx, h.recall.embedder, dec.Utterance) + vec, err := router.EmbedPassage(ctx, h.recall.embedder, noteText) if err != nil { log.Printf("voice: embed note: %v", err) return phraser.Ack(phraser.FailNote, nil) } noteTs := h.now() - noteID, err := h.api.WriteNote(ctx, noteTs, dec.Utterance, vec, "tap:voice") + noteID, err := h.api.WriteNote(ctx, noteTs, noteText, vec, "tap:voice") if err != nil { log.Printf("voice: write note: %v", err) return phraser.Ack(phraser.FailNote, nil) @@ -59,7 +64,7 @@ func (h *reactiveHandler) actionNote(ctx context.Context, dec router.Decision) s if err := h.recall.memStore.Insert(ctx, "note:"+strconv.FormatInt(noteID, 10), vec, map[string]string{ "source": "voice", "type": "note", - "text": dec.Utterance, + "text": noteText, "ts": strconv.FormatInt(noteTs.Unix(), 10), }); err != nil { log.Printf("voice: memory insert: %v", err) diff --git a/cmd/mavend/actions_reminder.go b/cmd/mavend/actions_reminder.go index a05050a..3ccfaaf 100644 --- a/cmd/mavend/actions_reminder.go +++ b/cmd/mavend/actions_reminder.go @@ -14,6 +14,12 @@ import ( // actionReminder handles router.IntentReminder: parse the time when stage-0 // skipped the extractor, then create the reminder. func (h *reactiveHandler) actionReminder(ctx context.Context, dec router.Decision) string { + // The pre-route belt normally answers this before routing. Keep the write + // boundary guarded as well: a model calling the sentence a reminder does + // not turn "don't ..." into permission to create a row. + if refusesCommand(dec) { + return commandProhibitionReply + } if !dec.Slots.HasTime { // Stage-0 (reminder-wakeword grammar) skips the extractor, so the // time wasn't parsed. Run the parser as a fallback. diff --git a/cmd/mavend/actions_task_test.go b/cmd/mavend/actions_task_test.go index d371b10..94dee9c 100644 --- a/cmd/mavend/actions_task_test.go +++ b/cmd/mavend/actions_task_test.go @@ -294,6 +294,36 @@ func TestResolveTaskStatusMovesTheNamedTask(t *testing.T) { } } +// The regression crosses the grammar/action seam instead of handing the +// action a repaired Decision. The stored title is a normal imperative title, +// while the spoken marker names only its topic; framing words must not become +// identity and the unrelated live row must remain untouched. +func TestActionActMarkerReferentMovesOnlyTheNamedStoredTask(t *testing.T) { + api := &taskAPI{tasks: []ipc.Task{ + {ID: 17, Text: "настроить бэкапы", Status: "open"}, + {ID: 18, Text: "обновить сертификаты", Status: "open"}, + }} + h := taskHandler(api) + dec, matched, accepted := router.TaskStatusGrammar().Evaluate("отметь задачу про бэкапы как сделанную") + if !matched || !accepted { + t.Fatalf("task-status grammar matched=%v accepted=%v", matched, accepted) + } + + reply := h.actionAct(context.Background(), dec) + if api.listArg != "live" { + t.Errorf("listed %q, want live", api.listArg) + } + if len(api.moved) != 1 { + t.Fatalf("moved %+v, want exactly the named stored task", api.moved) + } + if got := api.moved[0]; got.id != 17 || got.status != "done" || got.by != "tap:voice" { + t.Errorf("moved %+v, want task 17 → done by tap:voice", got) + } + if !strings.Contains(reply, "настроить бэкапы") { + t.Errorf("reply = %q, want the transitioned stored title", reply) + } +} + func TestResolveTaskStatusRefusesToGuess(t *testing.T) { cases := []struct { name string diff --git a/cmd/mavend/command_prohibition.go b/cmd/mavend/command_prohibition.go new file mode 100644 index 0000000..339fdd6 --- /dev/null +++ b/cmd/mavend/command_prohibition.go @@ -0,0 +1,49 @@ +package main + +import ( + "context" + + "github.com/kami/maven/internal/router" +) + +// commandProhibitionReply is deliberately operation-neutral. At this boundary +// Maven may know only that the user denied authority, not whether the model +// would have called it a reminder, board transition, local tool or Hexis act. +const commandProhibitionReply = "хорошо, не буду." + +// resolveCommandProhibition is the first mutation boundary in a turn. It runs +// before a parked clarify answer or candidate selection can consume the words, +// and before any route/model is consulted. A direct prohibition is complete in +// itself: it needs no target lookup and makes no external call. +// +// A parked clarify request is unrelated state. Preserve it and say the pending +// question again, using the same bounded suspend policy as every other side +// request. Candidate lists likewise remain untouched; no ordinal was selected. +func (h *reactiveHandler) resolveCommandProhibition(ctx context.Context, text string) (string, bool) { + if !router.IsCommandProhibition(text) { + return "", false + } + // A later bare "да" must not revive authority the user has just revoked. + // Confirmation slots are all mutation authority and are process-local, so + // clearing the three under their shared mutex is both conservative and + // atomic. Clarify questions and candidate lists are not authority and stay. + h.mu.Lock() + h.pending = nil + h.pendingHexis = nil + h.pendingRoutine = nil + h.mu.Unlock() + if h.clarifyStore != nil { + if q := h.clarifyStore.Get(dialogueIDOf(ctx), h.now()); q != nil { + h.noteSuspended(ctx, q) + } + } + return commandProhibitionReply, true +} + +// refusesCommand is the defense-in-depth form for execution entry points which +// can also be called with a reconstructed or test decision outside runTurn. +// The sentinel cannot be renamed into an enabled function, and the original +// utterance remains the authority even when a model rewrites Slots.Text. +func refusesCommand(dec router.Decision) bool { + return dec.Slots.Fn == router.ProhibitedActFn || router.IsCommandProhibition(dec.Utterance) +} diff --git a/cmd/mavend/decisiontrace.go b/cmd/mavend/decisiontrace.go index e49e245..9a779d4 100644 --- a/cmd/mavend/decisiontrace.go +++ b/cmd/mavend/decisiontrace.go @@ -31,8 +31,8 @@ import ( // and nothing should: a missing name costs one line of the record, while a // check that walks the ladder would have to run the ladder. var preRouteLadder = []string{ - "confirm", "repair", "repair-negative", "clarify-answer", "quiet-toggle", - "snooze", "ack", "ordinal", + "confirm", "repair", "repair-negative", "command-prohibition", "clarify-answer", "quiet-toggle", + "snooze", "ack", "reminder-cancel", "ordinal", } // notePreRoute records one rung of that ladder and passes its verdict through diff --git a/cmd/mavend/ecosystem_acts.go b/cmd/mavend/ecosystem_acts.go index fdf0210..9ee506e 100644 --- a/cmd/mavend/ecosystem_acts.go +++ b/cmd/mavend/ecosystem_acts.go @@ -658,6 +658,12 @@ func (h *reactiveHandler) resolveEntityCandidates(ctx context.Context, refs []st // matching capabilities through Hexis. Returns a reply string when handled, // or "" to fall through to the system command executor. func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decision) string { + // This method is intentionally callable outside runTurn by ecosystem + // harnesses. Refuse before correlation ids, Nexus resolution or capability + // discovery so the no-op sentinel can never leak into Hexis as a verb. + if refusesCommand(dec) { + return commandProhibitionReply + } if h.ecosystem == nil { return "" } @@ -841,10 +847,15 @@ func (h *reactiveHandler) execHexis(ctx context.Context, capID, capName, entityI // resolution stops on ambiguity and a mutating capability still goes through // the spoken confirm in handleHexisAct. func (h *reactiveHandler) hexisBeforeClarify(ctx context.Context, dec router.Decision) string { + // A thinned model act reaches this hook before actionAct. Negative authority + // must therefore stop here as well, before even a read to Nexus/Hexis. + if refusesCommand(dec) { + return commandProhibitionReply + } if h.ecosystem == nil || h.ecosystem.hexis == nil { return "" } - if dec.Intent != router.IntentAct || dec.Slots.HasFn || dec.Slots.Text == "" { + if dec.Intent != router.IntentAct || dec.Slots.HasFn || !router.ActHasEntityTarget(dec) { return "" } return h.handleHexisAct(ctx, dec) diff --git a/cmd/mavend/entityname_test.go b/cmd/mavend/entityname_test.go index 85fdc5b..5ccc111 100644 --- a/cmd/mavend/entityname_test.go +++ b/cmd/mavend/entityname_test.go @@ -147,6 +147,48 @@ func TestClarifyStillAsksWithoutHexis(t *testing.T) { } } +// A verb is not an entity. Before the reach gate, an exact local matcher hit +// with no arguments still sent the raw verb to Nexus and could discover a +// similarly named entity through Hexis. The local tool lane may handle or +// reject it, but the ecosystem must not be consulted without a target. +func TestBareMatchedActNeverReachesNexus(t *testing.T) { + nexus := newFakeNexus(t, fixtureNexusResolved("ent_power", "Power", "service")) + hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded")) + h, _, _ := newClarifyHandler(t) + h.ecosystem = ecoHandler(t, nexus, nil, hexis).ecosystem + + reply := h.actionAct(context.Background(), router.Decision{ + Utterance: "выключи", + Intent: router.IntentAct, + Slots: router.Slots{Fn: "выключи", HasFn: true, Text: "выключи"}, + }) + if len(nexus.Requests()) != 0 { + t.Fatalf("bare verb reached Nexus: %+v", nexus.Requests()) + } + if reply == "" { + t.Fatal("bare act disappeared instead of staying in Maven's local lane") + } +} + +func TestUnresolvedActNeverReachesNexusBeforeClarify(t *testing.T) { + nexus := newFakeNexus(t, fixtureNexusResolved("ent_it", "It", "service")) + hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded")) + h := ecoHandler(t, nexus, nil, hexis) + dec := router.Decision{ + Utterance: "сделай это", + Intent: router.IntentAct, + Stage: 3, + Clarify: true, + Slots: router.Slots{Text: "сделай это"}, + } + if reply := h.hexisBeforeClarify(context.Background(), dec); reply != "" { + t.Fatalf("unresolved act was answered by Hexis: %q", reply) + } + if len(nexus.Requests()) != 0 { + t.Fatalf("unresolved act reached Nexus: %+v", nexus.Requests()) + } +} + // nexusInOrder serves one resolve answer per call, in order, so a test can say // what Nexus knows about the first name and what it knows about the second. The // last body repeats once the list runs out. diff --git a/cmd/mavend/eval_scenarios_test.go b/cmd/mavend/eval_scenarios_test.go index cc2d030..e8d0cdb 100644 --- a/cmd/mavend/eval_scenarios_test.go +++ b/cmd/mavend/eval_scenarios_test.go @@ -106,7 +106,12 @@ func TestSystemSafetyScenarios(t *testing.T) { hexis := newFakeHexis(t, fixtureHexisCapabilities(map[string]any{"id": "restart", "name": "restart", "read_only": false}), fixtureHexisExecuted("exec_1", "succeeded")) h, _ := newSafetyHandler(t) h.ecosystem = stubEcosystem(nexus.URL, hexis.URL) - reply := h.applyAction(ctx, router.Decision{Intent: router.IntentAct, Slots: router.Slots{Fn: "restart", HasFn: true, Text: "indexer"}}) + // A matched function carries its entity target in Args. Text may be + // model phrasing, but Args is the production matcher contract and the + // ecosystem reach gate deliberately requires that evidence. + reply := h.applyAction(ctx, router.Decision{Intent: router.IntentAct, Slots: router.Slots{ + Fn: "restart", HasFn: true, Args: []string{"indexer"}, Text: "indexer", + }}) if !strings.Contains(reply, "Indexer A") || !strings.Contains(reply, "Indexer B") { t.Fatalf("ambiguous entity must prompt for clarification, got %q", reply) } diff --git a/cmd/mavend/factgate_test.go b/cmd/mavend/factgate_test.go index 72ecb87..c0f4391 100644 --- a/cmd/mavend/factgate_test.go +++ b/cmd/mavend/factgate_test.go @@ -2,6 +2,9 @@ package main import ( "context" + "net/http" + "net/url" + "strings" "testing" "time" @@ -33,6 +36,10 @@ func newFactGateHandler(t *testing.T, now time.Time) (*reactiveHandler, ipc.Core func TestActionFact_QuestionIsNotWritten(t *testing.T) { ctx := context.Background() h, api := newFactGateHandler(t, time.Now()) + searchH, seen := searchHandler(t, + `{"answers":["Актуальная версия Go — 1.25."],"results":[]}`, + http.StatusOK) + h.search = searchH.search reply := h.actionFact(ctx, router.Decision{ Intent: router.IntentFact, @@ -50,11 +57,14 @@ func TestActionFact_QuestionIsNotWritten(t *testing.T) { 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") + // It went down the world query chain instead. This asserts the actual + // destination, not merely that the write was refused: the regression was + // the personal boundary claiming this question before search. + if !strings.Contains(reply, "1.25") { + t.Fatalf("reply = %q, want live world evidence", reply) + } + if !strings.Contains(*seen, "q="+url.QueryEscape("какая последняя версия языка Go?")) { + t.Fatalf("search query = %q; world source was not reached verbatim", *seen) } } diff --git a/cmd/mavend/ordinal.go b/cmd/mavend/ordinal.go index 704cb6a..4e4aa3e 100644 --- a/cmd/mavend/ordinal.go +++ b/cmd/mavend/ordinal.go @@ -9,6 +9,7 @@ import ( "github.com/kami/maven/internal/dialogue" "github.com/kami/maven/internal/lexicon" + "github.com/kami/maven/internal/router" "github.com/kami/maven/internal/store" ) @@ -67,6 +68,44 @@ func parseOrdinal(text string) (int, bool) { return 0, false } +// parseReminderCancelChoice is intentionally narrower than parseOrdinal. A +// task ordinal may appear inside a sentence carrying its transition verb; the +// reminder list was already offered specifically for cancellation, so the next +// mutation requires the whole turn to be one affirmative position answer. +// Questions, negation, two positions and new requests all decline and route as +// fresh turns instead of cancelling whichever ordinal happened to appear. +func parseReminderCancelChoice(text string) (int, bool) { + if router.IsQuestionShaped(text) { + return 0, false + } + tokens := turnTokens(text) + nth, positions := 0, 0 + for _, tok := range tokens { + if reminderCancelNegation(tok) { + return 0, false + } + if n, ok := candidateDigits[tok]; ok { + nth, positions = n, positions+1 + continue + } + if n, ok := lexicon.Ordinal(tok); ok { + nth, positions = n, positions+1 + continue + } + if lexicon.IsFillerParticle(tok) || reminderCancelVerbs[tok] || + isReminderCancelTarget(tok) || reminderCancelFrame[tok] { + continue + } + switch tok { + case "номер", "вариант", "number", "option", "one": + continue + default: + return 0, false + } + } + return nth, positions == 1 +} + // candidateVerbs — what he wants done with the one he picked. Nothing here is // destructive: a task moves forward or is dropped, and both are recorded with a // provenance the /tasks page shows. @@ -112,7 +151,21 @@ func (h *reactiveHandler) resolveCandidate(ctx context.Context, text string, src if sess == nil || len(sess.Candidates) == 0 { return "", false } + reminderList := true + for _, candidate := range sess.Candidates { + if candidate.Kind != "reminder-cancel" { + reminderList = false + break + } + } + if reminderList && classifyConfirm(text) == confirmNo { + h.dialogueSessions.SetCandidates(dialogueIDOf(ctx), h.now(), nil) + return "хорошо, ничего не отменяю.", true + } nth, ok := parseOrdinal(text) + if reminderList { + nth, ok = parseReminderCancelChoice(text) + } if !ok { return "", false } @@ -125,6 +178,12 @@ func (h *reactiveHandler) resolveCandidate(ctx context.Context, text string, src return fmt.Sprintf("я назвала только %d.", len(sess.Candidates)), true } pick := sess.Candidates[nth-1] + if pick.Kind == "reminder-cancel" { + // Unlike a task list, this list was offered in answer to the explicit + // question "which reminder should I cancel?" A bare ordinal is the + // answer to that question and therefore completes the cancellation. + return h.cancelReminderChoice(ctx, pick.Ref, pick.Label), true + } status, say, hasVerb := parseCandidateVerb(text) if !hasVerb || pick.Kind != "task" { // Read it back and keep the list: naming one is often the first half of diff --git a/cmd/mavend/reminder_cancel.go b/cmd/mavend/reminder_cancel.go new file mode 100644 index 0000000..0b89bb5 --- /dev/null +++ b/cmd/mavend/reminder_cancel.go @@ -0,0 +1,383 @@ +package main + +import ( + "context" + "errors" + "fmt" + "log" + "strconv" + "strings" + "time" + + "github.com/kami/maven/internal/dialogue" + "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/lexicon" + "github.com/kami/maven/internal/morph" + "github.com/kami/maven/internal/router" + "github.com/kami/maven/internal/store" +) + +// reminderCancelRequest exists to make the parser's contract explicit: a hit +// proves only that the turn is an addressed imperative naming the reminder +// store. Subject and time are resolved separately after that safety boundary. +type reminderCancelRequest struct{} + +var reminderCancelVerbs = func() map[string]bool { + out := make(map[string]bool) + for _, word := range lexicon.ReminderCancelVerbs() { + out[strings.ToLower(word)] = true + } + return out +}() + +var reminderCancelFrame = func() map[string]bool { + out := make(map[string]bool) + for _, word := range lexicon.ReminderCancelFrame() { + out[strings.ToLower(word)] = true + } + return out +}() + +// isReminderCancelTarget is deliberately a noun test, not a substring test. +// A committed reminder must be named, otherwise "убери со стола" would reach +// the reminder store. Russian cases are grammar and go through morph; the +// English singular/plural forms are closed command vocabulary. +func isReminderCancelTarget(tok string) bool { + if morph.SameWord(tok, "напоминание") || morph.SameWord(tok, "будильник") { + return true + } + switch tok { + case "reminder", "reminders", "alarm", "alarms": + return true + default: + return false + } +} + +// reminderCancelLead reports which words may precede the imperative without +// becoming a subject of their own. Filler/politeness vocabulary already has +// one home in the lexicon; Maven's name is an address, not a Russian class. +func reminderCancelLead(tok string) bool { + return lexicon.IsFillerParticle(tok) || tok == "мавен" || tok == "maven" +} + +// parseReminderCancelRequest recognizes an exact cancel imperative at the +// start of the addressed command plus an explicit reminder noun. Both are +// whole tokens. Requiring command position is the safety boundary: infinitive +// questions ("как отменить ..."), reported speech ("он сказал: отмени ...") +// and past-tense remarks never reach the reminder store. A relative clause +// after a real command remains valid even though it may contain a question +// pronoun, so this is stronger and more precise than a punctuation test. +func parseReminderCancelRequest(text string) (reminderCancelRequest, bool) { + tokens := turnTokens(text) + verbAt := -1 + for i, tok := range tokens { + if reminderCancelVerbs[tok] { + verbAt = i + break + } + } + if verbAt < 0 { + return reminderCancelRequest{}, false + } + for _, tok := range tokens[:verbAt] { + if !reminderCancelLead(tok) { + return reminderCancelRequest{}, false + } + } + for _, tok := range tokens[verbAt+1:] { + if isReminderCancelTarget(tok) { + return reminderCancelRequest{}, true + } + } + return reminderCancelRequest{}, false +} + +func reminderCancelNegation(tok string) bool { + switch tok { + case "не", "ни", "not", "no", "don't", "dont": + return true + default: + return false + } +} + +func reminderCancelTimeLead(tok string) bool { + switch tok { + case "в", "во", "на", "к", "ко", "через", "спустя", + "at", "in", "by", "until", "after", "before": + return true + default: + return false + } +} + +func reminderCancelTimeUnit(tok string) bool { + if lexicon.IsHourUnit(tok) || lexicon.IsMinuteUnit(tok) { + return true + } + for _, part := range lexicon.PartsOfDay() { + if tok == part { + return true + } + } + return tok == "утра" || tok == "дня" || tok == "вечера" || tok == "ночи" || + tok == "am" || tok == "pm" || tok == "noon" || tok == "midnight" +} + +func reminderCancelNumeral(tok string) (int, bool) { + if n, ok := lexicon.Cardinal(tok); ok { + return n, true + } + if n, ok := lexicon.Ordinal(tok); ok && n > 0 { + return n, true + } + n, err := strconv.Atoi(tok) + return n, err == nil +} + +// reminderClockTokenBudget records the numeric pieces that came from a written +// clock. turnTokens deliberately splits 21:30 into 21 and 30, so a small +// multiset lets subject extraction ignore exactly those occurrences without +// discarding the same number when it also belongs to the reminder text. +func reminderClockTokenBudget(text string) map[string]int { + out := make(map[string]int) + for _, field := range strings.Fields(strings.ToLower(text)) { + field = strings.Trim(field, ".,!?;()[]{}«»\"'") + hour, minute, ok := strings.Cut(field, ":") + if !ok || len(minute) != 2 { + continue + } + h, herr := strconv.Atoi(hour) + m, merr := strconv.Atoi(minute) + if herr != nil || merr != nil || h < 0 || h > 23 || m < 0 || m > 59 { + continue + } + out[hour]++ + out[minute]++ + } + return out +} + +// reminderCancellationTerms keeps identity-bearing words, including negation +// and quantities. The old ownContent shortcut erased both, so "не звонить" and +// "звонить", or "одну таблетку" and "две таблетки", could select the same +// row. Time framing is removed only after the shared parser proved that this +// turn actually carries a readable time; numerals are removed only in a clock +// position, never merely because they are numbers. +func reminderCancellationTerms(text string, hasTime bool) []string { + tokens := turnTokens(text) + clockBudget := reminderClockTokenBudget(text) + out := make([]string, 0, len(tokens)) + for i, tok := range tokens { + if reminderCancelVerbs[tok] || isReminderCancelTarget(tok) || + reminderCancelFrame[tok] || lexicon.IsFillerParticle(tok) { + continue + } + if !hasTime || reminderCancelNegation(tok) { + out = append(out, tok) + continue + } + if clockBudget[tok] > 0 { + clockBudget[tok]-- + continue + } + if _, numeric := reminderCancelNumeral(tok); numeric { + prevTime := i > 0 && reminderCancelTimeLead(tokens[i-1]) + nextTime := i+1 < len(tokens) && reminderCancelTimeUnit(tokens[i+1]) + if prevTime || nextTime { + continue + } + } + // frameWords is assembled exclusively from the closed time/grammar + // lexicons. At this point a time was parsed, and negation has already + // been preserved above, so these words identify the time rather than + // the stored reminder body. + if frameWords[tok] { + continue + } + out = append(out, tok) + } + return out +} + +// reminderCancellationTime applies the same parse and resolved-hour gate as a +// newly created reminder. A time expression that is present but unread is not +// silently discarded: the caller asks for a clearer time instead of cancelling +// whichever row happens to match the remaining words. +func (h *reactiveHandler) reminderCancellationTime(ctx context.Context, text string) (time.Time, bool) { + if slots := h.extractor.Extract(ctx, router.IntentReminder, text, h.now()); slots.HasTime { + return slots.Time, true + } + if h.timeParser == nil { + return time.Time{}, false + } + parsed, ok, err := h.timeParser.Parse(ctx, text, h.now()) + if err != nil || !ok || !router.ResolvedTheHour(text, parsed) { + return time.Time{}, false + } + return parsed, true +} + +func reminderNextFire(r ipc.Reminder) time.Time { + if !r.NextFireTs.IsZero() { + return r.NextFireTs + } + return r.FireTs +} + +// reminderTimeMatches lets state disambiguate a clock when the day was not +// named. "На девять" can therefore select the sole 09:00/21:00 reminder, but +// if both exist they both remain candidates and Maven asks. A named day or an +// interval denotes an absolute minute and must match that minute exactly. +func reminderTimeMatches(text string, parsed, fire time.Time) bool { + local := fire.In(parsed.Location()) + if router.NamesADay(text) || router.NamesAnInterval(text) { + return local.Truncate(time.Minute).Equal(parsed.Truncate(time.Minute)) + } + if router.HourIsAmbiguous(text) { + return local.Minute() == parsed.Minute() && local.Hour()%12 == parsed.Hour()%12 + } + return local.Hour() == parsed.Hour() && local.Minute() == parsed.Minute() +} + +func reminderTextMatchesTerms(r ipc.Reminder, terms []string) bool { + if len(terms) == 0 { + return true + } + words := turnTokens(store.ReminderText(r.Payload)) + used := make([]bool, len(words)) + for _, term := range terms { + found := false + for i, word := range words { + if used[i] { + continue + } + tn, tok := reminderCancelNumeral(term) + wn, wok := reminderCancelNumeral(word) + if term == word || morph.SameWord(term, word) || (tok && wok && tn == wn) { + used[i] = true + found = true + break + } + } + if !found { + return false + } + } + return true +} + +func reminderCancellationLabel(r ipc.Reminder, now time.Time) string { + fire := reminderNextFire(r).In(now.Location()) + when := dayPrefix(now, fire) + if when == "это" { + when = fmt.Sprintf("%d %s", fire.Day(), lexicon.MonthGenitive(int(fire.Month()))) + } + return fmt.Sprintf("%s в %s — %s", when, fire.Format("15:04"), store.ReminderText(r.Payload)) +} + +// offerReminderCancellations binds exactly the rows Maven names, in that order. +// An ordinal on the next turn therefore points at the spoken list, never at a +// fresh query whose order may have changed in between. +func (h *reactiveHandler) offerReminderCancellations(ctx context.Context, text string, matches []ipc.Reminder) string { + const maxSpoken = 5 + truncated := len(matches) > maxSpoken + if len(matches) > maxSpoken { + matches = matches[:maxSpoken] + } + candidates := make([]dialogue.Candidate, 0, len(matches)) + parts := make([]string, 0, len(matches)) + for i, r := range matches { + label := reminderCancellationLabel(r, h.now()) + candidates = append(candidates, dialogue.Candidate{Kind: "reminder-cancel", Ref: r.ID, Label: label}) + parts = append(parts, fmt.Sprintf("%d: %s", i+1, label)) + } + + if h.dialogueSessions == nil { + return "нашла несколько подходящих напоминаний — уточни текст или время." + } + id, now := dialogueIDOf(ctx), h.now() + // This command is its own turn. Reusing an older session would keep stale + // intent/slots alive after the choice and let the next utterance inherit + // unrelated state, so the offered list gets a fresh system session. + h.dialogueSessions.Put(id, &dialogue.Session{ + Intent: dialogue.IntentSystem, Utterance: text, Timestamp: now, + Candidates: candidates, + }) + prefix := "нашла несколько подходящих. какое отменить? " + if truncated { + prefix = "нашла больше пяти подходящих; называю первые пять. если нужного здесь нет, уточни текст или время. какое отменить? " + } + return prefix + strings.Join(parts, "; ") + ". ответь одним порядковым словом, например «второе»." +} + +func (h *reactiveHandler) clearReminderCandidates(ctx context.Context) { + if h.dialogueSessions != nil { + h.dialogueSessions.SetCandidates(dialogueIDOf(ctx), h.now(), nil) + } +} + +func (h *reactiveHandler) cancelReminderChoice(ctx context.Context, id int64, label string) string { + if err := h.api.CancelReminder(ctx, id); err != nil { + switch { + case errors.Is(err, ipc.ErrReminderNotFound), errors.Is(err, ipc.ErrReminderState): + h.clearReminderCandidates(ctx) + return "это напоминание уже не ожидает отправки." + case errors.Is(err, ipc.ErrReminderInFlight): + h.clearReminderCandidates(ctx) + return "я уже начала отправлять это напоминание — надёжно отменить его уже нельзя." + default: + log.Printf("voice: cancel reminder %d: %v", id, err) + return "не получилось отменить напоминание." + } + } + h.clearReminderCandidates(ctx) + log.Printf("voice: cancelled reminder %d (%q)", id, label) + return "отменила напоминание: " + label + "." +} + +// resolveReminderCancellation is the stateful pre-route resolver for a +// committed reminder. It claims only the explicit structural command above, +// resolves against every pending row, and never ranks an ambiguous set down to +// one. One match cancels; more than one is an offered, ordinal-bound question. +func (h *reactiveHandler) resolveReminderCancellation(ctx context.Context, text string) (string, bool) { + _, ok := parseReminderCancelRequest(text) + if !ok { + return "", false + } + rows, err := h.api.ListPendingReminders(ctx, 0) + if err != nil { + log.Printf("voice: list reminders for cancellation: %v", err) + return "не получилось посмотреть напоминания.", true + } + if len(rows) == 0 { + return "ожидающих напоминаний нет.", true + } + + parsed, hasTime := h.reminderCancellationTime(ctx, text) + if router.MentionsTime(text) && !hasTime { + return "не смогла разобрать время напоминания — уточни его.", true + } + terms := reminderCancellationTerms(text, hasTime) + matches := make([]ipc.Reminder, 0, len(rows)) + for _, r := range rows { + if !reminderTextMatchesTerms(r, terms) { + continue + } + if hasTime && !reminderTimeMatches(text, parsed, reminderNextFire(r)) { + continue + } + matches = append(matches, r) + } + + switch len(matches) { + case 0: + return "не нашла такого ожидающего напоминания.", true + case 1: + label := reminderCancellationLabel(matches[0], h.now()) + return h.cancelReminderChoice(ctx, matches[0].ID, label), true + default: + return h.offerReminderCancellations(ctx, text, matches), true + } +} diff --git a/cmd/mavend/reminder_cancel_test.go b/cmd/mavend/reminder_cancel_test.go new file mode 100644 index 0000000..68d992c --- /dev/null +++ b/cmd/mavend/reminder_cancel_test.go @@ -0,0 +1,425 @@ +package main + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + "time" + + "github.com/kami/maven/internal/decision" + "github.com/kami/maven/internal/dialogue" + "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/router" + "github.com/kami/maven/internal/store" + "github.com/kami/maven/internal/tts" +) + +func TestParseReminderCancelRequest(t *testing.T) { + for _, tc := range []struct { + text string + ok bool + }{ + {"отмени напоминание про врача", true}, + {"убери моё напоминание о визите", true}, + {"удали будильник на девять", true}, + {"пожалуйста, Maven, cancel the reminder about doctor", true}, + {"отмени напоминание, которое стоит на завтра", true}, + {"напоминание про врача", false}, + {"отмени задачу про врача", false}, + {"я отменил напоминание про врача", false}, + {"как отменить напоминание про врача?", false}, + {"можно отменить напоминание про врача?", false}, + {"он сказал: отмени напоминание про врача", false}, + {"how to cancel the reminder about doctor?", false}, + {"can you cancel the reminder about doctor?", false}, + {"убери со стола", false}, + {"отмена", false}, + } { + _, ok := parseReminderCancelRequest(tc.text) + if ok != tc.ok { + t.Errorf("parseReminderCancelRequest(%q) ok = %v, want %v", tc.text, ok, tc.ok) + } + } +} + +func TestReminderCancellationTermsPreserveIdentity(t *testing.T) { + for _, tc := range []struct { + text string + hasTime bool + want []string + }{ + {"отмени напоминание про врача", false, []string{"врача"}}, + {"отмени напоминание не звонить врачу", false, []string{"не", "звонить", "врачу"}}, + {"отмени напоминание принять две таблетки", false, []string{"принять", "две", "таблетки"}}, + {"отмени напоминание принять две таблетки на девять", true, []string{"принять", "две", "таблетки"}}, + {"cancel the reminder to take 2 pills at 21:30", true, []string{"take", "2", "pills"}}, + } { + got := reminderCancellationTerms(tc.text, tc.hasTime) + if strings.Join(got, "|") != strings.Join(tc.want, "|") { + t.Errorf("reminderCancellationTerms(%q) = %v, want %v", tc.text, got, tc.want) + } + } +} + +func seedVoiceReminder(t *testing.T, st *store.Store, fire time.Time, text string) int64 { + t.Helper() + id, err := st.CreateReminder(context.Background(), fire, `{"text":"`+text+`"}`, "") + if err != nil { + t.Fatalf("create reminder: %v", err) + } + return id +} + +func reminderStatuses(t *testing.T, st *store.Store) map[int64]string { + t.Helper() + rows, err := st.ListReminders(context.Background(), 100) + if err != nil { + t.Fatalf("list reminders: %v", err) + } + out := make(map[int64]string, len(rows)) + for _, row := range rows { + out[row.ID] = row.Status + } + return out +} + +func TestReminderCancellationResolvesSubjectByMorphology(t *testing.T) { + h, st, now := newClarifyHandler(t) + h.timeParser = router.StubDateTimeParser{} + doctor := seedVoiceReminder(t, st, now.Add(3*time.Hour), "позвонить врачу") + bread := seedVoiceReminder(t, st, now.Add(4*time.Hour), "купить хлеб") + + reply, handled := h.resolveReminderCancellation(context.Background(), "отмени напоминание про врача") + if !handled || !strings.Contains(reply, "отменила") || !strings.Contains(reply, "позвонить врачу") { + t.Fatalf("reply = %q, handled=%v", reply, handled) + } + statuses := reminderStatuses(t, st) + if statuses[doctor] != store.ReminderCancelled || statuses[bread] != store.ReminderPending { + t.Fatalf("statuses = %+v, want doctor cancelled and bread pending", statuses) + } +} + +func TestReminderCancellationKeepsNegationAndQuantityDistinct(t *testing.T) { + t.Run("negation", func(t *testing.T) { + h, st, now := newClarifyHandler(t) + positive := seedVoiceReminder(t, st, now.Add(time.Hour), "звонить врачу") + negative := seedVoiceReminder(t, st, now.Add(2*time.Hour), "не звонить врачу") + + reply, handled := h.resolveReminderCancellation(context.Background(), "отмени напоминание не звонить врачу") + if !handled || !strings.Contains(reply, "не звонить врачу") { + t.Fatalf("reply = %q, handled=%v", reply, handled) + } + statuses := reminderStatuses(t, st) + if statuses[positive] != store.ReminderPending || statuses[negative] != store.ReminderCancelled { + t.Fatalf("negation selected the wrong row: %+v", statuses) + } + }) + + t.Run("quantity", func(t *testing.T) { + h, st, now := newClarifyHandler(t) + one := seedVoiceReminder(t, st, now.Add(time.Hour), "принять одну таблетку") + two := seedVoiceReminder(t, st, now.Add(2*time.Hour), "принять две таблетки") + + reply, handled := h.resolveReminderCancellation(context.Background(), "удали напоминание принять две таблетки") + if !handled || !strings.Contains(reply, "две таблетки") { + t.Fatalf("reply = %q, handled=%v", reply, handled) + } + statuses := reminderStatuses(t, st) + if statuses[one] != store.ReminderPending || statuses[two] != store.ReminderCancelled { + t.Fatalf("quantity selected the wrong row: %+v", statuses) + } + }) +} + +func TestReminderCancellationQuestionNeverMutates(t *testing.T) { + h, st, now := newClarifyHandler(t) + id := seedVoiceReminder(t, st, now.Add(time.Hour), "позвонить врачу") + for _, text := range []string{ + "как отменить напоминание про врача?", + "можно отменить напоминание про врача?", + "он сказал: отмени напоминание про врача", + } { + if reply, handled := h.resolveReminderCancellation(context.Background(), text); handled || reply != "" { + t.Fatalf("non-command %q was claimed: reply=%q handled=%v", text, reply, handled) + } + if got := reminderStatuses(t, st)[id]; got != store.ReminderPending { + t.Fatalf("non-command %q changed reminder to %q", text, got) + } + } +} + +func TestReminderCancellationUsesClockAndAsksWhenStateIsAmbiguous(t *testing.T) { + t.Run("one matching half of day is enough", func(t *testing.T) { + h, st, now := newClarifyHandler(t) + h.timeParser = router.StubDateTimeParser{} + evening := seedVoiceReminder(t, st, time.Date(now.Year(), now.Month(), now.Day(), 21, 0, 0, 0, now.Location()), "вечернее лекарство") + seedVoiceReminder(t, st, now.Add(2*time.Hour), "купить хлеб") + + reply, handled := h.resolveReminderCancellation(context.Background(), "убери напоминание на девять") + if !handled || !strings.Contains(reply, "отменила") { + t.Fatalf("reply = %q, handled=%v", reply, handled) + } + if got := reminderStatuses(t, st)[evening]; got != store.ReminderCancelled { + t.Fatalf("21:00 status = %q, want cancelled", got) + } + }) + + t.Run("two matching halves are offered and ordinal is bound", func(t *testing.T) { + h, st, now := newClarifyHandler(t) + h.timeParser = router.StubDateTimeParser{} + evening := seedVoiceReminder(t, st, time.Date(now.Year(), now.Month(), now.Day(), 21, 0, 0, 0, now.Location()), "вечернее лекарство") + morning := seedVoiceReminder(t, st, time.Date(now.Year(), now.Month(), now.Day()+1, 9, 0, 0, 0, now.Location()), "утреннее лекарство") + + reply, handled := h.resolveReminderCancellation(context.Background(), "убери напоминание на девять") + if !handled || !strings.Contains(reply, "порядковым словом") { + t.Fatalf("ambiguous reply = %q, handled=%v", reply, handled) + } + statuses := reminderStatuses(t, st) + if statuses[evening] != store.ReminderPending || statuses[morning] != store.ReminderPending { + t.Fatalf("ambiguous command mutated rows: %+v", statuses) + } + sess := h.dialogueSessions.Get(dialogueIDOf(context.Background()), h.now()) + if sess == nil || len(sess.Candidates) != 2 || sess.Candidates[1].Ref != morning { + t.Fatalf("bound candidates = %+v", sess) + } + + reply, handled = h.resolveCandidate(context.Background(), "второе", sourceVoice) + if !handled || !strings.Contains(reply, "утреннее лекарство") { + t.Fatalf("ordinal reply = %q, handled=%v", reply, handled) + } + statuses = reminderStatuses(t, st) + if statuses[evening] != store.ReminderPending || statuses[morning] != store.ReminderCancelled { + t.Fatalf("ordinal cancelled the wrong row: %+v", statuses) + } + if sess := h.dialogueSessions.Get(dialogueIDOf(context.Background()), h.now()); sess == nil || len(sess.Candidates) != 0 { + t.Fatalf("spent candidates survived: %+v", sess) + } + }) +} + +func TestReminderCancellationChoiceRequiresAWholeAffirmativeOrdinal(t *testing.T) { + unsafe := []string{ + "почему второе?", + "не второе", + "второе не отменяй", + "первое и второе", + "напомни мне первого сентября оплатить счёт", + } + for _, answer := range unsafe { + t.Run(answer, func(t *testing.T) { + h, st, now := newClarifyHandler(t) + h.timeParser = router.StubDateTimeParser{} + first := seedVoiceReminder(t, st, now.Add(time.Hour), "первое лекарство") + second := seedVoiceReminder(t, st, now.Add(2*time.Hour), "второе лекарство") + if _, handled := h.resolveReminderCancellation(context.Background(), "отмени напоминание"); !handled { + t.Fatal("ambiguous cancellation was not offered") + } + if reply, handled := h.resolveCandidate(context.Background(), answer, sourceVoice); handled || reply != "" { + t.Fatalf("unsafe answer was claimed: reply=%q handled=%v", reply, handled) + } + statuses := reminderStatuses(t, st) + if statuses[first] != store.ReminderPending || statuses[second] != store.ReminderPending { + t.Fatalf("unsafe answer mutated rows: %+v", statuses) + } + }) + } +} + +func TestReminderCancellationChoiceCanBeAbandoned(t *testing.T) { + for _, answer := range []string{"отмена", "не надо", "no"} { + t.Run(answer, func(t *testing.T) { + h, st, now := newClarifyHandler(t) + first := seedVoiceReminder(t, st, now.Add(time.Hour), "первое") + second := seedVoiceReminder(t, st, now.Add(2*time.Hour), "второе") + if _, handled := h.resolveReminderCancellation(context.Background(), "отмени напоминание"); !handled { + t.Fatal("ambiguous cancellation was not offered") + } + reply, handled := h.resolveCandidate(context.Background(), answer, sourceVoice) + if !handled || !strings.Contains(reply, "ничего не отменяю") { + t.Fatalf("cancel answer = %q handled=%v", reply, handled) + } + statuses := reminderStatuses(t, st) + if statuses[first] != store.ReminderPending || statuses[second] != store.ReminderPending { + t.Fatalf("abandoning the choice mutated rows: %+v", statuses) + } + if sess := h.dialogueSessions.Get(dialogueIDOf(context.Background()), h.now()); sess == nil || len(sess.Candidates) != 0 { + t.Fatalf("abandoned candidates survived: %+v", sess) + } + }) + } +} + +func TestReminderCancellationOfferStartsFreshAndNamesTruncation(t *testing.T) { + h, st, now := newClarifyHandler(t) + id := dialogueIDOf(context.Background()) + h.dialogueSessions.Put(id, &dialogue.Session{ + Intent: dialogue.IntentReminder, + Slots: dialogue.Slots{Text: "stale subject", HasTime: true, Time: now.Add(time.Hour)}, + Timestamp: now.Add(-time.Minute), + }) + for i := 0; i < 6; i++ { + seedVoiceReminder(t, st, now.Add(time.Duration(i+1)*time.Hour), fmt.Sprintf("row %d", i+1)) + } + reply, handled := h.resolveReminderCancellation(context.Background(), "отмени напоминание") + if !handled || !strings.Contains(reply, "первые пять") || !strings.Contains(reply, "уточни текст или время") { + t.Fatalf("truncated offer = %q handled=%v", reply, handled) + } + sess := h.dialogueSessions.Get(id, h.now()) + if sess == nil || sess.Intent != dialogue.IntentSystem || sess.Slots.Text != "" || + len(sess.Candidates) != 5 || sess.Utterance != "отмени напоминание" { + t.Fatalf("offer reused stale dialogue state: %+v", sess) + } +} + +func TestReminderCancellationNeverGuesses(t *testing.T) { + t.Run("bare command over several rows", func(t *testing.T) { + h, st, now := newClarifyHandler(t) + first := seedVoiceReminder(t, st, now.Add(time.Hour), "первое") + second := seedVoiceReminder(t, st, now.Add(2*time.Hour), "второе") + reply, handled := h.resolveReminderCancellation(context.Background(), "отмени напоминание") + if !handled || !strings.Contains(reply, "порядковым словом") { + t.Fatalf("reply = %q, handled=%v", reply, handled) + } + statuses := reminderStatuses(t, st) + if statuses[first] != store.ReminderPending || statuses[second] != store.ReminderPending { + t.Fatalf("bare ambiguous command mutated rows: %+v", statuses) + } + }) + + t.Run("unread time", func(t *testing.T) { + h, st, now := newClarifyHandler(t) + h.timeParser = router.StubDateTimeParser{} + id := seedVoiceReminder(t, st, now.Add(time.Hour), "позвонить врачу") + reply, handled := h.resolveReminderCancellation(context.Background(), "отмени напоминание через вечность") + if !handled || !strings.Contains(reply, "не смогла разобрать время") { + t.Fatalf("reply = %q, handled=%v", reply, handled) + } + if got := reminderStatuses(t, st)[id]; got != store.ReminderPending { + t.Fatalf("unread time cancelled reminder: %q", got) + } + }) +} + +type cancelReminderAPI struct { + ipc.UnimplementedCoreAPI + + rows []ipc.Reminder + listErr error + cancelErr error + calls []int64 +} + +func (a *cancelReminderAPI) ListPendingReminders(context.Context, int) ([]ipc.Reminder, error) { + return a.rows, a.listErr +} + +func (a *cancelReminderAPI) CancelReminder(_ context.Context, id int64) error { + a.calls = append(a.calls, id) + return a.cancelErr +} + +func cancelHandler(api ipc.CoreAPI) *reactiveHandler { + now := time.Date(2026, 8, 15, 9, 0, 0, 0, time.UTC) + parser := router.StubDateTimeParser{} + return &reactiveHandler{ + api: api, now: func() time.Time { return now }, timeParser: parser, + extractor: router.Extractor{Time: parser}, + dialogueSessions: dialogue.NewSessionStore(2 * time.Minute), + } +} + +func TestReminderCancellationReportsStoreOutcomes(t *testing.T) { + row := ipc.Reminder{ + ID: 7, FireTs: time.Date(2026, 8, 15, 12, 0, 0, 0, time.UTC), + NextFireTs: time.Date(2026, 8, 15, 12, 0, 0, 0, time.UTC), + Payload: `{"text":"позвонить врачу"}`, Status: store.ReminderPending, + } + for _, tc := range []struct { + name string + err error + want string + }{ + {"already terminal", ipc.ErrReminderState, "уже не ожидает"}, + {"delivery in flight", ipc.ErrReminderInFlight, "уже начала отправлять"}, + {"transport", errors.New("socket closed"), "не получилось отменить"}, + } { + t.Run(tc.name, func(t *testing.T) { + api := &cancelReminderAPI{rows: []ipc.Reminder{row}, cancelErr: tc.err} + reply, handled := cancelHandler(api).resolveReminderCancellation(context.Background(), "отмени напоминание про врача") + if !handled || !strings.Contains(reply, tc.want) || len(api.calls) != 1 || api.calls[0] != 7 { + t.Fatalf("reply=%q handled=%v calls=%v", reply, handled, api.calls) + } + }) + } + + t.Run("list failure", func(t *testing.T) { + api := &cancelReminderAPI{listErr: errors.New("offline")} + reply, handled := cancelHandler(api).resolveReminderCancellation(context.Background(), "отмени напоминание") + if !handled || !strings.Contains(reply, "не получилось посмотреть") || len(api.calls) != 0 { + t.Fatalf("reply=%q handled=%v calls=%v", reply, handled, api.calls) + } + }) + + t.Run("nothing pending", func(t *testing.T) { + api := &cancelReminderAPI{} + reply, handled := cancelHandler(api).resolveReminderCancellation(context.Background(), "отмени напоминание") + if !handled || !strings.Contains(reply, "ожидающих напоминаний нет") || len(api.calls) != 0 { + t.Fatalf("reply=%q handled=%v calls=%v", reply, handled, api.calls) + } + }) +} + +func TestReminderCancellationIsAPreRouteTurnAndDoesNotGetSwallowedByClarify(t *testing.T) { + h, st, now := newClarifyHandler(t) + h.timeParser = router.StubDateTimeParser{} + h.decisions = decision.NewRing() + id := seedVoiceReminder(t, st, now.Add(time.Hour), "позвонить врачу") + ctx := withDialogueID(context.Background(), dialogueIDFor(sourceText, "web")) + h.clarifyStore.Put(dialogueIDOf(ctx), &dialogue.PendingQuestion{ + Intent: dialogue.IntentReminder, Missing: []dialogue.Slot{dialogue.SlotTime}, + Utterance: "напомни позвонить маме", Asked: h.now(), TTL: clarifyTTL, + }) + + reply := h.runTurn(ctx, "отмени напоминание про врача", sourceText) + if !strings.Contains(reply, clarifyDropped) || !strings.Contains(reply, "отменила напоминание") { + t.Fatalf("reply = %q, want dropped clarify notice and cancellation", reply) + } + if h.clarifyStore.Get(dialogueIDOf(ctx), h.now()) != nil { + t.Fatal("the superseded clarify question survived the cancellation request") + } + if got := reminderStatuses(t, st)[id]; got != store.ReminderCancelled { + t.Fatalf("status = %q, want cancelled", got) + } + recs := h.decisions.Recent(1) + if len(recs) != 1 { + t.Fatalf("decision records = %d, want 1", len(recs)) + } + claim := findClaim(recs[0], "reminder-cancel") + if claim == nil || claim.Outcome != decision.Won { + t.Fatalf("reminder-cancel claim = %+v, want pre-route winner", claim) + } +} + +func TestReminderCancellationThroughPushToTalk(t *testing.T) { + h, st, now := newClarifyHandler(t) + h.stt = simTranscriber{text: "отмени напоминание про врача"} + h.tts = tts.NewStub() + h.timeParser = router.StubDateTimeParser{} + h.router = buildRouter(router.NewHashEmbedder(64), h.matcher, 0.55, nil, nil) + doctor := seedVoiceReminder(t, st, now.Add(time.Hour), "позвонить врачу") + bread := seedVoiceReminder(t, st, now.Add(2*time.Hour), "купить хлеб") + + resp, err := h.HandlePushToTalk(context.Background(), voicePTT(), 0) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(resp.ReplyText, "отменила напоминание") || len(resp.ReplyAudio.Bytes) == 0 { + t.Fatalf("PTT response = text %q audio=%d bytes", resp.ReplyText, len(resp.ReplyAudio.Bytes)) + } + statuses := reminderStatuses(t, st) + if statuses[doctor] != store.ReminderCancelled || statuses[bread] != store.ReminderPending { + t.Fatalf("PTT cancellation changed the wrong rows: %+v", statuses) + } +} diff --git a/cmd/mavend/replier_llm.go b/cmd/mavend/replier_llm.go index 36f0d7f..95ddbb8 100644 --- a/cmd/mavend/replier_llm.go +++ b/cmd/mavend/replier_llm.go @@ -39,6 +39,13 @@ func (r *llmReplier) Reply(ctx context.Context, d router.Decision) string { // что ты выпел стакан воды" for "я выпил воды". return phraser.FactAck(d.Utterance) } + if d.Intent == router.IntentNote { + // A successful durable write needs no generation. The resident model + // answered one live capture with masculine self-reference ("сохранил") + // despite the prompt; the hand-written line is both faster and a hard + // persona guarantee on the daemon's reply path (V-721). + return phraser.Ack(phraser.AckNote, nil) + } out, err := r.p.PhraseReply(ctx, d) if err != nil || out == "" { return r.stub.Reply(ctx, d) diff --git a/cmd/mavend/replier_llm_test.go b/cmd/mavend/replier_llm_test.go index 3b3a32c..e524d72 100644 --- a/cmd/mavend/replier_llm_test.go +++ b/cmd/mavend/replier_llm_test.go @@ -20,22 +20,46 @@ type stubCompleter struct { func (s stubCompleter) Complete(_ context.Context, _ llm.Req) (string, error) { return s.out, s.err } -func TestLLMReplierPassesTheModelReplyThrough(t *testing.T) { +func TestLLMReplierPassesTheModelReplyThroughForOtherIntents(t *testing.T) { r := newLLMReplier(stubCompleter{out: `{"response":"записала, кофе закончился","mood":"neutral"}`}, nil) - got := r.Reply(context.Background(), router.Decision{Intent: router.IntentNote, Slots: router.Slots{Text: "кофе закончился"}}) + got := r.Reply(context.Background(), router.Decision{Intent: router.IntentReminder, Slots: router.Slots{Text: "кофе закончился"}}) if got != "записала, кофе закончился" { t.Errorf("got %q, want %q", got, "записала, кофе закончился") } } +type countingCompleter struct { + out string + calls int +} + +func (c *countingCompleter) Complete(_ context.Context, _ llm.Req) (string, error) { + c.calls++ + return c.out, nil +} + +func TestLLMReplierNoteUsesFixedFeminineAcknowledgement(t *testing.T) { + c := &countingCompleter{out: `{"response":"Хорошо, сохранил.","mood":"neutral"}`} + r := newLLMReplier(c, nil) + got := r.Reply(context.Background(), router.Decision{ + Intent: router.IntentNote, Slots: router.Slots{Text: "запасной ключ лежит в синей коробке"}, + }) + if c.calls != 0 { + t.Fatalf("note acknowledgement called the resident model %d time(s), want none", c.calls) + } + if got != "сохранила заметку." { + t.Fatalf("note acknowledgement = %q, want the fixed feminine line", got) + } +} + func TestLLMReplierFallsBackToStubOnError(t *testing.T) { r := newLLMReplier(stubCompleter{err: errReplierTest}, nil) - assertAck(t, r, router.Decision{Intent: router.IntentNote}, phraser.AckNote, "llm error") + assertAck(t, r, router.Decision{Intent: router.IntentReminder}, phraser.AckReminder, "llm error") } func TestLLMReplierFallsBackToStubOnEmpty(t *testing.T) { r := newLLMReplier(stubCompleter{out: ""}, nil) - assertAck(t, r, router.Decision{Intent: router.IntentNote}, phraser.AckNote, "empty llm") + assertAck(t, r, router.Decision{Intent: router.IntentReminder}, phraser.AckReminder, "empty llm") } // A clarify never reaches the model, and since Vikunja #457 it is answered from diff --git a/cmd/mavend/self.go b/cmd/mavend/self.go index dbe5dec..322059b 100644 --- a/cmd/mavend/self.go +++ b/cmd/mavend/self.go @@ -6,6 +6,7 @@ import ( "regexp" "github.com/kami/maven/internal/phraser" + "github.com/kami/maven/internal/router" ) // A question about her — "что ты умеешь", "кто ты" — used to have no answer at @@ -98,6 +99,16 @@ func selfFloor(utterance string) bool { // asked — "что ты умеешь" and "кто ты" want different halves of it — and falls // back to the text itself, which is already readable, if the model is down. func (h *reactiveHandler) querySelf(ctx context.Context, t *queryTurn) (string, bool) { + // Product help is self knowledge too (Vikunja V-720), but unlike the prose description it + // must be exact: these examples name the grammar Maven actually accepts. + // Answer them before topic scoring so a phrasing such as "как отменить + // задачу" cannot leak to SearXNG as generic third-party instructions. + switch router.LocalHelpTopic(t.dec.Utterance) { + case router.HelpReminderCancel: + return "Скажи, например: «отмени напоминание про молоко». Если совпадений несколько, я попрошу выбрать одно.", true + case router.HelpTaskDrop: + return "Скажи, например: «убери из задач настроить бэкапы». Я уберу задачу из активного списка, не отмечая её выполненной.", true + } if !h.turnIsAbout(ctx, t, topicSelf, selfFloor) { return "", false } diff --git a/cmd/mavend/self_test.go b/cmd/mavend/self_test.go index 816201f..7505a79 100644 --- a/cmd/mavend/self_test.go +++ b/cmd/mavend/self_test.go @@ -65,6 +65,36 @@ func TestSelfSourceAnswersFromTheDescription(t *testing.T) { } } +// V-720: asking how to operate Maven is never a third-party web-search query. +func TestMavenHowToAnswersLocallyWithoutSearch(t *testing.T) { + for _, testCase := range []struct { + utterance string + want string + }{ + {"как отменить напоминание про молоко?", "отмени напоминание"}, + {"как отменить задачу настроить бэкапы?", "убери из задач"}, + {"можно ли отменить напоминание?", "отмени напоминание"}, + {"can I cancel a reminder?", "отмени напоминание"}, + {"could I cancel a task?", "убери из задач"}, + } { + h, seen := searchHandler(t, + `{"answers":["Инструкция стороннего приложения"],"results":[]}`, + 200) + reply := h.actionQuery(context.Background(), router.Decision{ + Intent: router.IntentQuery, + Utterance: testCase.utterance, + Source: router.SourceSelf, + SourceAnchored: true, + }) + if !strings.Contains(reply, testCase.want) { + t.Errorf("%q reply = %q, want local usage example containing %q", testCase.utterance, reply, testCase.want) + } + if *seen != "" { + t.Errorf("%q leaked to search as %q", testCase.utterance, *seen) + } + } +} + // TestSelfDescriptionHoldsThePersona — it is her own text and she reads it out, // so the same rules the phrasing eval enforces apply to it. Feminine // self-reference, informal address, no pet names. diff --git a/cmd/mavend/simulator_test.go b/cmd/mavend/simulator_test.go index ebc8e9e..bc4d39c 100644 --- a/cmd/mavend/simulator_test.go +++ b/cmd/mavend/simulator_test.go @@ -12,6 +12,7 @@ // what was SENT — every delivery.Sendable the dispatcher emitted // what ARRIVED — the unified intake journal from #283 // what TOOLS were called — the recorded requests against fake Praxis/Nexis/Hexis +// what is DURABLE — typed notes/tasks/reminders/facts store state // what did NOT happen — expect_no_send / expect_no_call, first-class // // The last one is the point. Maven's hard constraints are mostly negative — @@ -190,14 +191,81 @@ type step struct { // --- assertions --- - ExpectReply []string `json:"expect_reply_contains,omitempty"` - ExpectNotReply []string `json:"expect_reply_lacks,omitempty"` - ExpectSent []string `json:"expect_sent_contains,omitempty"` - ExpectNoSend bool `json:"expect_no_send,omitempty"` - ExpectCalled []string `json:"expect_called,omitempty"` - ExpectNotCalled []string `json:"expect_not_called,omitempty"` - ExpectEvents []string `json:"expect_events,omitempty"` - ExpectNoEvents bool `json:"expect_no_events,omitempty"` + ExpectReply []string `json:"expect_reply_contains,omitempty"` + ExpectNotReply []string `json:"expect_reply_lacks,omitempty"` + ExpectSent []string `json:"expect_sent_contains,omitempty"` + ExpectNoSend bool `json:"expect_no_send,omitempty"` + ExpectCalled []string `json:"expect_called,omitempty"` + ExpectNotCalled []string `json:"expect_not_called,omitempty"` + ExpectEvents []string `json:"expect_events,omitempty"` + ExpectNoEvents bool `json:"expect_no_events,omitempty"` + ExpectStore *storeStateExpectation `json:"expect_store,omitempty"` +} + +// storeStateExpectation is a typed, exact read of Maven's four user-visible +// durable stores. Reply assertions prove what she said; these prove what the +// turn actually committed. Each selected collection can assert its total row +// count and exact row identity independently, so a duplicate insert cannot be +// hidden by finding one matching row. +type storeStateExpectation struct { + Notes *noteStateExpectation `json:"notes,omitempty"` + Tasks *taskStateExpectation `json:"tasks,omitempty"` + Reminders *reminderStateExpectation `json:"reminders,omitempty"` + Facts *factStateExpectation `json:"facts,omitempty"` +} + +type noteStateExpectation struct { + Count *int `json:"count,omitempty"` + Rows []noteRowExpectation `json:"rows,omitempty"` +} + +type noteRowExpectation struct { + ID int64 `json:"id,omitempty"` + At string `json:"at,omitempty"` + Text string `json:"text,omitempty"` + Source string `json:"source,omitempty"` +} + +type taskStateExpectation struct { + Count *int `json:"count,omitempty"` + Rows []taskRowExpectation `json:"rows,omitempty"` +} + +type taskRowExpectation struct { + ID int64 `json:"id,omitempty"` + CreatedAt string `json:"created_at,omitempty"` + Text string `json:"text,omitempty"` + Source string `json:"source,omitempty"` + Status string `json:"status,omitempty"` + ResolvedAt string `json:"resolved_at,omitempty"` + ResolvedBy string `json:"resolved_by,omitempty"` +} + +type reminderStateExpectation struct { + Count *int `json:"count,omitempty"` + Rows []reminderRowExpectation `json:"rows,omitempty"` +} + +type reminderRowExpectation struct { + ID int64 `json:"id,omitempty"` + FireAt string `json:"fire_at,omitempty"` + Text string `json:"text,omitempty"` + Status string `json:"status,omitempty"` +} + +type factStateExpectation struct { + Count *int `json:"count,omitempty"` + Rows []factRowExpectation `json:"rows,omitempty"` +} + +type factRowExpectation struct { + ID int64 `json:"id,omitempty"` + At string `json:"at,omitempty"` + Kind string `json:"kind,omitempty"` + Key string `json:"key,omitempty"` + Value string `json:"value,omitempty"` + Source string `json:"source,omitempty"` + Confidence *float64 `json:"confidence,omitempty"` } type signalStep struct { @@ -440,6 +508,17 @@ func newSimWorld(t *testing.T, sc scenario) *simWorld { }) tl := newTickLoop(st, gatherer, dispatcher, phraser.NewStub(), rules, time.Minute, 5*time.Minute, 0, nil, nil, nil, nil) + // Production upgrades the voice handler from the direct store adapter to + // daemonAPI after the tick loop exists. Mirror that seam so a simulated + // day-plan query reads the real store-backed plan instead of the direct + // adapter's "not available" refusal. The fake clock is the one deliberate + // difference from production's time.Now. + api = &daemonAPI{ + CoreAPI: api, + getDayPlan: func(ctx context.Context) ipc.DayPlan { + return tl.dayPlan(ctx, clock.Now()) + }, + } scripted := &scriptedLLM{entries: sc.Script} @@ -492,6 +571,7 @@ func newSimWorld(t *testing.T, sc scenario) *simWorld { // act panicked the moment the matcher was consulted. matcher := tool.NewMatcher(api) rtr := buildRouter(emb, matcher, config.DefaultRouterThreshold, router.NewLLMRouter(scripted), nil) + timeParser := router.NewPythonDateParser() w.handler = &reactiveHandler{ stt: simTranscriber{}, @@ -510,10 +590,11 @@ func newSimWorld(t *testing.T, sc scenario) *simWorld { replier: newLLMReplier(scripted, nil), now: clock.Now, dataStore: st, - timeParser: router.StubDateTimeParser{}, + timeParser: timeParser, dialogueSessions: dialogue.NewSessionStore(time.Hour), clarifyStore: dialogue.NewClarifyStore(time.Hour), clarifyMaxAttempts: dialogue.DefaultMaxAttempts, + extractor: router.Extractor{Time: timeParser, Acts: matcher, Facts: router.DefaultFactParser{}}, ecosystem: eco, } return w @@ -599,7 +680,7 @@ func (w *simWorld) run(sc scenario) { eventsBefore := w.publishCount() w.stimulate(ctx, s) - w.assert(i, s, sendsBefore, callsBefore, eventsBefore) + w.assert(ctx, i, s, sendsBefore, callsBefore, eventsBefore) } } @@ -800,7 +881,7 @@ func (w *simWorld) callPaths() []string { return w.callPathsSince(nil) } // Assertions // --------------------------------------------------------------------------- -func (w *simWorld) assert(i int, s step, sendsBefore int, callsBefore []int, eventsBefore int) { +func (w *simWorld) assert(ctx context.Context, i int, s step, sendsBefore int, callsBefore []int, eventsBefore int) { w.t.Helper() where := fmt.Sprintf("step %d (%s)", i+1, s.At) if s.Note != "" { @@ -864,6 +945,197 @@ func (w *simWorld) assert(i int, s step, sendsBefore int, callsBefore []int, eve fail("expected nothing to arrive, %d event(s) were published", w.publishCount()-eventsBefore) } + if s.ExpectStore != nil { + w.assertStoreState(ctx, *s.ExpectStore, fail) + } +} + +const simStateReadLimit = 10_000 + +func (w *simWorld) assertStoreState(ctx context.Context, want storeStateExpectation, fail func(string, ...any)) { + if want.Notes != nil { + rows, err := w.store.RecentNotes(ctx, simStateReadLimit) + if err != nil { + fail("read notes for store assertion: %v", err) + } else { + assertStateCount("notes", want.Notes.Count, len(rows), fail) + used := make([]bool, len(rows)) + for _, expected := range want.Notes.Rows { + matched, matchErr := matchDistinct(rows, used, func(row store.Note) (bool, error) { + return w.noteStateMatches(row, expected) + }) + if matchErr != nil { + fail("invalid note expectation %+v: %v", expected, matchErr) + } else if !matched { + fail("no distinct note matches %+v; notes: %+v", expected, rows) + } + } + } + } + + if want.Tasks != nil { + rows, err := w.store.ListTasks(ctx, "") + if err != nil { + fail("read tasks for store assertion: %v", err) + } else { + if want.Tasks.Count != nil && *want.Tasks.Count > store.MaxTaskRows { + fail("task count assertion %d exceeds the store read bound %d", *want.Tasks.Count, store.MaxTaskRows) + } else { + assertStateCount("tasks", want.Tasks.Count, len(rows), fail) + } + used := make([]bool, len(rows)) + for _, expected := range want.Tasks.Rows { + matched, matchErr := matchDistinct(rows, used, func(row store.Task) (bool, error) { + return w.taskStateMatches(row, expected) + }) + if matchErr != nil { + fail("invalid task expectation %+v: %v", expected, matchErr) + } else if !matched { + fail("no distinct task matches %+v; tasks: %+v", expected, rows) + } + } + } + } + + if want.Reminders != nil { + rows, err := w.store.ListReminders(ctx, simStateReadLimit) + if err != nil { + fail("read reminders for store assertion: %v", err) + } else { + assertStateCount("reminders", want.Reminders.Count, len(rows), fail) + used := make([]bool, len(rows)) + for _, expected := range want.Reminders.Rows { + matched, matchErr := matchDistinct(rows, used, func(row store.Reminder) (bool, error) { + return w.reminderStateMatches(row, expected) + }) + if matchErr != nil { + fail("invalid reminder expectation %+v: %v", expected, matchErr) + } else if !matched { + fail("no distinct reminder matches %+v; reminders: %+v", expected, rows) + } + } + } + } + + if want.Facts != nil { + rows, err := w.store.RecentFacts(ctx, simStateReadLimit) + if err != nil { + fail("read facts for store assertion: %v", err) + } else { + assertStateCount("facts", want.Facts.Count, len(rows), fail) + used := make([]bool, len(rows)) + for _, expected := range want.Facts.Rows { + matched, matchErr := matchDistinct(rows, used, func(row store.Fact) (bool, error) { + return w.factStateMatches(row, expected) + }) + if matchErr != nil { + fail("invalid fact expectation %+v: %v", expected, matchErr) + } else if !matched { + fail("no distinct fact matches %+v; facts: %+v", expected, rows) + } + } + } + } +} + +func assertStateCount(kind string, want *int, got int, fail func(string, ...any)) { + if want != nil && got != *want { + fail("%s count = %d, want %d", kind, got, *want) + } +} + +// matchDistinct prevents two expectations from being satisfied by the same +// durable row. This is important for identity assertions where two records may +// intentionally carry the same text but have different lifecycle states. +func matchDistinct[T any](rows []T, used []bool, matches func(T) (bool, error)) (bool, error) { + for i, row := range rows { + if used[i] { + continue + } + ok, err := matches(row) + if err != nil { + return false, err + } + if ok { + used[i] = true + return true, nil + } + } + return false, nil +} + +func (w *simWorld) noteStateMatches(got store.Note, want noteRowExpectation) (bool, error) { + if want.ID != 0 && got.ID != want.ID || want.Text != "" && got.Text != want.Text || + want.Source != "" && got.Source != want.Source { + return false, nil + } + return w.stateTimeMatches(got.Ts, want.At) +} + +func (w *simWorld) taskStateMatches(got store.Task, want taskRowExpectation) (bool, error) { + if want.ID != 0 && got.ID != want.ID || want.Text != "" && got.Text != want.Text || + want.Source != "" && got.Source != want.Source || want.Status != "" && got.Status != want.Status || + want.ResolvedBy != "" && got.ResolvedBy != want.ResolvedBy { + return false, nil + } + if ok, err := w.stateTimeMatches(got.CreatedTs, want.CreatedAt); err != nil || !ok { + return ok, err + } + if want.ResolvedAt == "" { + return true, nil + } + if got.ResolvedTs == nil { + return false, nil + } + return w.stateTimeMatches(*got.ResolvedTs, want.ResolvedAt) +} + +func (w *simWorld) reminderStateMatches(got store.Reminder, want reminderRowExpectation) (bool, error) { + if want.ID != 0 && got.ID != want.ID || want.Text != "" && got.Text() != want.Text || + want.Status != "" && got.Status != want.Status { + return false, nil + } + return w.stateTimeMatches(got.FireTs, want.FireAt) +} + +func (w *simWorld) factStateMatches(got store.Fact, want factRowExpectation) (bool, error) { + if want.ID != 0 && got.ID != want.ID || want.Kind != "" && string(got.Kind) != want.Kind || + want.Key != "" && got.Key != want.Key || want.Value != "" && got.Value != want.Value || + want.Source != "" && got.Source != want.Source || + want.Confidence != nil && got.Confidence != *want.Confidence { + return false, nil + } + return w.stateTimeMatches(got.Ts, want.At) +} + +func (w *simWorld) stateTimeMatches(got time.Time, raw string) (bool, error) { + if raw == "" { + return true, nil + } + want, err := w.stateTime(raw) + if err != nil { + return false, err + } + return got.Equal(want), nil +} + +// stateTime accepts either an absolute RFC3339 instant or the same local +// HH:MM[:SS] shape scenario steps use. The latter keeps fixtures readable +// while still comparing exact instants after the store normalises to UTC. +func (w *simWorld) stateTime(raw string) (time.Time, error) { + if strings.Contains(raw, "T") { + return time.Parse(time.RFC3339, raw) + } + layout := "15:04" + if strings.Count(raw, ":") == 2 { + layout = "15:04:05" + } + hm, err := time.Parse(layout, raw) + if err != nil { + return time.Time{}, fmt.Errorf("expected HH:MM[:SS] or RFC3339, got %q: %w", raw, err) + } + return time.Date(w.start.Year(), w.start.Month(), w.start.Day(), + hm.Hour(), hm.Minute(), hm.Second(), 0, w.loc), nil } func sendableTexts(sends []delivery.Sendable) []string { @@ -933,6 +1205,64 @@ func TestSimulatorScenarios(t *testing.T) { } } +// TestSimulatorWorldMirrorsProductionConversationSeams pins the two daemon +// constructor upgrades the continuous scenario needs. A bare store API cannot +// answer DayPlan, and a nil handler extractor cannot complete a parked reminder +// from the next turn; either drift would make the simulator exercise a smaller +// system than production while still producing plausible replies. +func TestSimulatorWorldMirrorsProductionConversationSeams(t *testing.T) { + sc := scenario{SchemaVersion: 1, Name: "constructor-seams", Start: "2026-08-15T08:00:00+04:00"} + w := newSimWorld(t, sc) + if w.handler.api != w.api { + t.Fatal("handler did not receive the simulator's upgraded daemon API") + } + plan, err := w.handler.api.DayPlan(context.Background()) + if err != nil { + t.Fatalf("simulator day-plan seam is unavailable: %v", err) + } + planY, planM, planD := plan.Date.In(w.loc).Date() + wantY, wantM, wantD := w.start.Date() + if plan.Date.IsZero() || planY != wantY || planM != wantM || planD != wantD { + t.Fatalf("day plan date = %v, want the fake-clock day %v", plan.Date, w.start) + } + if w.handler.extractor.Time == nil || w.handler.timeParser == nil { + t.Fatal("simulator left the clarify time parser unwired") + } + slots := w.handler.extractor.Extract(context.Background(), router.IntentReminder, + "сегодня в 10:00", w.clock.Now()) + if !slots.HasTime || !slots.Time.Equal(w.timeOf("10:00")) { + t.Fatalf("clarify extractor parsed %+v, want the fake-clock day at 10:00", slots) + } +} + +// The reported duplicate transcript was a diagnostic artefact: two adjacent +// sed ranges both included boundary line 620. Source had one log call. Keep an +// executable exact-count assertion so a real duplicate cannot be introduced +// later and mistaken for another display artefact. +func TestSimulatorTranscriptRecordsEachSpokenTurnOnce(t *testing.T) { + sc := scenario{ + SchemaVersion: 1, + Name: "transcript-count", + Start: "2026-08-15T08:00:00+04:00", + Script: []scriptEntry{{ + Match: "привет", Route: `[{"intent":"chat","text":"привет"}]`, + Reply: `{"response":"Привет.","mood":"happy"}`, + }}, + } + w := newSimWorld(t, sc) + w.stimulate(context.Background(), step{Say: "привет"}) + want := "08:00:00 он: привет" + count := 0 + for _, line := range w.transcript { + if line == want { + count++ + } + } + if count != 1 { + t.Fatalf("owner transcript line occurred %d times, want exactly once: %v", count, w.transcript) + } +} + func loadScenario(t *testing.T, path string) scenario { t.Helper() raw, err := os.ReadFile(path) diff --git a/cmd/mavend/testdata/scenarios/assistant_workday.json b/cmd/mavend/testdata/scenarios/assistant_workday.json new file mode 100644 index 0000000..99fed3e --- /dev/null +++ b/cmd/mavend/testdata/scenarios/assistant_workday.json @@ -0,0 +1,219 @@ +{ + "schema_version": 1, + "name": "assistant_workday", + "description": "One continuous, deterministic workday through Maven's real conversation pipeline. It proves note capture and grounded high-overlap recall; a reminder that remains uncommitted while Maven clarifies its day, then survives a reported-action no-op, is cancelled exactly once, and stays cancelled on a repeated command; task capture, listing, completion, and a second live task; and the same calendar fact read through both agenda and composed day-plan sources. Store assertions are primary: every mutation and no-op pins exact row count, identity, lifecycle state, provenance, and fake-clock time.", + "start": "2026-08-15T08:00:00+04:00", + "script": [ + { + "match": "запомни: запасной ключ лежит", + "route": "[{\"intent\":\"note\",\"text\":\"запомни: запасной ключ лежит в синей коробке\"}]" + }, + { + "match": "запасной ключ лежит в синей коробке", + "route": "[{\"intent\":\"query\",\"text\":\"запасной ключ лежит в синей коробке?\",\"source\":\"recall\"}]" + }, + { + "match": "я отменил напоминание", + "route": "[{\"intent\":\"chat\",\"text\":\"я отменил напоминание про молоко\"}]", + "reply": "{\"response\":\"Поняла.\",\"mood\":\"neutral\"}" + }, + { + "match": "", + "route": "[{\"intent\":\"chat\",\"text\":\"\"}]", + "reply": "{\"response\":\"Поняла.\",\"mood\":\"neutral\"}" + } + ], + "steps": [ + { + "at": "08:00", + "note": "Capture only the dictated body as one durable note: the command frame is not memory, and no task, reminder, or fact is created.", + "say": "запомни: запасной ключ лежит в синей коробке", + "expect_no_send": true, + "expect_store": { + "notes": { "count": 1, "rows": [{ "id": 1, "at": "08:00", "text": "запасной ключ лежит в синей коробке", "source": "tap:voice" }] }, + "tasks": { "count": 0 }, + "reminders": { "count": 0 }, + "facts": { "count": 0 } + } + }, + { + "at": "08:01", + "note": "Recall reads the stored note and does not create a second row.", + "say": "запасной ключ лежит в синей коробке?", + "expect_reply_contains": ["синей коробке"], + "expect_no_send": true, + "expect_store": { + "notes": { "count": 1, "rows": [{ "id": 1, "text": "запасной ключ лежит в синей коробке", "source": "tap:voice" }] } + } + }, + { + "at": "08:02", + "note": "A clock without a day is not a committed reminder. Maven asks, and the reminder table remains empty.", + "say": "напомни купить молоко в 10:00", + "expect_reply_contains": ["В какой день"], + "expect_no_send": true, + "expect_store": { + "reminders": { "count": 0 } + } + }, + { + "at": "08:03", + "note": "The clarification completes the parked request against the fake clock and creates exactly one pending reminder.", + "say": "сегодня", + "expect_reply_contains": ["10:00"], + "expect_no_send": true, + "expect_store": { + "reminders": { "count": 1, "rows": [{ "id": 1, "fire_at": "10:00", "text": "купить молоко", "status": "pending" }] } + } + }, + { + "at": "08:04", + "note": "A first-person report is not another cancellation command and cannot mutate the pending row.", + "say": "я отменил напоминание про молоко", + "expect_reply_contains": ["Поняла"], + "expect_no_send": true, + "expect_store": { + "reminders": { "count": 1, "rows": [{ "id": 1, "fire_at": "10:00", "text": "купить молоко", "status": "pending" }] } + } + }, + { + "at": "08:05", + "note": "The addressed imperative cancels that exact durable reminder in place.", + "say": "отмени напоминание про молоко", + "expect_reply_contains": ["отменила напоминание", "купить молоко"], + "expect_no_send": true, + "expect_store": { + "reminders": { "count": 1, "rows": [{ "id": 1, "fire_at": "10:00", "text": "купить молоко", "status": "cancelled" }] } + } + }, + { + "at": "08:06", + "note": "Repeating the cancellation is an explicit no-op: no replacement row and no resurrection.", + "say": "отмени напоминание про молоко", + "expect_reply_contains": ["ожидающих напоминаний нет"], + "expect_no_send": true, + "expect_store": { + "reminders": { "count": 1, "rows": [{ "id": 1, "fire_at": "10:00", "text": "купить молоко", "status": "cancelled" }] } + } + }, + { + "at": "08:07", + "note": "An explicit task marker creates one open task, not a note.", + "say": "добавь в задачи настроить бэкапы", + "expect_reply_contains": ["настроить бэкапы"], + "expect_no_send": true, + "expect_store": { + "notes": { "count": 1 }, + "tasks": { "count": 1, "rows": [{ "id": 1, "created_at": "08:07", "text": "настроить бэкапы", "source": "tap:voice", "status": "open" }] } + } + }, + { + "at": "08:08", + "note": "Listing is read-only and returns the live task without duplicating it.", + "say": "какие у меня задачи?", + "expect_reply_contains": ["настроить бэкапы"], + "expect_no_send": true, + "expect_store": { + "tasks": { "count": 1, "rows": [{ "id": 1, "text": "настроить бэкапы", "status": "open" }] } + } + }, + { + "at": "08:09", + "note": "Naming the task moves the same row forward to done and records who resolved it.", + "say": "закрой задачу настроить бэкапы", + "expect_reply_contains": ["настроить бэкапы"], + "expect_no_send": true, + "expect_store": { + "tasks": { "count": 1, "rows": [{ "id": 1, "created_at": "08:07", "text": "настроить бэкапы", "source": "tap:voice", "status": "done", "resolved_at": "08:09", "resolved_by": "tap:voice" }] } + } + }, + { + "at": "08:10", + "note": "A second task remains live for the rest of the workday while the completed row remains durable history.", + "say": "добавь в задачи отправить отчёт", + "expect_reply_contains": ["отправить отчёт"], + "expect_no_send": true, + "expect_store": { + "tasks": { "count": 2, "rows": [ + { "id": 1, "text": "настроить бэкапы", "status": "done", "resolved_by": "tap:voice" }, + { "id": 2, "created_at": "08:10", "text": "отправить отчёт", "source": "tap:voice", "status": "open" } + ] } + } + }, + { + "at": "08:11", + "note": "A fully specified reminder commits directly and coexists with the cancelled history row.", + "say": "напомни сегодня в 12:00 размяться", + "expect_reply_contains": ["12:00"], + "expect_no_send": true, + "expect_store": { + "reminders": { "count": 2, "rows": [ + { "id": 1, "fire_at": "10:00", "text": "купить молоко", "status": "cancelled" }, + { "id": 2, "fire_at": "12:00", "text": "размяться", "status": "pending" } + ] } + } + }, + { + "at": "08:12", + "note": "A calendar poll contributes one exact env fact at the event instant.", + "arrive": { + "source": "poll:caldav", + "as_of": "11:00", + "fact": { + "key": "calendar_event_20260815_Планёрка", + "value": "Планёрка @ 11:00-11:30", + "kind": "env" + } + }, + "expect_events": ["calendar_event_20260815_Планёрка"], + "expect_no_send": true, + "expect_store": { + "facts": { "count": 1, "rows": [{ "id": 1, "at": "11:00", "kind": "env", "key": "calendar_event_20260815_Планёрка", "value": "Планёрка @ 11:00-11:30", "source": "poll:caldav", "confidence": 1.0 }] } + } + }, + { + "at": "08:13", + "note": "The agenda source reads the calendar fact without changing any durable state.", + "say": "что у меня сегодня?", + "expect_reply_contains": ["Планёрка"], + "expect_no_send": true, + "expect_store": { + "facts": { "count": 1, "rows": [{ "id": 1, "key": "calendar_event_20260815_Планёрка", "source": "poll:caldav" }] }, + "tasks": { "count": 2 }, + "reminders": { "count": 2 } + } + }, + { + "at": "08:14", + "note": "The daemon day-plan seam composes the same calendar fact with the still-pending reminder; cancelled reminders stay out.", + "say": "какие планы на сегодня?", + "expect_reply_contains": ["Планёрка", "размяться"], + "expect_reply_lacks": ["купить молоко"], + "expect_no_send": true, + "expect_store": { + "notes": { "count": 1, "rows": [{ "id": 1, "text": "запасной ключ лежит в синей коробке" }] }, + "tasks": { "count": 2, "rows": [ + { "id": 1, "text": "настроить бэкапы", "status": "done" }, + { "id": 2, "text": "отправить отчёт", "status": "open" } + ] }, + "reminders": { "count": 2, "rows": [ + { "id": 1, "text": "купить молоко", "status": "cancelled" }, + { "id": 2, "text": "размяться", "status": "pending" } + ] }, + "facts": { "count": 1, "rows": [{ "id": 1, "key": "calendar_event_20260815_Планёрка", "value": "Планёрка @ 11:00-11:30" }] } + } + }, + { + "at": "08:15", + "note": "A normal tick after the session remains silent; durable assistant state does not authorize an unsolicited message.", + "tick": true, + "expect_no_send": true, + "expect_store": { + "notes": { "count": 1 }, + "tasks": { "count": 2 }, + "reminders": { "count": 2 }, + "facts": { "count": 1 } + } + } + ] +} diff --git a/cmd/mavend/turnrole.go b/cmd/mavend/turnrole.go index 7b88a70..efe0576 100644 --- a/cmd/mavend/turnrole.go +++ b/cmd/mavend/turnrole.go @@ -213,7 +213,8 @@ func isPleasantry(text string) bool { // It is also the whole answer when there is no route to read — the classifier // is the failure floor and a turn must never break on the model. func offlineOwnRequest(text string) bool { - return router.IsQuestionShaped(text) || router.CarriesCaptureVerb(text) || carriesReminderVerb(text) + _, cancelsReminder := parseReminderCancelRequest(text) + return router.IsQuestionShaped(text) || router.CarriesCaptureVerb(text) || carriesReminderVerb(text) || cancelsReminder } // classifyTurnRole decides what this utterance is against the pending action. diff --git a/cmd/mavend/turnroute.go b/cmd/mavend/turnroute.go index a2018b7..ad68af8 100644 --- a/cmd/mavend/turnroute.go +++ b/cmd/mavend/turnroute.go @@ -121,5 +121,8 @@ func needsRoute(text string) bool { if isCancel(text) { return false } + if _, ok := parseReminderCancelRequest(text); ok { + return false + } return len(ownContent(text)) > 0 || router.IsQuestionShaped(text) } diff --git a/cmd/mavend/voice.go b/cmd/mavend/voice.go index 5ebf86f..1f157f9 100644 --- a/cmd/mavend/voice.go +++ b/cmd/mavend/voice.go @@ -261,8 +261,8 @@ const ( // runTurn — the reactive turn pipeline shared by the voice and text entry // points: expired-clarify notice → confirm answer → explicit correction → -// clarify answer → quiet toggle → route → dialogue merge → clarify question → -// action → replier. +// clarify answer → quiet toggle → reminder cancellation → route → dialogue +// merge → clarify question → action → replier. // Takes the already-transcribed utterance, returns the reply text; the voice // path wraps it in stt/tts, the text path returns it as-is. // @@ -335,6 +335,16 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour return withNotice(expiredNotice, reply) } + // 3c. explicit command prohibition — negative authority must be settled + // before a parked slot or candidate can consume these words. In particular, + // "не отменяй напоминание" is not the subject/time answer to an older + // reminder request. Confirmation stays above it: "don't" is already a + // closed no-answer to a destructive confirm, and that narrower stateful + // contract must retain first refusal. + if reply, handled := h.resolveCommandProhibition(ctx, text); notePreRoute(ctx, "command-prohibition", handled) { + return withNotice(expiredNotice, reply) + } + // 4. clarify answer — if she asked a live question last turn, this // utterance is its answer, not a fresh command. After the confirm check: a // y/n gate is armed by her own prompt and is the narrower claim on the @@ -372,7 +382,15 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour return withNotice(expiredNotice, reply) } - // 5d. ordinal selection — "второй", "первую сделал" pick from the list she + // 5d. committed-reminder cancellation — an explicit cancel verb plus the + // reminder noun resolves against pending rows. It runs before ordinal so a + // clock such as "на девять" cannot be mistaken for a position in an older + // task list; an ambiguous result binds its own list for the next turn. + if reply, handled := h.resolveReminderCancellation(ctx, text); notePreRoute(ctx, "reminder-cancel", handled) { + return withNotice(expiredNotice, reply) + } + + // 5e. ordinal selection — "второй", "первую сделал" pick from the list she // just read (ordinal.go). Before routing, and only when a list is actually // bound to the session: with nothing offered, "второй" is an ordinary word // and keeps routing. diff --git a/internal/dialogue/session.go b/internal/dialogue/session.go index 9280587..5bd1a57 100644 --- a/internal/dialogue/session.go +++ b/internal/dialogue/session.go @@ -185,6 +185,12 @@ func (s *SessionStore) SetCandidates(id string, now time.Time, cands []Candidate sess, ok := s.sessions[id] if ok && !sess.IsExpired(now) { sess.Candidates = cands + if len(cands) > 0 { + // The list was spoken now. Its reference window begins with this + // turn, not with whichever older turn created the session. Clearing + // a spent list must not revive unrelated, stale dialogue slots. + sess.Timestamp = now + } } else { ok = false } diff --git a/internal/dialogue/session_test.go b/internal/dialogue/session_test.go index 735896d..d36b578 100644 --- a/internal/dialogue/session_test.go +++ b/internal/dialogue/session_test.go @@ -72,6 +72,23 @@ func TestSessionStoreCustomTTL(t *testing.T) { } } +func TestCandidateListStartsItsOwnReferenceWindow(t *testing.T) { + now := time.Date(2026, 8, 15, 9, 0, 0, 0, time.UTC) + store := NewSessionStore(2 * time.Minute) + store.Put("offered", &Session{Intent: IntentQuery, Timestamp: now}) + store.SetCandidates("offered", now.Add(90*time.Second), []Candidate{{Kind: "task", Ref: 1, Label: "one"}}) + if got := store.Get("offered", now.Add(3*time.Minute)); got == nil || len(got.Candidates) != 1 { + t.Fatalf("freshly offered list expired on the older turn's clock: %+v", got) + } + + store.Put("spent", &Session{Intent: IntentQuery, Timestamp: now}) + store.SetCandidates("spent", now.Add(30*time.Second), []Candidate{{Kind: "task", Ref: 2, Label: "two"}}) + store.SetCandidates("spent", now.Add(90*time.Second), nil) + if got := store.Get("spent", now.Add(151*time.Second)); got != nil { + t.Fatalf("clearing a spent list revived unrelated dialogue state: %+v", got) + } +} + func TestInheritSlots(t *testing.T) { now := time.Date(2026, 7, 6, 0, 0, 0, 0, time.UTC) diff --git a/internal/phraser/ack_ru_v1.json b/internal/phraser/ack_ru_v1.json index 56350ce..67dac17 100644 --- a/internal/phraser/ack_ru_v1.json +++ b/internal/phraser/ack_ru_v1.json @@ -24,7 +24,8 @@ "variants": ["записала: {text}"] }, "ack_note": { - "variants": ["сохранила заметку.", "заметка сохранена.", "записала в заметки."] + "fixed": true, + "variants": ["сохранила заметку."] }, "ack_reminder": { "variants": ["напомню.", "напомню, не забуду.", "хорошо, напомню."]