Files
Maven/cmd/mavend/followup.go
T
kami 05236ad480 3.2 conversation depth: cross-intent anaphora + fact-by-key query
- session.go: add History []Turn + Turn type for multi-turn context
- slots.go: add AnaphoraResolver with Resolve() for RU pronoun detection
  (это/он/она/оно/тот/мой and inflected forms)
- followup.go: extend followUpMerge with cross-intent inheritance:
  Query/Fact/Reminder after a Fact with anaphora inherits the key.
  Same-intent path unchanged. Anaphora detection from utterance.
- voice.go: add fact-by-key lookup path in applyAction for IntentQuery
  when dialogue resolved an anaphoric reference (calls LatestFact,
  formats with formatTime helper). History tracked in Session.History
  capped at 4 most recent turns.
- followup_test.go: 7 new test cases: anaphora query-after-fact,
  no-inheritance-without-anaphora, three-turn break, anaphora in
  reminder, anaphora in fact, explicit key wins, time inheritance.
  make test green (303+, -race, all 29 packages).
2026-07-06 13:40:16 +04:00

95 lines
3.3 KiB
Go

package main
import (
"time"
"github.com/kami/maven/internal/dialogue"
"github.com/kami/maven/internal/router"
)
// voiceDialogueID — the single dialogue-session key. This is a single-user box
// (ponytail), so one slot suffices; a second speaker would need per-speaker ids,
// which waits on voice-print attribution (see PROGRESS multi-user deferral).
const voiceDialogueID = "voice"
// toDialogueSlots projects the router's slots onto the dialogue layer's subset
// (everything except the fact Value, which the dialogue layer doesn't carry).
func toDialogueSlots(s router.Slots) dialogue.Slots {
return dialogue.Slots{
Time: s.Time,
HasTime: s.HasTime,
Key: s.Key,
HasKey: s.HasKey,
Text: s.Text,
Fn: s.Fn,
Args: s.Args,
HasFn: s.HasFn,
}
}
// applyDialogueSlots writes inherited dialogue slots back onto router slots,
// preserving router-only fields (Value) the dialogue layer never touched.
func applyDialogueSlots(base router.Slots, d dialogue.Slots) router.Slots {
base.Time, base.HasTime = d.Time, d.HasTime
base.Key, base.HasKey = d.Key, d.HasKey
base.Text = d.Text
base.Fn, base.Args, base.HasFn = d.Fn, d.Args, d.HasFn
return base
}
// anaphoraResolver is a shared instance for pronoun detection.
var anaphoraResolver router.AnaphoraResolver
// followUpMerge fills the current turn's missing slots from a prior
// non-expired session — the multi-turn seam. It handles three cases:
//
// 1. Same-intent: inherit missing slots via InheritSlots (existing behavior).
// 2. Cross-intent anaphora: if the current utterance contains a pronoun
// ("это" / "он" / "она" etc.) AND the prior session has a key, inherit
// the key for fact-lookup queries and reminder creation.
// 3. Query after Fact: a query that references the prior fact's subject
// inherits the key so the handler can do a fact-by-key lookup.
//
// A clarify turn resolves nothing, so it never inherits. InheritSlots only
// fills gaps, so a fully-slotted current turn is unaffected.
func followUpMerge(prev *dialogue.Session, dec router.Decision, now time.Time) router.Decision {
if prev == nil || dec.Clarify || prev.IsExpired(now) {
return dec
}
// Case 1: same-intent inheritance (existing).
if prev.Intent == dialogue.Intent(dec.Intent) {
merged := dialogue.InheritSlots(prev.Slots, toDialogueSlots(dec.Slots))
dec.Slots = applyDialogueSlots(dec.Slots, merged)
return dec
}
// Cases 2 & 3: cross-intent anaphora + query-after-fact.
// A query after a fact may reference the fact's subject by pronoun.
_, isAnaphoric := anaphoraResolver.Resolve(dec.Utterance)
if !isAnaphoric && !dec.Slots.HasKey {
// No anaphora and no explicit key — this is a truly new topic.
return dec
}
// Inherit key from the prior session's key when the current utterance
// refers to it (anaphora) or when a query follows a fact.
switch {
case dec.Intent == router.IntentQuery && prev.Slots.HasKey:
dec.Slots.Key = prev.Slots.Key
dec.Slots.HasKey = true
if prev.Slots.HasTime {
dec.Slots.Time = prev.Slots.Time
dec.Slots.HasTime = true
}
case dec.Intent == router.IntentReminder && prev.Slots.HasKey && isAnaphoric:
dec.Slots.Key = prev.Slots.Key
dec.Slots.HasKey = true
case dec.Intent == router.IntentFact && !dec.Slots.HasKey && prev.Slots.HasKey && isAnaphoric:
dec.Slots.Key = prev.Slots.Key
dec.Slots.HasKey = true
}
return dec
}