Cancel a reminder by voice, and honour a refusal (V-719)
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>
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 —
|
||||
@@ -198,6 +199,73 @@ type step struct {
|
||||
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)
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
+21
-3
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -24,7 +24,8 @@
|
||||
"variants": ["записала: {text}"]
|
||||
},
|
||||
"ack_note": {
|
||||
"variants": ["сохранила заметку.", "заметка сохранена.", "записала в заметки."]
|
||||
"fixed": true,
|
||||
"variants": ["сохранила заметку."]
|
||||
},
|
||||
"ack_reminder": {
|
||||
"variants": ["напомню.", "напомню, не забуду.", "хорошо, напомню."]
|
||||
|
||||
Reference in New Issue
Block a user