Compare commits

...

38 Commits

Author SHA1 Message Date
claude 70b32af8a7 Merge: a reminder said whole is not asked about (#214)
V-572. "напомни в 11:00 позвонить маме" answered "Когда?". ReminderGrammar
builds its slots by hand and the extractor never ran over a stage 0 decision,
so HasTime was false however clearly the hour was spoken, and missingFor read
the silence as absence.

fillMatchedSlots in internal/router/router.go now runs the stage 2 extractor
over every stage 0 decision and fills only what the grammar left empty. A
matched value always wins. The LLM path had the same hole and the same fix, so
both share one function rather than ten grammars re-implementing extraction.

Slots.Text is deliberately not filled. A grammar that left Text empty meant
it: agendaQueryBuild hands the query chain the sentence itself. Filling it
would also make SlotText unaskable, which is the bug V-383 fixed on the LLM
side.

Enabled for all ten grammars and inert for nine. Extract fills Time for a
reminder, Fn for an act and Key for a fact, and nothing for query, system,
note or chat. Benchmarked at 20000x with the real date parser: every stage 0
shape stays inside the noise, and the reminder rule gains, because
actionReminder was already running that same parse one layer down.

TestONNXBaseline 64/91 before and after, no case regressed. The fixture's own
"slots deferred to daemon" line went 6 to 0. On the box: "хорошо, напомню
сегодня в 11:00."

Conflict in internal/router/router.go resolved by hand: V-564's grammar-outcome
note and V-572's slot fill both belong, fill first. Full -race suite green.

--no-verify: the pre-commit hook refuses master, and the owner asked for
straight-to-master merges for this unattended run.
2026-08-06 01:15:04 +04:00
claude fffd0cb5fa Merge: a confirm answer is a whole word, not a substring (#213)
V-567, severe. classifyConfirm was strings.Contains over bare stems, so
"погода" contained "да" and "покажи" contained "ок". resolveConfirm runs
before routing, so asking the weather while a confirm was parked executed the
destructive tool or the bound Hexis capability. Measured on the box before the
fix: "какая погода" ran the parked act.

Second defect found while fixing: an unrecognised utterance also disarmed the
confirm, because claim() cleared the pending slot before the verdict was read.
An utterance that is not an answer is not a cancellation either.

The yes and no words are now two closed sets in internal/lexicon, matched as
whole tokens, phrases longest-first so "не надо" is read before "нет", and
negatives before positives. The whole utterance must be answer words plus
filler, so "давай посмотрим погоду" is unknown and leaves the confirm parked.
"хорошо", "ладно" and "точно" are deliberately absent: they open a sentence
about something else as often as they answer one.

Conflict in lexicon_ru_v1.json resolved by hand: V-560's slot_value_frame and
dialogue_cancel and V-567's confirm_yes and confirm_no all belong. 22 sets,
JSON validated, lexicon, mavend, router and router/eval green with -race.

--no-verify: the pre-commit hook refuses master, and the owner asked for
straight-to-master merges for this unattended run.
2026-08-06 01:12:18 +04:00
claude 77a7c994d7 Merge: route first, then decide the turn role (#212)
Conflict in cmd/mavend/voice.go resolved by hand: V-564's decision record
install and V-560's memoised turn route both belong at the top of runTurn, as
steps 0 and 0b. Full -race suite green over ./internal/... ./cmd/... after the
resolution, 64 packages, no failures.

--no-verify: the pre-commit hook refuses master, and the owner asked for
straight-to-master merges for this unattended run.
2026-08-06 01:08:58 +04:00
claude 63812af920 CLAUDE.md: stage 0 is slot-extracted now (V-572) 2026-08-06 01:08:55 +04:00
claude 869580c913 a reminder said whole no longer asks "Когда?" (V-572)
"напомни в 11:00 позвонить маме" answered "Когда?" about an hour he had
just said. ReminderGrammar builds its slots by hand and the router ran no
extraction over a stage-0 decision, so HasTime was false however plainly
the hour was spoken; missingFor read the silence as absence.

The fix runs the stage-2 extractor over every stage-0 decision, filling
only the slots the grammar left empty. A matched value always wins: the
rule read a literal pattern, the extractor guesses. This is the same hole
the LLM path already had, so fillSlots and the new stage-0 call share one
fillMatchedSlots.

Enabled for all ten grammars rather than a chosen few, because for every
intent but reminder it is inert. Extract fills Time for a reminder, Fn for
an act and Key for a fact, and nothing at all for query, system, note or
chat — which is what the clock, agenda, feed, list, task, Praxis-adjacent
and narrative rules emit. The two act rules, wakeword-act and the Praxis
ones, already carry an Fn or they do not match, so the matcher has nothing
left to fill. Measured rather than asserted: benchmarked at 20000x, a
stage-0 query is 3.7µs against 3.9µs before and a clock or act rule is
0.7µs either way, both inside the noise. The reminder rule is the one that
gains, and its date parse is not new spend — actionReminder was already
running exactly that parse one layer down, and now skips it.

Slots.Text is deliberately not filled. Extract sets it to the raw
utterance, and a grammar that left it empty meant it: agendaQueryBuild
hands the query chain the sentence itself, and narrativeQueryBuild's Text
is the topic.

Fixture unchanged at 64/91 (70.3%) on TestONNXBaseline, no case regressed,
no new false clarify. What moved is the line the fixture calls "slots
deferred to daemon": 6 to 0.

Verified on homesrv: "напомни в 11:00 позвонить маме" now answers
"хорошо, напомню сегодня в 11:00."
2026-08-06 01:08:38 +04:00
claude 31deb7d565 confirm answers match whole words from the lexicon (V-567)
classifyConfirm was a substring test over bare stems, so "погода",
"дальше", "надо" and "давление" all read as "да", and "покажи" and
"около" read as "ок". resolveConfirm runs before routing, so a question
about the weather executed a parked destructive act. Reproduced on the box:
with "restart nonexistent-xyz" parked, "какая погода" answered "не
получилось выполнить команду".

The yes and no answers are now two closed sets in internal/lexicon, matched
as whole tokens longest-first, and the WHOLE utterance must be answer words
and filler — a leading "давай" does not make "давай посмотрим погоду" an
answer. Anything else is confirmUnknown, which now leaves the confirm parked
instead of disarming it: an utterance that is not an answer is not a
cancellation either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 01:05:50 +04:00
claude 1338ec6e2a Merge: an infrastructure error does not claim the turn (#211)
V-568. queryEmbed claimed the turn on an EmbedQuery error and answered
QueryFailAnswer from position 12 of querySources, above memory, notes, the
personal boundary, search, Kiwix and general knowledge. So one failing embed
call made every question below it answer "не смогла ответить", including the
ones search and Kiwix would have answered without the embedder at all.

The notes source had the same bug one position lower, on a QueryNotes error.
Both now log once and pass. Both also gained an empty-vector guard, because
scores off a nil vector are not a "there is nothing" answer.

The distinction the audit used: a source that looked and found nothing may
claim, a source that could not look must pass. day-plan, habits, feeds,
calendar, weather, home, network and web keep claiming, because each already
matched a question about his own data and nothing below can answer it.
Answering a personal question with a paragraph about the world is V-474 and
V-479.
2026-08-06 00:58:46 +04:00
claude 7843728174 Merge the common unit for claims (#210)
V-565. internal/claim holds Claim{Claimant, Intent, Filled, Consumed,
Unexplained, Band, Veto} and imports nothing from Maven, so the dialogue to
router edge stays impossible. internal/router/claim.go builds one from a
Decision. Additive: nothing in Route calls it and Decision.Confidence is
untouched.

Measured first, on the 91-case fixture. Stage 0 emits 1.0 always and is right
20/20. The classifier cosine spans 0.859 to 0.942 and is right 62% of the
time, with 62% correct below its median and 62% above, so the number carries
no signal about correctness. The top1 to top2 margin is worse: p50 0.009, 68
of 71 cases under 0.02. A calibrated float is not cheaply available from the
classifier, which is what the task's ledger asked to be checked.

So four ordinal bands, highest first: anchored, structural, nearest, vetoed,
with unknown at the bottom so a builder that forgot cannot outrank a measured
claim. Anchored against nearest is 100% against 62% on the same utterances.
Nearest is one band and not a scale because the cosine is flat.

Coverage decides before the band does. That is what fixes Rome: the pending
claimant ate the question while explaining one token of it.

No fixture number moved. TestONNXBaseline is 64/91.
2026-08-06 00:58:34 +04:00
claude a3ad9b5040 an embedder error passes instead of ending the turn (V-568)
queryEmbed claimed the turn on a failed EmbedQuery and returned
QueryFailAnswer. It sits above memory, notes, the personal boundary,
search, Kiwix, the named page and general knowledge, so one ONNX error
answered every question below it with "не получилось найти ответ",
including the ones search and Kiwix answer without an embedder at all.

It now logs and passes, the shape turnVector already had. The two recall
sources below pass on an empty vector rather than searching on one, and
queryNotes passes on a store error too: a source that could not look is
not a source that looked and found nothing.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-06 00:56:29 +04:00
claude cb0a3a4f20 voice: tests for the turn role and the Rome pair (V-560)
The measured failure of 2026-08-05 end to end through the real cascade, plus
the content test the classifier rests on, the call-off, the cost bound, and the
persona checks over the two new lines.
2026-08-06 00:56:06 +04:00
claude 6abd2768e8 voice: clarify routes first and isOwnRequest is gone (V-560)
resolveClarifyAnswer now decides what the utterance IS before deciding what to
do with it: route, classify the role, then answer, cancel, or step aside. The
side_query and new_request arms drop the parked question and say so — nothing
may die in silence — and V-561 turns the first of those into a suspend.

isOwnRequest is deleted rather than extended. It only ran where the answer
filled nothing, which is why the greedy 'сейчас' in a weather question walked
straight past it and set a reminder for a time nobody asked for.
2026-08-06 00:56:06 +04:00
claude 6e6f73da35 voice: the turn is routed once and the decision is shared (V-560)
turnRoute memoises this turn's routing, so the resolver that reads it to
classify a role and the pipeline that acts on it cannot end up with two
different decisions, and the extra route is paid once. needsRoute is the fast
path: an utterance with no content of its own reaches the same role without the
model.
2026-08-06 00:55:54 +04:00
claude 1a64c30427 voice: the turn role, read off the routed decision (V-560)
The turn role — answer, correction, side_query, new_request, cancel, plus the
not_applicable a resolver may return — decided from what the router made of the
utterance instead of from whatever the extractor found inside it.

The content gate in front of the evidence is what separates a hedged slot value
from a question: 'а что если в 11:00' leaves nothing of its own behind and
'какая сейчас погода в Риме' leaves the weather and Rome. Nothing calls it yet.
2026-08-06 00:55:53 +04:00
claude 5753f90752 lexicon: the frame around a slot value, and the call-off words (V-560)
Two closed sets the turn-role classifier reads. slot_value_frame is what can
stand around a bare value without making the utterance a request — strip it
and the numbers and whatever is left is the utterance's own content.
dialogue_cancel is how he calls off a request she is still assembling, which
is not what task_drop_words means.
2026-08-06 00:55:29 +04:00
claude 4d94277836 build a claim from a Decision, beside the existing path (V-565)
router.ClaimOf maps a Decision onto the common unit. Stage 0 is anchored,
the LLM path is structural, the classifier is nearest, and anything with a
structural hole is vetoed whoever produced it.

The veto recovers the reason gateLLMDecision throws away. Folding three
named holes into llmThinConfidence leaves 0.3, which says something was
wrong and never which thing, so the same conditions are read here as
sentences a trace can print.

Nothing in Route calls this. Decision.Confidence keeps its float and keeps
working, because r.threshold and gateLLMDecision read it and the classifier
is the failure floor. TestClaimOfLeavesTheDecisionAlone asserts that.
TestONNXBaseline is unchanged at 64/91.
2026-08-06 00:54:55 +04:00
claude 530c3ff395 claim tests: band order, coverage, the Rome case (V-565)
TestCoverageBeatsBand is the V-558 failure as an ordering assertion. The
weather claim explains the whole utterance and the pending reminder explains
one token of it, so coverage settles it before the band is consulted.

TestBandOrder asserts the order rather than trusting the iota, and pins
BandUnknown at the bottom: a builder that forgot to set a band is a bug and
must not outrank a measured claim.
2026-08-06 00:54:43 +04:00
claude e031f8f5f5 a claim carries evidence, not a verdict (V-565)
internal/claim holds the common unit: who wants the turn, the intent, the
slots it would fill, the tokens it explains, the tokens it declines, and why
it should not win. Specificity reads Consumed against Unexplained and
negative constraint reads Veto, so neither needs a float.

Four ordinal bands where a number is unavoidable, argued from measurement in
docs/plans/19-dialogue-arbitration.md. Anchored, structural, nearest,
vetoed. Nearest is one band and not a scale because the classifier's cosine
measured flat against correctness.

MoreSpecificThan puts coverage first and the band second. That is the fix
for the failure V-558 opened with: a pending reminder ate the Rome weather
question while explaining none of it.

The package imports nothing from the rest of Maven. internal/dialogue must
not import internal/router, so Intent is a plain string.
2026-08-06 00:54:34 +04:00
claude 6c24e19b83 Merge the decision trace (#209)
V-564. One decision.Record per turn: the utterance, the winner, and a Claim
per claimant carrying its stage, name, the intent it would have made the turn,
the score it reported, the outcome and the reason. HasScore is separate from
the score so a real 0.0 is not read as no score. Outcomes are won, declined,
lost_on_order, lost_on_score, thinned, merged, never_asked.

Every stage declares its roster up front, so Finish names everyone who never
reported. NEVER ASKED is explicit rather than an absence, which is the fact
the hardcoded ordering hides.

Covered: the seven pre-route resolvers, eleven stage 0 grammar sets, the LLM
router and the classifier with which arm of gateLLMDecision thinned a route,
the classifier runners-up, the follow-up merge, 27 query sources, and a
terminal action-handler or clarify-ask claim.

On by default, no flag. It rides the context like querysource.go and is
installed in runTurn, so mic, telegram and web leave the same trail. Storage
is a 25-turn in-memory ring: no write on the answer path, no migration, and
none of his words outlive the diagnosis. Readable on /trace.

TestRecordingDoesNotChangeTheReply answers the same utterances with and
without the ring.
2026-08-06 00:54:18 +04:00
claude 934a28d67c measure what each claimant on an utterance reports (V-565)
Two reporting tests over the 91-case RU fixture, no ratchet: a ratchet here
would freeze a number nobody has decided to hold.

TestStage0Contention runs the 21 grammars one at a time instead of stopping
at the first match. One case of 91 draws two, ru-query-019, where
calendar-query beats agenda-query by list position alone.

TestONNXClaimConfidenceDistribution buckets the reported confidence by the
layer that produced it. Stage 0 is 20/20 at a hardcoded 1.0. The classifier
scores 62% below its median and 62% above, across a cosine range of 0.859
to 0.942, with a top-two margin of p50 0.009. The float is not a confidence.

newBaselineClassifier and baselineGrammars split out of newBaselineRouter so
the measurement runs the same rules the daemon runs. TestONNXBaseline is
unchanged at 64/91.
2026-08-06 00:54:13 +04:00
claude cf28f6fdf0 qa reads /trace for the query chain again (V-564) 2026-08-06 00:52:59 +04:00
claude eec3d9bed2 /trace grows a turn-decisions table under the rule trace (V-564)
Both tables answer the same question, who won and who lost, one about nudges
and the other about utterances, so they share a page rather than splitting the
nav. A turn is one collapsible row; never_asked is coloured like a block,
because it usually is one. A read failure is logged and the rule trace above it
still renders: a daemon too old to know the method is the ordinary case during
a rolling deploy.
2026-08-06 00:52:58 +04:00
claude a5b245dbf5 the ring reads out over ipc as turn decisions (V-564)
Same shape as TickTrace and RecentEvents: a bounded daemon ring, so the store
adapter refuses rather than pretending a table exists. No voice wiring means an
empty list and not an error, because a box with no voice path has had no turns
to arbitrate.
2026-08-06 00:52:48 +04:00
claude 3e6a427e85 a turn names its winner, its losers, and who never looked (V-564) 2026-08-06 00:52:48 +04:00
claude 5ac7347c38 the resolver ladder and the query chain report their claims (V-564)
The two claimant sets that live in the daemon are where the arbitration is
least visible: both are a hardcoded order of functions that each answer 'is
this mine?' alone. The ladder declares its roster up front, so a rung that
never ran is named rather than omitted, and the query chain does the same for
the sources below the one that claimed.

Recording is installed in runTurn and not in the IPC entry point, so the mic,
telegram and the web leave the same trail. A record only the web produced would
be missing exactly the turns that are hardest to reproduce.
2026-08-06 00:52:39 +04:00
claude 5417692566 the cascade says which grammar declined and which never ran (V-564)
Stage 0 records every grammar it reached, keeping a pattern that never matched
apart from a Build that refused the content, and names the ones after the
winner as never asked. The routing arm records the classifier's runners-up and
which arm of gateLLMDecision cut the confidence, because thinned alone is not
enough to act on.
2026-08-06 00:52:28 +04:00
claude b56e0e6248 the record's own tests: never-asked, bounds, fan-out (V-564) 2026-08-06 00:52:28 +04:00
claude 0558dfed0f a turn record holds every claim, not only the winner (V-564)
Arbitration between the claimants on the utterance stream is order, hardcoded
in three places, and a log that names the winner cannot explain a loss. The new
package holds one record per turn: who claimed, what it would have made the
turn, the score it reported, and why the rest did not get it. Being explicit
that a claimant was never asked is the point: that silence is what the
hardcoded ordering hides.

The record rides the context, the seam querysource.go already uses, so no claim
site can change a route and a context with no record costs nothing. The ring is
memory and bounded: a turn record is read minutes later or never, and his words
do not belong in a table that outlives the diagnosis.
2026-08-06 00:52:19 +04:00
claude 13a5ef0100 Merge the dialogue contract tests (#208)
V-563. cmd/mavend/dialogue_contract_test.go holds twelve whole multi-turn
traces. Each turn asserts the reply, what is parked afterwards including the
attempt count, and the end state: reminders with payload and fire time, fact
keys, note count, task texts.

Six traces pass today. Six carry the correct expectation and skip, naming the
task that makes them green: the owner's transcript and its parseable twin
(V-561), cancel and a correction under a parked question (V-560), a whole
reminder still being asked about and a short correction (V-562).
MAVEN_DIALOGUE_NO_SKIP=1 runs the skipped rows, so a fixer sees their row turn
green and a stale skip is caught.

Offline: hash embedder, no llama-server, no ONNX. Failures print a claimant
trace derived from the daemon's log lines, so a wrong claimant reads
differently from wrong copy.
2026-08-06 00:51:02 +04:00
claude ac78f83406 dialogue contract tests: the six traces that do not (V-563)
Each carries the correct expectation and is skipped with the task that
will unskip it, because a weakened expectation would pin the bug as the
contract. MAVEN_DIALOGUE_NO_SKIP=1 runs them.

V-561: the owner's transcript, and the same shape in words the offline
date parser reads — a side query drops the parked question instead of
suspending it, so Rome is never answered and the reminder is never set.
V-560: a cancel is scored as a failed answer and spends a retry; clarify
pre-empts the repair marker, so no correction can be spoken mid-flow.
V-562: a stage-0 reminder never meets the extractor, so a reminder said
whole with its hour in it is still asked about; and finishClarified goes
straight to applyAction, so a repaired decision that lands short answers
with a parse error instead of asking.
2026-08-06 00:49:47 +04:00
claude 40c59aa275 dialogue contract tests: the traces that hold today (V-563)
Six whole traces through the real cascade with no model: a reminder and a
fact each completed over two turns, an answer that arrives past the TTL,
three unclear answers and the give-up line, a correction of the previous
turn, and an abandoned flow. Each asserts the reply, what is parked after
every turn, and the end state of the store.
2026-08-06 00:47:27 +04:00
claude 84a75274bf dialogue contract tests: the trace vocabulary (V-563)
First slice: the types a multi-turn trace is written in, and the claimant
trace read out of the daemon's own log lines. No rows yet.
2026-08-06 00:46:57 +04:00
claude da2d11dab6 plan: the claim unit and its four bands, measured (V-565)
Measures what each claimant on an utterance reports across the 91-case RU
fixture, then argues an ordinal band set from that distribution.

The classifier's cosine is flat against correctness: 62% correct below its
median and 62% above, over a spread only 0.083 wide, with every case above
the 0.55 gate. Its top-two margin is p50 0.009 and never reaches 0.03. So a
calibrated float is not cheaply available and the ledger's assumption holds.

Stage 0 is 20/20 on the cases it claims and asserts 1.0 for all of them. The
LLM router emits two values, and the lower one is a self-veto with a reason
flattened into a number.
2026-08-06 00:46:50 +04:00
claude de3f2b5fc2 Merge the typed pending action and the dialogue stack (#207)
V-559. internal/dialogue gains PendingAction: capability, slots, missing
slots, TTL and attempt cap, with CapabilityFor as the one intent to
capability map. PendingQuestion derives its action rather than storing a
second copy, so the TTL and attempt rules have one implementation.

The clarify store now holds a bounded stack, MaxStackDepth 2. Behaviour is
identical: Put replaces the top, nothing calls Push, so the daemon runs at
depth one. Push returns what the bound evicted, so nothing dies silently.

Groundwork for V-560 and V-561.
2026-08-06 00:37:56 +04:00
claude e8f4baf407 dialogue: stack tests — push, peek, pop, the bound and expiry (V-559)
Push/peek/pop including that a peek does not consume and that the flow
under a popped entry survives; that a popped entry stays gone; that a push
past MaxStackDepth returns the evicted entry rather than dropping it
silently; that Put keeps the depth at one; that an expired top takes the
stack with it and is reported once by TakeExpired; and that two dialogue
ids do not read each other's stack.
2026-08-06 00:37:01 +04:00
claude 92eb6cf6e1 dialogue: the clarify store holds a bounded stack (V-559)
One parked question per dialogue id meant a side query could only destroy
the flow it interrupted. The store now keeps a stack per id, newest last,
with Push, Peek, Pop, Depth and Delete as drop-all. MaxStackDepth is 2:
one flow plus the thing he interrupted it with, because spoken
conversation does not nest deeper, and because every level she keeps is a
level she has to be able to speak when it dies.

Behaviour is unchanged. Put still replaces the top rather than growing the
stack — a re-ask is another question about the same action — and nothing
calls Push yet, so the daemon runs at depth one exactly as before. Get is
Peek under the name the callers already use. An expired top takes the
stack with it and TakeExpired reports it, so no parked action dies without
a word; Push returns the entry the depth bound forced out for the same
reason.

PendingQuestion.IsExpired and CanAsk now answer through PendingAction, so
the TTL and attempt-cap rules have one copy and the widening cannot drift.
2026-08-06 00:37:01 +04:00
claude 6759ff6003 dialogue: a typed pending action behind the parked question (V-559)
A parked clarify said what she heard (an intent) and not what she was
about to do, so the resolver had to infer the action from conversational
history instead of reading it off an object. PendingAction names the
capability being assembled in the ecosystem's dotted form
(reminder.create, fact.write, act.run), the slots it has, the slots it
still wants, when it was asked, attempts and TTL.

Gaps() computes the missing slots from the slots rather than trusting
Missing, because Missing is what she asked and the slots are what she
got. CapabilityFor maps every dialogue.Intent, so the mapping lives here
and dialogue still does not import router (the cycle rule).

Nothing reads it yet: this is the widening V-560 to V-562 build on.
2026-08-06 00:36:33 +04:00
claude 39284cd851 Merge: a missing slot asks, whatever the confidence (#206) 2026-08-06 00:08:53 +04:00
claude ea0eb167fd a missing slot asks, whatever the confidence (V-557)
The clarify path was gated on dec.Clarify, so a turn the cascade routed
confidently but incompletely skipped it. "напомни позвонить" reached applyAction,
failed on the missing time and parked nothing, and the "в семь вечера" that
followed was routed as a world question and web-searched.

The gate now also fires when missingFor names a required slot. A bare capture
verb gets a stage-0 rule of its own: it was reaching the resident model as chat,
which answered by agreeing to a wording change nobody asked for.
2026-08-06 00:08:43 +04:00
50 changed files with 4352 additions and 148 deletions
+29
View File
@@ -162,6 +162,19 @@ on in deploy** — this section used to say it was wired `nil`, which stopped be
Cascade order: `stage0.go` exact-match fast-path → LLM router (when non-nil) → classifier
fallback. Any LLM error falls through to the classifier so a turn never breaks on the model.
**A stage-0 decision is slot-extracted too, since 06-08-2026** (V-572). `fillMatchedSlots`
in `router.go` runs the stage-2 extractor over whatever a grammar built and fills only the
slots it left empty — a matched value always wins, because the rule read a literal pattern
and the extractor guesses. It did not run before, so `ReminderGrammar` handed the daemon
`HasTime: false` for "напомни в 11:00 позвонить маме" and `missingFor` read the silence as
absence and asked "Когда?". It applies to every grammar and is inert for all but the
reminder: `Extract` fills Time, Fn and Key and nothing else, and the query, clock, agenda,
feed, list, task and narrative rules all emit intents with no such slot. Benchmarked at
20000x, a stage-0 query costs 3.7µs against 3.9µs before. **`Slots.Text` is deliberately not
filled** — a grammar that left it empty meant it, and `agendaQueryBuild` hands the query
chain the utterance itself. Fixture unchanged at 64/91, with "slots deferred to daemon"
6 → 0.
Measured on the 77-case RU fixture. **Re-measured 2026-08-02: the classifier scores 68.8%
full accuracy at p50 16.6µs**, not the 36.8% at p50 31ms that stood here from
`docs/evals/2026-07-31-model-bakeoff.md`. That older figure predates the stage 0 rules and the
@@ -261,6 +274,22 @@ of the house. A demonstrative ("отметь это как сделанное")
`h.surfacedItems` only when exactly one item was spoken. Otherwise the turn goes back to
the cascade rather than transitioning the wrong item.
**Who claimed a turn is now recorded, and so is who did not** (V-564, umbrella
V-558). Arbitration between the claimants on the utterance stream is order,
hardcoded in the pre-route resolver ladder, in `buildRouter` and in
`querySources`. `internal/decision` records one `Record` per turn: every
claimant, what it would have made the turn, the score it reported, and whether
it won, declined, lost on score, was thinned by a gate or was **never asked**.
The record rides the context, the same seam `querysource.go` uses, so a claim
site cannot change a route and a context with no record costs nothing. It is
installed in `runTurn`, so the mic, telegram and the web all leave the same
trail. Storage is a 25-turn in-memory ring on the handler (`decision.Ring`),
read over `ipc.TurnDecisions` and rendered as the second table on `/trace`.
Nothing persists: a turn record is read minutes later or never, and his words do
not belong in a table that outlives the diagnosis. Adding a rung to the ladder
in `runTurn` means adding its name to `preRouteLadder` in
`cmd/mavend/decisiontrace.go`, or that rung is silently missing from the record.
## LLM output contract
All phrasing paths emit `{"response":"...","mood":"..."}` (parsed in `replier_llm.go` and
+46 -3
View File
@@ -11,6 +11,7 @@ import (
"unicode"
"github.com/kami/maven/internal/crawl"
"github.com/kami/maven/internal/decision"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/memory"
"github.com/kami/maven/internal/morning"
@@ -158,8 +159,17 @@ var querySources = []querySource{
func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision) string {
t := &queryTurn{dec: dec}
// The roster, so the record can say which sources were never reached rather
// than leaving them out and letting a reader assume they looked and passed
// (V-564). Finish names everyone below the winner.
decision.Expect(ctx, decision.StageQuery, querySourceNames())
rec := decision.From(ctx)
for _, src := range querySources {
if dec.Continued && !src.dateAware {
rec.Note(decision.Claim{
Stage: decision.StageQuery, Claimant: src.name, Outcome: decision.NeverAsked,
Reason: "a continuation turn only asks the date-aware sources",
})
continue
}
if reply, ok := src.answer(h, ctx, t); ok {
@@ -172,8 +182,16 @@ func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision)
// caller asked for one, so /chat can show it (V-539).
log.Printf("voice: query claimed by source %q", src.name)
noteQuerySource(ctx, src.name)
rec.Note(decision.Claim{
Stage: decision.StageQuery, Claimant: src.name,
Intent: string(dec.Intent), Outcome: decision.Won,
})
return reply
}
rec.Note(decision.Claim{
Stage: decision.StageQuery, Claimant: src.name, Outcome: decision.Declined,
Reason: "it had no answer for this turn",
})
}
if dec.Continued {
// The previous question cannot be re-asked for another day. Saying so
@@ -485,7 +503,15 @@ func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (strin
// queryEmbed isn't an answer source — it's the shared cost the two recall
// sources below both need, run once, in the position it always ran in. It
// only claims the turn when the embedder fails.
// never claims the turn.
//
// It used to claim on an embedder error, and that made a RAG hint a hard gate
// over everything below it (V-568): one failing EmbedQuery and the memory, the
// notes, the boundary, the search, the ZIMs, the named page and the model all
// answered "не смогла ответить", including the questions search and Kiwix
// would have answered without ever touching the embedder. A failed embed means
// this source cannot claim, not that the turn is over — same shape as
// turnVector in topics.go, which had it right.
func (h *reactiveHandler) queryEmbed(ctx context.Context, t *queryTurn) (string, bool) {
// A topic source above already paid for this one; see turnVector.
if len(t.vec) > 0 {
@@ -493,8 +519,11 @@ func (h *reactiveHandler) queryEmbed(ctx context.Context, t *queryTurn) (string,
}
vec, err := router.EmbedQuery(ctx, h.recall.embedder, t.dec.Utterance)
if err != nil {
// Logged once, here, and the chain walks on. The two recall sources
// below read the empty vector and pass; the boundary drops to its
// offline floor.
log.Printf("voice: embed query: %v", err)
return phraser.Q(phraser.QueryFailAnswer, nil), true
return "", false
}
t.vec = vec
return "", false
@@ -514,6 +543,12 @@ func (h *reactiveHandler) queryMemory(ctx context.Context, t *queryTurn) (string
if h.recall.memStore == nil {
return "", false
}
if len(t.vec) == 0 {
// No query vector: the embed above failed or there is no embedder.
// Searching on an empty vector is not a search, and its scores are not
// a "there is nothing" answer — pass rather than gate the chain.
return "", false
}
hits, herr := h.recall.memStore.Search(ctx, t.vec, 3)
if herr != nil {
log.Printf("voice: memory search: %v", herr)
@@ -560,10 +595,18 @@ func (h *reactiveHandler) queryMemory(ctx context.Context, t *queryTurn) (string
// band. See memory.Confident. Failing the gate passes the turn on to general
// knowledge, which is what "don't read back the runner-up" means here.
func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string, bool) {
if len(t.vec) == 0 {
// Same reason as queryMemory above (V-568): with no query vector this
// source could not look, and could-not-look passes.
return "", false
}
notes, err := h.api.QueryNotes(ctx, t.vec, 5)
if err != nil {
// The store failed, so this source could not look either. It used to
// claim here, which stopped the search, the ZIMs and the model from
// answering a question that never needed a note (V-568).
log.Printf("voice: query notes: %v", err)
return phraser.Q(phraser.QueryFailAnswer, nil), true
return "", false
}
t.notes = notes
noteScores := make([]float64, len(notes))
+59 -38
View File
@@ -175,58 +175,69 @@ func (h *reactiveHandler) askClarify(ctx context.Context, dec router.Decision) (
return question, true
}
// resolveClarifyAnswer reads an utterance as the answer to a parked question.
// Returns ("", false) when no live question is parked (or it expired), so the
// caller routes the utterance normally as a fresh request. Sibling of
// resolveConfirm and checked in the same place.
//
// The answer is parsed with the same extractor the router uses, for the intent
// she parked — no second parser. If it still does not fill the gap she asks
// again, up to MaxAttempts; after that she says out loud that she did not
// understand. She never drops the request in silence.
// isOwnRequest reports whether an utterance asks for something in its own
// right, which is what a clarify answer never does. Two offline tests over
// tokens, both already written for other callers: a question shape, and a
// capture verb. Cheap on purpose — this runs on the answer to every parked
// question, and it must not cost a model call.
//
// It is not a general relevance test. A bare noun that answers nothing ("синий"
// after "Что сделать?") is still treated as an answer and still re-asked, and
// that is the intended shape: only an utterance that carries its own request
// wins over the question in front of it.
func isOwnRequest(text string) bool {
return router.IsQuestionShaped(text) || router.CarriesCaptureVerb(text)
}
// clarifyCancelled — he called the half-built request off. Said out loud, like
// every other way it can end: a silent drop reads as "done". Feminine
// self-reference ("отменила"), as everywhere.
const clarifyCancelled = "Хорошо, отменила."
// clarifyDropped — he asked for something else instead, so the parked request
// is gone. Glued in front of the answer to what he actually asked, because
// nothing may be dropped in silence. V-561 suspends and resumes it instead of
// letting it go, and this line goes away with it.
const clarifyDropped = "Прошлую просьбу отпускаю."
// resolveClarifyAnswer reads an utterance against the parked question and
// decides what it IS before deciding what to do with it. Returns ("", false)
// when the turn is not this resolver's — nothing parked, or the utterance turned
// out to be a request of its own — so the caller dispatches it normally.
//
// The order is the point (Vikunja #560). The utterance is ROUTED first, and the
// role is read off that decision: a routed decision that stands on its own is
// not an answer, whatever the extractor found inside it. Before this the
// extractor decided, so "какая сейчас погода в Риме?" became the time of a
// reminder on the strength of the word "сейчас".
//
// The answer itself is parsed with the same extractor the router uses, for the
// intent she parked — no second parser. If it still does not fill the gap she
// asks again, up to MaxAttempts; after that she says out loud that she did not
// understand. She never drops the request in silence.
func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string) (string, bool) {
if h.clarifyStore == nil {
return "", false
}
q := h.clarifyStore.Get(dialogueIDOf(ctx), h.now())
if q == nil {
return "", false
return "", false // not_applicable: nothing is pending
}
intent := router.Intent(q.Intent)
answer := h.extractor.Extract(ctx, intent, text, h.now())
merged := q.Answer(text, toDialogueSlots(answer))
// He moved on. A parked question used to swallow whatever came next, so one
// act she could not fulfil ate the following three turns: "выключи свет в
// спальне" asked "Что сделать?", and "кто изобрёл телефон" was scored as an
// answer to it, then "как дела" after that (Vikunja #554). Nothing checked
// whether the words could be an answer at all.
//
// Deliberately narrow. It only fires where the answer filled nothing, so a
// turn that closes the gap is still an answer whatever shape it has, and
// the retry budget is untouched — the count was never the problem. Dropping
// the question and routing the utterance as itself is what he meant either
// way: if he really was answering, he can say it again, and if he was not,
// he gets the thing he asked for instead of being asked a third time.
if len(dialogue.StillMissing(q.Missing, merged)) > 0 && isOwnRequest(text) {
var (
routed router.Decision
routedOK bool
)
if needsRoute(text) {
routed, routedOK = h.routeForRole(ctx, text)
}
role := classifyTurnRole(q, text, toDialogueSlots(answer), routed, routedOK)
log.Printf("voice: clarify — %q is a %s against %s (routed=%v)", text, role, dialogue.CapabilityFor(q.Intent), routedOK)
switch role {
case roleCancel:
h.clarifyStore.Delete(dialogueIDOf(ctx))
log.Printf("voice: clarify — %q is its own request, not an answer to %v; dropping the question", text, q.Missing)
return clarifyCancelled, true
case roleSideQuery, roleNewRequest:
// He moved on. A parked question used to swallow whatever came next, so
// one act she could not fulfil ate the following three turns (Vikunja
// #554) and a world question set a reminder for a time nobody asked for
// (#558). Drop the question, say so, and let these words be themselves.
h.clarifyStore.Delete(dialogueIDOf(ctx))
h.noteDropped(ctx)
return "", false
}
merged := q.Answer(text, toDialogueSlots(answer))
// Fold a newly answered subject into the raw utterance. Downstream actions
// phrase from Utterance, not from the text slot — actionReminder stores it
// as the reminder payload — so a reminder clarified out of a bare "напомни"
@@ -262,6 +273,16 @@ func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string)
return h.finishClarified(ctx, dec), true
}
// noteDropped records that the parked request was let go this turn, so runTurn
// can say it in front of whatever these words are answered with. Nothing to
// record outside runTurn — a unit test calling one resolver has no turn to glue
// a notice onto.
func (h *reactiveHandler) noteDropped(ctx context.Context) {
if rt := turnRouteFrom(ctx); rt != nil {
rt.dropped = clarifyDropped
}
}
// foldAnswerIntoUtterance appends an answered subject to the original words,
// unless they already carry it. "напомни" + "позвонить маме" reads as the
// request he would have made in one breath. Nothing is appended when the
+78 -1
View File
@@ -10,6 +10,7 @@ import (
"github.com/kami/maven/internal/dialogue"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/memory"
"github.com/kami/maven/internal/phraser/eval"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/store"
@@ -418,7 +419,7 @@ func TestClarifyProseHoldsThePersona(t *testing.T) {
eval.CheckAddress: true,
eval.CheckCringe: true,
}
lines := append([]string{clarifyGaveUp}, clarifyExpiredVariants...)
lines := append([]string{clarifyGaveUp, clarifyCancelled, clarifyDropped}, clarifyExpiredVariants...)
lines = append(lines, clarifyMissedVariants...)
for _, variants := range clarifyQuestionVariants {
lines = append(lines, variants...)
@@ -646,3 +647,79 @@ func TestUnresolvedActSaysItDoesNotKnowTheCommand(t *testing.T) {
}
// newRoutingClarifyHandler wires the real cascade (hash embedder, no model) onto
// the clarify handler, so a test can drive handleText end to end and see which
// gate claimed the turn.
func newRoutingClarifyHandler(t *testing.T) (*reactiveHandler, *store.Store) {
t.Helper()
h, st, _ := newClarifyHandler(t)
h.router = buildRouter(router.NewHashEmbedder(1024), h.matcher, 0.55, nil)
h.recall = recallWiring{embedder: router.NewHashEmbedder(1024), memStore: memory.NewInMemoryStore()}
return h, st
}
// TestIncompleteReminderAsksInsteadOfFailing — Vikunja #557. "напомни позвонить"
// is routed confidently and is still half a request. It used to reach applyAction,
// fail on the missing time and park nothing, so the "в семь вечера" that followed
// was routed as a world question and web-searched.
func TestIncompleteReminderAsksInsteadOfFailing(t *testing.T) {
ctx := context.Background()
h, st := newRoutingClarifyHandler(t)
reply := h.handleText(ctx, "web", "напомни позвонить маме")
want, _ := clarifyQuestionFor(dialogue.SlotTime, 1)
if reply != want {
t.Fatalf("reply = %q, want the time question %q", reply, want)
}
if h.clarifyStore.Get(dialogueIDFor(sourceText, "web"), h.now()) == nil {
t.Fatal("the request must be parked, or the answer has nowhere to land")
}
if reply := h.handleText(ctx, "web", "в семь вечера"); strings.Contains(reply, "нашла") {
t.Fatalf("the answer to her own question must not be looked up: %q", reply)
}
if reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)); err != nil || len(reminders) != 1 {
t.Fatalf("the answer did not complete the reminder: reminders=%v err=%v", reminders, err)
}
}
// TestBareCaptureVerbAsksWhatToRecord — the other half of #557. A bare "запиши"
// went to the resident model as chat, which agreed to a wording change nobody
// asked for. It is a fact with no key, and that gap has a question.
func TestBareCaptureVerbAsksWhatToRecord(t *testing.T) {
ctx := context.Background()
h, _ := newRoutingClarifyHandler(t)
reply := h.handleText(ctx, "web", "запиши")
want, _ := clarifyQuestionFor(dialogue.SlotKey, 1)
if reply != want {
t.Fatalf("reply = %q, want %q", reply, want)
}
if h.clarifyStore.Get(dialogueIDFor(sourceText, "web"), h.now()) == nil {
t.Fatal("the request must be parked so the next utterance completes it")
}
}
// TestACompleteTurnStillDoesNotAsk — the gate reads a missing slot, not any
// slot, so a request she can act on must never turn into a question. Checked on
// the decision rather than through the cascade: what is at stake is the gate's
// condition, and driving it through the hash embedder would measure routing.
func TestACompleteTurnStillDoesNotAsk(t *testing.T) {
ctx := context.Background()
h, _, _ := newClarifyHandler(t)
complete := []router.Decision{
{Intent: router.IntentReminder, Slots: router.Slots{Text: "позвонить маме", HasTime: true}, Utterance: "напомни в 11 позвонить маме"},
{Intent: router.IntentFact, Slots: router.Slots{Key: "water", Value: "выпил", HasKey: true}, Utterance: "я выпил воды"},
{Intent: router.IntentNote, Slots: router.Slots{Text: "купить хлеб"}, Utterance: "запиши купить хлеб"},
{Intent: router.IntentQuery, Slots: router.Slots{Text: "что у меня сегодня"}, Utterance: "что у меня сегодня"},
}
for _, dec := range complete {
if gaps := missingFor(dec); len(gaps) > 0 {
t.Errorf("%q reads as incomplete: %v", dec.Utterance, gaps)
}
if reply, asked := h.askClarify(ctx, dec); asked {
t.Errorf("%q was answered with a question: %q", dec.Utterance, reply)
}
}
}
+98 -21
View File
@@ -3,9 +3,13 @@ package main
import (
"context"
"log"
"slices"
"sort"
"strings"
"time"
"unicode"
"github.com/kami/maven/internal/lexicon"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router"
)
@@ -57,10 +61,18 @@ func (h *reactiveHandler) park(fn string, args []string, phrase string) {
// resolveConfirm interprets an utterance as the answer to a parked destructive
// act OR a parked routine proposal. Returns (reply, true) when it consumed the
// utterance as a y/n answer; ("", false) when there's nothing pending (or the
// parked act expired), so the caller routes the utterance normally. An
// unrecognised answer cancels the pending and routes normally — a confirm that
// can't be answered clearly is safer abandoned than left armed.
// parked act expired), so the caller routes the utterance normally.
//
// An utterance that is not clearly yes or no is not an answer at all, so it is
// handed straight back and the pending stays parked until it expires (V-567).
// This resolver runs before routing and holds the most dangerous trigger on the
// box; it may only claim a turn it is certain about.
func (h *reactiveHandler) resolveConfirm(ctx context.Context, text string) (string, bool) {
verdict := classifyConfirm(text)
if verdict == confirmUnknown {
return "", false
}
h.mu.Lock()
defer h.mu.Unlock()
@@ -68,16 +80,12 @@ func (h *reactiveHandler) resolveConfirm(ctx context.Context, text string) (stri
if !r.claim() {
continue
}
// The slot is already cleared by claim(): every branch below drops the
// pending, including the unclear one — a confirm that can't be
// answered clearly is safer abandoned than left armed.
switch classifyConfirm(text) {
// The slot is already cleared by claim().
switch verdict {
case confirmYes:
return r.yes(), true
case confirmNo:
return r.no(), true
default:
return "", false
return r.no(), true
}
}
return "", false
@@ -194,23 +202,92 @@ const (
confirmNo
)
// classifyConfirm reads a short ru/en yes-or-no answer. Substring match on the
// stems so inflections/fillers ("да, давай", "нет, отмени") still land.
// confirmWords are the two closed sets, tokenized once and ordered
// longest-first so "не надо" is read before "нет" could claim any of it.
var (
confirmYesPhrases = confirmPhrases(lexicon.ConfirmYes())
confirmNoPhrases = confirmPhrases(lexicon.ConfirmNo())
)
// confirmPhrases splits each lexicon member into tokens and sorts the result
// longest-first, so a walk that tries them in order matches the longest member
// that fits.
func confirmPhrases(words []string) [][]string {
out := make([][]string, 0, len(words))
for _, w := range words {
if toks := confirmTokens(w); len(toks) > 0 {
out = append(out, toks)
}
}
sort.SliceStable(out, func(i, j int) bool { return len(out[i]) > len(out[j]) })
return out
}
// confirmTokens splits an utterance into lowercase word tokens. Punctuation and
// spacing are separators; an apostrophe is not, because "don't" is one word.
func confirmTokens(text string) []string {
return strings.FieldsFunc(strings.ToLower(text), func(r rune) bool {
if r == '\'' || r == '' {
return false
}
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
})
}
// classifyConfirm reads a short ru/en yes-or-no answer to a parked confirm.
//
// The whole utterance must consist of confirmation words and filler, matched as
// whole tokens against the closed lexicon sets. Anything else is
// confirmUnknown, which leaves the confirm parked and routes the turn — see
// resolveConfirm. Both halves of that are the fix for V-567: this used to be a
// substring test over bare stems, so "погода", "дальше", "надо" and "давление"
// all read as "да", and "покажи" and "около" read as "ок". A parked destructive
// act fired on a question about the weather.
//
// Requiring the WHOLE utterance is the second half. A leading confirm word does
// not make a sentence an answer: "давай посмотрим погоду" opens a request, and
// the only safe reading of a sentence that carries its own subject is that he
// moved on. Guessing wrong here executes something; guessing wrong the other way
// asks again.
func classifyConfirm(text string) confirmVerdict {
t := strings.ToLower(strings.TrimSpace(text))
// negatives first — "не надо" contains no "да", but check no-stems before
// yes so a leading "нет" isn't shadowed.
for _, no := range []string{"нет", "не надо", "отмен", "стоп", "no", "cancel", "stop", "don't"} {
if strings.Contains(t, no) {
tokens := confirmTokens(text)
if len(tokens) == 0 {
return confirmUnknown
}
verdict := confirmUnknown
for i := 0; i < len(tokens); {
// Negatives first: "не надо" and "не хочу" open with a token that is
// not itself an answer, and a yes hit must never shadow them.
if n := matchConfirm(confirmNoPhrases, tokens[i:]); n > 0 {
return confirmNo
}
if n := matchConfirm(confirmYesPhrases, tokens[i:]); n > 0 {
verdict, i = confirmYes, i+n
continue
}
if lexicon.IsFillerParticle(tokens[i]) {
i++
continue
}
// A word that is neither an answer nor filler carries a subject of its
// own, so this utterance is not an answer to her question.
return confirmUnknown
}
for _, yes := range []string{"да", "ага", "давай", "подтвер", "конечно", "yes", "yeah", "yep", "confirm", "ок", "okay", "ok"} {
if strings.Contains(t, yes) {
return confirmYes
return verdict
}
// matchConfirm reports the length of the longest phrase matching at the head of
// tokens, or 0.
func matchConfirm(phrases [][]string, tokens []string) int {
for _, p := range phrases {
if len(p) > len(tokens) {
continue
}
if slices.Equal(p, tokens[:len(p)]) {
return len(p)
}
}
return confirmUnknown
return 0
}
// actPhrase renders "fn arg1 arg2" for the confirm prompt.
+103
View File
@@ -0,0 +1,103 @@
package main
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/tool"
)
// TestClassifyConfirmRejectsSubstrings — V-567. The old matcher tested bare
// stems with strings.Contains, so every word below answered a question she had
// asked about something else: "погода", "дальше", "надо" and "давление" carry
// "да"; "покажи", "около" and "окно" carry "ок". A parked destructive act fired
// on a question about the weather.
func TestClassifyConfirmRejectsSubstrings(t *testing.T) {
for _, text := range []string{
"погода",
"какая погода",
"что дальше",
"надо ещё",
"покажи заметки",
"около окна",
"давление",
"давай посмотрим погоду",
"не забудь купить хлеб",
"окно открыто",
"стоит ли брать зонт",
"",
} {
if got := classifyConfirm(text); got != confirmUnknown {
t.Errorf("classifyConfirm(%q) = %v, want confirmUnknown", text, got)
}
}
}
// TestClassifyConfirmAcceptsAnswers keeps every genuine answer the substring
// matcher accepted, and pins the pair the fix could most easily get wrong:
// "надо" is not an answer and "не надо" is the opposite of one.
func TestClassifyConfirmAcceptsAnswers(t *testing.T) {
yes := []string{"да", "Да!", "ага", "давай", "да, давай", "конечно", "подтверждаю", "ну да", "yes", "yeah", "ok", "okay", "confirm"}
no := []string{"нет", "Нет.", "не надо", "не нужно", "не сейчас", "отмена", "отмени", "стоп", "нет, отмени", "no", "nope", "cancel", "stop", "don't"}
for _, text := range yes {
if got := classifyConfirm(text); got != confirmYes {
t.Errorf("classifyConfirm(%q) = %v, want confirmYes", text, got)
}
}
for _, text := range no {
if got := classifyConfirm(text); got != confirmNo {
t.Errorf("classifyConfirm(%q) = %v, want confirmNo", text, got)
}
}
}
// TestUnrelatedTurnLeavesConfirmParked — the whole point of V-567. An utterance
// that is not an answer must not execute the parked act, must not consume the
// turn, and must not disarm the confirm either: the answer he has not given yet
// is still answerable until it expires.
func TestUnrelatedTurnLeavesConfirmParked(t *testing.T) {
ctx := context.Background()
st := newTestStore(t)
api := ipc.NewStoreAPI(st)
now := time.Date(2026, 8, 6, 9, 0, 0, 0, time.UTC)
h := &reactiveHandler{
api: api,
dataStore: st,
now: func() time.Time { return now },
tools: tool.NewExecutor(api, time.Second),
}
marker := filepath.Join(t.TempDir(), "destructive-tool-ran")
if err := st.EnableTool(ctx, "delete_backups", []string{"touch", marker}, true, "test", h.now()); err != nil {
t.Fatal(err)
}
h.park("delete_backups", nil, "delete_backups")
if reply, handled := h.resolveConfirm(ctx, "какая погода"); handled {
t.Fatalf("the weather question was consumed as a confirm: %q", reply)
}
if _, err := os.Stat(marker); !os.IsNotExist(err) {
t.Fatalf("the parked destructive command ran on an unrelated turn: %v", err)
}
if h.pending == nil {
t.Fatal("the confirm was disarmed by a turn that did not answer it")
}
// It is still answerable, and answering it still runs the act.
reply, handled := h.resolveConfirm(ctx, "да")
if !handled || !strings.Contains(reply, "готово") {
t.Fatalf("the still-parked confirm did not resolve: handled=%v reply=%q", handled, reply)
}
if _, err := os.Stat(marker); err != nil {
t.Fatalf("confirmed destructive command did not run: %v", err)
}
if h.pending != nil {
t.Fatal("the confirm stayed parked after being answered")
}
}
+132
View File
@@ -0,0 +1,132 @@
// mavend/decisiontrace.go — the daemon's half of the per-turn decision record.
//
// V-564. The router says what the cascade did (internal/router/decisiontrace.go);
// this file covers the two claimant sets that live in the daemon: the stateful
// resolvers that run BEFORE routing and pre-empt it unconditionally, and the
// query source chain that runs after. Those two are where the arbitration is
// least visible, because both are a hardcoded order of functions that each
// answer "is this mine?" alone and none of which answers "is this more mine
// than yours?" (V-558).
//
// Recording changes no route. Every helper here is a no-op on a context with no
// record, which is what every test that does not ask for one gets.
package main
import (
"context"
"github.com/kami/maven/internal/decision"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/router"
)
// preRouteLadder — the resolvers runTurn offers the utterance to before the
// router sees it, in the order they get their say. Kept here as a roster rather
// than derived from the code, so a resolver that returns early and skips the
// rest still leaves the rest NAMED in the record: a claimant that never looked
// and one that looked and passed are the distinction the ordering hides, and
// they are the difference between a bug in the ladder and a bug in a resolver.
//
// Adding a step to runTurn means adding its name here. Nothing enforces that,
// and nothing should: a missing name costs one line of the record, while a
// check that walks the ladder would have to run the ladder.
var preRouteLadder = []string{
"confirm", "clarify-answer", "quiet-toggle", "snooze", "ack", "repair", "ordinal",
}
// notePreRoute records one rung of that ladder and passes its verdict through
// unchanged, so the call site stays the single `if handled` it already was.
func notePreRoute(ctx context.Context, name string, handled bool) bool {
rec := decision.From(ctx)
if rec == nil {
return handled
}
if handled {
rec.Note(decision.Claim{
Stage: decision.StagePreRoute, Claimant: name, Outcome: decision.Won,
Reason: "it pre-empted routing, so the router never saw this turn",
})
return handled
}
rec.Note(decision.Claim{
Stage: decision.StagePreRoute, Claimant: name, Outcome: decision.Declined,
Reason: "nothing of its own was pending",
})
return handled
}
// noteTerminal records whoever actually produced the reply, but only if the
// turn is still unclaimed. A route decides the intent; it does not answer, and
// on a thinned route or a plain act nothing downstream keeps a scoreboard. So
// the record would otherwise close with an empty winner, which reads as a lost
// turn instead of an asked question.
func noteTerminal(ctx context.Context, claimant string, intent router.Intent, reason string) {
decision.From(ctx).NoteIfUnclaimed(decision.Claim{
Stage: decision.StageAction, Claimant: claimant,
Intent: string(intent), Reason: reason,
})
}
// noteMerge records the follow-up merge, which is the one claimant that edits
// the winning decision instead of taking the turn from it. It is compared on
// the four slots the merge can fill, because a Decision holds a slice and is
// not comparable.
func noteMerge(ctx context.Context, before, after router.Decision) {
rec := decision.From(ctx)
if rec == nil {
return
}
changed := before.Slots.HasTime != after.Slots.HasTime ||
before.Slots.HasKey != after.Slots.HasKey ||
before.Slots.HasFn != after.Slots.HasFn ||
before.Slots.Text != after.Slots.Text ||
before.Intent != after.Intent
if !changed {
rec.Note(decision.Claim{
Stage: decision.StageMerge, Claimant: "follow-up-merge", Outcome: decision.Declined,
Reason: "no slot of this turn was left for a previous one to fill",
})
return
}
rec.Note(decision.Claim{
Stage: decision.StageMerge, Claimant: "follow-up-merge", Intent: string(after.Intent),
Outcome: decision.Merged, Reason: "filled this turn's gaps from the previous turn",
})
}
// turnDecisionsFn — the reader mavweb gets, or nil when voice was never wired.
// Same shape as intakeEventsFn: the daemon holds the ring, the IPC layer only
// converts it.
func turnDecisionsFn(w *voiceWiring) func(int) []ipc.TurnDecision {
if w == nil || w.handler == nil || w.handler.decisions == nil {
return nil
}
ring := w.handler.decisions
return func(n int) []ipc.TurnDecision {
recs := ring.Recent(n)
out := make([]ipc.TurnDecision, 0, len(recs))
for _, rec := range recs {
claims := make([]ipc.TurnClaim, 0, len(rec.Claims))
for _, c := range rec.Claims {
claims = append(claims, ipc.TurnClaim{
Stage: c.Stage, Claimant: c.Claimant, Intent: c.Intent,
Score: c.Score, HasScore: c.HasScore,
Outcome: c.Outcome, Reason: c.Reason,
})
}
out = append(out, ipc.TurnDecision{
Ts: rec.Ts, Utterance: rec.Utterance, Winner: rec.Winner, Claims: claims,
})
}
return out
}
}
// querySourceNames — the query chain's roster, in chain order.
func querySourceNames() []string {
names := make([]string, len(querySources))
for i, src := range querySources {
names[i] = src.name
}
return names
}
+163
View File
@@ -0,0 +1,163 @@
package main
import (
"context"
"strings"
"testing"
"time"
"github.com/kami/maven/internal/decision"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/memory"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/tool"
"github.com/kami/maven/internal/voice"
)
// traceHandler — a handler with the decision ring wired, the same shape the
// daemon builds in wireVoice.
func traceHandler(t *testing.T, ring *decision.Ring) *reactiveHandler {
t.Helper()
st := newTestStore(t)
api := ipc.NewStoreAPI(st)
now := time.Now()
emb := router.NewHashEmbedder(1024)
return &reactiveHandler{
api: api,
recall: recallWiring{embedder: emb, memStore: memory.NewInMemoryStore()},
router: buildRouter(emb, tool.NewMatcher(api), 0.55, nil),
replier: voice.NewStubReplier(),
now: func() time.Time { return now },
dataStore: st,
decisions: ring,
}
}
// findClaim — the first claim for a claimant, or nil.
func findClaim(rec *decision.Record, claimant string) *decision.Claim {
for i := range rec.Claims {
if rec.Claims[i].Claimant == claimant {
return &rec.Claims[i]
}
}
return nil
}
// TestTurnRecordNamesWinnerAndLosers — the point of V-564. A turn a stage-0
// grammar claims must leave a record naming that grammar as the winner, naming
// a pre-route resolver that declined, and naming the routing engines that were
// never reached at all. The last of those is the fact the hardcoded ordering
// hides: "classifier" absent from the record and "classifier" never asked read
// the same to a human, and only one of them is the truth.
func TestTurnRecordNamesWinnerAndLosers(t *testing.T) {
ring := decision.NewRing()
h := traceHandler(t, ring)
reply := h.handleText(context.Background(), "web", "сколько сейчас времени")
if reply == "" {
t.Fatal("turn produced no reply")
}
recs := ring.Recent(5)
if len(recs) != 1 {
t.Fatalf("want 1 record, got %d", len(recs))
}
rec := recs[0]
if rec.Utterance != "сколько сейчас времени" {
t.Errorf("utterance = %q", rec.Utterance)
}
if !strings.HasPrefix(rec.Winner, "stage0:") {
t.Errorf("want a stage-0 grammar as the winner, got %q", rec.Winner)
}
// A loser that examined the turn: the confirm resolver ran first and had
// nothing pending.
confirm := findClaim(rec, "confirm")
if confirm == nil || confirm.Outcome != decision.Declined {
t.Errorf("confirm claim = %+v, want a decline", confirm)
}
// A loser that never looked: stage 0 answered, so neither routing engine
// was reached.
for _, name := range []string{"llm-router", "classifier"} {
c := findClaim(rec, name)
if c != nil && c.Outcome == decision.Won {
t.Errorf("%s cannot have won a stage-0 turn: %+v", name, c)
}
}
// And every rung of the ladder below the winner is named, not omitted.
for _, name := range preRouteLadder {
if findClaim(rec, name) == nil {
t.Errorf("ladder rung %q is missing from the record", name)
}
}
}
// TestRecordingDoesNotChangeTheReply — instrumentation, so a turn with the ring
// wired and the same turn without it must answer identically. If this ever
// fails, a claim site is doing more than noting.
func TestRecordingDoesNotChangeTheReply(t *testing.T) {
for _, utt := range []string{
"сколько сейчас времени",
"запиши что я пил воду",
"что у меня сегодня",
} {
withRing := traceHandler(t, decision.NewRing()).handleText(context.Background(), "web", utt)
without := traceHandler(t, nil).handleText(context.Background(), "web", utt)
if withRing != without {
t.Errorf("%q: recorded reply %q != unrecorded %q", utt, withRing, without)
}
}
}
// TestQueryChainRecordsWhoWasNeverAsked — a query source below the claimant is
// never consulted, and the record must say so rather than leave it out. This is
// the arm that would have explained the Rome misroute in one read.
func TestQueryChainRecordsWhoWasNeverAsked(t *testing.T) {
ring := decision.NewRing()
h := traceHandler(t, ring)
h.handleText(context.Background(), "web", "что у меня сегодня")
rec := ring.Recent(1)[0]
var asked, never int
for _, c := range rec.Claims {
if c.Stage != decision.StageQuery {
continue
}
if c.Outcome == decision.NeverAsked {
never++
} else {
asked++
}
}
if asked == 0 {
t.Fatal("no query source reported at all")
}
if never == 0 {
t.Fatal("no query source was recorded as never asked; the chain cannot have run to the end")
}
if got := len(querySourceNames()); asked+never != got {
t.Errorf("record covers %d of %d query sources", asked+never, got)
}
}
// TestTurnDecisionsFnConvertsTheRing — the IPC read path. Nil when voice was
// never wired, because a box with no turns is an empty page and not an error.
func TestTurnDecisionsFnConvertsTheRing(t *testing.T) {
if fn := turnDecisionsFn(nil); fn != nil {
t.Error("no wiring should mean no reader")
}
ring := decision.NewRing()
h := traceHandler(t, ring)
h.handleText(context.Background(), "web", "сколько сейчас времени")
fn := turnDecisionsFn(&voiceWiring{handler: h})
if fn == nil {
t.Fatal("wired handler produced no reader")
}
out := fn(10)
if len(out) != 1 || out[0].Winner == "" || len(out[0].Claims) == 0 {
t.Fatalf("conversion lost the record: %+v", out)
}
}
+556
View File
@@ -0,0 +1,556 @@
package main
import (
"bytes"
"context"
"log"
"os"
"strings"
"testing"
"time"
"github.com/kami/maven/internal/dialogue"
"github.com/kami/maven/internal/memory"
"github.com/kami/maven/internal/router"
"github.com/kami/maven/internal/store"
)
// Dialogue contract tests (V-563, child of V-558).
//
// Every other clarify test is single-shot: one ask, one answer, one assertion.
// Three bugs of the same family shipped in two days that way — V-554 (a parked
// question ate the three turns after it), V-557 (a confidently routed but
// incomplete reminder parked nothing, so the answer was web-searched) and the
// Rome case in V-558 (a side question was eaten as the time answer). None of
// them is visible in one turn. The dialogue path is a state machine, so it can
// be enumerated instead: whole traces, each with a per-turn expectation and an
// expected END state — what was written to the store, and what is still parked.
//
// Two rules for the rows below.
//
// Where today's behaviour is correct, it is asserted. Where it is WRONG, the row
// carries the CORRECT expectation and is skipped with the Vikunja id that will
// unskip it. A weakened expectation would be worse than no row: it would pin the
// bug as the contract.
//
// Everything runs on the offline floor — hash embedder, no llama-server, no
// ONNX, StubDateTimeParser. That has one consequence worth knowing before
// reading a fire time here: the stub reads "в 11:00" and "через час" and does
// not read "на 9" or "на завтра", so a trace that needs those is noted where it
// sits.
// claim — which claimant consumed an utterance. Not asserted: it is derived from
// the log lines the daemon already emits and printed on every failure, because
// "the reply differed" does not distinguish a wrong claimant from wrong copy,
// and that distinction is the whole point of V-558.
type claim struct {
utterance string
steps []string
}
func (c claim) String() string { return c.utterance + " ⇒ " + strings.Join(c.steps, " → ") }
// claimMarkers — log fragment to claimant name, in the order runTurn checks
// them. The fragments are the daemon's own words (clarify.go, repair.go,
// voice.go); a rename there shows up here as an "unclaimed" step rather than a
// silent mislabel.
var claimMarkers = []struct{ fragment, name string }{
{"parked question expired", "clarify:expired"},
{"is its own request", "clarify:stepped-aside"},
{"gave up on", "clarify:gave-up"},
{"did not fill", "clarify:re-ask"},
{"one gap filled", "clarify:ask-second-gap"},
{"asked about", "clarify:ask"},
{"repair —", "repair"},
{"route result: intent=", "route"},
}
// claimsOf reads the turn's log output and names the claimants that touched it.
func claimsOf(utterance, logged string) claim {
c := claim{utterance: utterance}
for _, line := range strings.Split(logged, "\n") {
for _, m := range claimMarkers {
if strings.Contains(line, m.fragment) {
name := m.name
if m.name == "route" {
name = "route:" + intentInLine(line)
}
c.steps = append(c.steps, name)
break
}
}
}
if len(c.steps) == 0 {
c.steps = []string{"unclaimed"}
}
return c
}
func intentInLine(line string) string {
_, rest, ok := strings.Cut(line, "intent=")
if !ok {
return "?"
}
intent, _, _ := strings.Cut(rest, " ")
return intent
}
// parkedWant — the question that must be armed after a turn. Attempt matters:
// a claimant that spends a retry on an utterance that was never an answer is
// exactly the V-554 shape, and the count is the only place it shows.
type parkedWant struct {
slot dialogue.Slot
attempt int
// carries — a substring the parked utterance must still hold, so a re-park
// that lost the answered subject fails here rather than three turns later.
carries string
}
// turn — one utterance and everything that must be true right after it.
type turn struct {
say string
// wait — the clock moves this far BEFORE the utterance. The only way to
// reach the TTL without sleeping.
wait time.Duration
// question — the reply must be exactly this clarify question, worded for
// this attempt. Zero slot ⇒ not checked.
question dialogue.Slot
attempt int
contains []string
notContain []string
// noQuestion — the reply must not be any clarify question. Used where the
// correct behaviour is known but her wording for it is not written yet: a
// cancel must not be answered with another question, whatever it does say.
noQuestion bool
expired bool // the reply must open with the TTL notice
// parked — what is armed after the turn. nil ⇒ nothing may be armed.
parked *parkedWant
}
// endState — what the store holds once the trace is over. Counts and
// substrings, not rows: a trace is about who claimed what, and a payload
// substring is enough to catch a request landing under the wrong words.
type endState struct {
reminders []reminderWant
factKeys []string
notes int
tasks []string
}
type reminderWant struct {
payload string // substring of the stored payload
fireAt string // "2006-01-02 15:04" in UTC, "" ⇒ not checked
}
// trace — a named conversation, its turns, and the end state.
type trace struct {
name string
skip string // non-empty ⇒ t.Skip: today's behaviour is wrong, this names the fix
turns []turn
end endState
}
// newDialogueHandler — the offline floor with the real cascade and a movable
// clock: newClarifyHandler's wiring (stub date parser, real fact parser, tool
// matcher) plus the router newRoutingClarifyHandler builds, and the `now`
// pointer so a turn can carry a wait.
func newDialogueHandler(t *testing.T) (*reactiveHandler, *store.Store, *time.Time) {
t.Helper()
h, st, now := newClarifyHandler(t)
h.router = buildRouter(router.NewHashEmbedder(1024), h.matcher, 0.55, nil)
h.recall = recallWiring{embedder: router.NewHashEmbedder(1024), memStore: memory.NewInMemoryStore()}
return h, st, now
}
// runTrace drives one trace through handleText and checks every turn, then the
// end state. Every failure carries the decision trace so far, so a wrong
// claimant reads differently from wrong copy.
func runTrace(t *testing.T, tr trace) {
t.Helper()
// MAVEN_DIALOGUE_NO_SKIP=1 runs the rows that fail today. That is how
// whoever lands V-560, V-561 or V-562 sees their row go green before
// deleting its skip, and it is also the check that a skip is still earned:
// a row that passes with the skip in place is a fix nobody noticed.
if tr.skip != "" && os.Getenv("MAVEN_DIALOGUE_NO_SKIP") == "" {
t.Skip(tr.skip)
}
ctx := context.Background()
h, st, now := newDialogueHandler(t)
const conversation = "web"
id := dialogueIDFor(sourceText, conversation)
var claims []claim
fail := func(turnIdx int, format string, args ...any) {
t.Helper()
lines := make([]string, 0, len(claims))
for _, c := range claims {
lines = append(lines, " "+c.String())
}
t.Fatalf("turn %d: "+format+"\n who claimed what:\n%s",
append([]any{turnIdx}, append(args, strings.Join(lines, "\n"))...)...)
}
for i, tn := range tr.turns {
if tn.wait > 0 {
*now = now.Add(tn.wait)
}
var logged bytes.Buffer
prev := log.Writer()
log.SetOutput(&logged)
reply := h.handleText(ctx, conversation, tn.say)
log.SetOutput(prev)
claims = append(claims, claimsOf(tn.say, logged.String()))
body := reply
if tn.expired {
if !isClarifyExpired(reply) {
fail(i, "reply %q must open with the expiry notice", reply)
}
body = trimClarifyExpired(reply)
// The notice is glued in front of this turn's reply, and both halves
// have to survive: the words he just said are routed fresh, and
// answering only "I let the old one go" drops them.
if body == "" {
fail(i, "the notice was the whole reply; the fresh words were never answered")
}
} else if isClarifyExpired(reply) {
fail(i, "reply %q announced an expiry nothing asked for", reply)
}
if tn.question != "" {
want, ok := clarifyQuestionFor(tn.question, tn.attempt)
if !ok {
fail(i, "no question exists for slot %s attempt %d", tn.question, tn.attempt)
}
if body != want {
fail(i, "reply %q, want the %s question worded for attempt %d, %q", body, tn.question, tn.attempt, want)
}
}
if tn.noQuestion && isAnyClarifyQuestion(body) {
fail(i, "reply %q is another question; this turn is not something to ask about", body)
}
for _, want := range tn.contains {
if !strings.Contains(body, want) {
fail(i, "reply %q does not carry %q", body, want)
}
}
for _, unwanted := range tn.notContain {
if strings.Contains(body, unwanted) {
fail(i, "reply %q carries %q and must not", body, unwanted)
}
}
checkParked(t, fail, i, h.clarifyStore.Get(id, h.now()), tn.parked)
}
checkEnd(t, ctx, st, h, tr.end, claims)
}
// isAnyClarifyQuestion — is this reply one of her clarify questions, at any
// attempt wording? Reads the templates rather than a list of its own.
func isAnyClarifyQuestion(reply string) bool {
for _, variants := range clarifyQuestionVariants {
for _, v := range variants {
if reply == v {
return true
}
}
}
return false
}
func checkParked(t *testing.T, fail func(int, string, ...any), i int, got *dialogue.PendingQuestion, want *parkedWant) {
t.Helper()
if want == nil {
if got != nil {
fail(i, "a question about %v is still armed and nothing should be: %+v", got.Missing, got.Slots)
}
return
}
if got == nil {
fail(i, "nothing is armed, want a question about %s (attempt %d)", want.slot, want.attempt)
return
}
if len(got.Missing) != 1 || got.Missing[0] != want.slot {
fail(i, "armed question is about %v, want %s", got.Missing, want.slot)
}
if got.Attempts != want.attempt {
fail(i, "armed question is on attempt %d, want %d — a retry spent on something that was never an answer is the V-554 shape", got.Attempts, want.attempt)
}
if want.carries != "" && !strings.Contains(got.Utterance, want.carries) {
fail(i, "the parked request no longer carries %q: %q", want.carries, got.Utterance)
}
}
func checkEnd(t *testing.T, ctx context.Context, st *store.Store, h *reactiveHandler, want endState, claims []claim) {
t.Helper()
lines := make([]string, 0, len(claims))
for _, c := range claims {
lines = append(lines, " "+c.String())
}
trace := "\n who claimed what:\n" + strings.Join(lines, "\n")
reminders, err := st.DueReminders(ctx, h.now().Add(14*24*time.Hour))
if err != nil {
t.Fatalf("DueReminders: %v", err)
}
if len(reminders) != len(want.reminders) {
t.Fatalf("end state: %d reminder(s), want %d: %+v%s", len(reminders), len(want.reminders), reminders, trace)
}
for i, w := range want.reminders {
if !strings.Contains(reminders[i].Payload, w.payload) {
t.Fatalf("end state: reminder %d payload %q does not carry %q%s", i, reminders[i].Payload, w.payload, trace)
}
if w.fireAt != "" {
if got := reminders[i].FireTs.UTC().Format("2006-01-02 15:04"); got != w.fireAt {
t.Fatalf("end state: reminder %d fires at %s, want %s%s", i, got, w.fireAt, trace)
}
}
}
facts, err := st.RecentFacts(ctx, 20)
if err != nil {
t.Fatalf("RecentFacts: %v", err)
}
if len(facts) != len(want.factKeys) {
t.Fatalf("end state: %d fact(s), want %d: %+v%s", len(facts), len(want.factKeys), facts, trace)
}
for i, key := range want.factKeys {
if facts[i].Key != key {
t.Fatalf("end state: fact %d is %q, want %q%s", i, facts[i].Key, key, trace)
}
}
notes, err := st.RecentNotes(ctx, 20)
if err != nil {
t.Fatalf("RecentNotes: %v", err)
}
if len(notes) != want.notes {
t.Fatalf("end state: %d note(s), want %d%s", len(notes), want.notes, trace)
}
tasks, err := st.ListTasks(ctx, store.TaskOpen)
if err != nil {
t.Fatalf("ListTasks: %v", err)
}
if len(tasks) != len(want.tasks) {
t.Fatalf("end state: %d open task(s), want %d: %+v%s", len(tasks), len(want.tasks), tasks, trace)
}
for i, text := range want.tasks {
if !strings.Contains(tasks[i].Text, text) {
t.Fatalf("end state: task %d is %q, want it to carry %q%s", i, tasks[i].Text, text, trace)
}
}
}
func TestDialogueTraces(t *testing.T) {
for _, tr := range dialogueTraces() {
tr := tr
t.Run(tr.name, func(t *testing.T) { runTrace(t, tr) })
}
}
// dialogueTraces — the fixture. Order is the order the shapes were found, not a
// dependency: each trace builds its own handler and store.
func dialogueTraces() []trace {
return []trace{
// The plain two-turn shape, and the one every other row is a deviation
// from: she asks for the time, he gives it, the reminder lands with the
// subject he said in the FIRST turn.
{
name: "reminder completed over two turns",
turns: []turn{
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "маме"}},
{say: "в 11:00", contains: []string{"11:00"}, notContain: []string{"?"}},
},
end: endState{reminders: []reminderWant{{payload: "позвонить маме", fireAt: "2026-07-31 11:00"}}},
},
// The same shape on the fact path, where the answer carries both halves
// of what was missing — the key and the value — in one breath.
{
name: "fact completed over two turns",
turns: []turn{
{say: "запиши", question: dialogue.SlotKey, attempt: 1,
parked: &parkedWant{slot: dialogue.SlotKey, attempt: 1}},
{say: "пил воду", contains: []string{"water"}},
},
end: endState{factKeys: []string{"water"}},
},
// An answer past the TTL is a new request, not an answer (V-385). She
// says the old one is gone and routes the words fresh. A bare time on
// its own carries no request, so the fresh routing lands on the canned
// reply — the point of the row is that NOTHING is created: a reminder
// here would fire with the subject of a request she had already let go.
{
name: "answer arrives after the TTL",
turns: []turn{
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
{say: "в 11:00", wait: clarifyTTL + time.Second, expired: true},
},
end: endState{},
},
// Three questions is the budget, and running out is SPOKEN: a mute
// give-up reads as "done" and he would wait for a reminder that was
// never set. The wording changes with the attempt (V-457).
{
name: "three unclear answers then the give-up line",
turns: []turn{
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
{say: "ну не знаю", question: dialogue.SlotTime, attempt: 2,
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2}},
{say: "ну не знаю", question: dialogue.SlotTime, attempt: 3,
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 3}},
{say: "ну не знаю", contains: []string{clarifyGaveUp}, noQuestion: true},
},
end: endState{},
},
// A correction points at the previous ACTED turn (repair.go): she redoes
// it under the intent he names and says so out loud, because a
// correction he cannot see is indistinguishable from one that was
// dropped. The task she filed first stays filed — repair redoes, it does
// not retract, and V-455 decided that deliberately.
//
// The corrected-to intent has to differ from the one she used, or repair
// declines: teaching the classifier the label it already produced is
// worse than doing nothing.
{
name: "correction of the previous turn",
turns: []turn{
{say: "добавь в задачи купить молоко", contains: []string{"купить молоко"}},
{say: "нет, это был вопрос", contains: []string{"поняла, это вопрос"}},
},
end: endState{tasks: []string{"купить молоко"}},
},
// He walks away from his own request: a question is parked, the next
// utterance is an unrelated request of its own, and nothing follows.
// V-554's fix is what makes this row pass — the question steps aside
// rather than scoring "добавь в задачи" as the time. The reminder is
// dropped in silence and that is the decision: if he meant it he says it
// again, and a question left armed eats the turn after next.
{
name: "abandoned flow: parked, then an unrelated request",
turns: []turn{
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
{say: "добавь в задачи купить молоко", contains: []string{"купить молоко"}},
{say: "спасибо"},
},
end: endState{tasks: []string{"купить молоко"}},
},
// ---- rows below carry the CORRECT expectation and fail today ----
// The owner's target transcript, V-561. He asks for a reminder, she asks
// when, he asks something else entirely, and then comes back to her
// question. On the box this created a reminder at 00:12 and never
// answered Rome; on the offline floor the side question is recognised as
// its own request and the flow is dropped instead, so the wrong reminder
// is not made and the right one is not either.
//
// Both are the same defect: there is no suspend and resume. The correct
// shape is the middle turn answered on its own and the parked question
// still standing, on the same attempt — a side query is not a failed
// answer and must not spend a retry.
//
// Unskipping this needs more than V-561. "на 9" and "на завтра" are not
// read by StubDateTimeParser, which is what the offline floor runs, so
// the row below it is the same shape in words the floor can parse and is
// the one to watch first.
{
name: "the owner's transcript from V-561",
skip: "V-561: a parked question is not suspended for a side query and never resumes",
turns: []turn{
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
{say: "какая сейчас погода в Риме?",
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "маме"}},
{say: "а, да, прости - на 9.",
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "маме"}},
{say: "на завтра."},
},
end: endState{reminders: []reminderWant{{payload: "позвонить маме", fireAt: "2026-08-01 09:00"}}},
},
// The same shape said in words StubDateTimeParser reads, so this row
// turns green on V-561 alone. Same three claims: Rome is answered, the
// question survives the side query on the same attempt, and the answer
// after it completes the reminder he actually asked for.
{
name: "nested question: a parked question, then one of his own",
skip: "V-561: a side query drops the parked question instead of suspending it",
turns: []turn{
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
{say: "какая сейчас погода в Риме?",
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "маме"}},
{say: "в 11:00", contains: []string{"11:00"}},
},
end: endState{reminders: []reminderWant{{payload: "позвонить маме", fireAt: "2026-07-31 11:00"}}},
},
// A cancel is one of the five turn roles V-560 names, and today it is
// none of them: "неважно" fills no slot and carries no request of its
// own, so it reads as a failed answer and spends a retry. Two turns
// later she is still asking about a reminder he called off.
//
// The row asserts what is knowable — nothing armed, nothing written, and
// not another question — rather than her wording for it, which is not
// written yet and is not this task's to invent.
{
name: "cancel: a parked question, then never mind",
skip: "V-560: a cancel is scored as a failed answer, not as a cancel",
turns: []turn{
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
{say: "неважно", noQuestion: true},
},
end: endState{},
},
// Order in runTurn is the whole arbitration (V-558), and this is what it
// costs: the clarify answer is checked at step 3 and the repair marker at
// step 4d, so while a question is parked no correction can be made. She
// scores "нет, это была заметка" as a bad time answer and asks again.
{
name: "correction while a question is parked",
skip: "V-560: clarify pre-empts the repair marker, so a correction cannot be spoken mid-flow",
turns: []turn{
{say: "добавь в задачи купить молоко", contains: []string{"купить молоко"}},
{say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1,
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
{say: "нет, это был вопрос", contains: []string{"поняла, это вопрос"},
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "маме"}},
},
end: endState{tasks: []string{"купить молоко"}},
},
// A reminder said whole, in one breath, with the hour in it — and she
// asks when. ReminderGrammar (stage0.go) builds its slots by hand and
// never runs the extractor, so a stage-0 reminder carries no time
// whatever the sentence says, and the clarify gate reads the gap as
// real. It costs a turn on the commonest reminder shape there is.
//
// Hermetic despite the date parser: stage 0 calls no parser at all, so
// this fails the same way with or without python dateparser installed.
{
name: "a reminder said whole is not asked about",
skip: "V-562: a stage-0 decision never meets the extractor, so its slots are never validated",
turns: []turn{
{say: "напомни в 11:00 позвонить маме", contains: []string{"11:00"}, noQuestion: true},
},
end: endState{reminders: []reminderWant{{payload: "позвонить маме", fireAt: "2026-07-31 11:00"}}},
},
// The same gap on the repair path. A correction redoes the request
// through finishClarified, which goes straight to applyAction — it never
// passes the clarify gate — so a redo that lands short answers with the
// parse error V-557 removed from the routing path: "не поняла, на когда
// напомнить." She should ask, exactly as she does for a fresh reminder
// with no time.
{
name: "a correction that lands short asks rather than failing",
skip: "V-562: finishClarified skips the clarify gate, so a repaired decision is never checked for gaps",
turns: []turn{
{say: "добавь в задачи купить молоко", contains: []string{"купить молоко"}},
{say: "нет, это было напоминание", contains: []string{"поняла, это напоминание"},
parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}},
},
end: endState{tasks: []string{"купить молоко"}},
},
}
}
+2
View File
@@ -350,6 +350,7 @@ func run(args []string) error {
getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return tl.morningStatus(ctx, time.Now()) },
getDayPlan: func(ctx context.Context) ipc.DayPlan { return tl.dayPlan(ctx, time.Now()) },
getEvents: intakeEventsFn(evBus),
getDecisions: turnDecisionsFn(voiceW),
seedStore: seedStoreIfAllowed(st),
nexus: nexusOf(voiceW),
}
@@ -619,6 +620,7 @@ func run(args []string) error {
getMorningStatus: func(ctx context.Context) []ipc.MorningRoutineStatus { return tl.morningStatus(ctx, time.Now()) },
getDayPlan: func(ctx context.Context) ipc.DayPlan { return tl.dayPlan(ctx, time.Now()) },
getEvents: intakeEventsFn(evBus),
getDecisions: turnDecisionsFn(voiceW),
seedStore: seedStoreIfAllowed(st),
}
if voiceW != nil && voiceW.handler != nil {
+54
View File
@@ -2,8 +2,11 @@ package main
import (
"context"
"errors"
"fmt"
"math"
"net/http"
"strings"
"testing"
"time"
@@ -31,6 +34,57 @@ func (f *fixedEmbedder) Embed(_ context.Context, text string) ([]float32, error)
return v, nil
}
// brokenEmbedder fails every call, which is what an ONNX session error looks
// like from the query chain's side.
type brokenEmbedder struct{}
func (brokenEmbedder) Dim() int { return 4 }
func (brokenEmbedder) Close() error { return nil }
func (brokenEmbedder) Embed(context.Context, string) ([]float32, error) {
return nil, errors.New("onnx: session failed")
}
// TestQueryEmbedFailureDoesNotStopTheChain — V-568. The embed source used to
// claim the turn on an embedder error, so one failing EmbedQuery answered every
// question below it with "не смогла ответить", including the ones the search
// answers without an embedder at all. A source that could not look must pass.
func TestQueryEmbedFailureDoesNotStopTheChain(t *testing.T) {
const q = "почему небо голубое"
h, _ := searchHandler(t, searchBody, http.StatusOK)
h.api = ipc.NewStoreAPI(newTestStore(t))
h.recall = recallWiring{embedder: brokenEmbedder{}, minScore: 0.55, minMargin: 0.008}
h.now = time.Now
// The embed source itself passes rather than claiming.
turn := &queryTurn{dec: router.Decision{Intent: router.IntentQuery, Utterance: q}}
if reply, ok := h.queryEmbed(context.Background(), turn); ok {
t.Fatalf("queryEmbed claimed the turn on an embedder error: %q", reply)
}
// And the whole chain still reaches the search below it.
reply := askQuery(t, h, q)
if !strings.Contains(reply, "рэлеевского рассеяния") {
t.Fatalf("reply = %q, want the search answer", reply)
}
}
// The recall sources read the empty vector the failed embed left behind, and
// neither of them may turn that into an answer: no vector means they could not
// look, which is not the same as looking and finding nothing.
func TestQueryRecallPassesWithoutAVector(t *testing.T) {
h, _ := buildRecallHandler(t, "где молоко", []recallCase{
{text: "молоко стоит в холодильнике", score: 0.90, kind: "note"},
})
turn := &queryTurn{dec: router.Decision{Intent: router.IntentQuery, Utterance: "где молоко"}}
if reply, ok := h.queryMemory(context.Background(), turn); ok {
t.Errorf("queryMemory claimed with no vector: %q", reply)
}
if reply, ok := h.queryNotes(context.Background(), turn); ok {
t.Errorf("queryNotes claimed with no vector: %q", reply)
}
}
// scoreVec builds a unit vector whose cosine against the query vector
// (1,0,0,0) is exactly score.
func scoreVec(score float64) []float32 {
+12
View File
@@ -23,6 +23,7 @@ type daemonAPI struct {
chatFn func(ctx context.Context, conversation, text string) string
getMCPServers func() []ipc.MCPServerStatus
getEvents func(n int) []ipc.IntakeEvent
getDecisions func(n int) []ipc.TurnDecision
// nexus — the identity client, nil when no nexus block is configured. It
// is what makes ResolveEntity answerable at all; without it the store
// adapter's refusal stands, and a surface that wanted an entity id says so
@@ -118,6 +119,17 @@ func (d *daemonAPI) TickTrace(ctx context.Context) (ipc.TickTrace, error) {
return toIPCTickTrace(*trace), nil
}
// TurnDecisions — the arbitration records of the last few turns (V-564). Nil
// getter means voice was never wired, and that is an empty list rather than an
// error: a box with no voice path has had no turns to arbitrate, which is not a
// fault and renders as an empty table.
func (d *daemonAPI) TurnDecisions(ctx context.Context, n int) ([]ipc.TurnDecision, error) {
if d.getDecisions == nil {
return nil, nil
}
return d.getDecisions(n), nil
}
func (d *daemonAPI) MorningStatus(ctx context.Context) ([]ipc.MorningRoutineStatus, error) {
if d.getMorningStatus == nil {
return nil, errors.New("mavend: morning status not available")
+251
View File
@@ -0,0 +1,251 @@
package main
import (
"strings"
"unicode"
"github.com/kami/maven/internal/dialogue"
"github.com/kami/maven/internal/lexicon"
"github.com/kami/maven/internal/router"
)
// turnRole — what this utterance IS, relative to the action Maven is in the
// middle of assembling. The five roles are the owner's vocabulary (Vikunja
// #558), plus the sixth answer a resolver is allowed to give: not_applicable,
// which hands the turn back to generic dispatch.
//
// It exists because the arbitration used to be ordering. The clarify resolver,
// the confirm gate, the follow-up merge and the repair marker all ran BEFORE
// the router, so the claimant holding conversational state decided what an
// utterance was without asking the one component whose job that is — and on
// 2026-08-05 "какая сейчас погода в Риме?" became the time of a reminder,
// because the extractor found "сейчас" in it and nothing looked at the rest.
//
// The rule that fixes that: a routed decision which stands on its own — its own
// intent, its own slots filled from its own words — is not an answer, whatever
// the extractor found inside it.
type turnRole string
const (
roleAnswer turnRole = "answer" // it fills the slot she asked about
roleCorrection turnRole = "correction" // it replaces a value she already had
roleSideQuery turnRole = "side_query" // a question of its own, asked mid-flow
roleNewRequest turnRole = "new_request" // a different request entirely
roleCancel turnRole = "cancel" // call the pending action off
roleNotApplicable turnRole = "not_applicable" // nothing is pending; not our turn
)
// frameWords — the words that can stand around a bare slot value without adding
// a request. Every member is a closed class from internal/lexicon: the frame
// itself, the interrogatives, the parts of a spoken clock, the days and the
// months. Assembled once; the sets are copies, so this cannot edit them.
var frameWords = buildFrameWords()
func buildFrameWords() map[string]bool {
out := make(map[string]bool)
add := func(list []string) {
for _, w := range list {
out[strings.ToLower(w)] = true
}
}
add(lexicon.SlotValueFrame())
add(lexicon.Interrogatives())
add(lexicon.PartsOfDay())
add(lexicon.HalfHourWords())
add(lexicon.DayOffsetWords())
for i := 0; i < 7; i++ {
out[lexicon.Weekday(i)] = true
}
for m := 1; m <= 12; m++ {
out[lexicon.MonthGenitive(m)] = true
}
for hh := 0; hh <= 23; hh++ {
add(strings.Fields(lexicon.HourSpoken(hh)))
}
return out
}
// cancelWords — the same, for the words that call the pending action off.
var cancelWords = buildCancelWords()
func buildCancelWords() map[string]bool {
out := make(map[string]bool)
for _, w := range lexicon.DialogueCancel() {
out[strings.ToLower(w)] = true
}
return out
}
// turnTokens splits an utterance the way the router's own predicates do: over
// letters and digits, lowercased, so punctuation and a clock's colon fall out.
func turnTokens(text string) []string {
return strings.FieldsFunc(strings.ToLower(text), func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
})
}
// ownContent lists the tokens of an utterance that are neither frame nor value:
// what it is about, over and above the thing she asked for. Numbers go out
// because a number is the commonest slot value there is, and the closed number
// and day lexicons go out with them.
//
// Empty ⇒ the utterance is a slot value and nothing else, however it is dressed
// up. That is the whole test, and it is what separates "а что если в 11:00" —
// which is a time, hedged — from "какая сейчас погода в Риме?", which leaves
// "погода" and "риме" behind and is therefore about something.
func ownContent(text string) []string {
var out []string
for _, tok := range turnTokens(text) {
if frameWords[tok] || lexicon.IsFillerParticle(tok) {
continue
}
if _, ok := lexicon.Cardinal(tok); ok {
continue
}
if _, ok := lexicon.Ordinal(tok); ok {
continue
}
if isNumeric(tok) {
continue
}
out = append(out, tok)
}
return out
}
func isNumeric(tok string) bool {
for _, r := range tok {
if !unicode.IsDigit(r) {
return false
}
}
return tok != ""
}
// isCancel reports whether the utterance is nothing but a call-off. Every
// content token has to be a cancel word, so "забудь" ends the exchange and
// "забудь купить молоко" does not.
func isCancel(text string) bool {
content := ownContent(text)
if len(content) == 0 {
return false
}
for _, tok := range content {
if !cancelWords[tok] {
return false
}
}
return true
}
// carriesOwnRequest reads the ROUTED decision for the thing that decides this:
// does the utterance ask for something in its own right? Each intent is asked
// the question in its own terms, because "its own slots filled from its own
// words" means a different field for each of them.
//
// A query or a system question needs no further evidence — the router already
// read a question in these words. The write intents need the verb or the slot
// that names the request, so a bare value the router guessed a home for does
// not count as one.
func carriesOwnRequest(dec router.Decision, text string) bool {
if dec.Clarify {
// The router itself was unsure. An utterance she could not route is
// not an utterance that outranks the question in front of it.
return false
}
switch dec.Intent {
case router.IntentQuery, router.IntentSystem:
return true
case router.IntentReminder:
return carriesReminderVerb(text)
case router.IntentFact, router.IntentNote:
return router.CarriesCaptureVerb(text)
case router.IntentAct:
// An act that resolved to a capability is a command. One that did not
// is words she cannot execute anyway, so it stays an answer and gets
// re-asked — the same thing that happens to it today.
return dec.Slots.HasFn
default: // chat
return false
}
}
// carriesReminderVerb — "напомни" and its forms, matched over tokens. The
// reminder verbs are a closed lexicon and are not capture verbs, so
// CarriesCaptureVerb never sees them.
func carriesReminderVerb(text string) bool {
toks := turnTokens(text)
for _, v := range lexicon.ReminderVerbs() {
for _, t := range toks {
if t == strings.ToLower(v) {
return true
}
}
}
return false
}
// offlineOwnRequest is the shape half of the evidence: the offline token tests,
// which cost nothing and never depend on the model that produced the routing.
// It is also the whole answer when there is no route to read — the classifier
// is the failure floor and a turn must never break on the model.
func offlineOwnRequest(text string) bool {
return router.IsQuestionShaped(text) || router.CarriesCaptureVerb(text) || carriesReminderVerb(text)
}
// classifyTurnRole decides what this utterance is against the pending action.
//
// The fast path is the first line and it is a fast path to the SAME answer, not
// a second decision procedure: an utterance with no content of its own can
// never be a request of its own, so it can never be anything but an answer, and
// the route below would spend a second on the resident model to say so. Every
// other utterance is routed first, and the role is read off the decision.
//
// `routed` is the turn's routing, already computed; ok is false when there was
// none to compute (no router wired, or the route failed). A failed route falls
// to the offline shape tests rather than breaking the turn.
func classifyTurnRole(q *dialogue.PendingQuestion, text string, answer dialogue.Slots, routed router.Decision, ok bool) turnRole {
if isCancel(text) {
return roleCancel
}
// Two pieces of evidence, and the content gate in front of both. The shape
// tests are the floor and answer for free; the route is what sees a request
// with no shape to it — "погода в риме" asks a question and carries neither
// a question mark nor an interrogative, and only the router knows that.
own := false
if len(ownContent(text)) > 0 {
own = offlineOwnRequest(text) || (ok && carriesOwnRequest(routed, text))
}
if !own {
if replacesFilledSlot(q, answer) {
return roleCorrection
}
return roleAnswer
}
if router.IsQuestionShaped(text) || (ok && (routed.Intent == router.IntentQuery || routed.Intent == router.IntentSystem)) {
return roleSideQuery
}
return roleNewRequest
}
// replacesFilledSlot reports whether the utterance overwrites something the
// pending action already had, rather than filling the gap she asked about —
// "нет, на девять" while she is waiting for the subject. Both are handled the
// same way (dialogue.Answer already prefers the newer value), so this only
// names the turn honestly for the log and for the decision trace V-564 adds.
func replacesFilledSlot(q *dialogue.PendingQuestion, answer dialogue.Slots) bool {
if q == nil {
return false
}
asked := make(map[dialogue.Slot]bool, len(q.Missing))
for _, s := range q.Missing {
asked[s] = true
}
if answer.HasTime && q.Slots.HasTime && !asked[dialogue.SlotTime] && !answer.Time.Equal(q.Slots.Time) {
return true
}
if answer.HasKey && q.Slots.HasKey && !asked[dialogue.SlotKey] && answer.Key != q.Slots.Key {
return true
}
return false
}
+233
View File
@@ -0,0 +1,233 @@
package main
import (
"context"
"strings"
"testing"
"time"
"github.com/kami/maven/internal/dialogue"
"github.com/kami/maven/internal/router"
)
// TestOwnContentSeparatesAValueFromAQuestion pins the test the whole role
// classifier rests on: after the frame, the numbers and the closed time sets
// come out, does anything of his own remain? A hedged time leaves nothing. A
// question about the weather leaves the weather.
func TestOwnContentSeparatesAValueFromAQuestion(t *testing.T) {
cases := []struct {
text string
own bool
}{
{"в 11:00", false},
{"в семь вечера", false},
{"нет, в 15:00", false},
{"а что если в 11:00", false},
{"на 9", false},
{"а, да, прости — на 9", false},
{"завтра", false},
{"в половине восьмого", false},
{"какая сейчас погода в Риме?", true},
{"кто изобрёл телефон", true},
{"напомни в 11:00", true},
{"позвонить маме", true},
{"запиши что я пил воду", true},
}
for _, tc := range cases {
if got := len(ownContent(tc.text)) > 0; got != tc.own {
t.Errorf("ownContent(%q) = %v, want own content = %v", tc.text, ownContent(tc.text), tc.own)
}
}
}
// TestCancelIsTheWholeUtterance — a call-off calls the request off, and a
// sentence that merely contains the word does not.
func TestCancelIsTheWholeUtterance(t *testing.T) {
for _, yes := range []string{"отмена", "забудь", "неважно", "проехали", "cancel", "ой, отмена"} {
if !isCancel(yes) {
t.Errorf("isCancel(%q) = false, want true", yes)
}
}
for _, no := range []string{"забудь купить молоко", "в 11:00", "позвонить маме", ""} {
if isCancel(no) {
t.Errorf("isCancel(%q) = true, want false", no)
}
}
}
// TestTurnRoleReadsTheRoutedDecision — the inversion itself. The same utterance
// gets a different role depending on what the router made of it, which is the
// evidence the old guard never had.
func TestTurnRoleReadsTheRoutedDecision(t *testing.T) {
q := &dialogue.PendingQuestion{
Intent: dialogue.Intent(router.IntentReminder),
Missing: []dialogue.Slot{dialogue.SlotTime},
}
dec := func(in router.Intent, s router.Slots) router.Decision {
return router.Decision{Intent: in, Slots: s}
}
cases := []struct {
name string
text string
routed router.Decision
ok bool
answer dialogue.Slots
want turnRole
}{
{
// The measured defect. The extractor finds "сейчас" and would have
// closed the gap with it; the route says this is a question of its
// own, and the question wins.
name: "a world question mid-flow is a side query",
text: "какая сейчас погода в Риме?",
routed: dec(router.IntentQuery, router.Slots{Text: "какая сейчас погода в Риме?"}),
ok: true,
answer: dialogue.Slots{HasTime: true, Time: time.Now()},
want: roleSideQuery,
},
{
name: "a hedged time is an answer even routed as a query",
text: "а что если в 11:00",
routed: dec(router.IntentQuery, router.Slots{Text: "а что если в 11:00"}),
ok: true,
answer: dialogue.Slots{HasTime: true, Time: time.Now()},
want: roleAnswer,
},
{
name: "a fresh reminder is a new request",
text: "напомни завтра позвонить маме",
routed: dec(router.IntentReminder, router.Slots{Text: "позвонить маме", HasTime: true}),
ok: true,
want: roleNewRequest,
},
{
name: "a capture is a new request",
text: "запиши что я пил воду",
routed: dec(router.IntentFact, router.Slots{Key: "water", HasKey: true}),
ok: true,
want: roleNewRequest,
},
{
name: "an act that resolved to a capability is a new request",
text: "выключи свет в спальне",
routed: dec(router.IntentAct, router.Slots{Fn: "light_off", HasFn: true}),
ok: true,
want: roleNewRequest,
},
{
// She could not route it. An utterance she did not understand does
// not outrank the question in front of it.
name: "a clarify decision is not a request of its own",
text: "выключи свет",
routed: router.Decision{Intent: router.IntentAct, Slots: router.Slots{Fn: "light_off", HasFn: true}, Clarify: true},
ok: true,
want: roleAnswer,
},
{
name: "a bare noun that answers nothing is still an answer",
text: "ага",
ok: false,
want: roleAnswer,
},
{
name: "no route to read falls back to the shape",
text: "кто изобрёл телефон",
ok: false,
want: roleSideQuery,
},
{
name: "a call-off needs no route at all",
text: "отмена",
ok: false,
want: roleCancel,
},
}
for _, tc := range cases {
if got := classifyTurnRole(q, tc.text, tc.answer, tc.routed, tc.ok); got != tc.want {
t.Errorf("%s: classifyTurnRole(%q) = %s, want %s", tc.name, tc.text, got, tc.want)
}
}
}
// TestTurnRoleNamesACorrection — the answer overwrites a slot she was not
// asking about. Handled like an answer, named as what it is.
func TestTurnRoleNamesACorrection(t *testing.T) {
nine := time.Date(2026, 8, 6, 9, 0, 0, 0, time.UTC)
q := &dialogue.PendingQuestion{
Intent: dialogue.Intent(router.IntentReminder),
Missing: []dialogue.Slot{dialogue.SlotText},
Slots: dialogue.Slots{HasTime: true, Time: nine.Add(2 * time.Hour)},
}
got := classifyTurnRole(q, "нет, на 9", dialogue.Slots{HasTime: true, Time: nine}, router.Decision{}, false)
if got != roleCorrection {
t.Fatalf("role = %s, want %s", got, roleCorrection)
}
}
// TestRomeIsAnsweredAndTheReminderIsNotInvented — the measured failure of
// 2026-08-05, end to end through the real cascade. "напомни позвонить маме"
// parks the time question; the weather question that follows must not become
// its answer, must not create a reminder for a time nobody asked for, and must
// not be dropped in silence.
func TestRomeIsAnsweredAndTheReminderIsNotInvented(t *testing.T) {
ctx := context.Background()
h, st := newRoutingClarifyHandler(t)
if reply := h.handleText(ctx, "web", "напомни позвонить маме"); !strings.Contains(reply, "?") {
t.Fatalf("expected the time question, got %q", reply)
}
reply := h.handleText(ctx, "web", "какая сейчас погода в Риме?")
if strings.Contains(reply, "напомню") {
t.Fatalf("the question was eaten as the reminder's time again: %q", reply)
}
if !strings.HasPrefix(reply, clarifyDropped) {
t.Fatalf("the parked request died without a word: %q", reply)
}
if reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)); err != nil || len(reminders) != 0 {
t.Fatalf("a reminder was invented for a time nobody asked for: %v err=%v", reminders, err)
}
if h.clarifyStore.Get(dialogueIDFor(sourceText, "web"), h.now()) != nil {
t.Fatal("the parked question must be gone, not left to eat the next turn")
}
}
// TestClarifyCancelEndsTheExchange — "отмена" while she is waiting calls the
// half-built request off, out loud, and creates nothing.
func TestClarifyCancelEndsTheExchange(t *testing.T) {
ctx := context.Background()
h, st := newRoutingClarifyHandler(t)
if reply := h.handleText(ctx, "web", "напомни позвонить маме"); !strings.Contains(reply, "?") {
t.Fatalf("expected the time question, got %q", reply)
}
if reply := h.handleText(ctx, "web", "отмена"); reply != clarifyCancelled {
t.Fatalf("reply = %q, want %q", reply, clarifyCancelled)
}
if reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)); err != nil || len(reminders) != 0 {
t.Fatalf("a cancelled request still landed: %v err=%v", reminders, err)
}
if h.clarifyStore.Get(dialogueIDFor(sourceText, "web"), h.now()) != nil {
t.Fatal("a cancelled exchange must leave nothing parked")
}
}
// TestTheTurnIsRoutedOnce — the cost bound. A turn with a question parked pays
// for one extra route and not two: the clarify resolver and the pipeline read
// the same memo.
func TestTheTurnIsRoutedOnce(t *testing.T) {
h, _ := newRoutingClarifyHandler(t)
rt := h.newTurnRoute("какая сейчас погода в Риме?", h.now())
ctx := withTurnRoute(withDialogueID(context.Background(), voiceDialogueID), rt)
first, ok := h.routeForRole(ctx, rt.text)
if !ok {
t.Fatal("the cascade must produce a decision to classify against")
}
second, _, _, err := rt.resolve(ctx)
if err != nil {
t.Fatalf("resolve: %v", err)
}
if second.Intent != first.Intent || second.Utterance != first.Utterance {
t.Fatalf("the pipeline routed again and got something else: %+v vs %+v", second, first)
}
}
+104
View File
@@ -0,0 +1,104 @@
package main
import (
"context"
"log"
"sync"
"time"
"github.com/kami/maven/internal/dialogue"
"github.com/kami/maven/internal/router"
)
// turnRoute is this turn's routing, computed at most once.
//
// It exists because the arbitration was inverted (Vikunja #560): the clarify
// resolver now reads the routed decision before deciding what the utterance is,
// and the pipeline then acts on that same decision. Routing twice would cost a
// second on the resident model and — worse — could disagree with itself, which
// is exactly the class of bug this task is about.
type turnRoute struct {
h *reactiveHandler
text string
now time.Time
once sync.Once
dec router.Decision
cont bool
prev *dialogue.Session
err error
// dropped — what she let go of this turn and must say out loud. A parked
// request that dies without a word leaves him thinking it landed.
dropped string
}
type turnRouteKey struct{}
func (h *reactiveHandler) newTurnRoute(text string, now time.Time) *turnRoute {
return &turnRoute{h: h, text: text, now: now}
}
func withTurnRoute(ctx context.Context, rt *turnRoute) context.Context {
return context.WithValue(ctx, turnRouteKey{}, rt)
}
// turnRouteFrom returns the turn's memo, or nil when the caller is not inside
// runTurn — a unit test calling one resolver directly, most often.
func turnRouteFrom(ctx context.Context) *turnRoute {
rt, _ := ctx.Value(turnRouteKey{}).(*turnRoute)
return rt
}
// resolve does the routing exactly as step 5 of runTurn does it: an elliptical
// follow-up is answered from the previous turn, everything else goes to the
// router. One copy of that, so the pre-route the clarify resolver reads and the
// decision the pipeline acts on cannot drift apart.
func (r *turnRoute) resolve(ctx context.Context) (router.Decision, bool, *dialogue.Session, error) {
r.once.Do(func() {
if r.h.dialogueSessions != nil {
r.prev = r.h.dialogueSessions.Get(dialogueIDOf(ctx), r.now)
}
if dec, cont := continuationDecision(r.prev, r.text, r.now); cont {
log.Printf("voice: continuation of %s from the previous turn", dec.Intent)
r.dec, r.cont = dec, true
return
}
if r.h.router == nil {
r.err = router.ErrNoIntents
return
}
r.dec, r.err = r.h.router.Route(ctx, r.text, r.now)
})
return r.dec, r.cont, r.prev, r.err
}
// routeForRole gives the role classifier the turn's routed decision. The second
// return is false when there is no usable decision — no router wired, or the
// route failed — and the classifier falls back to its offline tests then. A
// turn must never break on the model, so the error is logged and swallowed
// here; step 5 reads the same memo and reports it the way it always has.
func (h *reactiveHandler) routeForRole(ctx context.Context, text string) (router.Decision, bool) {
rt := turnRouteFrom(ctx)
if rt == nil {
rt = h.newTurnRoute(text, h.now())
}
dec, _, _, err := rt.resolve(ctx)
if err != nil {
log.Printf("voice: role — no route to classify against (%v), falling back to the offline tests", err)
return router.Decision{}, false
}
return dec, true
}
// needsRoute reports whether classifying this utterance's role is worth a
// route. It is not: an utterance with no content of its own carries no request
// of its own, so the classifier reaches the same answer without the model. A
// call-off is the same — it is read off a closed lexicon and nothing else.
//
// This is a fast path to the SAME answer and must stay one. If it ever needs a
// rule the classifier does not have, it has become a second decision procedure
// and it is the thing V-560 deleted.
func needsRoute(text string) bool {
return !isCancel(text) && len(ownContent(text)) > 0
}
+58 -28
View File
@@ -53,6 +53,7 @@ import (
"github.com/kami/maven/internal/audio"
"github.com/kami/maven/internal/crawl"
"github.com/kami/maven/internal/decision"
"github.com/kami/maven/internal/dialogue"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/lexicon"
@@ -137,6 +138,13 @@ type reactiveHandler struct {
// slot per reach, not one for the box. nil ⇒ no carry-over.
dialogueSessions *dialogue.SessionStore
// decisions holds the last few turns' arbitration records (V-564): who
// claimed the turn, who lost it and who was never asked. In memory and
// bounded, because a turn record is read minutes later or never, and none
// of his words belong in a table that outlives the diagnosis. nil ⇒ nothing
// is recorded, which is what a test that did not ask for one gets.
decisions *decision.Ring
// clarifyStore parks the request behind an open question she asked (see
// clarify.go). nil ⇒ she falls back to the canned "не поняла" reply.
clarifyStore *dialogue.ClarifyStore
@@ -248,6 +256,26 @@ const (
//
// The ordering is load-bearing — see the step comments.
func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSource) string {
// 0. the decision record (V-564). Installed here rather than in the IPC
// entry point, so the mic, telegram and the web all leave the same trail —
// a record only the web produced would be missing exactly the turns that
// are hardest to reproduce. It rides the context, costs a few dozen structs
// on a human-rate path, and no claim site can change a route with it.
if h.decisions != nil {
var rec *decision.Record
ctx, rec = decision.With(ctx, text)
decision.Expect(ctx, decision.StagePreRoute, preRouteLadder)
defer func() { h.decisions.Push(rec.Finish(h.now())) }()
}
// 0b. the turn's routing, computed at most once and shared (Vikunja #560).
// The clarify resolver reads it to decide what this utterance IS before
// claiming it, and step 5 acts on the same decision — routing twice would
// cost a second on the resident model and could disagree with itself.
now := h.now()
rt := h.newTurnRoute(text, now)
ctx = withTurnRoute(ctx, rt)
// 1. expired clarify — a question was parked but its TTL ran out, so the
// request behind it is gone. Say that out loud (see clarify.go) and carry
// on: these words are still routed as a fresh utterance below, with the
@@ -263,7 +291,7 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
// 2. confirm turn — if a destructive act is parked, this utterance is its
// y/n answer, not a fresh command. Handled before routing so "да" doesn't
// get classified as some other intent.
if reply, handled := h.resolveConfirm(ctx, text); handled {
if reply, handled := h.resolveConfirm(ctx, text); notePreRoute(ctx, "confirm", handled) {
return withNotice(expiredNotice, reply)
}
@@ -275,15 +303,19 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
// so the notice is empty here in practice. withNotice anyway: every exit
// from runTurn carries it, and that is what stops the next one from
// forgetting.
if reply, handled := h.resolveClarifyAnswer(ctx, text); handled {
if reply, handled := h.resolveClarifyAnswer(ctx, text); notePreRoute(ctx, "clarify-answer", handled) {
return withNotice(expiredNotice, reply)
}
// It did not claim the turn. If it let a parked request go to get out of the
// way, that has to be said in front of whatever these words are answered
// with — carried on the same notice, so every exit below keeps it.
expiredNotice = withNotice(expiredNotice, rt.dropped)
// 4. quiet-hours toggle — keyword match, not classifier-dependent.
// "тихий режим" / "quiet on" would route through the classifier
// unreliably (it's a command, not a free-form query), so we match it
// before routing. Same pattern as the confirm turn above.
if reply, handled := h.resolveQuietToggle(ctx, text, src); handled {
if reply, handled := h.resolveQuietToggle(ctx, text, src); notePreRoute(ctx, "quiet-toggle", handled) {
return withNotice(expiredNotice, reply)
}
@@ -291,14 +323,14 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
// sent. Only handled when a pending nudge is actually inside the window
// (snooze.go); otherwise the words route normally, because "потом" is an
// ordinary word and eating every one of them would break real sentences.
if reply, handled := h.resolveSnooze(ctx, text, src); handled {
if reply, handled := h.resolveSnooze(ctx, text, src); notePreRoute(ctx, "snooze", handled) {
return withNotice(expiredNotice, reply)
}
// 4c. spoken ack — "готово" closes that same nudge as `acted`. Only the
// contentless form is intercepted here; "выпил воды" keeps routing and
// closes the nudge after its fact lands (ackFromFact, step 8b).
if reply, handled := h.resolveAck(ctx, text, src); handled {
if reply, handled := h.resolveAck(ctx, text, src); notePreRoute(ctx, "ack", handled) {
return withNotice(expiredNotice, reply)
}
@@ -306,7 +338,7 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
// turn and names what it should have been (repair.go). Before routing,
// like the confirm and clarify turns: routing the correction as a fresh
// utterance files the correction itself instead of fixing anything.
if reply, handled := h.resolveRepair(ctx, text); handled {
if reply, handled := h.resolveRepair(ctx, text); notePreRoute(ctx, "repair", handled) {
return withNotice(expiredNotice, reply)
}
@@ -314,7 +346,7 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
// just read (ordinal.go). Before routing, and only when a list is actually
// bound to the session: with nothing offered, "второй" is an ordinary word
// and keeps routing.
if reply, handled := h.resolveCandidate(ctx, text, src); handled {
if reply, handled := h.resolveCandidate(ctx, text, src); notePreRoute(ctx, "ordinal", handled) {
return withNotice(expiredNotice, reply)
}
@@ -323,22 +355,7 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
// missing, so no amount of routing recovers it, and the model's guess
// costs seconds to obtain and is close to a coin flip. Everything else
// goes to the router.
var (
dec router.Decision
err error
prev *dialogue.Session
)
now := h.now()
if h.dialogueSessions != nil {
prev = h.dialogueSessions.Get(dialogueIDOf(ctx), now)
}
cont := false
if dec, cont = continuationDecision(prev, text, now); cont {
log.Printf("voice: continuation of %s from the previous turn", dec.Intent)
}
if !cont {
dec, err = h.router.Route(ctx, text, now)
}
dec, cont, prev, err := rt.resolve(ctx)
if err != nil {
// ErrNoIntents ⇒ classifier unseeded (cold boot). reply with a
// "still warming up" rather than a wire error.
@@ -359,21 +376,31 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
// ("а завтра?" … "а послезавтра?") keeps working.
if h.dialogueSessions != nil {
if !cont {
dec = followUpMerge(prev, dec, now)
merged := followUpMerge(prev, dec, now)
noteMerge(ctx, dec, merged)
dec = merged
}
if !dec.Clarify {
h.rememberTurn(ctx, prev, dec, now)
}
}
// 7. clarify — she is not sure. If one named thing is missing, ask about it
// and park the request (clarify.go); otherwise the replier's canned reply
// stands.
if dec.Clarify {
// 7. clarify — something she needs is missing. If one named thing is missing,
// ask about it and park the request (clarify.go); otherwise the replier's
// canned reply stands.
//
// Not gated on dec.Clarify alone (Vikunja #557). A turn the cascade routed
// confidently but incompletely skipped this entirely: "напомни позвонить"
// reached applyAction, failed on the missing time, parked nothing, and the
// "в семь вечера" that followed was web-searched as a world question. A
// required slot that missingFor names is a gap whatever the confidence.
if dec.Clarify || len(missingFor(dec)) > 0 {
if reply := h.hexisBeforeClarify(ctx, dec); reply != "" {
return withNotice(expiredNotice, reply)
}
if question, asked := h.askClarify(ctx, dec); asked {
noteTerminal(ctx, "clarify-ask", dec.Intent,
"the route was below the threshold, so she asked instead of acting")
return withNotice(expiredNotice, question)
}
}
@@ -390,6 +417,9 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
// the round-trip stays alive.
replyText := h.applyAction(ctx, dec)
log.Printf("voice: applyAction returned: %q", replyText)
// A query turn was already claimed by a source inside the chain; every other
// intent has no chain and no scoreboard, so the handler is the winner.
noteTerminal(ctx, "action-handler", dec.Intent, "")
// 8b. a fact that answers a live nudge closes it as `acted` (ack.go).
// Silent: the fact reply stands, she does not congratulate him for it.
+10 -1
View File
@@ -11,6 +11,7 @@ import (
"time"
"github.com/kami/maven/internal/config"
"github.com/kami/maven/internal/decision"
"github.com/kami/maven/internal/delivery"
"github.com/kami/maven/internal/delivery/voicesink"
"github.com/kami/maven/internal/dialogue"
@@ -293,7 +294,11 @@ func wireVoice(cfg *config.Config, coreAPI ipc.CoreAPI, phr phraser.Phraser, mem
},
dataStore: dataStore,
dialogueSessions: dialogueSessions,
clarifyStore: clarifyStore,
// Always on (V-564). The record is the instrument the rest of V-558 is
// measured with, and one that only runs when a flag is set is not there
// on the night the misroute happens.
decisions: decision.NewRing(),
clarifyStore: clarifyStore,
// 0 here (unset config) ⇒ the dialogue default.
clarifyMaxAttempts: cfg.Voice.ClarifyMaxAttempts,
extractor: router.Extractor{Time: timeParser, Acts: matcher, Facts: router.DefaultFactParser{}},
@@ -404,6 +409,10 @@ func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64,
// board noun), and before the capture marker, which would otherwise read
// "убери из задач купить молоко" as a new task (Vikunja #512).
grammars = append(grammars, router.TaskStatusGrammar())
// Before the capture markers, which all need an object. A capture verb
// alone is a fact with no key, and the clarify path asks for it rather than
// letting the model invent an answer (Vikunja #557).
grammars = append(grammars, router.BareCaptureGrammar()...)
grammars = append(grammars, router.TaskCaptureGrammar())
// After the capture marker, so "запиши" still wins over "расскажи", and
// last overall because it matches on the first word alone: "расскажи про
+40
View File
@@ -65,6 +65,7 @@ type fakeCore struct {
// for handleTrace tests
tickTrace ipc.TickTrace
traceErr error
turns []ipc.TurnDecision
// for handleChatAPI tests
chatText string
@@ -173,6 +174,10 @@ func (f *fakeCore) RevertFact(_ context.Context, key string) (int64, error) {
return f.revertNewID, nil
}
func (f *fakeCore) TurnDecisions(_ context.Context, _ int) ([]ipc.TurnDecision, error) {
return f.turns, nil
}
func (f *fakeCore) TickTrace(_ context.Context) (ipc.TickTrace, error) {
if f.traceErr != nil {
return ipc.TickTrace{}, f.traceErr
@@ -825,6 +830,41 @@ func TestHandleTrace(t *testing.T) {
t.Error("rendered 'nothing fired' but a winner was set")
}
})
// The turn arbitration shares this page (V-564). A reader must see the
// winner, a loser and the claimants that were never asked, because the last
// of those is what the hardcoded ordering hides.
t.Run("renders the turn decision record", func(t *testing.T) {
core := &fakeCore{turns: []ipc.TurnDecision{{
Ts: time.Date(2025, 6, 1, 12, 0, 0, 0, time.UTC),
Utterance: "какая погода в риме",
Winner: "query:weather",
Claims: []ipc.TurnClaim{
{Stage: "query", Claimant: "weather", Intent: "query", Outcome: "won"},
{Stage: "query", Claimant: "calendar", Outcome: "declined", Reason: "no answer"},
{Stage: "query", Claimant: "kiwix", Outcome: "never_asked"},
},
}}}
rr := httptest.NewRecorder()
handleTrace(rr, httptest.NewRequest(http.MethodGet, "/trace", nil), core)
body := rr.Body.String()
for _, want := range []string{"какая погода в риме", "query:weather", "calendar", "kiwix", "never_asked"} {
if !strings.Contains(body, want) {
t.Errorf("rendered page is missing %q", want)
}
}
})
t.Run("no turns renders the empty note, not an error", func(t *testing.T) {
rr := httptest.NewRecorder()
handleTrace(rr, httptest.NewRequest(http.MethodGet, "/trace", nil), &fakeCore{})
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
if !strings.Contains(rr.Body.String(), "no turn has run") {
t.Error("empty ring did not render its note")
}
})
}
// --- handleRevert ---
+18 -1
View File
@@ -1415,12 +1415,29 @@ func handleTrace(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
http.Error(w, "core read failed", http.StatusBadGateway)
return
}
// The turn records share this page rather than getting one of their own
// (V-564): both answer the same question — who won, who lost and why — and
// one is about nudges while the other is about utterances. A read failure
// here is not fatal to the page: the rule trace above it still renders, and
// a daemon too old to know the method is the ordinary case during a rolling
// deploy.
turns, err := core.TurnDecisions(ctx, 25)
if err != nil {
log.Printf("trace: turn decisions: %v", err)
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if err := traceTmpl.Execute(w, trace); err != nil {
if err := traceTmpl.Execute(w, traceData{Tick: trace, Turns: turns}); err != nil {
log.Printf("trace render: %v", err)
}
}
// traceData — what trace.html renders: the last tick's rule arbitration and the
// last turns' claim arbitration.
type traceData struct {
Tick ipc.TickTrace
Turns []ipc.TurnDecision
}
func handleMorning(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
if core == nil {
http.Error(w, "morning disabled (no -core)", http.StatusServiceUnavailable)
+22 -2
View File
@@ -1,9 +1,9 @@
{{template "shellTop" "trace"}}
<h1>Rule Trace</h1>
<div class="hint mb-4">{{.Now | ago}} — winner: <strong>{{if .Winner}}{{.Winner}}{{else}}nothing fired{{end}}</strong></div>
<div class="hint mb-4">{{.Tick.Now | ago}} — winner: <strong>{{if .Tick.Winner}}{{.Tick.Winner}}{{else}}nothing fired{{end}}</strong></div>
<div class=scroll><table class=mono>
<tr><th>rule<th>sev<th>predicate<th>gate<th>blocked by<th>detail<th>selected<th>lost to</tr>
{{range .Rules}}<tr>
{{range .Tick.Rules}}<tr>
<td>{{.RuleName}}</td>
<td>{{.Severity}}</td>
<td class={{if .PredicateResult}}green{{else}}gray{{end}}>{{.PredicateResult}}</td>
@@ -21,5 +21,25 @@
<td>{{.LostTo}}</td>
</tr>{{end}}
</table></div>
<h1 class=mt-4>Turn Decisions</h1>
<div class="hint mb-4">Who claimed each utterance, who lost it, and who was never asked. In memory, newest first, cleared on restart.</div>
{{if not .Turns}}<div class=hint>no turn has run since the daemon started</div>{{end}}
{{range .Turns}}
<details class=mb-4>
<summary><span class=mono>{{.Utterance}}</span><strong>{{if .Winner}}{{.Winner}}{{else}}nobody{{end}}</strong> <span class=hint>{{.Ts | ago}}</span></summary>
<div class=scroll><table class=mono>
<tr><th>stage<th>claimant<th>would have been<th>score<th>outcome<th>why</tr>
{{range .Claims}}<tr>
<td>{{.Stage}}</td>
<td>{{.Claimant}}</td>
<td>{{if .Intent}}{{.Intent}}{{else}}—{{end}}</td>
<td>{{if .HasScore}}{{printf "%.3f" .Score}}{{else}}—{{end}}</td>
<td class={{if eq .Outcome "won"}}green{{else if eq .Outcome "never_asked"}}red{{else}}gray{{end}}>{{.Outcome}}</td>
<td>{{.Reason}}</td>
</tr>{{end}}
</table></div>
</details>
{{end}}
{{template "shellBottom"}}
</html>
+135
View File
@@ -0,0 +1,135 @@
# Plan: dialogue arbitration, one channel and many claimants
Umbrella V-558. This file collects the design for its children.
Last verified: 06-08-2026 @ b6305f1
## A common unit for claims on an utterance (V-565)
**Verdict: four ordinal bands, and the band is the tie-break rather than the decision.
Coverage decides first.** The measurement below says no claimant Maven has today can produce
a graded confidence. A float would be an invention either way. What is available is the KIND
of evidence a claimant holds, and there are exactly four kinds.
### What the claimants report today
Measured 06-08-2026 on the 91-case RU fixture (`internal/router/eval`), through the deployed
cascade with the quantized multilingual-e5-small embedder. The harness is
`TestONNXClaimConfidenceDistribution` and `TestStage0Contention` in
`internal/router/eval/claims_test.go`. Correct means the right intent, or a refusal where the
fixture wants one. Slots are excluded, because a slot miss is a parser question and would
blur what the number is being asked to predict.
| Claimant | Values it can emit | Distribution on the fixture | Correct |
|---|---|---|---|
| Stage 0 grammars, 21 of them | `1.0`, always | claimed 20 of 91 cases | 20/20 (100%) |
| Classifier, cosine | continuous in principle | observed range 0.859 to 0.942 over 71 cases | 44/71 (62%) |
| LLM router | `1.0` or `0.3`, nothing between | not run here, no llama-server | see below |
| Query sources, 22 of them | a bool | not routed by the fixture | n/a |
| Stateful four | nothing at all | n/a | n/a |
Four findings, and each one constrains the band set.
**The classifier's cosine carries no signal about correctness.** It scores 62% below the
median and 62% above it. That is 13/21 in 0.8 to 0.9, and 31/50 in 0.9 to 1.0. The spread is
0.083 wide. Every case sits above the 0.55 threshold, so the gate never fires here. A number
flat against correctness, which never crosses its own gate, is not a confidence.
**Nor does the margin between its top two intents.** Top1 minus top2 is min 0.000, p50
0.009, max 0.025. Sixty-eight of the 71 classified cases sit under 0.02 and score 60%. Three
clear 0.02 and score 3/3, which is a sample of three. So the ledger's question is answered:
a calibrated float is NOT cheaply available from the classifier alone. Nearest-centroid over
frozen seeds ranks intents, and the ranking is decided in the third decimal place. It can say
which intent is nearest. It cannot say how near.
**Stage 0 asserts 1.0 by fiat, and on this fixture the fiat is right.** Twenty of twenty.
That is not evidence that a hand-written anchored pattern is always right. It is evidence
that anchored and nearest are different kinds of claim, and must not share a scale. The gap
is 100% against 62% on the same 91 utterances.
**Stage 0 contention is rarer than the list order suggests.** Exactly one case of 91 draws
two grammars. That is `ru-query-019`, where `calendar-query` and `agenda-query` both match,
and `calendar-query` wins because it is earlier in `buildRouter`. Both would route
`IntentQuery`, so the ordering costs nothing there. The finding is not that ordering is
harmless. It is that the fixture barely exercises what V-558 is about. Part of what a claim
object buys is making the contention countable.
**The LLM router emits two values, and one of them is not a confidence.** `llmFullConfidence`
is 1.0 and `llmThinConfidence` is 0.3. `gateLLMDecision` moves a decision to 0.3 through
three named arms. A fact with no key, an act with no allowlisted fn, a reminder with no
subject. Each is a self-veto with a reason, flattened into a number that then loses the
reason. Both values are meaningful only against `config.DefaultRouterThreshold`. 0.3 is below
0.55 and 1.0 is above it, and nothing anywhere reads any other property of either.
### The band set
Four bands, ordinal, highest first. They name the kind of evidence, because that is the one
thing every claimant can report without inventing it.
**`BandAnchored`.** A literal pattern anchored in the utterance matched, and the matched span
is what decides the intent. Stage 0 grammars and query-source matchers. The claimant is
certain about the shape of the sentence. That is not the same as being certain about the
answer. Measured 20/20.
**`BandStructural`.** A claimant read the whole sentence and produced a complete route. Every
slot the intent requires is filled. The LLM router at `llmFullConfidence` sits here, and so
does a stateful claimant holding a pending question. Not anchored, because nothing in the
utterance is pointed at.
**`BandNearest`.** The claim rests only on resemblance to something else. No anchor in the
utterance, no structural check behind it. The classifier. One band rather than a graded
scale, and the measurement is the argument. 62% at both ends of the cosine range, and a
top-two margin that never reaches 0.03.
**`BandVetoed`.** The claimant will take the turn only if nobody else will, and says why it
should not. The three arms of `gateLLMDecision` land here with their reason preserved. A
vetoed claim is still a claim. Maven asking "о чём напомнить?" beats silence.
There is no fifth band, and that is a measurement result rather than a preference. No
claimant in the cascade today can report what a fifth band would carry. V-546 lands a softmax
head whose max probability is a calibrated number. That one gets read as a number, not
squeezed into these four.
### Coverage decides before the band does
The band is the tie-break. The first question is how much of the utterance a claim explains,
and that is `Consumed` against `Unexplained` on the claim object. Two reasons.
It is the fix for the failure that opened V-558. "какая сейчас погода в Риме?" arrived while
a reminder was pending. The pending claimant ate the whole utterance as a time answer while
explaining none of it. Not "погода", not "Риме", not the question mark. A weather claim
explains all of it. Coverage-first arbitration prefers the weather claim without knowing that
a pending reminder is less trustworthy than a grammar. The pending question then survives to
be asked again.
It also keeps the stateful four out of the top slot without special-casing them. They sit at
`BandStructural`, below any anchored claim. That is the whole V-558 complaint about the
highest-priority claimants being the least informed, expressed as one rule.
### The claim object
```go
type Claim struct {
Claimant string // who wants the turn
Intent string // plain string: internal/dialogue must not import internal/router
Filled []string // the slots this claim would fill
Consumed []string // utterance tokens this claim explains
Unexplained []string // the rest, in order
Band Band
Veto string // why this claim should NOT win, empty when there is none
}
```
`Intent` is a plain `string` rather than `router.Intent` on purpose. `internal/dialogue` must
not import `internal/router`, so the claim package must not either, and a shared string costs
one conversion at each edge.
`Unexplained` is carried rather than derived at read time. A claimant can then decline to
explain a span it did match.
### What this task does not do
`router.Decision.Confidence` stays and keeps its float. `r.threshold` and `gateLLMDecision`
read it, and the classifier is the failure floor. A rewire that broke either would trade a
measured floor for an unmeasured design. V-565 lands the type and the builder beside the
existing path. The arbiter that reads claims is V-560.
+9 -5
View File
@@ -423,11 +423,15 @@ something only a ZIM answers, with the search block on. Live search leads and th
ZIMs are the fallback since 02-08-2026. **286**'s remaining half is doc and
git ingestion, which is build work, not a check.
**Do not read `/trace` for this.** `/trace` is the nudge-rule trace: rule,
severity, predicate, gate, selected. No query-source field exists anywhere in the
codebase. The only evidence of which query source claimed a turn is the
`voice: search:` and `voice: kiwix:` lines in `docker compose logs mavend`
(`actions_query.go:589` and `:660`).
**Read `/trace` for this.** It carries two tables since 06-08-2026 (V-564). The
nudge-rule trace it always had, and below it the **turn decisions**: one
collapsible record per utterance. Each names every claimant, what it would have
made the turn, the score it reported, and whether it won, declined, lost or was
**never asked**. That last one answers "did Kiwix pass, or was it never
reached". The log lines cannot tell you that. The ring holds the last 25 turns
in daemon memory and is empty after a restart, so read it in the same sitting.
`/chat` still shows the claiming source as a badge, and the `voice: query
claimed by source` line is still in `docker compose logs mavend`.
Run 02-08-2026, 20 turns. **Search leads and the personal boundary holds.** Every
world question that reached the boundary was claimed by search. All three
+192
View File
@@ -0,0 +1,192 @@
// Package claim is the common unit for the many claimants that compete for one
// utterance (V-565, umbrella V-558, design in
// docs/plans/19-dialogue-arbitration.md).
//
// Maven's cascade has roughly ten stage-0 grammars, seven router intents,
// twenty-two query sources and four stateful pre-emptors, and every one of them
// answers "is this mine?" alone. None can answer "is this more mine than
// yours?", because their scores are not comparable: stage 0 asserts 1.0 by
// fiat, the classifier reports a cosine, the LLM router derives one from
// structure. So list order is the whole arbitration.
//
// A Claim carries evidence rather than a verdict. Two things read that evidence
// and neither needs a float:
//
// - specificity — a claim explaining more of the utterance is preferred, and
// that is Consumed against Unexplained;
// - negative constraint — a claimant may veto itself and say why, and that is
// Veto.
//
// Where a number is unavoidable, it is an ordinal Band and not a probability.
// The band set is argued from measurement in the plan doc: the classifier's
// cosine is flat against correctness (62% at both ends of a spread 0.083 wide)
// and its top-two margin is p50 0.009, so no claimant Maven has today can
// produce a graded confidence.
//
// This package deliberately imports nothing from the rest of Maven.
// internal/dialogue must not import internal/router, so a shared unit that
// pulled in router.Intent would smuggle that edge back in. Intent is a plain
// string and the conversion happens at each edge.
package claim
import "strings"
// Band — the kind of evidence behind a claim, ordinal and comparable. Higher
// wins a tie. Four values, because four is what the claimants can report.
type Band int
const (
// BandUnknown — the zero value. A claim that never set a band is a bug in
// its builder, not a weak claim, so it must not silently rank as one.
BandUnknown Band = iota
// BandVetoed — the claimant will take the turn only if nobody else will,
// and Veto says why it should not. The three arms of gateLLMDecision (a
// fact with no key, an act with no allowlisted fn, a reminder with no
// subject) land here. Still a claim: asking "о чём напомнить?" beats
// silence.
BandVetoed
// BandNearest — the claim rests only on resemblance to something else,
// with no anchor in the utterance and no structural check behind it. The
// nearest-centroid classifier. One band and not a graded scale, because
// the cosine measured flat against correctness.
BandNearest
// BandStructural — the claimant read the whole sentence and produced a
// complete route, every slot its intent requires filled. The LLM router at
// full confidence, and a stateful claimant holding a pending question.
// Below BandAnchored on purpose: the four stateful claimants pre-empt
// unconditionally today, and that is the V-558 defect.
BandStructural
// BandAnchored — a literal pattern anchored in the utterance matched, and
// the matched span is what decides the intent. Stage 0 grammars and
// query-source matchers. Certainty about the shape of the sentence, which
// is not certainty about the answer.
BandAnchored
)
// String — the band's name, for a trace line and for a test failure that has to
// say which band it got.
func (b Band) String() string {
switch b {
case BandVetoed:
return "vetoed"
case BandNearest:
return "nearest"
case BandStructural:
return "structural"
case BandAnchored:
return "anchored"
default:
return "unknown"
}
}
// Claim — one claimant's bid for one utterance.
type Claim struct {
// Claimant — who wants the turn. A grammar name, a query source name, a
// stage label. Read by the trace and by a test naming a loser.
Claimant string
// Intent — the route this claim would take. A plain string and not
// router.Intent: see the package comment.
Intent string
// Filled — the slot names this claim would fill ("time", "fn", "key",
// "text"). Names and not values, because arbitration compares shape.
Filled []string
// Consumed — the utterance tokens this claim explains, in the order they
// appear. The numerator of specificity.
Consumed []string
// Unexplained — the tokens this claim does not explain, in order. Carried
// rather than derived, so a claimant may decline a span it did match.
Unexplained []string
// Band — the kind of evidence. The tie-break, after coverage.
Band Band
// Veto — why this claim should NOT win, empty when there is none. A
// non-empty Veto and a Band above BandVetoed is legal: a claim can be
// well-evidenced and still name a reason to prefer somebody else.
Veto string
}
// Coverage — the fraction of the utterance this claim explains, in [0,1]. A
// claim with no tokens either way covers nothing; it is not division by zero
// and it is not a full claim.
func (c Claim) Coverage() float64 {
total := len(c.Consumed) + len(c.Unexplained)
if total == 0 {
return 0
}
return float64(len(c.Consumed)) / float64(total)
}
// Vetoed reports whether the claimant named a reason against itself.
func (c Claim) Vetoed() bool { return c.Veto != "" }
// MoreSpecificThan — the ordering V-560's arbiter will read. Coverage first,
// because that is what fixes the failure this program opened with: a pending
// reminder ate "какая сейчас погода в Риме?" as a time answer while explaining
// none of it. Band only breaks a coverage tie.
//
// Deliberately NOT wired into the cascade by V-565. It is here so the ordering
// is one function with tests on it, rather than a rule restated at each of the
// sites that will eventually call it.
func (c Claim) MoreSpecificThan(other Claim) bool {
cc, oc := c.Coverage(), other.Coverage()
if cc != oc {
return cc > oc
}
return c.Band > other.Band
}
// Tokens — the utterance split for coverage accounting. Whitespace, then
// trailing and leading punctuation, then lowercased.
//
// This is tokenization over the raw string and not a Russian pattern: it
// contains no word list, and its output is a count rather than a fact or a
// route (CLAUDE.md § Russian patterns). Lowercasing is Unicode-aware, so
// Cyrillic folds the same way Latin does.
func Tokens(utterance string) []string {
fields := strings.FieldsFunc(utterance, func(r rune) bool {
return r == ' ' || r == '\t' || r == '\n' || r == '\r'
})
out := make([]string, 0, len(fields))
for _, f := range fields {
t := strings.Trim(strings.ToLower(f), ".,!?;:()\"'«»…-–—")
if t == "" {
continue
}
out = append(out, t)
}
return out
}
// Split partitions the utterance's tokens into the ones a claim explains and
// the rest, preserving order in both. A token is explained when it appears in
// one of the spans the claimant filled (a slot value, a matched substring).
//
// Duplicates are handled by membership and not by count: "напомни напомни
// позвонить" with span "напомни" explains both copies. The alternative is a
// multiset, and no claimant Maven has can say which copy it meant.
func Split(utterance string, spans ...string) (consumed, unexplained []string) {
explained := map[string]bool{}
for _, s := range spans {
for _, t := range Tokens(s) {
explained[t] = true
}
}
for _, t := range Tokens(utterance) {
if explained[t] {
consumed = append(consumed, t)
} else {
unexplained = append(unexplained, t)
}
}
return consumed, unexplained
}
+120
View File
@@ -0,0 +1,120 @@
package claim
import (
"reflect"
"testing"
)
func TestTokensStripsPunctuationAndCase(t *testing.T) {
got := Tokens("Какая сейчас погода в Риме?")
want := []string{"какая", "сейчас", "погода", "в", "риме"}
if !reflect.DeepEqual(got, want) {
t.Errorf("Tokens = %q, want %q", got, want)
}
}
// The dash forms matter: an STT transcript routinely carries "а, да, прости -
// на 9", and a stray dash counted as a token would dilute every coverage score
// in the sentence.
func TestTokensDropsBareDashes(t *testing.T) {
got := Tokens("а, да, прости — на 9")
want := []string{"а", "да", "прости", "на", "9"}
if !reflect.DeepEqual(got, want) {
t.Errorf("Tokens = %q, want %q", got, want)
}
}
func TestSplitPartitionsInOrder(t *testing.T) {
consumed, unexplained := Split("напомни позвонить маме", "позвонить маме")
if want := []string{"позвонить", "маме"}; !reflect.DeepEqual(consumed, want) {
t.Errorf("consumed = %q, want %q", consumed, want)
}
if want := []string{"напомни"}; !reflect.DeepEqual(unexplained, want) {
t.Errorf("unexplained = %q, want %q", unexplained, want)
}
}
func TestCoverageIsZeroWithoutTokens(t *testing.T) {
if got := (Claim{}).Coverage(); got != 0 {
t.Errorf("Coverage of an empty claim = %v, want 0", got)
}
}
func TestCoverageFraction(t *testing.T) {
c := Claim{Consumed: []string{"a", "b", "c"}, Unexplained: []string{"d"}}
if got := c.Coverage(); got != 0.75 {
t.Errorf("Coverage = %v, want 0.75", got)
}
}
// The band order is load-bearing, so it is asserted rather than assumed from
// the iota. BandUnknown must sit at the bottom: a claim whose builder forgot to
// set a band is a bug and must not outrank a measured one.
func TestBandOrder(t *testing.T) {
ordered := []Band{BandUnknown, BandVetoed, BandNearest, BandStructural, BandAnchored}
for i := 1; i < len(ordered); i++ {
if !(ordered[i-1] < ordered[i]) {
t.Errorf("%v is not below %v", ordered[i-1], ordered[i])
}
}
for _, b := range ordered {
if b.String() == "" {
t.Errorf("band %d has no name", b)
}
}
if BandUnknown.String() != "unknown" {
t.Errorf("BandUnknown.String() = %q", BandUnknown.String())
}
}
// The failure V-558 opened with, as an ordering test. A pending reminder eats
// "какая сейчас погода в Риме?" as a time answer and explains none of it; the
// weather source explains all of it. Coverage decides, and the band never gets
// consulted, which is the point: the pending claimant is structural and the
// weather claim is anchored, but even if the bands were equal the weather claim
// wins.
func TestCoverageBeatsBand(t *testing.T) {
utterance := "какая сейчас погода в Риме?"
pendingConsumed, pendingRest := Split(utterance, "сейчас")
pending := Claim{
Claimant: "reminder-followup", Intent: "reminder",
Consumed: pendingConsumed, Unexplained: pendingRest,
Band: BandStructural,
}
weatherConsumed, weatherRest := Split(utterance, utterance)
weather := Claim{
Claimant: "weather", Intent: "query",
Consumed: weatherConsumed, Unexplained: weatherRest,
Band: BandNearest,
}
if !weather.MoreSpecificThan(pending) {
t.Errorf("weather (%v cover) did not beat pending (%v cover)",
weather.Coverage(), pending.Coverage())
}
if pending.MoreSpecificThan(weather) {
t.Error("pending beat weather, so the ordering is not asymmetric")
}
}
// Where coverage ties, the band decides. Two grammars claiming the same
// utterance is the stage-0 contention case (ru-query-019 on the fixture), and
// today list order settles it with nothing recorded.
func TestBandBreaksACoverageTie(t *testing.T) {
anchored := Claim{Claimant: "calendar-query", Consumed: []string{"a"}, Band: BandAnchored}
nearest := Claim{Claimant: "classifier", Consumed: []string{"a"}, Band: BandNearest}
if !anchored.MoreSpecificThan(nearest) {
t.Error("anchored did not beat nearest on equal coverage")
}
if nearest.MoreSpecificThan(anchored) {
t.Error("nearest beat anchored on equal coverage")
}
}
func TestVetoed(t *testing.T) {
if (Claim{}).Vetoed() {
t.Error("a claim with no veto reports itself vetoed")
}
if !(Claim{Veto: "fact with no key"}).Vetoed() {
t.Error("a claim with a veto reason does not report itself vetoed")
}
}
+185
View File
@@ -0,0 +1,185 @@
// Package decision records who claimed one turn and who lost it (V-564).
//
// Arbitration between the claimants on the utterance stream is order, hardcoded
// in the resolver ladder, in buildRouter and in querySources (V-558). Order is
// invisible in a log: the daemon says which intent won and which query source
// answered, never who else wanted the turn, with what score, or why it did not
// get it. The Rome misroute took a probe, a log read and a code read to explain,
// which is one diagnosis too many for a defect family already three deep.
//
// The record rides the context, the same seam querysource.go uses and for the
// same reason: a turn answers through one string that the mic, telegram and the
// web all share, so a second return value is not threadable. A context with no
// recorder notes nothing, so every Note here is free in a test or a tool that
// did not ask for one.
//
// The most important thing it holds is not a loss but a silence. A claimant
// that was NEVER ASKED — because something earlier in the ladder returned
// first — looks identical to one that examined the turn and declined, and it is
// that confusion the hardcoded ordering hides. So a stage declares its roster
// up front and Finish names everyone who never reported.
package decision
import (
"context"
"sync"
"time"
)
// Stages, in the order a turn passes through them.
const (
StagePreRoute = "pre-route" // clarify, confirm, repair and their siblings
StageZero = "stage0" // grammar rules
StageRoute = "route" // LLM router, classifier
StageMerge = "merge" // the follow-up merge, which edits rather than claims
StageQuery = "query" // the query source chain
StageAction = "action" // whoever actually produced the reply
)
// Outcomes. Coarse on purpose: the record answers "who wanted this turn and
// what happened to their claim", not "re-derive the branch".
const (
Won = "won" // this claimant produced the turn
Declined = "declined" // it looked at the turn and said not mine
LostOnOrder = "lost_on_order" // it wanted the turn, something earlier had it
LostOnScore = "lost_on_score" // it was scored against a rival and scored lower
Thinned = "thinned" // it claimed, and a gate cut its confidence
Merged = "merged" // it changed the winning claim without owning it
NeverAsked = "never_asked" // it never got to look at all
)
// Claim is one claimant's say on one turn.
type Claim struct {
Stage string `json:"stage"`
Claimant string `json:"claimant"`
Intent string `json:"intent,omitempty"` // what it would have made the turn
Score float64 `json:"score,omitempty"` // only meaningful with HasScore
HasScore bool `json:"has_score,omitempty"`
Outcome string `json:"outcome"`
Reason string `json:"reason,omitempty"` // why it lost, in its own terms
}
// Record is one turn's arbitration. Utterance is held because a record with no
// utterance is unreadable, and this store is diagnostics with a short life —
// unlike the facts table, which is the audit trail.
type Record struct {
Ts time.Time `json:"ts"`
Utterance string `json:"utterance"`
Winner string `json:"winner"`
Claims []Claim `json:"claims"`
mu sync.Mutex
rosters []roster
}
type roster struct {
stage string
names []string
}
// Expect declares the claimants a stage could have asked, so Finish can tell a
// decline from a silence. The slice is held, not copied: every caller passes a
// package-level table.
func (r *Record) Expect(stage string, names []string) {
if r == nil {
return
}
r.mu.Lock()
defer r.mu.Unlock()
r.rosters = append(r.rosters, roster{stage: stage, names: names})
}
// Note appends one claim. The winner is whoever noted Won last, which is the
// claimant that actually returned the reply.
func (r *Record) Note(c Claim) {
if r == nil {
return
}
r.mu.Lock()
defer r.mu.Unlock()
r.Claims = append(r.Claims, c)
if c.Outcome == Won {
r.Winner = c.Stage + ":" + c.Claimant
}
}
// NoteIfUnclaimed records a win only when nobody has claimed the turn yet. It
// is what closes a record whose route was decided but whose reply came from
// somewhere with no scoreboard — a clarify question, or an action handler with
// no chain in front of it. Without it a thinned route leaves the record with no
// winner at all, which reads as a lost turn rather than an asked question.
func (r *Record) NoteIfUnclaimed(c Claim) {
if r == nil {
return
}
r.mu.Lock()
claimed := r.Winner != ""
r.mu.Unlock()
if claimed {
return
}
c.Outcome = Won
r.Note(c)
}
// Finish fills in the never-asked claimants and returns the record. Called once
// by whoever installed the recorder, after the turn has answered.
func (r *Record) Finish(now time.Time) *Record {
if r == nil {
return nil
}
r.mu.Lock()
defer r.mu.Unlock()
r.Ts = now
reported := map[string]bool{}
for _, c := range r.Claims {
reported[c.Stage+":"+c.Claimant] = true
}
for _, ros := range r.rosters {
for _, name := range ros.names {
if !reported[ros.stage+":"+name] {
r.Claims = append(r.Claims, Claim{
Stage: ros.stage, Claimant: name, Outcome: NeverAsked,
})
}
}
}
return r
}
// --- the context seam ---
type recorderKey struct{}
// With returns a context carrying a fresh record, and the record to read after
// the turn has answered.
func With(ctx context.Context, utterance string) (context.Context, *Record) {
rec := &Record{Utterance: utterance}
return context.WithValue(ctx, recorderKey{}, rec), rec
}
// From returns the record on the context, or nil. Every method on *Record is
// nil-safe, so a caller does not have to check.
func From(ctx context.Context) *Record {
rec, _ := ctx.Value(recorderKey{}).(*Record)
return rec
}
// Note is the shorthand every claim site uses: a no-op when nobody is recording.
func Note(ctx context.Context, c Claim) {
From(ctx).Note(c)
}
// Expect is the roster shorthand, likewise a no-op with no recorder.
func Expect(ctx context.Context, stage string, names []string) {
From(ctx).Expect(stage, names)
}
// Scored is a claim carrying a confidence, kept as a constructor so a caller
// cannot forget HasScore and have a real 0.0 read as "no score".
func Scored(stage, claimant, intent string, score float64, outcome, reason string) Claim {
return Claim{
Stage: stage, Claimant: claimant, Intent: intent,
Score: score, HasScore: true, Outcome: outcome, Reason: reason,
}
}
+98
View File
@@ -0,0 +1,98 @@
package decision
import (
"context"
"sync"
"testing"
"time"
)
func TestFinishNamesTheNeverAsked(t *testing.T) {
ctx, rec := With(context.Background(), "какая погода в риме?")
Expect(ctx, StageQuery, []string{"calendar", "weather", "search", "kiwix"})
Note(ctx, Claim{Stage: StageQuery, Claimant: "calendar", Outcome: Declined})
Note(ctx, Claim{Stage: StageQuery, Claimant: "weather", Outcome: Won})
rec.Finish(time.Now())
outcomes := map[string]string{}
for _, c := range rec.Claims {
outcomes[c.Claimant] = c.Outcome
}
if outcomes["weather"] != Won || rec.Winner != StageQuery+":weather" {
t.Errorf("winner = %q, weather = %q", rec.Winner, outcomes["weather"])
}
if outcomes["calendar"] != Declined {
t.Errorf("calendar = %q, want a decline", outcomes["calendar"])
}
// The two below the winner never looked, and saying so is the whole point.
for _, name := range []string{"search", "kiwix"} {
if outcomes[name] != NeverAsked {
t.Errorf("%s = %q, want %q", name, outcomes[name], NeverAsked)
}
}
}
// A real 0.0 confidence must not read as "this claimant has no score".
func TestScoredKeepsAZeroScore(t *testing.T) {
c := Scored(StageRoute, "classifier", "chat", 0, LostOnScore, "")
if !c.HasScore || c.Score != 0 {
t.Errorf("claim = %+v", c)
}
}
func TestNoteIfUnclaimedYieldsToARealWinner(t *testing.T) {
ctx, rec := With(context.Background(), "x")
Note(ctx, Claim{Stage: StageQuery, Claimant: "weather", Outcome: Won})
rec.NoteIfUnclaimed(Claim{Stage: StageAction, Claimant: "action-handler"})
if rec.Winner != StageQuery+":weather" {
t.Errorf("winner = %q, want the query source", rec.Winner)
}
}
// A context with no record must cost nothing and crash nothing: that is what
// makes the claim sites safe to leave in every test and every fixture run.
func TestNoRecorderIsANoOp(t *testing.T) {
ctx := context.Background()
Note(ctx, Claim{Claimant: "x", Outcome: Won})
Expect(ctx, StageQuery, []string{"y"})
if From(ctx) != nil {
t.Error("bare context reported a record")
}
From(ctx).NoteIfUnclaimed(Claim{Claimant: "z"})
if rec := From(ctx).Finish(time.Now()); rec != nil {
t.Error("finishing a nil record produced one")
}
}
func TestRingIsBoundedAndNewestFirst(t *testing.T) {
r := NewRing()
for i := 0; i < ringSize+5; i++ {
r.Push(&Record{Utterance: string(rune('a' + i))})
}
got := r.Recent(ringSize + 10)
if len(got) != ringSize {
t.Fatalf("kept %d records, want %d", len(got), ringSize)
}
if got[0].Utterance != string(rune('a'+ringSize+4)) {
t.Errorf("newest = %q", got[0].Utterance)
}
}
// A query source may fan out to goroutines of its own, so two of them noting at
// once must not race. Run under -race, which is where this earns its keep.
func TestConcurrentNotes(t *testing.T) {
ctx, rec := With(context.Background(), "x")
var wg sync.WaitGroup
for i := 0; i < 8; i++ {
wg.Add(1)
go func() {
defer wg.Done()
Note(ctx, Claim{Stage: StageQuery, Claimant: "fanout", Outcome: Declined})
}()
}
wg.Wait()
if len(rec.Claims) != 8 {
t.Errorf("recorded %d claims, want 8", len(rec.Claims))
}
}
+49
View File
@@ -0,0 +1,49 @@
package decision
import "sync"
// ringSize is how many turns are kept. Turns arrive at human rate, not machine
// rate, so the whole store is memory: no migration, no insert on the answer
// path, and nothing of his words survives a restart. That is what makes this
// cheap enough to leave on always (V-564). Ecosystem traces went to SQLite
// because one act writes several hops and they must outlive the turn; an
// arbitration record is read minutes later or never.
const ringSize = 25
// Ring holds the newest records, newest first on read.
type Ring struct {
mu sync.Mutex
recs []*Record
}
func NewRing() *Ring { return &Ring{} }
// Push adds one finished record and drops the oldest past the bound.
func (r *Ring) Push(rec *Record) {
if r == nil || rec == nil {
return
}
r.mu.Lock()
defer r.mu.Unlock()
r.recs = append(r.recs, rec)
if len(r.recs) > ringSize {
r.recs = r.recs[len(r.recs)-ringSize:]
}
}
// Recent returns up to n records, newest first.
func (r *Ring) Recent(n int) []*Record {
if r == nil || n <= 0 {
return nil
}
r.mu.Lock()
defer r.mu.Unlock()
if n > len(r.recs) {
n = len(r.recs)
}
out := make([]*Record, 0, n)
for i := 0; i < n; i++ {
out = append(out, r.recs[len(r.recs)-1-i])
}
return out
}
+137 -24
View File
@@ -38,21 +38,34 @@ type PendingQuestion struct {
MaxAttempts int
}
// maxAttempts is MaxAttempts with the default filled in.
func (q *PendingQuestion) maxAttempts() int {
if q.MaxAttempts <= 0 {
return DefaultMaxAttempts
// Action reads the parked question as the typed action it is assembling
// (pending.go). Derived rather than stored: the question's fields stay the one
// copy of the truth, so a caller that fills them the old way cannot end up with
// a capability that disagrees with the intent.
func (q *PendingQuestion) Action() PendingAction {
return PendingAction{
Capability: CapabilityFor(q.Intent),
Slots: q.Slots,
Missing: q.Missing,
Utterance: q.Utterance,
Asked: q.Asked,
TTL: q.TTL,
Attempts: q.Attempts,
MaxAttempts: q.MaxAttempts,
}
return q.MaxAttempts
}
// IsExpired and CanAsk answer through the action, so there is exactly one copy
// of the TTL and attempt-cap rules and the widening cannot drift from them.
func (q *PendingQuestion) IsExpired(now time.Time) bool {
return now.After(q.Asked.Add(q.TTL))
a := q.Action()
return a.IsExpired(now)
}
// CanAsk reports whether Maven may ask another question about this request.
func (q *PendingQuestion) CanAsk() bool {
return q.Attempts < q.maxAttempts()
a := q.Action()
return a.CanAsk()
}
// ClarifyStore holds the parked questions. Same shape and locking as
@@ -66,11 +79,26 @@ func (q *PendingQuestion) CanAsk() bool {
// next words route fresh, which is the right answer with or without a notice.
// Do not give this store a persister without re-arguing that.
type ClarifyStore struct {
mu sync.RWMutex
questions map[string]*PendingQuestion
mu sync.RWMutex
// stacks — one stack of parked questions per dialogue id, newest last. It
// was a single question per id until V-559; a side query has to be able to
// suspend the active flow and find it still there afterwards (V-561 does
// the suspending, this only holds the room for it).
stacks map[string][]*PendingQuestion
defaultTTL time.Duration
}
// MaxStackDepth — how many parked questions one dialogue id may hold.
//
// Two, not three. One is the flow he is in, one is the thing he interrupted it
// with, and out loud he does not nest deeper than that: a side query inside a
// side query is a shape typed conversation has and spoken conversation does
// not. The bound is also a promise — every level she keeps is a level she must
// be able to SPEAK when it dies (clarifyGaveUp, clarifyExpiredVariants), and
// two lines of "and the other thing I dropped" is already the limit of what a
// reply can carry.
const MaxStackDepth = 2
func NewClarifyStore(defaultTTL time.Duration) *ClarifyStore {
if defaultTTL <= 0 {
// Short, like confirmTTL in voice.go: a clarifying question is a
@@ -78,27 +106,58 @@ func NewClarifyStore(defaultTTL time.Duration) *ClarifyStore {
defaultTTL = 90 * time.Second
}
return &ClarifyStore{
questions: make(map[string]*PendingQuestion),
stacks: make(map[string][]*PendingQuestion),
defaultTTL: defaultTTL,
}
}
// Put parks a question. Called on a clarify decision (cmd/mavend/clarify.go).
// Put parks a question, replacing the one on top. Called on a clarify decision
// (cmd/mavend/clarify.go), and it is still what the daemon uses: re-asking the
// same request is a new question about the SAME action, so it overwrites rather
// than growing the stack. Push is the deeper one, and nothing calls it yet.
func (s *ClarifyStore) Put(id string, q *PendingQuestion) {
if q.TTL <= 0 {
q.TTL = s.defaultTTL
}
s.fillTTL(q)
s.mu.Lock()
s.questions[id] = q
s.mu.Unlock()
defer s.mu.Unlock()
stack := s.stacks[id]
if len(stack) == 0 {
s.stacks[id] = []*PendingQuestion{q}
return
}
stack[len(stack)-1] = q
}
// Get returns the live parked question, or nil when there is none.
func (s *ClarifyStore) Get(id string, now time.Time) *PendingQuestion {
// Push suspends whatever is parked and puts q on top. The returned question is
// one the depth bound forced out of the bottom of the stack, and the caller MUST
// tell him about it — a parked request that dies without a word leaves him
// thinking it landed, which is the whole reason clarifyGaveUp exists. nil is the
// ordinary case.
func (s *ClarifyStore) Push(id string, q *PendingQuestion) *PendingQuestion {
s.fillTTL(q)
s.mu.Lock()
defer s.mu.Unlock()
stack := append(s.stacks[id], q)
var dropped *PendingQuestion
if len(stack) > MaxStackDepth {
dropped = stack[0]
stack = stack[1:]
}
s.stacks[id] = stack
return dropped
}
// Peek returns the live question on top, or nil when there is none. Expired
// entries below it are left alone: TakeExpired is what reports those, and
// dropping one here would be the silent death this store is careful about.
func (s *ClarifyStore) Peek(id string, now time.Time) *PendingQuestion {
s.mu.RLock()
q, ok := s.questions[id]
stack := s.stacks[id]
var q *PendingQuestion
if len(stack) > 0 {
q = stack[len(stack)-1]
}
s.mu.RUnlock()
if !ok {
if q == nil {
return nil
}
if q.IsExpired(now) {
@@ -108,27 +167,81 @@ func (s *ClarifyStore) Get(id string, now time.Time) *PendingQuestion {
return q
}
// Get is Peek under the name every caller already uses. Kept because a clarify
// answer is always about the top of the stack, so the two are the same call.
func (s *ClarifyStore) Get(id string, now time.Time) *PendingQuestion {
return s.Peek(id, now)
}
// Pop takes the live question off the top and returns it, so the flow beneath
// becomes current again. nil when the top is empty or expired — an expired top
// is dropped along with the rest of the stack, exactly as Peek does, because the
// clock that killed it has been running for everything underneath too.
func (s *ClarifyStore) Pop(id string, now time.Time) *PendingQuestion {
s.mu.Lock()
stack := s.stacks[id]
if len(stack) == 0 {
s.mu.Unlock()
return nil
}
q := stack[len(stack)-1]
if q.IsExpired(now) {
delete(s.stacks, id)
s.mu.Unlock()
return nil
}
if len(stack) == 1 {
delete(s.stacks, id)
} else {
s.stacks[id] = stack[:len(stack)-1]
}
s.mu.Unlock()
return q
}
// Depth — how many questions are parked for this id, expired ones included.
// Diagnostic; the arbiter in V-560 reads it to know it is inside a flow.
func (s *ClarifyStore) Depth(id string) int {
s.mu.RLock()
defer s.mu.RUnlock()
return len(s.stacks[id])
}
// TakeExpired reports whether a question was parked here but its TTL ran out,
// and drops it. Get drops such a question silently, which leaves the user
// thinking his request is still alive — the caller uses this to tell him it is
// gone before treating his words as a fresh utterance.
//
// It looks at the top only, and drops the whole stack when that one is dead: one
// notice is what a reply can carry, and anything parked under a question that
// timed out has been waiting at least as long.
func (s *ClarifyStore) TakeExpired(id string, now time.Time) bool {
s.mu.Lock()
defer s.mu.Unlock()
q, ok := s.questions[id]
if !ok || !q.IsExpired(now) {
stack := s.stacks[id]
if len(stack) == 0 || !stack[len(stack)-1].IsExpired(now) {
return false
}
delete(s.questions, id)
delete(s.stacks, id)
return true
}
// Delete drops every question parked for this id. The old single-slot Delete
// under the old name: at depth one the two are the same, and a caller that means
// "this exchange is over" means all of it.
func (s *ClarifyStore) Delete(id string) {
s.mu.Lock()
delete(s.questions, id)
delete(s.stacks, id)
s.mu.Unlock()
}
// fillTTL applies the store default to a question parked without one.
func (s *ClarifyStore) fillTTL(q *PendingQuestion) {
if q.TTL <= 0 {
q.TTL = s.defaultTTL
}
}
// Answer merges the slots parsed from the user's answer into the parked ones.
// Only the slots listed in Missing are touched. Within those, a value the answer
// carries WINS over what was parked: she asked about this slot, so «нет, в пять»
+101
View File
@@ -0,0 +1,101 @@
package dialogue
import "time"
// Capability names the thing being assembled across a clarify exchange —
// "reminder.create", not "reminder". A router intent says what she heard; a
// capability says what she is about to do, and those are not the same word:
// three intents currently reach exactly one capability each, but a fact key
// that turns out to be a Hexis target does not. Named in the ecosystem's
// dotted form because that is what a confirmation binds (cmd/mavend/confirm.go)
// and what Hexis registers.
//
// This package must stay free of internal/router (the cycle rule that makes
// Slots a hand-kept copy), so the mapping from an intent lives here and reads
// off dialogue.Intent only.
type Capability string
const (
CapReminderCreate Capability = "reminder.create"
CapFactWrite Capability = "fact.write"
CapNoteWrite Capability = "note.write"
CapActRun Capability = "act.run"
CapQueryAnswer Capability = "query.answer"
CapChatReply Capability = "chat.reply"
CapSystemControl Capability = "system.control"
)
// intentCapability — the one place an intent becomes a capability. Every intent
// is listed, including the four that are never worth a clarifying question, so a
// parked action always knows what it is even when nothing asks it.
var intentCapability = map[Intent]Capability{
IntentReminder: CapReminderCreate,
IntentFact: CapFactWrite,
IntentNote: CapNoteWrite,
IntentAct: CapActRun,
IntentQuery: CapQueryAnswer,
IntentChat: CapChatReply,
IntentSystem: CapSystemControl,
}
// CapabilityFor maps a router intent (already narrowed to dialogue.Intent by
// the caller) to the capability being assembled. "" for an intent she does not
// recognise — an unknown intent must not silently become a real capability.
func CapabilityFor(in Intent) Capability {
return intentCapability[in]
}
// PendingAction is the action Maven is assembling, as an object rather than as
// conversational history: which capability, the slots it already has, the slots
// it is still missing, when she asked, how many questions that has cost and how
// long the answer stays welcome.
//
// It exists because the resolver used to have to infer all of that from a
// parked question plus the previous turn (Vikunja #558): "is this his answer or
// a new request" is answerable against an object and guessy against a
// transcript. PendingQuestion carries one of these and keeps its own flat
// fields, so this is a widening — nothing reads the capability yet.
type PendingAction struct {
Capability Capability
Slots Slots // what is filled so far
Missing []Slot // what she is waiting for, in the order to ask about
Utterance string // his original raw words, as the action's provenance
Asked time.Time
TTL time.Duration
Attempts int // questions already asked about this action
// MaxAttempts caps Attempts. 0 ⇒ DefaultMaxAttempts.
MaxAttempts int
}
// maxAttempts is MaxAttempts with the default filled in.
func (a *PendingAction) maxAttempts() int {
if a.MaxAttempts <= 0 {
return DefaultMaxAttempts
}
return a.MaxAttempts
}
// IsExpired — the answer came too late for this action to still be his answer.
func (a *PendingAction) IsExpired(now time.Time) bool {
return now.After(a.Asked.Add(a.TTL))
}
// CanAsk reports whether she may ask another question about this action.
func (a *PendingAction) CanAsk() bool {
return a.Attempts < a.maxAttempts()
}
// Gaps lists the slots this action asked for and still does not have. Computed
// from the slots rather than trusted from Missing, because Missing is what she
// asked about and the slots are what she got — an answer can fill a gap she
// never asked about, and a re-park must not ask again for something now filled.
func (a *PendingAction) Gaps() []Slot {
return StillMissing(a.Missing, a.Slots)
}
// Complete reports whether every slot this action was waiting for is filled, so
// it can run. Note that this is completeness against what she ASKED, not
// against the capability's whole schema — validating that is V-562.
func (a *PendingAction) Complete() bool {
return len(a.Gaps()) == 0
}
+117
View File
@@ -0,0 +1,117 @@
package dialogue
import (
"testing"
"time"
)
var pendingBase = time.Date(2026, 8, 6, 9, 0, 0, 0, time.UTC)
func TestCapabilityForCoversEveryIntent(t *testing.T) {
for _, in := range []Intent{
IntentAct, IntentReminder, IntentFact, IntentNote,
IntentQuery, IntentChat, IntentSystem,
} {
if CapabilityFor(in) == "" {
t.Errorf("intent %q maps to no capability", in)
}
}
if got := CapabilityFor(Intent("nonsense")); got != "" {
t.Errorf("unknown intent became capability %q, want empty", got)
}
}
// A parked question must read as the action it is assembling, without the
// caller having to name the capability twice.
func TestPendingQuestionActionDerivesCapability(t *testing.T) {
q := &PendingQuestion{
Intent: IntentReminder,
Slots: Slots{Text: "позвонить маме"},
Missing: []Slot{SlotTime},
Utterance: "напомни позвонить маме",
Asked: pendingBase,
TTL: time.Minute,
Attempts: 1,
MaxAttempts: 2,
}
a := q.Action()
if a.Capability != CapReminderCreate {
t.Errorf("capability = %q, want %q", a.Capability, CapReminderCreate)
}
if a.Utterance != q.Utterance || a.Attempts != 1 || a.MaxAttempts != 2 || !a.Asked.Equal(pendingBase) {
t.Errorf("action did not carry the question's fields: %+v", a)
}
}
func TestPendingActionGaps(t *testing.T) {
for _, tc := range []struct {
name string
action PendingAction
want []Slot
complete bool
}{
{
name: "time still missing",
action: PendingAction{Missing: []Slot{SlotTime}, Slots: Slots{Text: "позвонить маме"}},
want: []Slot{SlotTime},
complete: false,
},
{
name: "asked slot now filled",
action: PendingAction{Missing: []Slot{SlotTime}, Slots: Slots{Time: pendingBase, HasTime: true}},
want: nil,
complete: true,
},
{
name: "two gaps reported in ask order",
action: PendingAction{Missing: []Slot{SlotText, SlotTime}},
want: []Slot{SlotText, SlotTime},
complete: false,
},
{
name: "nothing asked is complete",
action: PendingAction{},
want: nil,
complete: true,
},
} {
t.Run(tc.name, func(t *testing.T) {
got := tc.action.Gaps()
if len(got) != len(tc.want) {
t.Fatalf("gaps = %v, want %v", got, tc.want)
}
for i := range got {
if got[i] != tc.want[i] {
t.Fatalf("gaps = %v, want %v", got, tc.want)
}
}
if tc.action.Complete() != tc.complete {
t.Errorf("Complete() = %v, want %v", tc.action.Complete(), tc.complete)
}
})
}
}
// The typed action must answer the TTL and attempt-cap questions the same way
// the parked question always did — this is a widening, not new behaviour.
func TestPendingActionTTLAndAttempts(t *testing.T) {
a := PendingAction{Asked: pendingBase, TTL: time.Minute}
if a.IsExpired(pendingBase.Add(30 * time.Second)) {
t.Error("expired inside the TTL")
}
if !a.IsExpired(pendingBase.Add(2 * time.Minute)) {
t.Error("not expired past the TTL")
}
a.Attempts = DefaultMaxAttempts - 1
if !a.CanAsk() {
t.Error("cannot ask with an attempt left")
}
a.Attempts = DefaultMaxAttempts
if a.CanAsk() {
t.Error("asked past the default cap")
}
a = PendingAction{Asked: pendingBase, TTL: time.Minute, MaxAttempts: 1, Attempts: 1}
if a.CanAsk() {
t.Error("asked past an explicit cap of 1")
}
}
+155
View File
@@ -0,0 +1,155 @@
package dialogue
import (
"testing"
"time"
)
func parked(text string, asked time.Time) *PendingQuestion {
return &PendingQuestion{
Intent: IntentReminder,
Missing: []Slot{SlotTime},
Utterance: text,
Asked: asked,
TTL: time.Minute,
}
}
func TestStackPushPeekPop(t *testing.T) {
s := NewClarifyStore(time.Minute)
if dropped := s.Push("voice", parked("напомни позвонить маме", pendingBase)); dropped != nil {
t.Fatalf("first push dropped %q", dropped.Utterance)
}
if dropped := s.Push("voice", parked("погода в риме", pendingBase)); dropped != nil {
t.Fatalf("second push dropped %q", dropped.Utterance)
}
if got := s.Depth("voice"); got != 2 {
t.Fatalf("depth = %d, want 2", got)
}
if got := s.Peek("voice", pendingBase); got == nil || got.Utterance != "погода в риме" {
t.Fatalf("peek = %+v, want the newest", got)
}
// Peek must not consume: two peeks are the same question.
if got := s.Peek("voice", pendingBase); got == nil || got.Utterance != "погода в риме" {
t.Fatalf("second peek = %+v, want the newest still", got)
}
got := s.Pop("voice", pendingBase)
if got == nil || got.Utterance != "погода в риме" {
t.Fatalf("pop = %+v, want the newest", got)
}
// The flow underneath survived the one on top of it.
if got := s.Peek("voice", pendingBase); got == nil || got.Utterance != "напомни позвонить маме" {
t.Fatalf("after pop, peek = %+v, want the suspended flow", got)
}
if got := s.Pop("voice", pendingBase); got == nil {
t.Fatal("pop of the last entry returned nil")
}
if s.Peek("voice", pendingBase) != nil || s.Depth("voice") != 0 {
t.Error("stack not empty after popping everything")
}
if s.Pop("voice", pendingBase) != nil {
t.Error("pop of an empty stack returned something")
}
}
// A popped entry is gone: it must not come back on the next peek.
func TestStackPoppedEntryIsGone(t *testing.T) {
s := NewClarifyStore(time.Minute)
s.Push("voice", parked("напомни", pendingBase))
s.Pop("voice", pendingBase)
if got := s.Peek("voice", pendingBase); got != nil {
t.Errorf("peek after pop = %+v, want nil", got)
}
}
// Past MaxStackDepth the oldest entry comes back to the caller instead of
// vanishing — it is the caller's job to say it was dropped.
func TestStackDepthBoundReturnsTheDroppedEntry(t *testing.T) {
s := NewClarifyStore(time.Minute)
for i := 0; i < MaxStackDepth; i++ {
if dropped := s.Push("voice", parked("first", pendingBase)); dropped != nil {
t.Fatalf("push %d dropped early", i)
}
}
dropped := s.Push("voice", parked("newest", pendingBase))
if dropped == nil {
t.Fatal("push past the bound dropped an entry silently")
}
if dropped.Utterance != "first" {
t.Errorf("dropped %q, want the oldest", dropped.Utterance)
}
if got := s.Depth("voice"); got != MaxStackDepth {
t.Errorf("depth = %d, want %d", got, MaxStackDepth)
}
if got := s.Peek("voice", pendingBase); got == nil || got.Utterance != "newest" {
t.Errorf("peek = %+v, want the newest", got)
}
}
// Put still replaces rather than stacks: a re-ask is another question about the
// same action, so the daemon's depth stays one.
func TestPutReplacesTopWithoutGrowing(t *testing.T) {
s := NewClarifyStore(time.Minute)
s.Put("voice", parked("напомни", pendingBase))
s.Put("voice", parked("напомни ещё раз", pendingBase))
if got := s.Depth("voice"); got != 1 {
t.Fatalf("depth = %d, want 1", got)
}
if got := s.Peek("voice", pendingBase); got == nil || got.Utterance != "напомни ещё раз" {
t.Fatalf("peek = %+v, want the replacement", got)
}
}
// An expired top takes the stack with it, and TakeExpired is what reports it —
// the whole exchange timed out, and one notice is what a reply can carry.
func TestStackExpiryDropsTheStackAndIsReported(t *testing.T) {
s := NewClarifyStore(time.Minute)
s.Push("voice", parked("напомни", pendingBase))
s.Push("voice", parked("погода", pendingBase))
late := pendingBase.Add(2 * time.Minute)
if s.Peek("voice", late) != nil {
t.Error("peek returned an expired question")
}
if s.Depth("voice") != 0 {
t.Error("expired stack survived a peek")
}
s.Push("voice", parked("напомни", pendingBase))
s.Push("voice", parked("погода", pendingBase))
if !s.TakeExpired("voice", late) {
t.Error("TakeExpired did not report the timed-out exchange")
}
if s.Depth("voice") != 0 {
t.Error("TakeExpired left entries behind")
}
if s.TakeExpired("voice", late) {
t.Error("TakeExpired reported twice")
}
// Pop of an expired top yields nothing rather than a dead action.
s.Push("voice", parked("напомни", pendingBase))
if s.Pop("voice", late) != nil {
t.Error("pop returned an expired question")
}
}
// Delete ends the exchange, every level of it.
func TestStackDeleteDropsAll(t *testing.T) {
s := NewClarifyStore(time.Minute)
s.Push("voice", parked("напомни", pendingBase))
s.Push("voice", parked("погода", pendingBase))
s.Delete("voice")
if s.Depth("voice") != 0 || s.Peek("voice", pendingBase) != nil {
t.Error("Delete left questions parked")
}
}
// Stacks are per dialogue id: the mic and the web must not read each other's.
func TestStacksAreIsolatedByID(t *testing.T) {
s := NewClarifyStore(time.Minute)
s.Push("voice", parked("напомни", pendingBase))
s.Push("web", parked("погода", pendingBase))
s.Delete("voice")
if got := s.Peek("web", pendingBase); got == nil || got.Utterance != "погода" {
t.Errorf("web stack = %+v, want its own question", got)
}
}
+24
View File
@@ -836,6 +836,30 @@ type TickTrace struct {
Rules []RuleTrace `json:"rules"`
}
// --- Turn decision trace DTOs (V-564) ---
// TurnClaim — one claimant's say on one turn: who, at which stage, what it
// would have made the turn, the score it reported if it has one, and what
// happened to the claim. Same shape as RuleTrace above and for the same reason:
// a winner alone does not explain an arbitration, the losers do.
type TurnClaim struct {
Stage string `json:"stage"`
Claimant string `json:"claimant"`
Intent string `json:"intent,omitempty"`
Score float64 `json:"score,omitempty"`
HasScore bool `json:"has_score,omitempty"`
Outcome string `json:"outcome"`
Reason string `json:"reason,omitempty"`
}
// TurnDecision — one turn's arbitration, newest first when read as a list.
type TurnDecision struct {
Ts time.Time `json:"ts"`
Utterance string `json:"utterance"`
Winner string `json:"winner"`
Claims []TurnClaim `json:"claims"`
}
// MorningRoutineItem — one checklist entry's current state.
type MorningRoutineItem struct {
Key string `json:"key"`
+8
View File
@@ -672,6 +672,14 @@ func (c *Client) TickTrace(ctx context.Context) (TickTrace, error) {
return t, nil
}
func (c *Client) TurnDecisions(ctx context.Context, n int) ([]TurnDecision, error) {
var d []TurnDecision
if err := c.call(ctx, MethodTurnDecisions, nReq{N: n}, &d); err != nil {
return nil, err
}
return d, nil
}
func (c *Client) RecentEvents(ctx context.Context, n int) ([]IntakeEvent, error) {
var e []IntakeEvent
if err := c.call(ctx, MethodRecentEvents, nReq{N: n}, &e); err != nil {
+7
View File
@@ -169,6 +169,13 @@ type SystemAPI interface {
// persisted — it's a daemon-level cache).
TickTrace(ctx context.Context) (TickTrace, error)
// TurnDecisions returns the newest turn arbitration records, newest first
// (V-564). Same shape as TickTrace and RecentEvents: a bounded in-memory
// ring on the daemon, so the store adapter returns an error rather than
// pretending a table exists. Empty is a normal answer — it means no turn
// has run since the daemon started.
TurnDecisions(ctx context.Context, n int) ([]TurnDecision, error)
// RecentEcosystemTraces reads the ecosystem call log, which lives in its
// own table so machine-rate traces never crowd out human-rate facts.
RecentEcosystemTraces(ctx context.Context, n int) ([]EcosystemTrace, error)
+7
View File
@@ -583,6 +583,13 @@ var methodTable = map[Method]handlerFunc{
MethodTickTrace: withoutParams(func(ctx context.Context, api CoreAPI) (TickTrace, error) {
return api.TickTrace(ctx)
}),
MethodTurnDecisions: withParams(func(ctx context.Context, api CoreAPI, p nReq) ([]TurnDecision, error) {
d, err := api.TurnDecisions(ctx, p.N)
if d == nil {
d = []TurnDecision{}
}
return d, err
}),
// MorningStatus intentionally has no nil→[]T{} normalization here — the
// pre-table arm marshaled api.MorningStatus's result as-is (a nil slice
// serializes as JSON null), and this preserves that exact wire shape.
+6
View File
@@ -265,6 +265,12 @@ func (a *storeAPI) TickTrace(ctx context.Context) (TickTrace, error) {
return TickTrace{}, errors.New("store: tick trace not available via direct store API")
}
// TurnDecisions — same story as TickTrace: the arbitration record is a daemon
// ring, not a table, so there is nothing here to read it from (V-564).
func (a *storeAPI) TurnDecisions(ctx context.Context, n int) ([]TurnDecision, error) {
return nil, errors.New("store: turn decisions not available via direct store API")
}
// SeedEvent — same shape as MorningStatus: writing the fact is a store call,
// but extraction and detect-and-propose live in mavend, and a seed that wrote
// the fact without running them would be the one thing this seam must not be,
+3
View File
@@ -144,6 +144,9 @@ func (UnimplementedCoreAPI) RevertFact(ctx context.Context, key string) (int64,
func (UnimplementedCoreAPI) TickTrace(ctx context.Context) (TickTrace, error) {
return TickTrace{}, ErrNotImplemented
}
func (UnimplementedCoreAPI) TurnDecisions(ctx context.Context, n int) ([]TurnDecision, error) {
return nil, ErrNotImplemented
}
func (UnimplementedCoreAPI) MorningStatus(ctx context.Context) ([]MorningRoutineStatus, error) {
return nil, ErrNotImplemented
}
+1
View File
@@ -48,6 +48,7 @@ const (
MethodAcceptProposedRoutine Method = "accept_proposed_routine"
MethodRevertFact Method = "revert_fact"
MethodTickTrace Method = "tick_trace"
MethodTurnDecisions Method = "turn_decisions"
MethodMorningStatus Method = "morning_status"
MethodMCPServers Method = "mcp_servers"
MethodDayPlan Method = "day_plan"
+21
View File
@@ -65,6 +65,7 @@ func mustLoad() lexiconFile {
"day_offsets", "weekdays", "months_genitive", "hours_spoken",
"not_place_after_v", "parts_of_day", "reminder_verbs", "half_hour",
"filler_particles", "task_done_words", "task_drop_words",
"confirm_yes", "confirm_no",
} {
s, ok := f.Sets[name]
if !ok || (len(s.Words) == 0 && len(s.Values) == 0) {
@@ -121,9 +122,29 @@ func ReminderVerbs() []string { return words("reminder_verbs") }
// notes say: an imperative exactly, a stative by lemma.
func TaskDoneWords() []string { return words("task_done_words") }
// ConfirmYes returns the words that answer a parked confirm with yes, and
// ConfirmNo the ones that answer it with no. Some members are multi-word ("не
// надо"), so a caller matches longest-first over tokens rather than looking up
// one word at a time. See the sets' notes for why neither may be matched as a
// substring.
func ConfirmYes() []string { return words("confirm_yes") }
// ConfirmNo — see ConfirmYes.
func ConfirmNo() []string { return words("confirm_no") }
// TaskDropWords — see TaskDoneWords.
func TaskDropWords() []string { return words("task_drop_words") }
// SlotValueFrame returns the words that can surround a bare slot value without
// making the utterance a request of its own. A caller strips these (along with
// the numbers and the other closed time sets) to see whether an utterance
// carries any content beside the value it was asked for. See the set's note.
func SlotValueFrame() []string { return words("slot_value_frame") }
// DialogueCancel returns the ways he calls off the request Maven is assembling.
// Distinct from TaskDropWords, which abandons an item that already exists.
func DialogueCancel() []string { return words("dialogue_cancel") }
// IsFillerParticle reports whether a word can never be the subject of a
// request: a particle, a politeness word, or the first-person object. See the
// set's own note for why this is not a stopword list.
+36
View File
@@ -197,6 +197,42 @@
"drop", "remove", "cancel",
"передумал", "передумала", "неактуально"
]
},
"slot_value_frame": {
"note": "The words that can stand around a bare slot value without making the utterance a request of its own (Vikunja #560). Prepositions, hedges and the nouns a spoken time is built from: strip these, the numbers, the interrogatives, the filler particles and the other time sets, and whatever is left is the utterance's OWN content. \"а что если в 11:00\" leaves nothing and is an answer; \"какая сейчас погода в Риме\" leaves \"погода\" and \"Риме\" and is not. Closed because each part of it is closed — Russian has a fixed list of prepositions, and a clock is built from a fixed list of nouns. It is not a stopword list: a word goes in only if it can never be the thing he is asking about.",
"words": [
"в", "во", "на", "к", "ко", "до", "с", "со", "за", "по", "под", "около", "через", "после", "перед", "от", "из", "у", "при", "про",
"at", "on", "in", "by", "to", "till", "until", "after", "before", "about", "for",
"нет", "не", "да", "ага", "угу", "ой", "ох", "тогда", "лучше", "может", "можно", "наверное", "наверно", "пожалуй", "точнее", "скорее", "если", "пусть", "прости", "извини", "слушай", "значит", "как-то", "типа", "вообще-то",
"no", "yes", "yeah", "ok", "okay", "sorry", "maybe", "actually", "rather", "then", "well",
"час", "часа", "часов", "часу", "часам", "минут", "минута", "минуты", "минуту", "минутах", "полдень", "полночь", "полдня",
"утра", "утро", "утру", "дня", "день", "днями", "вечера", "вечер", "вечеру", "ночи", "ночь", "ночью",
"сейчас", "теперь", "сегодняшний", "ближайший", "ближайшее",
"hour", "hours", "minute", "minutes", "noon", "midnight", "am", "pm", "oclock", "now"
]
},
"dialogue_cancel": {
"note": "The ways he calls off the request Maven is in the middle of assembling (Vikunja #560). Not task_drop_words: those abandon a Praxis item that exists, these abandon a question she has only just asked, and \"удали\" must never mean the second. Matched as the WHOLE utterance minus its frame, because \"забудь\" alone calls off the reminder and \"забудь купить молоко\" is a sentence with content of its own.",
"words": [
"отмена", "отмени", "отменить", "отставить", "забудь", "забей", "неважно", "проехали", "передумал", "передумала",
"cancel", "nevermind", "forget"
]
},
"confirm_yes": {
"note": "The whole vocabulary of saying yes to a parked confirm, Russian and English. Closed because it is her question that is being answered: she asked \"да или нет\", and the answers to that question can be listed. Matched as whole tokens and never as substrings — \"погода\", \"давление\" and \"дальше\" all contain \"да\", and a substring test executed a destructive act when he asked about the weather (V-567). Words that merely sound agreeable — \"хорошо\", \"ладно\", \"точно\" — are deliberately absent: they open a sentence about something else as often as they answer, and an unclear answer must route rather than execute.",
"words": [
"да", "ага", "угу", "давай", "давайте", "конечно",
"подтверждаю", "подтверди", "подтвердить", "выполняй", "валяй",
"yes", "yeah", "yep", "yup", "ok", "okay", "sure", "confirm", "affirmative"
]
},
"confirm_no": {
"note": "The answers that decline a parked confirm. Same matching rule as confirm_yes and the same reason. The multi-word members are here rather than assembled by a caller because \"надо\" alone is not an answer and \"не надо\" is the opposite of one: the two must land on opposite sides, and only the phrase says which. \"не\" on its own is NOT a member — \"не забудь купить хлеб\" is a reminder, not a refusal.",
"words": [
"нет", "неа", "нельзя", "отмена", "отмени", "отменить", "отставить",
"стоп", "стой", "не надо", "не нужно", "не стоит", "не сейчас", "не хочу",
"no", "nope", "nah", "negative", "cancel", "stop", "don't", "dont"
]
}
}
}
+51
View File
@@ -0,0 +1,51 @@
package router
import (
"regexp"
"strings"
)
// BareCaptureGrammar — a capture verb with nothing after it is a fact she has
// yet to hear, not a conversation (Vikunja #557).
//
// A bare "запиши" reached the resident model as chat, and the model answered by
// agreeing to something nobody asked for: "Я поняла, теперь я буду говорить
// «записала» или «записала заметку»." It read its own instruction block as the
// subject of the turn. Whatever the routing, that reply is invented.
//
// The route this rule asks for is a fact with no key, which is a gap the clarify
// path already has copy for ("Что записать?"). So the rule does not answer the
// turn — it hands it to the one mechanism that asks.
//
// Wired before TaskCaptureGrammar, whose pattern needs an object, so it can
// never claim this shape. There is nothing to disambiguate: the whole utterance
// is one verb from a closed lexicon.
func BareCaptureGrammar() []Grammar {
return []Grammar{{
Name: "bare-capture",
Pattern: bareCapturePattern,
Build: bareCaptureBuild,
}}
}
// Anchored at both ends, so only the verb and punctuation are in the utterance.
// Trailing "-ка" and "пожалуйста" are the same request said politely.
var bareCapturePattern = regexp.MustCompile(`(?i)^\s*([\p{L}]+)(?:-ка)?[,\s]*(?:пожалуйста)?[\s.!?]*$`)
func bareCaptureBuild(m []string) (Decision, bool) {
word := strings.ToLower(strings.TrimSpace(m[1]))
for _, v := range captureVerbs {
if word != v {
continue
}
// No key, no text: she was told to record and not what. Confidence is
// 1.0 about the shape, which is all stage 0 ever claims — the gap is
// carried by the empty slots, not by a doubt.
return Decision{
Stage: 0,
Intent: IntentFact,
Confidence: 1.0,
}, true
}
return Decision{}, false
}
+37
View File
@@ -0,0 +1,37 @@
package router
import "testing"
// TestBareCaptureIsAFactWithNoKey — Vikunja #557. The whole point is the gap:
// she must route it as a fact she cannot write yet, so the clarify path asks.
func TestBareCaptureIsAFactWithNoKey(t *testing.T) {
for _, u := range []string{"запиши", "Запиши.", "запомни, пожалуйста", "отметь!", "note", "запиши-ка"} {
m := bareCapturePattern.FindStringSubmatch(u)
if m == nil {
t.Errorf("%q did not match the shape", u)
continue
}
dec, ok := bareCaptureBuild(m)
if !ok {
t.Errorf("%q should route as a fact with no key", u)
continue
}
if dec.Intent != IntentFact || dec.Slots.HasKey || dec.Slots.Text != "" {
t.Errorf("%q built %+v, want an empty fact", u, dec)
}
}
}
// TestBareCaptureDeclinesAnythingWithAnObject — the object cases belong to the
// capture markers and the cascade, and a lone non-capture word is not this rule.
func TestBareCaptureDeclinesAnythingWithAnObject(t *testing.T) {
for _, u := range []string{"запиши что я пил воду", "добавь задачу купить хлеб", "привет", "вода", "расскажи"} {
m := bareCapturePattern.FindStringSubmatch(u)
if m == nil {
continue // shape already declined it
}
if _, ok := bareCaptureBuild(m); ok {
t.Errorf("%q must not be claimed as a bare capture", u)
}
}
}
+113
View File
@@ -0,0 +1,113 @@
package router
import "github.com/kami/maven/internal/claim"
// ClaimOf — build a claim.Claim from a Decision (V-565, design in
// docs/plans/19-dialogue-arbitration.md).
//
// Additive and beside the existing path. Decision.Confidence keeps its float
// and keeps working: r.threshold and gateLLMDecision read it, and the
// classifier cascade is the failure floor. Nothing in Route calls this yet.
// The arbiter that reads claims is V-560.
//
// claimant names who produced the decision. The cascade does not record which
// stage-0 grammar matched, so the caller passes what it knows and the builder
// does not guess.
func ClaimOf(claimant string, d Decision) claim.Claim {
consumed, unexplained := claim.Split(d.Utterance, claimSpans(d)...)
return claim.Claim{
Claimant: claimant,
Intent: string(d.Intent),
Filled: filledSlots(d.Slots),
Consumed: consumed,
Unexplained: unexplained,
Band: bandOf(d),
Veto: vetoOf(d),
}
}
// claimSpans — the parts of the utterance the decision says it read. Slot
// values, not the utterance, because coverage is the question of how much of
// the sentence the claim actually explains.
//
// A stage-0 grammar reports whatever its Build put in the slots, which for the
// reminder rule is the text after "напомни" and not the verb itself. That
// under-reports coverage rather than over-reporting it, which is the safe
// direction: a claim that overstates what it explains wins arbitrations it
// should lose.
func claimSpans(d Decision) []string {
spans := []string{d.Slots.Text, d.Slots.Key, d.Slots.Value, d.Slots.Fn}
return append(spans, d.Slots.Args...)
}
// filledSlots — the slot names this decision would fill. Text counts only when
// it differs from the whole utterance: fillSlots backfills the raw utterance
// into Text for a note, a query and a chat turn, so a set Text is not by itself
// evidence that anything was extracted.
func filledSlots(s Slots) []string {
var out []string
if s.HasTime {
out = append(out, "time")
}
if s.HasFn {
out = append(out, "fn")
}
if s.HasKey {
out = append(out, "key")
}
if s.Text != "" {
out = append(out, "text")
}
return out
}
// bandOf — which kind of evidence this decision rests on.
//
// Stage 0 is anchored: a literal pattern matched and its span decided the
// intent. The LLM path (stage 1) is structural: the model read the whole
// sentence, and gateLLMDecision already checked the route for structural
// holes. The classifier (stages 2 and 3) is nearest, and the measurement is why
// it is one band rather than a scale — on the 91-case RU fixture its cosine
// spans 0.859 to 0.942 and scores 62% at both ends.
//
// A decision carrying a veto lands in BandVetoed regardless of who produced it.
// That is the point of the band: a self-vetoed claim should lose to any claim
// that is not, whatever machinery built it.
func bandOf(d Decision) claim.Band {
if vetoOf(d) != "" {
return claim.BandVetoed
}
switch d.Stage {
case 0:
return claim.BandAnchored
case 1:
return claim.BandStructural
default:
return claim.BandNearest
}
}
// vetoOf — why this decision should not win, recovered as a reason rather than
// a number.
//
// gateLLMDecision flattens three named structural holes into
// llmThinConfidence, and the reason is lost at that point: 0.3 tells a reader
// that something was wrong and never which thing. The same three conditions are
// checked here so the claim carries the sentence a trace can print and the
// owner can be told.
//
// Clarify is checked last and is the general case. A decision below threshold
// has already asked to be doubted, whichever path set it.
func vetoOf(d Decision) string {
switch {
case d.Intent == IntentFact && !d.Slots.HasKey:
return "fact with no key: nothing to write, or a confident write under the wrong key"
case d.Intent == IntentAct && !d.Slots.HasFn:
return "act with no allowlisted fn: running an unlisted command or silently doing nothing"
case d.Intent == IntentReminder && !reminderHasSubject(d.Slots.Text):
return "reminder with no subject: it would fire empty at the hour"
case d.Clarify:
return "below the confidence gate"
}
return ""
}
+160
View File
@@ -0,0 +1,160 @@
package router
import (
"context"
"reflect"
"testing"
"time"
"github.com/kami/maven/internal/claim"
)
func TestClaimOfBands(t *testing.T) {
cases := []struct {
name string
dec Decision
want claim.Band
}{
{
name: "stage 0 is anchored",
dec: Decision{
Utterance: "сколько времени", Stage: 0, Intent: IntentSystem,
Confidence: 1.0, Slots: Slots{Text: "сколько времени"},
},
want: claim.BandAnchored,
},
{
name: "the llm path is structural",
dec: Decision{
Utterance: "выпил воды", Stage: 1, Intent: IntentFact,
Confidence: llmFullConfidence,
Slots: Slots{Key: "water", Value: "1", HasKey: true, Text: "выпил воды"},
},
want: claim.BandStructural,
},
{
name: "the classifier is nearest",
dec: Decision{
Utterance: "что нового", Stage: 2, Intent: IntentQuery,
Confidence: 0.91, Slots: Slots{Text: "что нового"},
},
want: claim.BandNearest,
},
{
name: "a structural hole vetoes whoever found it",
dec: Decision{
Utterance: "запиши", Stage: 1, Intent: IntentFact,
Confidence: llmThinConfidence, Slots: Slots{Text: "запиши"},
},
want: claim.BandVetoed,
},
{
name: "clarify vetoes a classifier decision",
dec: Decision{
Utterance: "сделай это", Stage: 3, Intent: IntentNote,
Confidence: 0.2, Clarify: true, Slots: Slots{Text: "сделай это"},
},
want: claim.BandVetoed,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := ClaimOf("test", tc.dec)
if got.Band != tc.want {
t.Errorf("band = %v, want %v (veto %q)", got.Band, tc.want, got.Veto)
}
})
}
}
// The veto has to name the hole. Folding all three arms into llmThinConfidence
// is what lost the reason, and 0.3 tells a reader that something was wrong but
// never which thing.
func TestClaimOfVetoNamesTheHole(t *testing.T) {
cases := []struct {
name string
dec Decision
want string
}{
{"keyless fact", Decision{Intent: IntentFact}, "fact with no key"},
{"act with no fn", Decision{Intent: IntentAct}, "act with no allowlisted fn"},
{"subjectless reminder", Decision{Intent: IntentReminder, Slots: Slots{Text: "напомни"}}, "reminder with no subject"},
{"below the gate", Decision{Intent: IntentQuery, Clarify: true}, "below the confidence gate"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := ClaimOf("test", tc.dec)
if !got.Vetoed() {
t.Fatalf("no veto, want one about %q", tc.want)
}
if len(got.Veto) < len(tc.want) || got.Veto[:len(tc.want)] != tc.want {
t.Errorf("veto = %q, want it to start with %q", got.Veto, tc.want)
}
})
}
}
// A route with every slot filled must NOT be vetoed. The three arms are
// structural holes, not a tax on every decision.
func TestClaimOfCompleteRouteIsNotVetoed(t *testing.T) {
d := Decision{
Utterance: "напомни позвонить маме в семь", Stage: 1, Intent: IntentReminder,
Confidence: llmFullConfidence,
Slots: Slots{Text: "позвонить маме", Time: time.Now(), HasTime: true},
}
c := ClaimOf("llm", d)
if c.Vetoed() {
t.Errorf("complete reminder vetoed: %q", c.Veto)
}
if c.Band != claim.BandStructural {
t.Errorf("band = %v, want structural", c.Band)
}
}
func TestClaimOfCoverageAndFilledSlots(t *testing.T) {
d := Decision{
Utterance: "напомни позвонить маме", Stage: 0, Intent: IntentReminder,
Confidence: 1.0, Slots: Slots{Text: "позвонить маме"},
}
c := ClaimOf("reminder-wakeword", d)
// The grammar captures what follows the verb, so "напомни" itself is
// unexplained. Under-reporting is the safe direction.
if len(c.Consumed) != 2 || len(c.Unexplained) != 1 {
t.Errorf("consumed %q / unexplained %q, want 2 and 1", c.Consumed, c.Unexplained)
}
if got := c.Coverage(); got < 0.66 || got > 0.67 {
t.Errorf("coverage = %v, want about 2/3", got)
}
if c.Intent != string(IntentReminder) {
t.Errorf("intent = %q", c.Intent)
}
if len(c.Filled) != 1 || c.Filled[0] != "text" {
t.Errorf("filled = %q, want [text]", c.Filled)
}
if c.Claimant != "reminder-wakeword" {
t.Errorf("claimant = %q", c.Claimant)
}
}
// The point of V-565's "additive" constraint, asserted rather than trusted:
// building a claim reads a Decision and changes nothing about it, so the
// classifier floor and the two consumers of Confidence are untouched.
func TestClaimOfLeavesTheDecisionAlone(t *testing.T) {
r := New(Config{
Grammars: []Grammar{ReminderGrammar()},
Extractor: Extractor{},
Threshold: 0.55,
})
before, err := r.Route(context.Background(), "напомни полить цветы", time.Now())
if err != nil {
t.Fatalf("Route: %v", err)
}
after := before
_ = ClaimOf("reminder-wakeword", after)
if !reflect.DeepEqual(after, before) {
t.Errorf("ClaimOf mutated the decision: %+v vs %+v", after, before)
}
if before.Confidence != 1.0 {
t.Errorf("stage 0 confidence = %v, want 1.0 — the float still has to work", before.Confidence)
}
}
+83
View File
@@ -0,0 +1,83 @@
// router/decisiontrace.go — what the cascade tells the per-turn decision record.
//
// The cascade's arbitration is order (V-558): the first grammar whose Build
// agrees wins, and the model and the classifier are only reached because nobody
// upstream did. None of that is visible afterwards, so V-564 has each stage say
// its piece into the record riding the context. Nothing here reads the record
// back and nothing here can change a route — a nil recorder is the normal case
// in the fixture runner and every router test.
package router
import (
"context"
"github.com/kami/maven/internal/decision"
)
// The two routing engines, named as claimants. They are one stage and not two,
// because only one of them ever runs: the classifier is reached when the model
// is absent or errored, never alongside it.
const (
claimantLLM = "llm-router"
claimantClassifier = "classifier"
)
// thinReason names which arm of gateLLMDecision cut the confidence. The gate
// has three structural holes and they are three different defects, so "thinned"
// alone is not enough to act on.
func thinReason(d *Decision) string {
switch {
case d.Intent == IntentFact && !d.Slots.HasKey:
return "a fact with no key even after the parser tried"
case d.Intent == IntentAct && !d.Slots.HasFn:
return "an act that never resolved to an allowlisted fn"
case d.Intent == IntentReminder && !reminderHasSubject(d.Slots.Text):
return "a reminder with no subject to say at the hour"
default:
return "below the clarify threshold"
}
}
// Reasons a stage-0 grammar did not take a turn. Kept apart because they are
// different defects: a pattern that never matched is a rule that does not know
// the shape, a Build that declined is a rule that knew the shape and refused
// the content (narrative-query and the wakeword acts do this by design), and a
// grammar after the winner was never consulted at all.
const (
reasonNoMatch = "pattern did not match"
reasonBuildDeclmn = "matched the shape, Build declined the content"
reasonEarlierClaim = "an earlier grammar claimed the turn"
)
// noteGrammarOutcomes records the stage-0 pass. examined is how many grammars
// were reached; declined holds the names whose Build said no; won is the winner
// or empty. Everything past the winner is named as never asked, because that
// silence is the thing the hardcoded order hides.
func (r *Router) noteGrammarOutcomes(ctx context.Context, examined int, declined map[int]bool, won string, intent Intent) {
rec := decision.From(ctx)
if rec == nil {
return
}
for i, g := range r.grammars {
switch {
case i >= examined:
rec.Note(decision.Claim{
Stage: decision.StageZero, Claimant: g.Name,
Outcome: decision.NeverAsked, Reason: reasonEarlierClaim,
})
case g.Name == won:
rec.Note(decision.Scored(decision.StageZero, g.Name, string(intent), 1.0,
decision.Won, ""))
case declined[i]:
rec.Note(decision.Claim{
Stage: decision.StageZero, Claimant: g.Name,
Outcome: decision.Declined, Reason: reasonBuildDeclmn,
})
default:
rec.Note(decision.Claim{
Stage: decision.StageZero, Claimant: g.Name,
Outcome: decision.Declined, Reason: reasonNoMatch,
})
}
}
}
+230
View File
@@ -0,0 +1,230 @@
package eval
import (
"context"
"fmt"
"os"
"path/filepath"
"sort"
"testing"
"github.com/kami/maven/internal/router"
)
// Package-level note for V-565. The cascade's arbitration is list order, and
// the reason is that no two claimants report a comparable number. These tests
// measure what each claimant actually reports across the 91-case RU fixture,
// so the ordinal band set in docs/plans/19-dialogue-arbitration.md is argued
// from a distribution rather than from taste. They report and never assert:
// a ratchet here would freeze a number nobody has decided to hold yet.
// TestStage0Contention — how often more than one stage-0 grammar matches the
// same utterance. Every one of them reports Confidence 1.0, so where two
// match, list order is the entire decision and nothing in the Decision says a
// second rule wanted the turn.
func TestStage0Contention(t *testing.T) {
f, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
grammars := baselineGrammars(router.DefaultActMatcher{Fns: actFns})
t.Logf("stage 0: %d grammars over %d cases", len(grammars), len(f.Cases))
matched, contended := 0, 0
pairs := map[string]int{}
for _, c := range f.Cases {
claimants := matchingGrammars(grammars, c.Utterance)
if len(claimants) == 0 {
continue
}
matched++
if len(claimants) < 2 {
continue
}
contended++
t.Logf(" contended %s %q: %v (winner %q by order)", c.ID, c.Utterance, claimants, claimants[0])
for _, loser := range claimants[1:] {
pairs[claimants[0]+" beats "+loser]++
}
}
t.Logf("stage 0 claimed %d/%d cases, %d of those with more than one claimant", matched, len(f.Cases), contended)
for _, k := range sortedKeys(pairs) {
t.Logf(" %s ×%d", k, pairs[k])
}
}
// matchingGrammars — every grammar whose pattern matches AND whose Build
// accepts, in the daemon's order. Route stops at the first; this does not.
func matchingGrammars(grammars []router.Grammar, utterance string) []string {
stripped, hadWake := router.StripWakeToken(utterance)
var out []string
for _, g := range grammars {
m := g.Pattern.FindStringSubmatch(utterance)
if m == nil && hadWake {
m = g.Pattern.FindStringSubmatch(stripped)
}
if m == nil {
continue
}
if _, ok := g.Build(m); !ok {
continue
}
out = append(out, g.Name)
}
return out
}
// TestClaimConfidenceDistributionHash — the confidence each claimant reports,
// on the deterministic hash embedder so it runs anywhere. The ONNX run below
// is the one whose cosines are the deployed numbers.
func TestClaimConfidenceDistributionHash(t *testing.T) {
reportConfidences(t, "hash", router.NewHashEmbedder(1024))
}
// TestONNXClaimConfidenceDistribution — the same measurement on the embedder
// homesrv runs, so the cosine column is the real one. Opt-in via
// MAVEN_ONNX_LIB, same as TestONNXBaseline, and one TestONNX* per process.
func TestONNXClaimConfidenceDistribution(t *testing.T) {
lib := os.Getenv("MAVEN_ONNX_LIB")
if lib == "" {
t.Skip("MAVEN_ONNX_LIB unset — see AGENTS.md § Embedder model for intent routing")
}
model := filepath.Join("../../..", "models/embedder/multilingual-e5-small/model_quantized.onnx")
tok := filepath.Join("../../..", "models/embedder/multilingual-e5-small/tokenizer.json")
for _, p := range []string{lib, model, tok} {
if _, err := os.Stat(p); err != nil {
t.Skipf("missing %s: %v", p, err)
}
}
emb, err := router.NewONNXEmbedder(model, tok, lib)
if err != nil {
t.Skipf("onnx embedder unavailable: %v", err)
}
defer emb.Close()
reportConfidences(t, "onnx", emb)
}
// reportConfidences runs the fixture through the deployed cascade and buckets
// the reported confidence by which layer produced it, then reports how well
// each bucket predicts a correct route. A band is only worth defining if the
// accuracy inside it differs from the accuracy outside it.
func reportConfidences(t *testing.T, name string, emb router.Embedder) {
t.Helper()
f, err := Load()
if err != nil {
t.Fatalf("Load: %v", err)
}
now, err := f.Now()
if err != nil {
t.Fatalf("Now: %v", err)
}
r := newBaselineRouter(t, emb, nil)
cls := newBaselineClassifier(t, emb)
type bucket struct{ n, correct int }
byValue := map[string]*bucket{}
byMargin := map[string]*bucket{}
var cosines, margins []float64
for _, c := range f.Cases {
d, err := r.Route(context.Background(), c.Utterance, now)
if err != nil {
t.Fatalf("%s: %v", c.ID, err)
}
layer := "classifier"
if d.Stage == 0 {
layer = "stage0"
} else {
cosines = append(cosines, d.Confidence)
}
key := fmt.Sprintf("%s conf=%.2f", layer, d.Confidence)
if layer == "classifier" {
key = fmt.Sprintf("%s conf=%.1f..%.1f", layer, floorTo(d.Confidence, 0.1), floorTo(d.Confidence, 0.1)+0.1)
}
b := byValue[key]
if b == nil {
b = &bucket{}
byValue[key] = b
}
ok := routeCorrect(c, d)
b.n++
if ok {
b.correct++
}
// The margin between the classifier's top two intents is the other
// float one could call a confidence. Measured on the same cases, so
// the ledger's "is a calibrated float available cheaply" question
// gets an answer instead of an assumption.
if layer != "classifier" {
continue
}
res, err := cls.Classify(context.Background(), c.Utterance)
if err != nil || len(res) < 2 {
continue
}
margin := res[0].Score - res[1].Score
margins = append(margins, margin)
mk := fmt.Sprintf("margin %.2f..%.2f", floorTo(margin, 0.02), floorTo(margin, 0.02)+0.02)
mb := byMargin[mk]
if mb == nil {
mb = &bucket{}
byMargin[mk] = mb
}
mb.n++
if ok {
mb.correct++
}
}
t.Logf("%s: confidence buckets over %d cases (correct = right intent, or clarified when the fixture wants a refusal)", name, len(f.Cases))
for _, k := range sortedKeys2(byValue) {
b := byValue[k]
t.Logf(" %-32s n=%2d correct=%2d (%.0f%%)", k, b.n, b.correct, 100*float64(b.correct)/float64(b.n))
}
if len(cosines) > 0 {
sort.Float64s(cosines)
t.Logf(" classifier cosine spread: min %.3f p25 %.3f p50 %.3f p75 %.3f max %.3f",
cosines[0], cosines[len(cosines)/4], cosines[len(cosines)/2],
cosines[3*len(cosines)/4], cosines[len(cosines)-1])
}
if len(margins) > 0 {
sort.Float64s(margins)
t.Logf(" classifier top1-top2 margin: min %.3f p50 %.3f max %.3f",
margins[0], margins[len(margins)/2], margins[len(margins)-1])
for _, k := range sortedKeys2(byMargin) {
b := byMargin[k]
t.Logf(" %-32s n=%2d correct=%2d (%.0f%%)", k, b.n, b.correct, 100*float64(b.correct)/float64(b.n))
}
}
}
// routeCorrect — the intent contract only. Slots are a parser question and
// would blur what the confidence number is being asked to predict.
func routeCorrect(c Case, d router.Decision) bool {
if c.WantClarify {
return d.Clarify
}
return d.Intent == c.Intent && !d.Clarify
}
func floorTo(v, step float64) float64 {
return float64(int(v/step)) * step
}
func sortedKeys(m map[string]int) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
func sortedKeys2[T any](m map[string]T) []string {
out := make([]string, 0, len(m))
for k := range m {
out = append(out, k)
}
sort.Strings(out)
return out
}
+31 -13
View File
@@ -216,6 +216,27 @@ func TestONNXBaseline(t *testing.T) {
func newBaselineRouter(t *testing.T, emb router.Embedder, llmR *router.LLMRouter) *router.Router {
t.Helper()
acts := router.DefaultActMatcher{Fns: actFns}
cls := newBaselineClassifier(t, emb)
return router.New(router.Config{
Grammars: baselineGrammars(acts),
Classifier: cls,
Extractor: router.Extractor{
Time: router.StubDateTimeParser{},
Acts: acts,
Facts: router.DefaultFactParser{},
},
// The deployed gate, not a test-local one: a fixture scored at a looser
// threshold reports an accuracy no real turn would see.
Threshold: config.DefaultRouterThreshold,
LLM: llmR,
})
}
// newBaselineClassifier — the seeded nearest-centroid classifier the cascade
// runs. Split out of newBaselineRouter so the claim measurement can ask it for
// its full ranking, not just the winner the Decision carries.
func newBaselineClassifier(t *testing.T, emb router.Embedder) *router.Classifier {
t.Helper()
cls := router.NewClassifier(emb)
ctx := context.Background()
seeds := seedsWithIntent(t)
@@ -231,6 +252,15 @@ func newBaselineRouter(t *testing.T, emb router.Embedder, llmR *router.LLMRouter
t.Fatalf("seed %q: %v", text, err)
}
}
return cls
}
// baselineGrammars — the stage-0 rule set in the daemon's order (buildRouter in
// cmd/mavend/voicewire.go). Split out of newBaselineRouter so the claim
// measurement can run the same rules one at a time and see which of them
// contend for the same utterance, which the cascade hides by stopping at the
// first match.
func baselineGrammars(acts router.ActMatcher) []router.Grammar {
grammars := router.DefaultGrammars(acts)
grammars = append(grammars, router.SystemTimeDateGrammars()...)
// Same order as buildRouter (voicewire.go). The fixture is only worth
@@ -248,19 +278,7 @@ func newBaselineRouter(t *testing.T, emb router.Embedder, llmR *router.LLMRouter
// "расскажи про X" is a world question the model called a fact, and the
// rule goes last because it matches on the first word alone (Vikunja #498).
grammars = append(grammars, router.NarrativeQueryGrammars()...)
return router.New(router.Config{
Grammars: grammars,
Classifier: cls,
Extractor: router.Extractor{
Time: router.StubDateTimeParser{},
Acts: acts,
Facts: router.DefaultFactParser{},
},
// The deployed gate, not a test-local one: a fixture scored at a looser
// threshold reports an accuracy no real turn would see.
Threshold: config.DefaultRouterThreshold,
LLM: llmR,
})
return grammars
}
// seedOrder — fixed iteration order over the corpus. Not cosmetic: a few
+95 -6
View File
@@ -5,6 +5,8 @@ import (
"errors"
"log"
"time"
"github.com/kami/maven/internal/decision"
)
// Config — wires the cascade. Build via New; a zero-value Router is unusable.
@@ -67,7 +69,11 @@ func (r *Router) Route(ctx context.Context, utterance string, now time.Time) (De
// the STT often includes one (transcribed phonetically, any script) — try
// the wake-stripped utterance too so those grammars still fire.
stripped, hadWake := StripWakeToken(utterance)
for _, g := range r.grammars {
// declinedBuild — the grammars that matched the shape and refused the
// content, kept for the decision record (V-564) so a reader can tell that
// rule from one whose pattern never fired.
var declinedBuild map[int]bool
for i, g := range r.grammars {
m := g.Pattern.FindStringSubmatch(utterance)
if m == nil && hadWake {
m = g.Pattern.FindStringSubmatch(stripped)
@@ -77,11 +83,20 @@ func (r *Router) Route(ctx context.Context, utterance string, now time.Time) (De
}
d, ok := g.Build(m)
if !ok {
if declinedBuild == nil {
declinedBuild = map[int]bool{}
}
declinedBuild[i] = true
continue // grammar matched shape but not content → fall through
}
d.Utterance = utterance
// The grammar decided the intent; the extractor fills the slots it did
// not match (V-572). See fillMatchedSlots for why every grammar gets it.
r.fillMatchedSlots(ctx, &d, now)
r.noteGrammarOutcomes(ctx, i+1, declinedBuild, g.Name, d.Intent)
return d, nil
}
r.noteGrammarOutcomes(ctx, len(r.grammars), declinedBuild, "", "")
// stage 1a — LLM router (when wired). It reasons over the utterance instead
// of nearest-centroid guessing. On any error/parse-fail, fall through to the
@@ -90,11 +105,38 @@ func (r *Router) Route(ctx context.Context, utterance string, now time.Time) (De
if d, ok, err := r.llm.Route(ctx, utterance, now); err == nil && ok {
d.Utterance = utterance
r.fillSlots(ctx, &d, now)
before := d.Confidence
r.gateLLMDecision(&d)
// The classifier is the floor and it never ran, which is the whole
// reason a wrong LLM route reads as unexplainable (V-564).
decision.Note(ctx, decision.Claim{
Stage: decision.StageRoute, Claimant: claimantClassifier,
Outcome: decision.NeverAsked, Reason: "the LLM router answered",
})
outcome, reason := decision.Won, ""
if d.Confidence < before {
outcome, reason = decision.Thinned, thinReason(&d)
}
decision.Note(ctx, decision.Scored(decision.StageRoute, claimantLLM,
string(d.Intent), d.Confidence, outcome, reason))
return d, nil
} else if err != nil {
log.Printf("router: llm route fell back to classifier: %v", err)
decision.Note(ctx, decision.Claim{
Stage: decision.StageRoute, Claimant: claimantLLM,
Outcome: decision.Declined, Reason: "error: " + err.Error(),
})
} else {
decision.Note(ctx, decision.Claim{
Stage: decision.StageRoute, Claimant: claimantLLM,
Outcome: decision.Declined, Reason: "no parsable route in the reply",
})
}
} else {
decision.Note(ctx, decision.Claim{
Stage: decision.StageRoute, Claimant: claimantLLM,
Outcome: decision.NeverAsked, Reason: "no LLM router is wired",
})
}
// stage 1 — intent classifier.
@@ -103,6 +145,16 @@ func (r *Router) Route(ctx context.Context, utterance string, now time.Time) (De
return Decision{}, err
}
best := results[0]
// The runners-up are the interesting part: two intents a hundredth apart is
// a different defect from one that won outright (V-564). Two are enough to
// see that, and the rest of a seven-intent scoreboard is noise on the page.
if rec := decision.From(ctx); rec != nil {
for _, res := range results[1:min(len(results), 3)] {
rec.Note(decision.Scored(decision.StageRoute, claimantClassifier,
string(res.Intent), res.Score, decision.LostOnScore,
"lower similarity than "+string(best.Intent)))
}
}
// stage 2 — slot extraction for the winning intent.
d := Decision{
@@ -118,17 +170,46 @@ func (r *Router) Route(ctx context.Context, utterance string, now time.Time) (De
d.Stage = 3
d.Clarify = true
}
outcome, reason := decision.Won, ""
if d.Clarify {
outcome, reason = decision.Thinned, "below the clarify threshold, so she asks instead"
}
decision.Note(ctx, decision.Scored(decision.StageRoute, claimantClassifier,
string(d.Intent), d.Confidence, outcome, reason))
return d, nil
}
// fillSlots — run stage-2 extraction on an LLM decision and fill only the slots
// the model left empty. The LLM wins where it answered: it saw the sentence, the
// parsers are keyword tables. Extraction covers what the model cannot produce at
// all — a parsed reminder time and an allowlist fn.
// fillMatchedSlots — run stage-2 extraction over a decision some earlier
// claimant produced, and fill only the slots that claimant left empty. A
// matched value always wins: the claimant read the sentence, the extractor
// guesses from keyword tables.
//
// Shared by the stage-0 grammars and the LLM router, which had the same hole
// for the same reason. A grammar asserts an intent at confidence 1.0 and says
// nothing about the slots, so "напомни в 11:00 позвонить маме" arrived with
// HasTime false however plainly the hour was spoken, and the daemon read the
// silence as absence and asked "Когда?" (V-572). The alternative was ten
// grammars each re-implementing extraction.
//
// It is applied to every stage-0 decision rather than to a chosen few, because
// for every intent but reminder it is inert: Extract fills Time for a reminder,
// Fn for an act and Key for a fact, and nothing at all for query, system, note
// or chat, which is what the query, clock, agenda, feed, list, task and
// narrative rules emit. The act rules — wakeword-act and the Praxis ones —
// already carry an Fn or they do not match, so there is nothing left for the
// matcher to fill. The reminder rule is the one that gains, and its time parse
// is a cost the daemon was already paying one layer down in actionReminder.
//
// Slots.Text is deliberately NOT filled here. Extract sets it to the raw
// utterance, and a grammar that left it empty meant it: agendaQueryBuild hands
// the query chain the utterance itself, and narrativeQueryBuild's Text is the
// topic, not the sentence.
//
// If a reminder still has no time, leave it missing. The daemon then says it
// could not read the time; inventing one would set a wrong alarm.
func (r *Router) fillSlots(ctx context.Context, d *Decision, now time.Time) {
// Returns what the extractor read, so a caller that wants more of it does not
// pay for a second extraction — the reminder parser is the expensive one.
func (r *Router) fillMatchedSlots(ctx context.Context, d *Decision, now time.Time) Slots {
ex := r.extractor.Extract(ctx, d.Intent, d.Utterance, now)
if !d.Slots.HasTime && ex.HasTime {
d.Slots.Time, d.Slots.HasTime = ex.Time, ex.HasTime
@@ -139,6 +220,14 @@ func (r *Router) fillSlots(ctx context.Context, d *Decision, now time.Time) {
if !d.Slots.HasFn && ex.HasFn {
d.Slots.Fn, d.Slots.Args, d.Slots.HasFn = ex.Fn, ex.Args, ex.HasFn
}
return ex
}
// fillSlots — fillMatchedSlots for an LLM decision, plus the two backfills that
// only make sense there. The LLM wins where it answered: it saw the sentence,
// the parsers are keyword tables.
func (r *Router) fillSlots(ctx context.Context, d *Decision, now time.Time) {
ex := r.fillMatchedSlots(ctx, d, now)
// For an act the model returns the verb in Text ("restart nginx"), which is
// often cleaner than the raw utterance ("maven, could you restart nginx").
// Try it too when the utterance did not match the allowlist.
+67
View File
@@ -121,6 +121,73 @@ func TestStage0GrammarFiresThroughCyrillicWakeWord(t *testing.T) {
}
}
// TestStage0ReminderCarriesTheHourHeSaid — "напомни в 11:00 позвонить маме" is
// the commonest reminder there is, and it used to reach the daemon with HasTime
// false, because ReminderGrammar builds its slots by hand and the router ran no
// extraction over a stage-0 decision. The daemon read the silence as absence and
// asked "Когда?" about an hour he had just said (V-572).
func TestStage0ReminderCarriesTheHourHeSaid(t *testing.T) {
r := newTestRouter(t, 0.0)
r.grammars = append(r.grammars, ReminderGrammar())
d, err := r.Route(context.Background(), "напомни в 11:00 позвонить маме", refNow())
if err != nil {
t.Fatalf("route: %v", err)
}
if d.Stage != 0 || d.Intent != IntentReminder {
t.Fatalf("want stage0 reminder, got %+v", d)
}
if !d.Slots.HasTime {
t.Fatalf("the hour was spoken, so the slot must be filled: %+v", d.Slots)
}
if got, want := d.Slots.Time.Format("15:04"), "11:00"; got != want {
t.Errorf("fire time = %s, want %s", got, want)
}
// The subject is the grammar's, not the extractor's: Slots.Text is what she
// says at the hour, and Extract would have overwritten it with the sentence.
if d.Slots.Text != "в 11:00 позвонить маме" {
t.Errorf("Text = %q, want the grammar's capture", d.Slots.Text)
}
}
// TestStage0MatchedSlotBeatsTheExtractor — a grammar that matched a literal
// pattern outranks a parser that guessed. The wake-word act names its fn from
// the remainder after the wake token; extraction over the raw utterance must not
// be able to replace it.
func TestStage0MatchedSlotBeatsTheExtractor(t *testing.T) {
r := newTestRouter(t, 0.0)
d, err := r.Route(context.Background(), "maven, restart nginx", refNow())
if err != nil {
t.Fatalf("route: %v", err)
}
if d.Slots.Fn != "restart" || len(d.Slots.Args) != 1 || d.Slots.Args[0] != "nginx" {
t.Fatalf("matched fn was overwritten: %+v", d.Slots)
}
if d.Slots.Text != "restart nginx" {
t.Errorf("Text = %q, want the grammar's remainder", d.Slots.Text)
}
}
// TestStage0QueryKeepsAnEmptyText — agendaQueryBuild deliberately leaves Text
// empty so the query chain reads the utterance itself. Extraction fills Time,
// Key and Fn and never Text, or every stage-0 query would start carrying the
// whole sentence in a slot that means something narrower.
func TestStage0QueryKeepsAnEmptyText(t *testing.T) {
r := newTestRouter(t, 0.0)
r.grammars = append(r.grammars, AgendaQueryGrammars()...)
d, err := r.Route(context.Background(), "что у меня сегодня", refNow())
if err != nil {
t.Fatalf("route: %v", err)
}
if d.Stage != 0 || d.Intent != IntentQuery {
t.Fatalf("want stage0 query, got %+v", d)
}
if d.Slots.Text != "" {
t.Errorf("Text = %q, want it left empty", d.Slots.Text)
}
}
// ----------------------------- stage 1 ---------------------------------------
func TestStage1ClassifiesAct(t *testing.T) {
+6 -5
View File
@@ -88,11 +88,12 @@ func DefaultGrammars(actMatcher ActMatcher) []Grammar {
// non-reminder time queries toward it, and the verb+action overlap pushes
// actual reminders toward fact — a double contamination. Stage 0 fixes both.
//
// The grammar captures the part after "напомни"/"remind me" into Slots.Text
// so the daemon's time parser can extract the fire time from it. The grammar
// itself does NOT parse time — that's the extractor's job (stage 2), but
// stage 0 skips the extractor. The daemon's applyAction fallback calls the
// time parser for stage-0 reminders that arrive without HasTime.
// The grammar captures the part after "напомни"/"remind me" into Slots.Text
// what she says at the hour. The grammar itself does NOT parse time; that is
// the extractor's job, and since V-572 the router runs the extractor over a
// stage-0 decision too (fillMatchedSlots in router.go). Before that it did not,
// so "напомни в 11:00 позвонить маме" reached the daemon with HasTime false and
// was asked "Когда?" about an hour he had just said.
func ReminderGrammar() Grammar {
return Grammar{
Name: "reminder-wakeword",