From 06adc4702d5603b961d4d007a9b0a7279be29332 Mon Sep 17 00:00:00 2001 From: claude Date: Sun, 6 Sep 2026 13:50:03 +0400 Subject: [PATCH] router: set ResolvedBy at each function selection point Assign provenance where the exact fn is produced: - grammar_fixed: praxis/task-status grammars hardcode fn - grammar_matcher: wakeword-act grammar invokes ActMatcher - extractor_raw: Extractor.Extract matches over raw utterance - extractor_llm_text: fillSlots LLM backfill matches cleaned text - fallback_matcher: ResolveActionCandidate runs the fallback matcher ResolveActionCandidate propagates Slots.ResolvedBy into ActionCandidate.ResolvedBy. No selection behavior changes. --- internal/router/actioncandidate.go | 7 +++++++ internal/router/praxis.go | 20 ++++++++++---------- internal/router/router.go | 3 ++- internal/router/slots.go | 1 + internal/router/stage0.go | 12 ++++++------ internal/router/taskstatus.go | 2 +- 6 files changed, 27 insertions(+), 18 deletions(-) diff --git a/internal/router/actioncandidate.go b/internal/router/actioncandidate.go index b34a54d..04c6b17 100644 --- a/internal/router/actioncandidate.go +++ b/internal/router/actioncandidate.go @@ -22,6 +22,11 @@ type ActionCandidate struct { // here. Carried for observability; not used for dispatch. Producer RouteProducer + // ResolvedBy — which component actually selected the exact function. + // Carried from the routing decision's Slots.ResolvedBy. Five disjoint + // values; empty when no function was resolved. + ResolvedBy ActionResolutionMethod + // Confidence — the routing confidence from the decision. Carried for // observability; not used for dispatch. Confidence float64 @@ -146,6 +151,7 @@ func ResolveActionCandidate(dec Decision, m ActMatcher) ActionCandidate { Args: dec.Slots.Args, Source: ActionSourceRoute, Producer: dec.Producer, + ResolvedBy: dec.Slots.ResolvedBy, Confidence: dec.Confidence, } } @@ -158,6 +164,7 @@ func ResolveActionCandidate(dec Decision, m ActMatcher) ActionCandidate { Args: args, Source: ActionSourceMatcher, Producer: dec.Producer, + ResolvedBy: ActionResolutionFallbackMatcher, Confidence: dec.Confidence, } } diff --git a/internal/router/praxis.go b/internal/router/praxis.go index 0d411ea..ff0ff1c 100644 --- a/internal/router/praxis.go +++ b/internal/router/praxis.go @@ -210,12 +210,12 @@ func PraxisGrammars() []Grammar { if !ok { return Decision{}, false } - return Decision{ - Stage: 0, - Intent: IntentAct, - Confidence: 1.0, - Slots: Slots{Fn: c.Fn, HasFn: true, Value: c.Ref}, - }, true + return Decision{ + Stage: 0, + Intent: IntentAct, + Confidence: 1.0, + Slots: Slots{Fn: c.Fn, HasFn: true, Value: c.Ref, ResolvedBy: ActionResolutionGrammarFixed}, + }, true }, }, { @@ -226,7 +226,7 @@ func PraxisGrammars() []Grammar { Stage: 0, Intent: IntentAct, Confidence: 1.0, - Slots: Slots{Fn: "list_attention", HasFn: true}, + Slots: Slots{Fn: "list_attention", HasFn: true, ResolvedBy: ActionResolutionGrammarFixed}, }, true }, }, @@ -238,7 +238,7 @@ func PraxisGrammars() []Grammar { Stage: 0, Intent: IntentAct, Confidence: 1.0, - Slots: Slots{Fn: "list_changes", HasFn: true}, + Slots: Slots{Fn: "list_changes", HasFn: true, ResolvedBy: ActionResolutionGrammarFixed}, }, true }, }, @@ -261,7 +261,7 @@ func PraxisGrammars() []Grammar { // Text, not Value: entityAttentionCapability reads Value // first and that slot means an item id everywhere else in // the Praxis dispatch. - Slots: Slots{Fn: "entity_attention", HasFn: true, Text: subject}, + Slots: Slots{Fn: "entity_attention", HasFn: true, Text: subject, ResolvedBy: ActionResolutionGrammarFixed}, }, true }, }, @@ -284,7 +284,7 @@ func praxisServiceAttentionDecision(utterance string) (Decision, bool) { Stage: 0, Intent: IntentAct, Confidence: 1, - Slots: Slots{Fn: "entity_attention", HasFn: true, Text: tokens[3]}, + Slots: Slots{Fn: "entity_attention", HasFn: true, Text: tokens[3], ResolvedBy: ActionResolutionGrammarFixed}, }, true } diff --git a/internal/router/router.go b/internal/router/router.go index d7e5374..bd83e82 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -303,7 +303,7 @@ func (r *Router) fillMatchedSlots(ctx context.Context, d *Decision, now time.Tim d.Slots.Key, d.Slots.Value, d.Slots.HasKey = ex.Key, ex.Value, ex.HasKey } if !d.Slots.HasFn && ex.HasFn { - d.Slots.Fn, d.Slots.Args, d.Slots.HasFn = ex.Fn, ex.Args, ex.HasFn + d.Slots.Fn, d.Slots.Args, d.Slots.HasFn, d.Slots.ResolvedBy = ex.Fn, ex.Args, ex.HasFn, ex.ResolvedBy } return ex } @@ -320,6 +320,7 @@ func (r *Router) fillSlots(ctx context.Context, d *Decision, now time.Time) { d.Slots.Text != "" && d.Slots.Text != d.Utterance { if fn, args, ok := r.extractor.Acts.Match(d.Slots.Text); ok { d.Slots.Fn, d.Slots.Args, d.Slots.HasFn = fn, args, true + d.Slots.ResolvedBy = ActionResolutionExtractorLLMText } } // The extractor's Text is the raw utterance, which is the payload for a diff --git a/internal/router/slots.go b/internal/router/slots.go index 91010a0..8adbb65 100644 --- a/internal/router/slots.go +++ b/internal/router/slots.go @@ -73,6 +73,7 @@ func (e Extractor) Extract(ctx context.Context, intent Intent, utterance string, s.Fn = fn s.Args = args s.HasFn = true + s.ResolvedBy = ActionResolutionExtractorRaw } } case IntentFact: diff --git a/internal/router/stage0.go b/internal/router/stage0.go index 06bdc75..5106f99 100644 --- a/internal/router/stage0.go +++ b/internal/router/stage0.go @@ -95,12 +95,12 @@ func DefaultGrammars(actMatcher ActMatcher) []Grammar { if !ok { return Decision{}, false // fall through to classifier } - return Decision{ - Stage: 0, - Intent: IntentAct, - Confidence: 1.0, - Slots: Slots{Fn: fn, Args: args, HasFn: true, Text: rest}, - }, true + return Decision{ + Stage: 0, + Intent: IntentAct, + Confidence: 1.0, + Slots: Slots{Fn: fn, Args: args, HasFn: true, Text: rest, ResolvedBy: ActionResolutionGrammarMatcher}, + }, true }, }, } diff --git a/internal/router/taskstatus.go b/internal/router/taskstatus.go index 140f9ab..8069589 100644 --- a/internal/router/taskstatus.go +++ b/internal/router/taskstatus.go @@ -389,7 +389,7 @@ func TaskStatusGrammar() Grammar { // which is the pairing handlePraxisAct uses for an item and its // reference. Empty Text is a claim, not a refusal: the daemon // asks which task, having the list she does not. - Slots: Slots{Fn: TaskStatusFn, HasFn: true, Value: c.Status, Text: c.Text}, + Slots: Slots{Fn: TaskStatusFn, HasFn: true, Value: c.Status, Text: c.Text, ResolvedBy: ActionResolutionGrammarFixed}, }, true }, }