mavend: answer "а завтра?" from the previous turn, not from the model
An elliptical follow-up carries no intent of its own. followUpMerge cannot help — it inherits slots once the intent is known, and here the intent is the missing part. So "а завтра?" went to the router, which on a 1.7B is close to a coin flip, and the guess cost ~2.7s. continuationDecision runs before the router and rebuilds the turn from the previous one: same intent, same key, new day. Deterministic and free. Three guards, all narrow on purpose. A parseable date is required, which is what separates an ellipsis from an ordinary short utterance. Four tokens max. And only query, system and reminder may be inherited: fact and note would write something he did not say, and act would let a two-word utterance re-run an allowlisted fn, which is a way to fire a destructive command nobody typed. A continuation is still remembered, so "а завтра?" then "а послезавтра?" chains. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
This commit is contained in:
@@ -0,0 +1,100 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/dialogue"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
var contNow = time.Date(2026, 8, 1, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
func contSession(intent dialogue.Intent, key string) *dialogue.Session {
|
||||
return &dialogue.Session{
|
||||
Intent: intent,
|
||||
Slots: dialogue.Slots{Key: key, HasKey: key != "", Text: "какие напоминания на сегодня"},
|
||||
Timestamp: contNow.Add(-30 * time.Second),
|
||||
TTL: 2 * time.Minute,
|
||||
}
|
||||
}
|
||||
|
||||
func TestContinuationInheritsTheQuestion(t *testing.T) {
|
||||
prev := contSession(dialogue.IntentQuery, "water")
|
||||
dec, ok := continuationDecision(prev, "а завтра?", contNow)
|
||||
if !ok {
|
||||
t.Fatal("continuationDecision returned false, want a decision")
|
||||
}
|
||||
if dec.Intent != router.IntentQuery {
|
||||
t.Errorf("intent = %q, want query", dec.Intent)
|
||||
}
|
||||
if dec.Slots.Key != "water" || !dec.Slots.HasKey {
|
||||
t.Errorf("key = %q, want water carried over", dec.Slots.Key)
|
||||
}
|
||||
if !dec.Slots.HasTime {
|
||||
t.Fatal("no time slot; the whole point is re-aiming the day")
|
||||
}
|
||||
if got, want := dec.Slots.Time.Format("2006-01-02"), "2026-08-02"; got != want {
|
||||
t.Errorf("time = %s, want %s", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestContinuationAcceptsABareDate(t *testing.T) {
|
||||
prev := contSession(dialogue.IntentQuery, "water")
|
||||
for _, s := range []string{"завтра?", "вчера", "а вчера?", "и завтра"} {
|
||||
if _, ok := continuationDecision(prev, s, contNow); !ok {
|
||||
t.Errorf("continuationDecision(%q) = false, want true", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestContinuationDeclinesWhatIsNotAnEllipsis(t *testing.T) {
|
||||
prev := contSession(dialogue.IntentQuery, "water")
|
||||
for _, s := range []string{
|
||||
// No date to re-aim at — an ordinary short utterance, the router's job.
|
||||
"а что там", "а бэкап?", "привет", "",
|
||||
// Content of its own: the verb is not an ellipsis.
|
||||
"напомни завтра позвонить маме",
|
||||
// Too long to be an ellipsis even with a date in it.
|
||||
"а что у меня стоит в календаре на завтра",
|
||||
} {
|
||||
if _, ok := continuationDecision(prev, s, contNow); ok {
|
||||
t.Errorf("continuationDecision(%q) = true, want false", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestContinuationDeclinesUncontinuableIntents(t *testing.T) {
|
||||
// act is the one that matters: inheriting an allowlisted fn from a
|
||||
// two-word utterance would be a way to run a destructive command.
|
||||
for _, in := range []dialogue.Intent{
|
||||
dialogue.IntentAct, dialogue.IntentFact, dialogue.IntentNote, dialogue.IntentChat,
|
||||
} {
|
||||
if _, ok := continuationDecision(contSession(in, "water"), "а завтра?", contNow); ok {
|
||||
t.Errorf("continuationDecision inherited intent %q, want refusal", in)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestContinuationDeclinesWithoutALiveSession(t *testing.T) {
|
||||
if _, ok := continuationDecision(nil, "а завтра?", contNow); ok {
|
||||
t.Error("continued with no previous turn")
|
||||
}
|
||||
stale := contSession(dialogue.IntentQuery, "water")
|
||||
stale.Timestamp = contNow.Add(-10 * time.Minute)
|
||||
if _, ok := continuationDecision(stale, "а завтра?", contNow); ok {
|
||||
t.Error("continued an expired session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestContinuationNeverCarriesAnFn(t *testing.T) {
|
||||
prev := contSession(dialogue.IntentQuery, "water")
|
||||
prev.Slots.Fn, prev.Slots.HasFn = "restart", true
|
||||
dec, ok := continuationDecision(prev, "а завтра?", contNow)
|
||||
if !ok {
|
||||
t.Fatal("want a decision")
|
||||
}
|
||||
if dec.Slots.HasFn || dec.Slots.Fn != "" {
|
||||
t.Fatalf("carried fn %q into a continuation", dec.Slots.Fn)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user