diff --git a/internal/dialogue/pending.go b/internal/dialogue/pending.go new file mode 100644 index 0000000..101407e --- /dev/null +++ b/internal/dialogue/pending.go @@ -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 +} diff --git a/internal/dialogue/pending_test.go b/internal/dialogue/pending_test.go new file mode 100644 index 0000000..490e84e --- /dev/null +++ b/internal/dialogue/pending_test.go @@ -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") + } +}