Compare commits
38 Commits
b6305f1b6e
...
70b32af8a7
| Author | SHA1 | Date | |
|---|---|---|---|
| 70b32af8a7 | |||
| fffd0cb5fa | |||
| 77a7c994d7 | |||
| 63812af920 | |||
| 869580c913 | |||
| 31deb7d565 | |||
| 1338ec6e2a | |||
| 7843728174 | |||
| a3ad9b5040 | |||
| cb0a3a4f20 | |||
| 6abd2768e8 | |||
| 6e6f73da35 | |||
| 1a64c30427 | |||
| 5753f90752 | |||
| 4d94277836 | |||
| 530c3ff395 | |||
| e031f8f5f5 | |||
| 6c24e19b83 | |||
| 934a28d67c | |||
| cf28f6fdf0 | |||
| eec3d9bed2 | |||
| a5b245dbf5 | |||
| 3e6a427e85 | |||
| 5ac7347c38 | |||
| 5417692566 | |||
| b56e0e6248 | |||
| 0558dfed0f | |||
| 13a5ef0100 | |||
| ac78f83406 | |||
| 40c59aa275 | |||
| 84a75274bf | |||
| da2d11dab6 | |||
| de3f2b5fc2 | |||
| e8f4baf407 | |||
| 92eb6cf6e1 | |||
| 6759ff6003 | |||
| 39284cd851 | |||
| ea0eb167fd |
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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{"купить молоко"}},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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: "расскажи про
|
||||
|
||||
@@ -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
@@ -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
@@ -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>
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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 «нет, в пять»
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user