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:
2026-08-15 17:19:25 +04:00
parent 0b057df2a3
commit 85a3397bf4
26 changed files with 1746 additions and 37 deletions
+6
View File
@@ -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
}
+17
View File
@@ -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)
+2 -1
View File
@@ -24,7 +24,8 @@
"variants": ["записала: {text}"]
},
"ack_note": {
"variants": ["сохранила заметку.", "заметка сохранена.", "записала в заметки."]
"fixed": true,
"variants": ["сохранила заметку."]
},
"ack_reminder": {
"variants": ["напомню.", "напомню, не забуду.", "хорошо, напомню."]