d32eae8aac
The gesture was web-only, so the sample was skewing to the turns he happens to type. Voice is where the hard cases are. Half of it already existed: the repair rung has read "нет, это была заметка" since V-455. It taught the classifier and wrote no durable label, so the two paths disagreed about what a correction is. It now writes both. Two sinks and not one on purpose: the classifier seed makes the next turn better today, and the label is what a fitted head trains on after the transcript expires. The trace id is stamped onto the remembered turn after the fact, because the trace is written when the turn ends and recordTurn runs in the middle of it. New: the untargeted half. "нет, не так" writes the negative and redoes nothing, because there is no target to redo it as. Voice needs this more than the web does — naming an intent aloud means saying "заметка" or "факт", which is her vocabulary and not his. repair_negatives is a new closed lexicon set matched against the WHOLE utterance, never as a substring. That is what keeps it apart from repair_markers, where "это не" is a fragment that needs an intent word after it. A member that could appear inside an ordinary sentence does not belong in the set.
134 lines
5.1 KiB
Go
134 lines
5.1 KiB
Go
// 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",
|
|
"repair-negative", "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
|
|
}
|