da9114b623
Owner explicitly requested direct commits to master; bypass the branch-only hook.
241 lines
9.5 KiB
Go
241 lines
9.5 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/dialogue"
|
|
"github.com/kami/maven/internal/lexicon"
|
|
"github.com/kami/maven/internal/morph"
|
|
"github.com/kami/maven/internal/router"
|
|
)
|
|
|
|
// voiceDialogueID — the dialogue-session and clarify key for the microphone.
|
|
// This is a single-user box (ponytail), so one slot per reach suffices; a
|
|
// second speaker would need per-speaker ids, which waits on voice-print
|
|
// attribution (see PROGRESS multi-user deferral).
|
|
const voiceDialogueID = "voice"
|
|
|
|
// textDialogueID — the clarify key for a text turn that named no conversation.
|
|
// Separate from the mic: an old client that sends no id still must not answer
|
|
// a question she asked out loud.
|
|
const textDialogueID = "text"
|
|
|
|
// dialogueKey — the context key carrying the id of the conversation this turn
|
|
// belongs to. It rides the context rather than a parameter for the same reason
|
|
// the correlation id does: every step of the turn needs it, most of them only
|
|
// to hand to the next one, and threading it by hand would put it in six
|
|
// clarify signatures that have nothing else to say about it.
|
|
type dialogueKey struct{}
|
|
|
|
// dialogueIDFor builds the id a turn is held under: the conversation the reach
|
|
// named, qualified by the tap it arrived on, or the tap's own fallback when it
|
|
// named none.
|
|
//
|
|
// A parked clarifying question used to be held under voiceDialogueID no matter
|
|
// where the turn came from, so one unanswerable question captured the next
|
|
// three utterances from anywhere. Three independent curl sessions fed a
|
|
// capture attempt that had already failed, and a reminder among them was lost
|
|
// (Vikunja #466).
|
|
func dialogueIDFor(src turnSource, conversation string) string {
|
|
if conversation != "" {
|
|
return string(src) + ":" + conversation
|
|
}
|
|
if src == sourceVoice {
|
|
return voiceDialogueID
|
|
}
|
|
return textDialogueID
|
|
}
|
|
|
|
// withDialogueID tags a turn with that id.
|
|
func withDialogueID(ctx context.Context, id string) context.Context {
|
|
return context.WithValue(ctx, dialogueKey{}, id)
|
|
}
|
|
|
|
// dialogueIDOf reads it back. Falls back to the microphone's slot, which is
|
|
// what an unthreaded caller — a test, an internal replay — gets.
|
|
func dialogueIDOf(ctx context.Context) string {
|
|
if id, ok := ctx.Value(dialogueKey{}).(string); ok && id != "" {
|
|
return id
|
|
}
|
|
return voiceDialogueID
|
|
}
|
|
|
|
// toDialogueSlots and applyDialogueSlots are the only bridge between
|
|
// router.Slots and dialogue.Slots. dialogue must not import router (import
|
|
// cycle), so the two structs are hand-kept copies and every field has to be
|
|
// carried by hand here. Adding a field to either struct without adding it to
|
|
// BOTH functions loses a slot silently — nothing fails to build. The tests in
|
|
// slotsparity_test.go fail when the field sets or the converters stop matching;
|
|
// when they do, fix these two functions, not the tests.
|
|
|
|
// toDialogueSlots projects the router's slots onto the dialogue layer's copy.
|
|
func toDialogueSlots(s router.Slots) dialogue.Slots {
|
|
return dialogue.Slots{
|
|
Time: s.Time,
|
|
HasTime: s.HasTime,
|
|
Key: s.Key,
|
|
Value: s.Value,
|
|
HasKey: s.HasKey,
|
|
Text: s.Text,
|
|
Fn: s.Fn,
|
|
Args: s.Args,
|
|
HasFn: s.HasFn,
|
|
}
|
|
}
|
|
|
|
// applyDialogueSlots writes dialogue slots back onto router slots.
|
|
func applyDialogueSlots(base router.Slots, d dialogue.Slots) router.Slots {
|
|
base.Time, base.HasTime = d.Time, d.HasTime
|
|
base.Key, base.Value, base.HasKey = d.Key, d.Value, 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 carries the current conversation across a prior non-expired
|
|
// session — the multi-turn seam. It handles four cases:
|
|
//
|
|
// 1. Same-intent: inherit missing slots via InheritSlots (existing behavior),
|
|
// except a reminder time the current sentence named and the parser missed.
|
|
// 2. An anaphoric query becomes chat. A question whose subject lives in this
|
|
// conversation is answered from its transcript, not sent through unrelated
|
|
// note, web and encyclopedia sources as a context-free lookup.
|
|
// 3. 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.
|
|
// 4. 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
|
|
}
|
|
|
|
ref, isAnaphoric := anaphoraResolver.Resolve(dec.Utterance)
|
|
if dec.Intent == router.IntentQuery && isAnaphoric && ref != "mine" && sessionHasContext(prev) {
|
|
// The router correctly identified a question. What it cannot know from
|
|
// one utterance is that its subject is in the live dialogue. Chat is the
|
|
// only action path that receives that dialogue, so preserve the route's
|
|
// slots but answer it there. Clear query-only provenance: no query source
|
|
// was selected and an anchored destination must not survive an intent
|
|
// change the daemon made from state the router could not see.
|
|
dec.Intent = router.IntentChat
|
|
dec.Source = router.SourceUnknown
|
|
dec.SourceAnchored = false
|
|
// Keep a structured referent when the prior route had one. The chat
|
|
// phraser primarily reads the transcript, but the session must not lose
|
|
// the fact identity merely because one follow-up crossed an intent.
|
|
if !dec.Slots.HasKey && prev.Slots.HasKey {
|
|
dec.Slots.Key = prev.Slots.Key
|
|
dec.Slots.HasKey = true
|
|
}
|
|
if dec.Slots.Value == "" {
|
|
dec.Slots.Value = prev.Slots.Value
|
|
}
|
|
if !dec.Slots.HasTime && prev.Slots.HasTime {
|
|
dec.Slots.Time = prev.Slots.Time
|
|
dec.Slots.HasTime = true
|
|
}
|
|
}
|
|
|
|
// Case 1: same-intent inheritance (existing).
|
|
if prev.Intent == dialogue.Intent(dec.Intent) {
|
|
// A reminder that named an hour nobody could read must not borrow the
|
|
// last one's. Two reminders in a row and the second landed at the
|
|
// first's time, confirmed as if it had been read from the sentence:
|
|
// "напомни без четверти восемь выходить" fired at 07:30 (V-543). The
|
|
// hour is also what fills before the action's own fallback parse can
|
|
// run, so inheriting it hid a time that did parse.
|
|
//
|
|
// Inheriting is still right when the sentence names no time at all,
|
|
// which is the follow-up this seam exists for.
|
|
blockTime := dec.Intent == router.IntentReminder &&
|
|
!dec.Slots.HasTime && router.MentionsTime(dec.Utterance)
|
|
merged := dialogue.InheritSlots(prev.Slots, toDialogueSlots(dec.Slots))
|
|
dec.Slots = applyDialogueSlots(dec.Slots, merged)
|
|
if blockTime {
|
|
dec.Slots.Time, dec.Slots.HasTime = time.Time{}, false
|
|
}
|
|
return dec
|
|
}
|
|
|
|
// Cases 3 & 4: cross-intent anaphora + query-after-fact.
|
|
// A query after a fact may reference the fact's subject by pronoun.
|
|
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
|
|
}
|
|
|
|
// sessionHasContext distinguishes a live transcript from a session that only
|
|
// carries timing/candidate bookkeeping. The raw utterance is the primary
|
|
// source. The slot fallback keeps sessions persisted by older binaries useful
|
|
// after an upgrade: those blobs have no Utterance field, but may still carry
|
|
// the exact turn in Text or a structured fact key/value.
|
|
func sessionHasContext(s *dialogue.Session) bool {
|
|
if s == nil {
|
|
return false
|
|
}
|
|
return s.Utterance != "" || s.Slots.Text != "" || s.Slots.HasKey || s.Slots.Value != ""
|
|
}
|
|
|
|
// opensConversation recognises an explicit cooperative opener without making
|
|
// it a competing route. The substantive clause after the colon may still be a
|
|
// fact worth storing; this function only chooses the session's lifetime.
|
|
//
|
|
// The marker is grammatical and closed (Russian давай/давайте, English let's),
|
|
// and the action vocabulary lives in lexicon rather than a substring pattern.
|
|
// A bare chat route needs none of this — rememberTurn marks it conversational
|
|
// from its intent. This catches the compound shape whose fact clause otherwise
|
|
// hides the opener from the single-intent router.
|
|
func opensConversation(text string) bool {
|
|
tokens := quietTokens(text)
|
|
if len(tokens) < 2 {
|
|
return false
|
|
}
|
|
from := 1
|
|
switch {
|
|
case tokens[0] == "давай" || tokens[0] == "давайте":
|
|
case tokens[0] == "lets":
|
|
case len(tokens) >= 3 && tokens[0] == "let" && tokens[1] == "s":
|
|
from = 2
|
|
default:
|
|
return false
|
|
}
|
|
verbs := lexicon.ConversationVerbs()
|
|
for _, token := range tokens[from:] {
|
|
for _, verb := range verbs {
|
|
if token == verb || morph.SameWord(token, verb) {
|
|
return true
|
|
}
|
|
}
|
|
}
|
|
return false
|
|
}
|