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
|
// 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.
|
// allowlist. proposeGap and the confirm gate itself live in confirm.go.
|
||||||
func (h *reactiveHandler) actionAct(ctx context.Context, dec router.Decision) string {
|
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.
|
// tool executor: run the matched fn against the enabled allowlist.
|
||||||
// HasFn=false ⇒ try the matcher (for LLM-routed acts where the verb
|
// HasFn=false ⇒ try the matcher (for LLM-routed acts where the verb
|
||||||
// didn't go through the stage-0 act grammar).
|
// 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
|
// Hexis ecosystem action: if ecosystem is configured and we have a verb
|
||||||
// + entity text, try to resolve the entity and execute via Hexis.
|
// + 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 != "" {
|
if reply := h.handleHexisAct(ctx, dec); reply != "" {
|
||||||
return 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.
|
// claim the turn before any real source ran.
|
||||||
q.Slots.Key, q.Slots.HasKey = "", false
|
q.Slots.Key, q.Slots.HasKey = "", false
|
||||||
q.Slots.Value = ""
|
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)
|
return h.actionQuery(ctx, q)
|
||||||
}
|
}
|
||||||
// A complaint is not a fact either (#481). "сеть какая-то медленная" and
|
// 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
|
// actionNote handles router.IntentNote: embed the note, persist it, and
|
||||||
// index it for recall.
|
// index it for recall.
|
||||||
//
|
//
|
||||||
// The stored body is dec.Utterance and nothing else (V-576). It is not
|
// The stored body comes only from dec.Utterance (V-576/V-721). An explicit
|
||||||
// Slots.Text, not phraser output and not any other model string: a note is
|
// leading capture frame is structurally removed; an unmarked note is otherwise
|
||||||
// durable, the embedder indexes it, and it comes back later as recall in his
|
// byte-for-byte his utterance. It is never Slots.Text, phraser output or any
|
||||||
// own words. Phrasing belongs in the spoken confirmation.
|
// 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 {
|
func (h *reactiveHandler) actionNote(ctx context.Context, dec router.Decision) string {
|
||||||
// A correction with no referent. Everything that could own one has already
|
// A correction with no referent. Everything that could own one has already
|
||||||
// run by here: clarify, confirm and repair are all resolved before routing,
|
// 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 {
|
if reply, ok := h.captureListFromNote(ctx, dec); ok {
|
||||||
return reply
|
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
|
// embed the note text with the same model the classifier uses, persist
|
||||||
// via CoreAPI (source=tap:voice). Semantic recall lives in `notes`, not
|
// via CoreAPI (source=tap:voice). Semantic recall lives in `notes`, not
|
||||||
// facts — no predicate reads it (spec's two-memory split).
|
// 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 {
|
if err != nil {
|
||||||
log.Printf("voice: embed note: %v", err)
|
log.Printf("voice: embed note: %v", err)
|
||||||
return phraser.Ack(phraser.FailNote, nil)
|
return phraser.Ack(phraser.FailNote, nil)
|
||||||
}
|
}
|
||||||
noteTs := h.now()
|
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 {
|
if err != nil {
|
||||||
log.Printf("voice: write note: %v", err)
|
log.Printf("voice: write note: %v", err)
|
||||||
return phraser.Ack(phraser.FailNote, nil)
|
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{
|
if err := h.recall.memStore.Insert(ctx, "note:"+strconv.FormatInt(noteID, 10), vec, map[string]string{
|
||||||
"source": "voice",
|
"source": "voice",
|
||||||
"type": "note",
|
"type": "note",
|
||||||
"text": dec.Utterance,
|
"text": noteText,
|
||||||
"ts": strconv.FormatInt(noteTs.Unix(), 10),
|
"ts": strconv.FormatInt(noteTs.Unix(), 10),
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Printf("voice: memory insert: %v", err)
|
log.Printf("voice: memory insert: %v", err)
|
||||||
|
|||||||
@@ -14,6 +14,12 @@ import (
|
|||||||
// actionReminder handles router.IntentReminder: parse the time when stage-0
|
// actionReminder handles router.IntentReminder: parse the time when stage-0
|
||||||
// skipped the extractor, then create the reminder.
|
// skipped the extractor, then create the reminder.
|
||||||
func (h *reactiveHandler) actionReminder(ctx context.Context, dec router.Decision) string {
|
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 {
|
if !dec.Slots.HasTime {
|
||||||
// Stage-0 (reminder-wakeword grammar) skips the extractor, so the
|
// Stage-0 (reminder-wakeword grammar) skips the extractor, so the
|
||||||
// time wasn't parsed. Run the parser as a fallback.
|
// 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) {
|
func TestResolveTaskStatusRefusesToGuess(t *testing.T) {
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
name string
|
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
|
// 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.
|
// check that walks the ladder would have to run the ladder.
|
||||||
var preRouteLadder = []string{
|
var preRouteLadder = []string{
|
||||||
"confirm", "repair", "repair-negative", "clarify-answer", "quiet-toggle",
|
"confirm", "repair", "repair-negative", "command-prohibition", "clarify-answer", "quiet-toggle",
|
||||||
"snooze", "ack", "ordinal",
|
"snooze", "ack", "reminder-cancel", "ordinal",
|
||||||
}
|
}
|
||||||
|
|
||||||
// notePreRoute records one rung of that ladder and passes its verdict through
|
// 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,
|
// matching capabilities through Hexis. Returns a reply string when handled,
|
||||||
// or "" to fall through to the system command executor.
|
// or "" to fall through to the system command executor.
|
||||||
func (h *reactiveHandler) handleHexisAct(ctx context.Context, dec router.Decision) string {
|
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 {
|
if h.ecosystem == nil {
|
||||||
return ""
|
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
|
// resolution stops on ambiguity and a mutating capability still goes through
|
||||||
// the spoken confirm in handleHexisAct.
|
// the spoken confirm in handleHexisAct.
|
||||||
func (h *reactiveHandler) hexisBeforeClarify(ctx context.Context, dec router.Decision) string {
|
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 {
|
if h.ecosystem == nil || h.ecosystem.hexis == nil {
|
||||||
return ""
|
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 ""
|
||||||
}
|
}
|
||||||
return h.handleHexisAct(ctx, dec)
|
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
|
// 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
|
// what Nexus knows about the first name and what it knows about the second. The
|
||||||
// last body repeats once the list runs out.
|
// 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"))
|
hexis := newFakeHexis(t, fixtureHexisCapabilities(map[string]any{"id": "restart", "name": "restart", "read_only": false}), fixtureHexisExecuted("exec_1", "succeeded"))
|
||||||
h, _ := newSafetyHandler(t)
|
h, _ := newSafetyHandler(t)
|
||||||
h.ecosystem = stubEcosystem(nexus.URL, hexis.URL)
|
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") {
|
if !strings.Contains(reply, "Indexer A") || !strings.Contains(reply, "Indexer B") {
|
||||||
t.Fatalf("ambiguous entity must prompt for clarification, got %q", reply)
|
t.Fatalf("ambiguous entity must prompt for clarification, got %q", reply)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ package main
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -33,6 +36,10 @@ func newFactGateHandler(t *testing.T, now time.Time) (*reactiveHandler, ipc.Core
|
|||||||
func TestActionFact_QuestionIsNotWritten(t *testing.T) {
|
func TestActionFact_QuestionIsNotWritten(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
h, api := newFactGateHandler(t, time.Now())
|
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{
|
reply := h.actionFact(ctx, router.Decision{
|
||||||
Intent: router.IntentFact,
|
Intent: router.IntentFact,
|
||||||
@@ -50,11 +57,14 @@ func TestActionFact_QuestionIsNotWritten(t *testing.T) {
|
|||||||
if len(hits) != 0 {
|
if len(hits) != 0 {
|
||||||
t.Fatalf("the question was indexed for recall: %+v", hits)
|
t.Fatalf("the question was indexed for recall: %+v", hits)
|
||||||
}
|
}
|
||||||
// It went down the query chain instead. Nothing is configured to answer a
|
// It went down the world query chain instead. This asserts the actual
|
||||||
// world question in this harness, so "не знаю." is the honest outcome —
|
// destination, not merely that the write was refused: the regression was
|
||||||
// what matters is that the turn was answered, not stored.
|
// the personal boundary claiming this question before search.
|
||||||
if reply == "" {
|
if !strings.Contains(reply, "1.25") {
|
||||||
t.Fatal("the turn was neither stored nor answered")
|
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/dialogue"
|
||||||
"github.com/kami/maven/internal/lexicon"
|
"github.com/kami/maven/internal/lexicon"
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
"github.com/kami/maven/internal/store"
|
"github.com/kami/maven/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -67,6 +68,44 @@ func parseOrdinal(text string) (int, bool) {
|
|||||||
return 0, false
|
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
|
// 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
|
// destructive: a task moves forward or is dropped, and both are recorded with a
|
||||||
// provenance the /tasks page shows.
|
// 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 {
|
if sess == nil || len(sess.Candidates) == 0 {
|
||||||
return "", false
|
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)
|
nth, ok := parseOrdinal(text)
|
||||||
|
if reminderList {
|
||||||
|
nth, ok = parseReminderCancelChoice(text)
|
||||||
|
}
|
||||||
if !ok {
|
if !ok {
|
||||||
return "", false
|
return "", false
|
||||||
}
|
}
|
||||||
@@ -125,6 +178,12 @@ func (h *reactiveHandler) resolveCandidate(ctx context.Context, text string, src
|
|||||||
return fmt.Sprintf("я назвала только %d.", len(sess.Candidates)), true
|
return fmt.Sprintf("я назвала только %d.", len(sess.Candidates)), true
|
||||||
}
|
}
|
||||||
pick := sess.Candidates[nth-1]
|
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)
|
status, say, hasVerb := parseCandidateVerb(text)
|
||||||
if !hasVerb || pick.Kind != "task" {
|
if !hasVerb || pick.Kind != "task" {
|
||||||
// Read it back and keep the list: naming one is often the first half of
|
// 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 "я выпил воды".
|
// что ты выпел стакан воды" for "я выпил воды".
|
||||||
return phraser.FactAck(d.Utterance)
|
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)
|
out, err := r.p.PhraseReply(ctx, d)
|
||||||
if err != nil || out == "" {
|
if err != nil || out == "" {
|
||||||
return r.stub.Reply(ctx, d)
|
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 (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)
|
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 != "записала, кофе закончился" {
|
if got != "записала, кофе закончился" {
|
||||||
t.Errorf("got %q, want %q", 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) {
|
func TestLLMReplierFallsBackToStubOnError(t *testing.T) {
|
||||||
r := newLLMReplier(stubCompleter{err: errReplierTest}, nil)
|
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) {
|
func TestLLMReplierFallsBackToStubOnEmpty(t *testing.T) {
|
||||||
r := newLLMReplier(stubCompleter{out: ""}, nil)
|
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
|
// A clarify never reaches the model, and since Vikunja #457 it is answered from
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"regexp"
|
"regexp"
|
||||||
|
|
||||||
"github.com/kami/maven/internal/phraser"
|
"github.com/kami/maven/internal/phraser"
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
)
|
)
|
||||||
|
|
||||||
// A question about her — "что ты умеешь", "кто ты" — used to have no answer at
|
// 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
|
// asked — "что ты умеешь" and "кто ты" want different halves of it — and falls
|
||||||
// back to the text itself, which is already readable, if the model is down.
|
// 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) {
|
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) {
|
if !h.turnIsAbout(ctx, t, topicSelf, selfFloor) {
|
||||||
return "", false
|
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,
|
// TestSelfDescriptionHoldsThePersona — it is her own text and she reads it out,
|
||||||
// so the same rules the phrasing eval enforces apply to it. Feminine
|
// so the same rules the phrasing eval enforces apply to it. Feminine
|
||||||
// self-reference, informal address, no pet names.
|
// self-reference, informal address, no pet names.
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
// what was SENT — every delivery.Sendable the dispatcher emitted
|
// what was SENT — every delivery.Sendable the dispatcher emitted
|
||||||
// what ARRIVED — the unified intake journal from #283
|
// what ARRIVED — the unified intake journal from #283
|
||||||
// what TOOLS were called — the recorded requests against fake Praxis/Nexis/Hexis
|
// 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
|
// 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 —
|
// 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"`
|
ExpectNotCalled []string `json:"expect_not_called,omitempty"`
|
||||||
ExpectEvents []string `json:"expect_events,omitempty"`
|
ExpectEvents []string `json:"expect_events,omitempty"`
|
||||||
ExpectNoEvents bool `json:"expect_no_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 {
|
type signalStep struct {
|
||||||
@@ -440,6 +508,17 @@ func newSimWorld(t *testing.T, sc scenario) *simWorld {
|
|||||||
})
|
})
|
||||||
tl := newTickLoop(st, gatherer, dispatcher, phraser.NewStub(), rules,
|
tl := newTickLoop(st, gatherer, dispatcher, phraser.NewStub(), rules,
|
||||||
time.Minute, 5*time.Minute, 0, nil, nil, nil, nil)
|
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}
|
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.
|
// act panicked the moment the matcher was consulted.
|
||||||
matcher := tool.NewMatcher(api)
|
matcher := tool.NewMatcher(api)
|
||||||
rtr := buildRouter(emb, matcher, config.DefaultRouterThreshold, router.NewLLMRouter(scripted), nil)
|
rtr := buildRouter(emb, matcher, config.DefaultRouterThreshold, router.NewLLMRouter(scripted), nil)
|
||||||
|
timeParser := router.NewPythonDateParser()
|
||||||
|
|
||||||
w.handler = &reactiveHandler{
|
w.handler = &reactiveHandler{
|
||||||
stt: simTranscriber{},
|
stt: simTranscriber{},
|
||||||
@@ -510,10 +590,11 @@ func newSimWorld(t *testing.T, sc scenario) *simWorld {
|
|||||||
replier: newLLMReplier(scripted, nil),
|
replier: newLLMReplier(scripted, nil),
|
||||||
now: clock.Now,
|
now: clock.Now,
|
||||||
dataStore: st,
|
dataStore: st,
|
||||||
timeParser: router.StubDateTimeParser{},
|
timeParser: timeParser,
|
||||||
dialogueSessions: dialogue.NewSessionStore(time.Hour),
|
dialogueSessions: dialogue.NewSessionStore(time.Hour),
|
||||||
clarifyStore: dialogue.NewClarifyStore(time.Hour),
|
clarifyStore: dialogue.NewClarifyStore(time.Hour),
|
||||||
clarifyMaxAttempts: dialogue.DefaultMaxAttempts,
|
clarifyMaxAttempts: dialogue.DefaultMaxAttempts,
|
||||||
|
extractor: router.Extractor{Time: timeParser, Acts: matcher, Facts: router.DefaultFactParser{}},
|
||||||
ecosystem: eco,
|
ecosystem: eco,
|
||||||
}
|
}
|
||||||
return w
|
return w
|
||||||
@@ -599,7 +680,7 @@ func (w *simWorld) run(sc scenario) {
|
|||||||
eventsBefore := w.publishCount()
|
eventsBefore := w.publishCount()
|
||||||
|
|
||||||
w.stimulate(ctx, s)
|
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
|
// 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()
|
w.t.Helper()
|
||||||
where := fmt.Sprintf("step %d (%s)", i+1, s.At)
|
where := fmt.Sprintf("step %d (%s)", i+1, s.At)
|
||||||
if s.Note != "" {
|
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",
|
fail("expected nothing to arrive, %d event(s) were published",
|
||||||
w.publishCount()-eventsBefore)
|
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 {
|
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 {
|
func loadScenario(t *testing.T, path string) scenario {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
raw, err := os.ReadFile(path)
|
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
|
// 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.
|
// is the failure floor and a turn must never break on the model.
|
||||||
func offlineOwnRequest(text string) bool {
|
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.
|
// classifyTurnRole decides what this utterance is against the pending action.
|
||||||
|
|||||||
@@ -121,5 +121,8 @@ func needsRoute(text string) bool {
|
|||||||
if isCancel(text) {
|
if isCancel(text) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
if _, ok := parseReminderCancelRequest(text); ok {
|
||||||
|
return false
|
||||||
|
}
|
||||||
return len(ownContent(text)) > 0 || router.IsQuestionShaped(text)
|
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
|
// runTurn — the reactive turn pipeline shared by the voice and text entry
|
||||||
// points: expired-clarify notice → confirm answer → explicit correction →
|
// points: expired-clarify notice → confirm answer → explicit correction →
|
||||||
// clarify answer → quiet toggle → route → dialogue merge → clarify question →
|
// clarify answer → quiet toggle → reminder cancellation → route → dialogue
|
||||||
// action → replier.
|
// merge → clarify question → action → replier.
|
||||||
// Takes the already-transcribed utterance, returns the reply text; the voice
|
// Takes the already-transcribed utterance, returns the reply text; the voice
|
||||||
// path wraps it in stt/tts, the text path returns it as-is.
|
// 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)
|
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
|
// 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
|
// 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
|
// 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)
|
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
|
// just read (ordinal.go). Before routing, and only when a list is actually
|
||||||
// bound to the session: with nothing offered, "второй" is an ordinary word
|
// bound to the session: with nothing offered, "второй" is an ordinary word
|
||||||
// and keeps routing.
|
// and keeps routing.
|
||||||
|
|||||||
@@ -185,6 +185,12 @@ func (s *SessionStore) SetCandidates(id string, now time.Time, cands []Candidate
|
|||||||
sess, ok := s.sessions[id]
|
sess, ok := s.sessions[id]
|
||||||
if ok && !sess.IsExpired(now) {
|
if ok && !sess.IsExpired(now) {
|
||||||
sess.Candidates = cands
|
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 {
|
} else {
|
||||||
ok = false
|
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) {
|
func TestInheritSlots(t *testing.T) {
|
||||||
now := time.Date(2026, 7, 6, 0, 0, 0, 0, time.UTC)
|
now := time.Date(2026, 7, 6, 0, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,8 @@
|
|||||||
"variants": ["записала: {text}"]
|
"variants": ["записала: {text}"]
|
||||||
},
|
},
|
||||||
"ack_note": {
|
"ack_note": {
|
||||||
"variants": ["сохранила заметку.", "заметка сохранена.", "записала в заметки."]
|
"fixed": true,
|
||||||
|
"variants": ["сохранила заметку."]
|
||||||
},
|
},
|
||||||
"ack_reminder": {
|
"ack_reminder": {
|
||||||
"variants": ["напомню.", "напомню, не забуду.", "хорошо, напомню."]
|
"variants": ["напомню.", "напомню, не забуду.", "хорошо, напомню."]
|
||||||
|
|||||||
Reference in New Issue
Block a user