Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c47881106e | |||
| 9a70f7378b | |||
| b18f608594 | |||
| d1f8a734c5 |
@@ -40,6 +40,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/phraser"
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -58,10 +59,14 @@ func (h *reactiveHandler) actionChat(ctx context.Context, dec router.Decision) s
|
|||||||
// Conversational: build history from dialogue session (prior user turns)
|
// Conversational: build history from dialogue session (prior user turns)
|
||||||
// and let the LLM respond from general knowledge + context.
|
// and let the LLM respond from general knowledge + context.
|
||||||
history := h.chatHistory()
|
history := h.chatHistory()
|
||||||
|
// The phraser hands back its own fallback text alongside the error, so the
|
||||||
|
// turn survives a dead server and the failure still reaches the log.
|
||||||
reply, err := h.phraser.PhraseChat(ctx, dec.Utterance, history)
|
reply, err := h.phraser.PhraseChat(ctx, dec.Utterance, history)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("voice: chat: %v", err)
|
log.Printf("voice: chat: %v", err)
|
||||||
return "поговорили."
|
}
|
||||||
|
if reply == "" {
|
||||||
|
return phraser.ChatFallback
|
||||||
}
|
}
|
||||||
return reply
|
return reply
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -445,7 +445,13 @@ func (h *reactiveHandler) queryMemory(ctx context.Context, t *queryTurn) (string
|
|||||||
// A note is phrased in Maven's voice; a fact is read back as it was
|
// A note is phrased in Maven's voice; a fact is read back as it was
|
||||||
// stored.
|
// stored.
|
||||||
if hit.Meta["type"] == "note" {
|
if hit.Meta["type"] == "note" {
|
||||||
if reply, perr := h.phraser.PhraseQuery(ctx, t.dec.Utterance, []string{text}); perr == nil && reply != "" {
|
reply, perr := h.phraser.PhraseQuery(ctx, t.dec.Utterance, []string{text})
|
||||||
|
switch {
|
||||||
|
case perr != nil:
|
||||||
|
// Reading the note back verbatim beats the phraser's own fallback,
|
||||||
|
// which only wraps the same text in "вот что я нашла:".
|
||||||
|
log.Printf("voice: recall phrase: %v", perr)
|
||||||
|
case reply != "":
|
||||||
return reply, true
|
return reply, true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -465,39 +465,3 @@ func TestExpiryNoticeSurvivesAConfirmTurn(t *testing.T) {
|
|||||||
t.Fatal("the expired question must be gone")
|
t.Fatal("the expired question must be gone")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The other half of the subject question: his answer must fill the empty slot,
|
|
||||||
// not replace the request. Slots.Text used to be the whole raw utterance for
|
|
||||||
// every intent, so the branch that fills a text slot could only ever overwrite
|
|
||||||
// (Vikunja #383). Here the parked request holds the hour and the answer holds
|
|
||||||
// what to say at it, and the reminder that lands has both.
|
|
||||||
func TestClarifySubjectAnswerFillsRatherThanClobbers(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
h, st, _ := newClarifyHandler(t)
|
|
||||||
at := h.now().Add(2 * time.Hour)
|
|
||||||
|
|
||||||
question, asked := h.askClarify(clarifyDec(router.IntentReminder,
|
|
||||||
router.Slots{Time: at, HasTime: true}, "напомни в 11"))
|
|
||||||
if !asked || question != "О чём напомнить?" {
|
|
||||||
t.Fatalf("expected the subject question, got %q asked=%v", question, asked)
|
|
||||||
}
|
|
||||||
|
|
||||||
reply, handled := h.resolveClarifyAnswer(ctx, "позвонить маме")
|
|
||||||
if !handled {
|
|
||||||
t.Fatal("the answer to an open question must be consumed as an answer")
|
|
||||||
}
|
|
||||||
if reply == clarifyGaveUp {
|
|
||||||
t.Fatalf("a good answer must not drop the request: %q", reply)
|
|
||||||
}
|
|
||||||
|
|
||||||
reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour))
|
|
||||||
if err != nil || len(reminders) != 1 {
|
|
||||||
t.Fatalf("clarified reminder was not created: reminders=%v err=%v", reminders, err)
|
|
||||||
}
|
|
||||||
if !strings.Contains(reminders[0].Payload, "маме") {
|
|
||||||
t.Fatalf("the answer never reached the reminder: %q", reminders[0].Payload)
|
|
||||||
}
|
|
||||||
if !strings.Contains(reminders[0].Payload, "11") {
|
|
||||||
t.Fatalf("the answer clobbered the original request: %q", reminders[0].Payload)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -54,7 +54,11 @@ func (h *reactiveHandler) phraseSource(ctx context.Context, name, utterance stri
|
|||||||
log.Printf("voice: %s: no world model, reading the source back instead", name)
|
log.Printf("voice: %s: no world model, reading the source back instead", name)
|
||||||
return ""
|
return ""
|
||||||
case err != nil:
|
case err != nil:
|
||||||
|
// The resident phraser answers this call with its fallback text and the
|
||||||
|
// error together. Drop the text: these callers hold the passage itself
|
||||||
|
// and read it back better than "вот что я нашла: <passage>" does.
|
||||||
log.Printf("voice: %s: phrase: %v", name, err)
|
log.Printf("voice: %s: phrase: %v", name, err)
|
||||||
|
return ""
|
||||||
}
|
}
|
||||||
return reply
|
return reply
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-17
@@ -24,12 +24,7 @@ type runner struct {
|
|||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
cmd *exec.Cmd
|
cmd *exec.Cmd
|
||||||
ready bool
|
ready bool
|
||||||
// yielding — stop() has sent the signal and the exit that follows is ours.
|
http *http.Client
|
||||||
// llama-server aborts on SIGTERM (its static teardown throws, upstream
|
|
||||||
// ggml-org/llama.cpp), so a routine yield and a real crash produce the same
|
|
||||||
// "signal: aborted" and used to log identically (Vikunja #491).
|
|
||||||
yielding bool
|
|
||||||
http *http.Client
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func newRunner(bin string, args []string, readyURL string) *runner {
|
func newRunner(bin string, args []string, readyURL string) *runner {
|
||||||
@@ -75,18 +70,13 @@ func (r *runner) start() error {
|
|||||||
if err := cmd.Start(); err != nil {
|
if err := cmd.Start(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
r.cmd, r.ready, r.yielding = cmd, false, false
|
r.cmd, r.ready = cmd, false
|
||||||
log.Printf("mavgpud: started llama-server pid=%d", cmd.Process.Pid)
|
log.Printf("mavgpud: started llama-server pid=%d", cmd.Process.Pid)
|
||||||
go func() {
|
go func() {
|
||||||
err := cmd.Wait()
|
err := cmd.Wait()
|
||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
yielded := r.yielding
|
r.cmd, r.ready = nil, false
|
||||||
r.cmd, r.ready, r.yielding = nil, false, false
|
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
if yielded {
|
|
||||||
log.Printf("mavgpud: llama-server stopped, card yielded (%v)", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
log.Printf("mavgpud: llama-server exited: %v", err)
|
log.Printf("mavgpud: llama-server exited: %v", err)
|
||||||
}()
|
}()
|
||||||
return nil
|
return nil
|
||||||
@@ -100,10 +90,6 @@ func (r *runner) stop(grace time.Duration) {
|
|||||||
r.mu.Lock()
|
r.mu.Lock()
|
||||||
cmd := r.cmd
|
cmd := r.cmd
|
||||||
r.ready = false
|
r.ready = false
|
||||||
if cmd != nil && cmd.Process != nil {
|
|
||||||
// The exit that follows is ours, not a crash.
|
|
||||||
r.yielding = true
|
|
||||||
}
|
|
||||||
r.mu.Unlock()
|
r.mu.Unlock()
|
||||||
if cmd == nil || cmd.Process == nil {
|
if cmd == nil || cmd.Process == nil {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -1,59 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
// fakeServer writes an executable standing in for llama-server: it ignores
|
|
||||||
// SIGTERM the way the real one effectively does — by dying messily rather than
|
|
||||||
// cleanly — and reports a non-zero status.
|
|
||||||
func fakeServer(t *testing.T, body string) string {
|
|
||||||
t.Helper()
|
|
||||||
path := filepath.Join(t.TempDir(), "fake-llama-server")
|
|
||||||
if err := os.WriteFile(path, []byte("#!/bin/sh\n"+body+"\n"), 0o755); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
|
|
||||||
// A deliberate stop is a yield, and the log has to say so.
|
|
||||||
//
|
|
||||||
// llama-server aborts inside its own static teardown on SIGTERM, so the exit
|
|
||||||
// status of a routine yield is identical to that of a real crash. Reading the
|
|
||||||
// mavgpud log, the two were indistinguishable (Vikunja #491).
|
|
||||||
func TestStopMarksTheExitAsAYield(t *testing.T) {
|
|
||||||
r := newRunner(fakeServer(t, "while : ; do sleep 1 ; done"), nil, "")
|
|
||||||
if err := r.start(); err != nil {
|
|
||||||
t.Fatalf("start: %v", err)
|
|
||||||
}
|
|
||||||
r.mu.Lock()
|
|
||||||
if r.yielding {
|
|
||||||
t.Error("a freshly started server is already marked as yielding")
|
|
||||||
}
|
|
||||||
r.mu.Unlock()
|
|
||||||
|
|
||||||
r.stop(2 * time.Second)
|
|
||||||
deadline := time.Now().Add(2 * time.Second)
|
|
||||||
for time.Now().Before(deadline) {
|
|
||||||
if !r.running() {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
time.Sleep(10 * time.Millisecond)
|
|
||||||
}
|
|
||||||
t.Fatal("the child outlived stop")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Stopping when nothing is running must not arm the flag for the next child.
|
|
||||||
// The next exit after that would be a real crash logged as a yield.
|
|
||||||
func TestStopWithNoChildDoesNotArmTheFlag(t *testing.T) {
|
|
||||||
r := newRunner("/nonexistent", nil, "")
|
|
||||||
r.stop(10 * time.Millisecond)
|
|
||||||
r.mu.Lock()
|
|
||||||
defer r.mu.Unlock()
|
|
||||||
if r.yielding {
|
|
||||||
t.Error("stop armed the yield flag with no child running")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -19,10 +19,6 @@ RestartSec=5
|
|||||||
# llama-server on SIGTERM, so give it longer than stop_grace to do that.
|
# llama-server on SIGTERM, so give it longer than stop_grace to do that.
|
||||||
KillSignal=SIGTERM
|
KillSignal=SIGTERM
|
||||||
TimeoutStopSec=60
|
TimeoutStopSec=60
|
||||||
# llama-server aborts inside its own static teardown on SIGTERM, so every
|
|
||||||
# routine yield used to write a multi-gigabyte core into systemd-coredump
|
|
||||||
# (Vikunja #491). Yielding is meant to happen several times a day.
|
|
||||||
LimitCORE=0
|
|
||||||
|
|
||||||
[Install]
|
[Install]
|
||||||
WantedBy=default.target
|
WantedBy=default.target
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import (
|
|||||||
"sort"
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
"unicode"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// Fact sources. A calendar event reaches the store as a
|
// Fact sources. A calendar event reaches the store as a
|
||||||
@@ -154,20 +153,14 @@ func Overlapping(events []Event, from, to time.Time) []Event {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// safeKey makes a summary safe to use inside a fact key: letters and digits in
|
// safeKey makes a summary safe to use inside a fact key (ASCII alphanumerics
|
||||||
// any script, plus dashes, with space and underscore folded to a dash.
|
// and dashes). Non-Latin summaries collapse to their punctuation, which is why
|
||||||
//
|
// the day prefix carries the identity and this only disambiguates within a day.
|
||||||
// It kept ASCII only until 04-08-2026, and dropped everything else. His
|
|
||||||
// calendar is Russian, so "Встреча с Аней" and "Обед с мамой" both reduced to
|
|
||||||
// "--" and produced the same key on the same day — the second event of the day
|
|
||||||
// silently overwrote the first (Vikunja #443). Letting the letters through is
|
|
||||||
// what makes the key identify the event. Migration #18 drops the keys written
|
|
||||||
// under the old rule; they are re-derived on the next poll.
|
|
||||||
func safeKey(s string) string {
|
func safeKey(s string) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
for _, r := range s {
|
for _, r := range s {
|
||||||
switch {
|
switch {
|
||||||
case unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-':
|
case (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '-':
|
||||||
b.WriteRune(r)
|
b.WriteRune(r)
|
||||||
case r == ' ' || r == '_':
|
case r == ' ' || r == '_':
|
||||||
b.WriteRune('-')
|
b.WriteRune('-')
|
||||||
|
|||||||
@@ -139,9 +139,6 @@ func TestSafeKey(t *testing.T) {
|
|||||||
{"Hello_World", "Hello-World"},
|
{"Hello_World", "Hello-World"},
|
||||||
{"special@#$chars!!", "specialchars"},
|
{"special@#$chars!!", "specialchars"},
|
||||||
{"ALL_CAPS_123", "ALL-CAPS-123"},
|
{"ALL_CAPS_123", "ALL-CAPS-123"},
|
||||||
// His calendar is Russian. These reduced to "--" and "--" (Vikunja #443).
|
|
||||||
{"Встреча с Аней", "Встреча-с-Аней"},
|
|
||||||
{"Обед с мамой", "Обед-с-мамой"},
|
|
||||||
}
|
}
|
||||||
for _, tt := range tests {
|
for _, tt := range tests {
|
||||||
if got := safeKey(tt.in); got != tt.want {
|
if got := safeKey(tt.in); got != tt.want {
|
||||||
@@ -266,19 +263,3 @@ func TestSourceTrust(t *testing.T) {
|
|||||||
t.Errorf("Sources() = %v", Sources())
|
t.Errorf("Sources() = %v", Sources())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Two Russian events on one day must not share a key. They did: safeKey kept
|
|
||||||
// ASCII only, so both summaries collapsed to their spaces and the second event
|
|
||||||
// overwrote the first in the store (Vikunja #443).
|
|
||||||
func TestFactKeyDistinguishesRussianEventsOnOneDay(t *testing.T) {
|
|
||||||
day := time.Date(2026, 8, 4, 0, 0, 0, 0, time.UTC)
|
|
||||||
a := Event{Summary: "Встреча с Аней", Start: day.Add(10 * time.Hour), End: day.Add(11 * time.Hour)}
|
|
||||||
b := Event{Summary: "Обед с мамой", Start: day.Add(13 * time.Hour), End: day.Add(14 * time.Hour)}
|
|
||||||
if FactKeyIn(a, time.UTC) == FactKeyIn(b, time.UTC) {
|
|
||||||
t.Fatalf("both events keyed as %q", FactKeyIn(a, time.UTC))
|
|
||||||
}
|
|
||||||
// The day prefix still has to survive, because the store range-scans on it.
|
|
||||||
if !strings.HasPrefix(FactKeyIn(a, time.UTC), KeyPrefixForDay(day)) {
|
|
||||||
t.Fatalf("key %q lost the day prefix %q", FactKeyIn(a, time.UTC), KeyPrefixForDay(day))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -53,31 +53,9 @@ const MinOnPatternFraction = 0.7
|
|||||||
// a repeat. False negatives cost one more observation and nothing else.
|
// a repeat. False negatives cost one more observation and nothing else.
|
||||||
const MinEvents = 4
|
const MinEvents = 4
|
||||||
|
|
||||||
// MinIntervalDays — the fastest rhythm that may be called a routine. Two
|
|
||||||
// hours.
|
|
||||||
//
|
|
||||||
// Without a floor, four taps of the same key minutes apart give intervals near
|
|
||||||
// 0.002 days. They all sit inside the ±50% band by construction, so the
|
|
||||||
// detector proposed a routine and PhraseRoutine worded it as "каждый день"
|
|
||||||
// (Vikunja #468). The damage outlives the mistake: UNIQUE(action, object)
|
|
||||||
// means dismissing the bogus proposal burns that pair permanently, so the real
|
|
||||||
// routine behind it can never be proposed again.
|
|
||||||
//
|
|
||||||
// Two hours rather than a day, because a genuine habit can run several times a
|
|
||||||
// day — meals, water, a break. Anything faster than that is not a habit she
|
|
||||||
// should be proposing to remind him about; the loop rules already cover that
|
|
||||||
// range, and they are rules, not guesses. It is checked against the median, so
|
|
||||||
// one quick repeat inside a real rhythm still counts.
|
|
||||||
//
|
|
||||||
// The other half of this is that hand-QA of the detector was unsafe: seeding a
|
|
||||||
// pattern the obvious way, four chat turns in a row, poisoned the very pair
|
|
||||||
// being tested.
|
|
||||||
const MinIntervalDays = 2.0 / 24.0
|
|
||||||
|
|
||||||
// Detect checks whether a sequence of events for the same action+object
|
// Detect checks whether a sequence of events for the same action+object
|
||||||
// forms a stable recurring pattern. Returns a ProposedRoutine when:
|
// forms a stable recurring pattern. Returns a ProposedRoutine when:
|
||||||
// - At least MinEvents events exist (≥3 intervals)
|
// - At least MinEvents events exist (≥3 intervals)
|
||||||
// - The median interval is at least MinIntervalDays
|
|
||||||
// - At least MinOnPatternFraction of the intervals sit within
|
// - At least MinOnPatternFraction of the intervals sit within
|
||||||
// MaxIntervalRatio of the median interval
|
// MaxIntervalRatio of the median interval
|
||||||
//
|
//
|
||||||
@@ -110,8 +88,8 @@ func Detect(events []Event) (*ProposedRoutine, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
center := medianFloat(intervals)
|
center := medianFloat(intervals)
|
||||||
if center <= 0 || center < MinIntervalDays {
|
if center <= 0 {
|
||||||
return nil, nil // a burst, not a rhythm — see MinIntervalDays
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Keep the intervals that sit inside the band around the median. The
|
// Keep the intervals that sit inside the band around the median. The
|
||||||
|
|||||||
@@ -216,46 +216,3 @@ func TestDetectMedianBandNotExtremes(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A burst is not a habit. Four taps of the same key minutes apart give
|
|
||||||
// intervals near 0.002 days, all inside the ±50% band by construction, so the
|
|
||||||
// detector called it a daily routine (Vikunja #468). Dismissing that proposal
|
|
||||||
// burns the action+object pair permanently, which also made hand-QA of the
|
|
||||||
// detector unsafe.
|
|
||||||
func TestDetectRejectsABurst(t *testing.T) {
|
|
||||||
base := time.Date(2026, 8, 4, 9, 0, 0, 0, time.UTC)
|
|
||||||
var events []Event
|
|
||||||
for i := 0; i < 4; i++ {
|
|
||||||
events = append(events, Event{
|
|
||||||
Action: "refill", Object: "cat_water",
|
|
||||||
Ts: base.Add(time.Duration(i) * 7 * time.Minute),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
r, err := Detect(events)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Detect: %v", err)
|
|
||||||
}
|
|
||||||
if r != nil {
|
|
||||||
t.Fatalf("four taps minutes apart proposed a routine every %.3f days", r.IntervalDays)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The floor is two hours, not a day: a habit that runs several times a day is
|
|
||||||
// still a habit.
|
|
||||||
func TestDetectKeepsASeveralTimesADayHabit(t *testing.T) {
|
|
||||||
base := time.Date(2026, 8, 4, 8, 0, 0, 0, time.UTC)
|
|
||||||
var events []Event
|
|
||||||
for i := 0; i < 5; i++ {
|
|
||||||
events = append(events, Event{
|
|
||||||
Action: "drink", Object: "water",
|
|
||||||
Ts: base.Add(time.Duration(i) * 4 * time.Hour),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
r, err := Detect(events)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Detect: %v", err)
|
|
||||||
}
|
|
||||||
if r == nil {
|
|
||||||
t.Fatal("a four-hour rhythm over five events is a habit, got nil")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -173,23 +173,12 @@ func checkFeminine(body string) Result {
|
|||||||
// Second pass: self-reference with the pronoun dropped — "напомнил тебе",
|
// Second pass: self-reference with the pronoun dropped — "напомнил тебе",
|
||||||
// "проверил за тебя". A masculine past-tense verb whose object is HIM can
|
// "проверил за тебя". A masculine past-tense verb whose object is HIM can
|
||||||
// only be her speaking about herself.
|
// only be her speaking about herself.
|
||||||
//
|
|
||||||
// Two guards, both from a false positive on the talk fixture: "ты заплатил
|
|
||||||
// за домен до марта" scored as her drift and cost the run a point it had
|
|
||||||
// earned (Vikunja #462). He is male, so a past-tense verb governed by "ты"
|
|
||||||
// must be masculine. And a bare "за" is not evidence of anything — "за
|
|
||||||
// домен" is a price, "за тебя" is her doing something on his behalf — so it
|
|
||||||
// only counts when he is the one it points at.
|
|
||||||
for i, w := range words {
|
for i, w := range words {
|
||||||
if !masculinePast(w) || i+1 >= len(words) || governedByYou(words, i) {
|
if !masculinePast(w) || i+1 >= len(words) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
next := words[i+1]
|
next := words[i+1]
|
||||||
aboutHim := next == "тебе" || next == "тебя"
|
if next == "тебе" || next == "тебя" || next == "за" {
|
||||||
if next == "за" && i+2 < len(words) && (words[i+2] == "тебя" || words[i+2] == "тебе") {
|
|
||||||
aboutHim = true
|
|
||||||
}
|
|
||||||
if aboutHim {
|
|
||||||
return Result{CheckFeminine, false,
|
return Result{CheckFeminine, false,
|
||||||
fmt.Sprintf("masculine self-reference %q before %q", w, next)}
|
fmt.Sprintf("masculine self-reference %q before %q", w, next)}
|
||||||
}
|
}
|
||||||
@@ -663,19 +652,3 @@ func checkEllipsis(body string) Result {
|
|||||||
}
|
}
|
||||||
return Result{CheckEllipsis, true, ""}
|
return Result{CheckEllipsis, true, ""}
|
||||||
}
|
}
|
||||||
|
|
||||||
// governedByYou reports whether "ты" stands close enough in front of the verb
|
|
||||||
// at index i to be its subject. Three words, the same window checkFeminine's
|
|
||||||
// first pass uses after "я", and it stops at a first-person pronoun so "ты
|
|
||||||
// просил, я напомнил" still trips.
|
|
||||||
func governedByYou(words []string, i int) bool {
|
|
||||||
for j := i - 1; j >= 0 && j >= i-3; j-- {
|
|
||||||
switch words[j] {
|
|
||||||
case "ты":
|
|
||||||
return true
|
|
||||||
case "я":
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -106,12 +106,6 @@ func TestChecksCatchWhatTheyClaim(t *testing.T) {
|
|||||||
{"masculine predicative", "я должен сказать: попей воды.", CheckFeminine},
|
{"masculine predicative", "я должен сказать: попей воды.", CheckFeminine},
|
||||||
// The other direction: HE is male, so second-person masculine is right.
|
// The other direction: HE is male, so second-person masculine is right.
|
||||||
{"second person masculine ok", "ты не пил воду четыре часа.", ""},
|
{"second person masculine ok", "ты не пил воду четыре часа.", ""},
|
||||||
// The recorded false positive: "заплатил" sits before "за", and the
|
|
||||||
// second pass read that as her dropping the pronoun. The subject is
|
|
||||||
// "ты" and he is male, so the reply is right (Vikunja #462).
|
|
||||||
{"second person masculine before за", "ты заплатил за домен до марта, а воду пить всё равно надо.", ""},
|
|
||||||
// The same shape she really does get wrong still trips.
|
|
||||||
{"masculine on his behalf", "проверил за тебя — воды не было четыре часа.", CheckFeminine},
|
|
||||||
// The real observed failure: she addressed him as a woman.
|
// The real observed failure: she addressed him as a woman.
|
||||||
{"feminine second person", "ты давно не отдыхала — попей воды.", CheckHisGender},
|
{"feminine second person", "ты давно не отдыхала — попей воды.", CheckHisGender},
|
||||||
{"feminine second person no dash", "ты пила воду четыре часа назад.", CheckHisGender},
|
{"feminine second person no dash", "ты пила воду четыре часа назад.", CheckHisGender},
|
||||||
|
|||||||
@@ -78,6 +78,12 @@ type TalkCase struct {
|
|||||||
Note string `json:"note,omitempty"`
|
Note string `json:"note,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TalkSchemaVersion — the version this loader understands. Separate from the
|
||||||
|
// nudge fixture's SchemaVersion: the two fixtures have different shapes and
|
||||||
|
// change on different days, and one shared constant would force a bump on the
|
||||||
|
// fixture that did not move.
|
||||||
|
const TalkSchemaVersion = 1
|
||||||
|
|
||||||
// TalkFixture — the versioned envelope, same gating as Fixture.
|
// TalkFixture — the versioned envelope, same gating as Fixture.
|
||||||
type TalkFixture struct {
|
type TalkFixture struct {
|
||||||
SchemaVersion int `json:"schema_version"`
|
SchemaVersion int `json:"schema_version"`
|
||||||
@@ -92,8 +98,8 @@ func LoadTalk() (TalkFixture, error) {
|
|||||||
if err := json.Unmarshal(talkFixtureJSON, &f); err != nil {
|
if err := json.Unmarshal(talkFixtureJSON, &f); err != nil {
|
||||||
return TalkFixture{}, fmt.Errorf("parse talk fixture: %w", err)
|
return TalkFixture{}, fmt.Errorf("parse talk fixture: %w", err)
|
||||||
}
|
}
|
||||||
if f.SchemaVersion != SchemaVersion {
|
if f.SchemaVersion != TalkSchemaVersion {
|
||||||
return TalkFixture{}, fmt.Errorf("talk fixture schema_version %d, want %d", f.SchemaVersion, SchemaVersion)
|
return TalkFixture{}, fmt.Errorf("talk fixture schema_version %d, want %d", f.SchemaVersion, TalkSchemaVersion)
|
||||||
}
|
}
|
||||||
if len(f.Cases) == 0 {
|
if len(f.Cases) == 0 {
|
||||||
return TalkFixture{}, fmt.Errorf("talk fixture has no cases")
|
return TalkFixture{}, fmt.Errorf("talk fixture has no cases")
|
||||||
|
|||||||
@@ -142,19 +142,13 @@ func TestLLMTalkBaseline(t *testing.T) {
|
|||||||
p := phraser.NewLLMPhraserAt(base, cfg)
|
p := phraser.NewLLMPhraserAt(base, cfg)
|
||||||
defer p.Close()
|
defer p.Close()
|
||||||
|
|
||||||
// Unreachable server is fatal here, not a logged warning, and that differs
|
// The model id names the run in the report. Since Vikunja #397 every path
|
||||||
// from the nudge test on purpose. PhraseNudge returns its errors, so a dead
|
// returns its errors, so a server that dies mid-run shows up in the Errors
|
||||||
// server there shows up honestly in the Errors column. PhraseChat and
|
// column instead of scoring as bad phrasing — the before-and-after probe that
|
||||||
// PhraseQuery do NOT: they swallow every failure and return a canned string
|
// used to stand in for that is gone.
|
||||||
// ("поговорили.", "не знаю.", "вот что я нашла: …"). So on these three paths
|
|
||||||
// a dead server produces a full report with 0 errors and a terrible score —
|
|
||||||
// a number that looks like bad phrasing and is really no phrasing at all.
|
|
||||||
// Refusing to score without a confirmed model is the only guard available
|
|
||||||
// until the phraser reports its failures (Vikunja #397).
|
|
||||||
model, err := llm.ModelID(ctx, base)
|
model, err := llm.ModelID(ctx, base)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("no model at %s: %v — refusing to score, these paths hide their errors "+
|
t.Fatalf("no model at %s: %v", base, err)
|
||||||
"and would report a plausible-looking result off a dead server", base, err)
|
|
||||||
}
|
}
|
||||||
t.Logf("scoring model %s at %s", model, base)
|
t.Logf("scoring model %s at %s", model, base)
|
||||||
|
|
||||||
@@ -169,10 +163,11 @@ func TestLLMTalkBaseline(t *testing.T) {
|
|||||||
}
|
}
|
||||||
t.Log("\n" + rep.String() + "\nreplies:\n" + rep.Replies() + "\nfailures:\n" + rep.Failures())
|
t.Log("\n" + rep.String() + "\nreplies:\n" + rep.Replies() + "\nfailures:\n" + rep.Failures())
|
||||||
|
|
||||||
// And again afterwards: the run takes minutes, and a server that died or got
|
// A run where nothing was phrased is not a low score, it is no measurement.
|
||||||
// OOM-killed halfway through would leave the first cases scored and the rest
|
if rep.Errors == rep.Total {
|
||||||
// silently canned. Checking only at the start would not catch that.
|
t.Fatalf("every case errored — nothing was measured, the score above is not a phrasing result")
|
||||||
if _, err := llm.ModelID(ctx, base); err != nil {
|
}
|
||||||
t.Fatalf("model at %s went away during the run: %v — the score above is not trustworthy", base, err)
|
if rep.Errors > 0 {
|
||||||
|
t.Logf("%d/%d cases errored — those are model failures, not phrasing failures", rep.Errors, rep.Total)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package phraser
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// A dead server must be distinguishable from bad phrasing. Both PhraseChat and
|
||||||
|
// PhraseQuery keep the turn alive with canned text — ChatFallback, "не знаю.",
|
||||||
|
// "вот что я нашла: …" — and every one of those is also a legitimate reply, so
|
||||||
|
// the text alone cannot say which happened. The error is the only signal, and
|
||||||
|
// before Vikunja #397 it was dropped: the talk scorer reported a full run with
|
||||||
|
// zero errors off a server that answered nothing.
|
||||||
|
func TestPhrasingReportsTheFailureWithTheFallback(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
http.Error(w, "model not loaded", http.StatusServiceUnavailable)
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
p := NewLLMPhraserAt(srv.URL, Config{})
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
call func() (string, error)
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"chat", func() (string, error) {
|
||||||
|
return p.PhraseChat(context.Background(), "как дела", nil)
|
||||||
|
}, ChatFallback},
|
||||||
|
{"knowledge", func() (string, error) {
|
||||||
|
return p.PhraseQuery(context.Background(), "кто написал войну и мир", nil)
|
||||||
|
}, "не знаю."},
|
||||||
|
{"evidence", func() (string, error) {
|
||||||
|
return p.PhraseQuery(context.Background(), "сколько воды я выпил", []string{"два литра"})
|
||||||
|
}, "вот что я нашла: два литра"},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
got, err := c.call()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("no error from a dead server; the scorer would count this as bad phrasing")
|
||||||
|
}
|
||||||
|
if got != c.want {
|
||||||
|
t.Errorf("fallback text = %q, want %q — the daemon still has to say something", got, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// An empty answer is a failure too: the server is up and produced no tokens,
|
||||||
|
// which is not an answer and must not score as one.
|
||||||
|
func TestEmptyKnowledgeAnswerIsAnError(t *testing.T) {
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
w.Write([]byte(`{"choices":[{"message":{"content":""}}]}`))
|
||||||
|
}))
|
||||||
|
t.Cleanup(srv.Close)
|
||||||
|
p := NewLLMPhraserAt(srv.URL, Config{})
|
||||||
|
|
||||||
|
got, err := p.PhraseQuery(context.Background(), "кто написал войну и мир", nil)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("an empty response scored as an answer")
|
||||||
|
}
|
||||||
|
if got != "не знаю." {
|
||||||
|
t.Errorf("fallback text = %q, want \"не знаю.\"", got)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "empty") {
|
||||||
|
t.Errorf("error = %v; want it to name the empty response", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log"
|
"log"
|
||||||
@@ -26,6 +27,11 @@ import (
|
|||||||
|
|
||||||
var listenRE = regexp.MustCompile(`listening on (https?://\S+)`)
|
var listenRE = regexp.MustCompile(`listening on (https?://\S+)`)
|
||||||
|
|
||||||
|
// errEmptyResponse — the server answered and said nothing. Separate from a
|
||||||
|
// transport failure: the model is up and produced no tokens, which is still not
|
||||||
|
// an answer and must not score as one.
|
||||||
|
var errEmptyResponse = errors.New("phraser: empty response from the model")
|
||||||
|
|
||||||
type LLMPhraser struct {
|
type LLMPhraser struct {
|
||||||
cfg Config
|
cfg Config
|
||||||
client *http.Client
|
client *http.Client
|
||||||
@@ -428,8 +434,11 @@ func (p *LLMPhraser) PhraseNudge(ctx context.Context, c loop.Candidate) (deliver
|
|||||||
}
|
}
|
||||||
|
|
||||||
// PhraseQuery prompts the LLM with the user's utterance and matching notes to
|
// PhraseQuery prompts the LLM with the user's utterance and matching notes to
|
||||||
// compose a natural answer. Falls back to "вот что я нашла: <notes>" on any
|
// compose a natural answer. On any LLM error it returns the fallback text —
|
||||||
// LLM error — better to give the raw data than silence.
|
// "вот что я нашла: <notes>", or "не знаю." with no notes — and the error
|
||||||
|
// together. The daemon uses the text and keeps the turn alive; a caller that is
|
||||||
|
// measuring counts the failure. Until Vikunja #397 the error was dropped, so a
|
||||||
|
// dead server scored as bad phrasing.
|
||||||
func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes []string) (string, error) {
|
func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes []string) (string, error) {
|
||||||
// Blank sources are no sources. A caller that hands over one empty string —
|
// Blank sources are no sources. A caller that hands over one empty string —
|
||||||
// a page that fetched to nothing, a snippet trimmed away — used to take the
|
// a page that fetched to nothing, a snippet trimmed away — used to take the
|
||||||
@@ -439,13 +448,15 @@ func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes []
|
|||||||
if len(notes) == 0 {
|
if len(notes) == 0 {
|
||||||
sys, prompt := p.knowledgePrompt(utterance)
|
sys, prompt := p.knowledgePrompt(utterance)
|
||||||
resp, err := p.chatWithSystem(ctx, sys, prompt, 768)
|
resp, err := p.chatWithSystem(ctx, sys, prompt, 768)
|
||||||
if err != nil || resp == "" {
|
if err != nil {
|
||||||
return "не знаю.", nil
|
return "не знаю.", fmt.Errorf("phrase query (knowledge): %w", err)
|
||||||
|
}
|
||||||
|
if resp == "" {
|
||||||
|
return "не знаю.", errEmptyResponse
|
||||||
}
|
}
|
||||||
text, _, perr := parseResponseMood(resp)
|
text, _, perr := parseResponseMood(resp)
|
||||||
if perr != nil {
|
if perr != nil {
|
||||||
log.Printf("phraser: PhraseQuery: %v", perr)
|
return "не знаю.", fmt.Errorf("phrase query (knowledge): %w", perr)
|
||||||
return "не знаю.", nil
|
|
||||||
}
|
}
|
||||||
if text != "" {
|
if text != "" {
|
||||||
return text, nil
|
return text, nil
|
||||||
@@ -457,13 +468,12 @@ func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes []
|
|||||||
text, _, perr := parseResponseMood(resp)
|
text, _, perr := parseResponseMood(resp)
|
||||||
if err != nil || perr != nil {
|
if err != nil || perr != nil {
|
||||||
// Read the notes out rather than ship a broken fragment.
|
// Read the notes out rather than ship a broken fragment.
|
||||||
if perr != nil {
|
cause := err
|
||||||
log.Printf("phraser: PhraseQuery: %v", perr)
|
if cause == nil {
|
||||||
|
cause = perr
|
||||||
}
|
}
|
||||||
if len(notes) == 1 {
|
return "вот что я нашла: " + strings.Join(notes, "; "),
|
||||||
return "вот что я нашла: " + notes[0], nil
|
fmt.Errorf("phrase query (evidence): %w", cause)
|
||||||
}
|
|
||||||
return "вот что я нашла: " + strings.Join(notes, "; "), nil
|
|
||||||
}
|
}
|
||||||
if text != "" {
|
if text != "" {
|
||||||
return text, nil
|
return text, nil
|
||||||
@@ -472,8 +482,9 @@ func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes []
|
|||||||
}
|
}
|
||||||
|
|
||||||
// PhraseChat uses the LLM to respond conversationally, building a multi-turn
|
// PhraseChat uses the LLM to respond conversationally, building a multi-turn
|
||||||
// message array from dialogue history + the current user utterance. Falls back
|
// message array from dialogue history + the current user utterance. On any LLM
|
||||||
// to a simple greeting on any LLM error — better to say something than nothing.
|
// error it returns both ChatFallback and the error, on the same rule as
|
||||||
|
// PhraseQuery: the fallback keeps the turn alive, the error stays visible.
|
||||||
func (p *LLMPhraser) PhraseChat(ctx context.Context, utterance string, history []dialogue.Turn) (string, error) {
|
func (p *LLMPhraser) PhraseChat(ctx context.Context, utterance string, history []dialogue.Turn) (string, error) {
|
||||||
sys := chatSystemPrompt(p.cfg.ContextBlock)
|
sys := chatSystemPrompt(p.cfg.ContextBlock)
|
||||||
msgs := []chatMsg{
|
msgs := []chatMsg{
|
||||||
@@ -490,13 +501,11 @@ func (p *LLMPhraser) PhraseChat(ctx context.Context, utterance string, history [
|
|||||||
|
|
||||||
resp, err := p.chatWithMessages(ctx, msgs, 768)
|
resp, err := p.chatWithMessages(ctx, msgs, 768)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("phraser: PhraseChat: %v", err)
|
return ChatFallback, fmt.Errorf("phrase chat: %w", err)
|
||||||
return "поговорили.", nil
|
|
||||||
}
|
}
|
||||||
text, _, perr := parseResponseMood(resp)
|
text, _, perr := parseResponseMood(resp)
|
||||||
if perr != nil {
|
if perr != nil {
|
||||||
log.Printf("phraser: PhraseChat: %v", perr)
|
return ChatFallback, fmt.Errorf("phrase chat: %w", perr)
|
||||||
return "поговорили.", nil
|
|
||||||
}
|
}
|
||||||
if text != "" {
|
if text != "" {
|
||||||
return text, nil
|
return text, nil
|
||||||
|
|||||||
@@ -66,11 +66,17 @@ type Stub struct{}
|
|||||||
// NewStub builds the floor phraser. no config — the Stub is stateless.
|
// NewStub builds the floor phraser. no config — the Stub is stateless.
|
||||||
func NewStub() *Stub { return &Stub{} }
|
func NewStub() *Stub { return &Stub{} }
|
||||||
|
|
||||||
|
// ChatFallback — what she says on the chat path when the model gave her
|
||||||
|
// nothing to say. It replaced "поговорили.", which reads as a summary of a
|
||||||
|
// conversation that did not happen. Said out loud this one is an admission,
|
||||||
|
// which is what it is.
|
||||||
|
const ChatFallback = "даже не знаю, что сказать."
|
||||||
|
|
||||||
// PhraseChat returns a stub reply — the LLMPhraser replaces this with a
|
// PhraseChat returns a stub reply — the LLMPhraser replaces this with a
|
||||||
// prompted response from the model. The history parameter is accepted but
|
// prompted response from the model. The history parameter is accepted but
|
||||||
// ignored at the stub level (the production impl uses it for multi-turn).
|
// ignored at the stub level (the production impl uses it for multi-turn).
|
||||||
func (s *Stub) PhraseChat(_ context.Context, _ string, _ []dialogue.Turn) (string, error) {
|
func (s *Stub) PhraseChat(_ context.Context, _ string, _ []dialogue.Turn) (string, error) {
|
||||||
return "поговорили.", nil
|
return ChatFallback, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// PhraseQuery returns a deterministic summary of the best matching notes.
|
// PhraseQuery returns a deterministic summary of the best matching notes.
|
||||||
|
|||||||
@@ -215,10 +215,12 @@ func TestSwap_RollbackFailureLeavesNoBackendAndDegrades(t *testing.T) {
|
|||||||
if _, _, aerr := p.acquire(); !errors.Is(aerr, ErrNoBackend) {
|
if _, _, aerr := p.acquire(); !errors.Is(aerr, ErrNoBackend) {
|
||||||
t.Errorf("acquire error = %v; want ErrNoBackend", aerr)
|
t.Errorf("acquire error = %v; want ErrNoBackend", aerr)
|
||||||
}
|
}
|
||||||
// Phrasing degrades to its fallback instead of failing the turn.
|
// Phrasing degrades to its fallback instead of failing the turn, and since
|
||||||
|
// Vikunja #397 it reports the error next to that fallback so a measuring
|
||||||
|
// caller can tell "no model" from "bad phrasing".
|
||||||
got, err := p.PhraseChat(context.Background(), "привет", nil)
|
got, err := p.PhraseChat(context.Background(), "привет", nil)
|
||||||
if err != nil {
|
if !errors.Is(err, ErrNoBackend) {
|
||||||
t.Fatalf("PhraseChat after a total failure returned an error: %v", err)
|
t.Errorf("PhraseChat error = %v; want ErrNoBackend alongside the fallback", err)
|
||||||
}
|
}
|
||||||
if got == "" {
|
if got == "" {
|
||||||
t.Error("PhraseChat returned empty; the fallback must still say something")
|
t.Error("PhraseChat returned empty; the fallback must still say something")
|
||||||
|
|||||||
@@ -75,47 +75,3 @@ func TestAgendaGrammarSparesStatements(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// The tomorrow form and the bare event noun. Both were measured answering
|
|
||||||
// "пока не умею" on the deployed daemon, 02-08-2026, while the same question
|
|
||||||
// about today worked — the first rule set needed "у меня" or a calendar noun
|
|
||||||
// and these phrasings carry neither (Vikunja #471).
|
|
||||||
func TestAgendaCoversOtherDaysAndNamedEvents(t *testing.T) {
|
|
||||||
r := agendaRouter(t)
|
|
||||||
for _, u := range []string{
|
|
||||||
"какие планы на завтра?",
|
|
||||||
"какие планы на послезавтра",
|
|
||||||
"что по делам в среду",
|
|
||||||
"какие планы на выходные",
|
|
||||||
"когда планёрка?",
|
|
||||||
"во сколько созвон",
|
|
||||||
"когда будет совещание",
|
|
||||||
} {
|
|
||||||
d, err := r.Route(context.Background(), u, refNow())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("route(%q): %v", u, err)
|
|
||||||
}
|
|
||||||
if d.Intent != IntentQuery {
|
|
||||||
t.Errorf("route(%q) = %s, want query", u, d.Intent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The two new rules are narrow on purpose. A world question that opens with
|
|
||||||
// "когда" is not an agenda question, and telling her about a plan is not
|
|
||||||
// asking about one.
|
|
||||||
func TestAgendaGrammarsLeaveTheWorldAlone(t *testing.T) {
|
|
||||||
r := agendaRouter(t)
|
|
||||||
for _, u := range []string{
|
|
||||||
"когда была битва при ватерлоо",
|
|
||||||
"когда изобрели телефон",
|
|
||||||
} {
|
|
||||||
d, err := r.Route(context.Background(), u, refNow())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("route(%q): %v", u, err)
|
|
||||||
}
|
|
||||||
if d.Stage == 0 {
|
|
||||||
t.Errorf("route(%q) was claimed at stage 0 as %s", u, d.Intent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -23,8 +23,6 @@
|
|||||||
{ "id": "ru-query-012", "utterance": "какие заметки я оставил про полив", "lang": "ru", "intent": "query", "tags": ["recall"] },
|
{ "id": "ru-query-012", "utterance": "какие заметки я оставил про полив", "lang": "ru", "intent": "query", "tags": ["recall"] },
|
||||||
{ "id": "ru-query-013", "utterance": "во сколько у меня встреча", "lang": "ru", "intent": "query", "tags": ["calendar"] },
|
{ "id": "ru-query-013", "utterance": "во сколько у меня встреча", "lang": "ru", "intent": "query", "tags": ["calendar"] },
|
||||||
{ "id": "ru-query-019", "utterance": "что у меня стоит в календаре на послезавтра", "lang": "ru", "intent": "query", "tags": ["calendar", "hard"], "note": "agenda, not the clock: the daemon answers this from CalendarEvents inside the query branch, so the clock/date system rule must not swallow it" },
|
{ "id": "ru-query-019", "utterance": "что у меня стоит в календаре на послезавтра", "lang": "ru", "intent": "query", "tags": ["calendar", "hard"], "note": "agenda, not the clock: the daemon answers this from CalendarEvents inside the query branch, so the clock/date system rule must not swallow it" },
|
||||||
{ "id": "ru-query-022", "utterance": "какие планы на завтра?", "lang": "ru", "intent": "query", "tags": ["calendar"], "note": "the same agenda question as ru-query-019 aimed at another day; it answered \u043f\u043e\u043a\u0430 \u043d\u0435 \u0443\u043c\u0435\u044e on the deployed daemon while the today form worked (Vikunja #471)" },
|
|
||||||
{ "id": "ru-query-023", "utterance": "\u043a\u043e\u0433\u0434\u0430 \u043f\u043b\u0430\u043d\u0451\u0440\u043a\u0430?", "lang": "ru", "intent": "query", "tags": ["calendar", "hard"], "note": "a named event with no calendar word — the noun is the only signal that this is a question about his day" },
|
|
||||||
{ "id": "ru-query-014", "utterance": "я успеваю до дедлайна", "lang": "ru", "intent": "query", "tags": ["hard", "no-question-word"] },
|
{ "id": "ru-query-014", "utterance": "я успеваю до дедлайна", "lang": "ru", "intent": "query", "tags": ["hard", "no-question-word"] },
|
||||||
{ "id": "ru-query-015", "utterance": "сколько я прошёл шагов", "lang": "ru", "intent": "query", "tags": ["aggregate"] },
|
{ "id": "ru-query-015", "utterance": "сколько я прошёл шагов", "lang": "ru", "intent": "query", "tags": ["aggregate"] },
|
||||||
{ "id": "ru-query-016", "utterance": "покажи давление за неделю", "lang": "ru", "intent": "query", "tags": ["hard", "imperative"], "note": "imperative form but a read — must not route to act" },
|
{ "id": "ru-query-016", "utterance": "покажи давление за неделю", "lang": "ru", "intent": "query", "tags": ["hard", "imperative"], "note": "imperative form but a read — must not route to act" },
|
||||||
|
|||||||
@@ -210,11 +210,7 @@ func (lr *LLMRouter) Route(ctx context.Context, utterance string, now time.Time)
|
|||||||
d.Slots.HasKey = a.Key != ""
|
d.Slots.HasKey = a.Key != ""
|
||||||
case IntentReminder:
|
case IntentReminder:
|
||||||
d.Intent = IntentReminder
|
d.Intent = IntentReminder
|
||||||
// No utterance fallback here, unlike every other intent below. The
|
d.Slots.Text = firstNonEmpty(a.Text, utterance)
|
||||||
// model returning no text for a reminder means it found no subject,
|
|
||||||
// and "напомни в 11" is not a subject. Leaving Text empty is what
|
|
||||||
// lets the gate turn that into a question (Vikunja #383).
|
|
||||||
d.Slots.Text = a.Text
|
|
||||||
case IntentNote:
|
case IntentNote:
|
||||||
d.Intent = IntentNote
|
d.Intent = IntentNote
|
||||||
d.Slots.Text = firstNonEmpty(a.Text, utterance)
|
d.Slots.Text = firstNonEmpty(a.Text, utterance)
|
||||||
|
|||||||
@@ -356,35 +356,3 @@ func TestRouterLLMFactWithResolvedKeyStaysConfident(t *testing.T) {
|
|||||||
t.Fatalf("a fact the parser could key must not clarify: %+v", d)
|
t.Fatalf("a fact the parser could key must not clarify: %+v", d)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// A reminder with a time and no subject must come back empty and gated, not
|
|
||||||
// backfilled with the raw words. "напомни в 11" carries an hour and nothing to
|
|
||||||
// say at that hour; parking the utterance in Text made the request look
|
|
||||||
// complete, so the daemon set a reminder that fires saying "напомни в 11"
|
|
||||||
// (Vikunja #383).
|
|
||||||
func TestLLMReminderWithoutSubjectAsksInsteadOfGuessing(t *testing.T) {
|
|
||||||
r := newLLMTestRouter(t, `{"intent":"reminder"}`)
|
|
||||||
d, err := r.Route(context.Background(), "напомни в 11", refNow())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("route: %v", err)
|
|
||||||
}
|
|
||||||
if d.Slots.Text != "" {
|
|
||||||
t.Fatalf("subject backfilled from the utterance: %q", d.Slots.Text)
|
|
||||||
}
|
|
||||||
if !d.Clarify {
|
|
||||||
t.Fatalf("a subjectless reminder was accepted, confidence %v", d.Confidence)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// The gate is about the subject, not about reminders in general: one that has
|
|
||||||
// both halves still runs without a question.
|
|
||||||
func TestLLMReminderWithSubjectIsNotGated(t *testing.T) {
|
|
||||||
r := newLLMTestRouter(t, `{"intent":"reminder","text":"позвонить маме"}`)
|
|
||||||
d, err := r.Route(context.Background(), "напомни в 11 позвонить маме", refNow())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("route: %v", err)
|
|
||||||
}
|
|
||||||
if d.Clarify {
|
|
||||||
t.Fatalf("a complete reminder was sent back as a question: %+v", d.Slots)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -147,15 +147,7 @@ func (r *Router) fillSlots(ctx context.Context, d *Decision, now time.Time) {
|
|||||||
d.Slots.Fn, d.Slots.Args, d.Slots.HasFn = fn, args, true
|
d.Slots.Fn, d.Slots.Args, d.Slots.HasFn = fn, args, true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// The extractor's Text is the raw utterance, which is the payload for a
|
if d.Slots.Text == "" {
|
||||||
// note, a query or a chat turn but not for a reminder — there Text is the
|
|
||||||
// subject, what she says at the hour. Backfilling it made Text impossible
|
|
||||||
// to be empty, so StillMissing never reported SlotText and "О чём
|
|
||||||
// напомнить?" was unaskable; the answer to a question she did manage to
|
|
||||||
// ask then overwrote the whole request instead of filling one gap
|
|
||||||
// (Vikunja #383). A reminder with no subject stays empty and is gated
|
|
||||||
// below into a question.
|
|
||||||
if d.Slots.Text == "" && d.Intent != IntentReminder {
|
|
||||||
d.Slots.Text = ex.Text
|
d.Slots.Text = ex.Text
|
||||||
}
|
}
|
||||||
// Stage stays 1: it says who decided the route, and that was the LLM.
|
// Stage stays 1: it says who decided the route, and that was the LLM.
|
||||||
@@ -185,12 +177,6 @@ func (r *Router) gateLLMDecision(d *Decision) {
|
|||||||
if d.Intent == IntentAct && !d.Slots.HasFn && d.Confidence > llmThinConfidence {
|
if d.Intent == IntentAct && !d.Slots.HasFn && d.Confidence > llmThinConfidence {
|
||||||
d.Confidence = llmThinConfidence
|
d.Confidence = llmThinConfidence
|
||||||
}
|
}
|
||||||
// A reminder with no subject: she knows when but not what to say then.
|
|
||||||
// Setting it anyway fires an empty reminder at the hour, which reads as a
|
|
||||||
// bug to him and cannot be repaired after the fact. Ask (Vikunja #383).
|
|
||||||
if d.Intent == IntentReminder && d.Slots.Text == "" && d.Confidence > llmThinConfidence {
|
|
||||||
d.Confidence = llmThinConfidence
|
|
||||||
}
|
|
||||||
if d.Confidence < r.threshold {
|
if d.Confidence < r.threshold {
|
||||||
d.Clarify = true
|
d.Clarify = true
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -182,38 +182,9 @@ func AgendaQueryGrammars() []Grammar {
|
|||||||
Pattern: regexp.MustCompile(`(?i)^\s*(что|чего|какие|сколько|во\s+сколько|когда)\s+у\s+меня(\s|[?!.]|$)`),
|
Pattern: regexp.MustCompile(`(?i)^\s*(что|чего|какие|сколько|во\s+сколько|когда)\s+у\s+меня(\s|[?!.]|$)`),
|
||||||
Build: agendaQueryBuild,
|
Build: agendaQueryBuild,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
// A plan noun aimed at a named day, with no possessive to anchor
|
|
||||||
// on: "какие планы на завтра", "что по делам в среду". The rule
|
|
||||||
// above wants "у меня" and this phrasing never has it, so
|
|
||||||
// "какие планы на завтра" answered "пока не умею" while "какие
|
|
||||||
// планы на сегодня" worked (Vikunja #471). The day word is what
|
|
||||||
// makes it an agenda question rather than a topic.
|
|
||||||
Name: "plan-day-query",
|
|
||||||
// Only "план" and "дел". A verb stem like "встреч" would take
|
|
||||||
// "встречаемся в среду", which is him telling her something, not
|
|
||||||
// asking.
|
|
||||||
Pattern: regexp.MustCompile(`(?i)(^|\s)(план|дел)[а-я]*\s+(на|в|во|по)\s+` + dayWordPattern + `(\s|[?!.]|$)`),
|
|
||||||
Build: agendaQueryBuild,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// A named event with no calendar word at all: "когда планёрка?",
|
|
||||||
// "во сколько созвон". He is asking when something on his calendar
|
|
||||||
// happens, and the noun is the only signal. Closed list, so "когда
|
|
||||||
// битва при Ватерлоо" is still a world question.
|
|
||||||
Name: "event-time-query",
|
|
||||||
Pattern: regexp.MustCompile(`(?i)^\s*(когда|во\s+сколько|в\s+котором\s+часу)\s+(будет\s+|у\s+нас\s+)?(планёрк|планерк|встреч|созвон|митинг|совещани|звонок|созвон|приём|прием|интервью|собеседовани|тренировк|урок|занятие|пара)[а-я]*(\s|[?!.]|$)`),
|
|
||||||
Build: agendaQueryBuild,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// dayWordPattern — the day words an agenda question can name. Weekdays appear
|
|
||||||
// in the accusative and prepositional forms the questions actually use ("в
|
|
||||||
// среду", "на среде"), which is why the stems carry an inflection tail rather
|
|
||||||
// than a fixed ending.
|
|
||||||
const dayWordPattern = `(сегодня|завтра|послезавтра|выходн[а-я]+|недел[а-я]+|понедельник[а-я]*|вторник[а-я]*|сред[ауые][а-я]*|четверг[а-я]*|пятниц[ауые][а-я]*|суббот[ауые][а-я]*|воскресень[ея][а-я]*)`
|
|
||||||
|
|
||||||
// agendaQueryBuild — shared Build for the agenda grammars. Confidence 1.0 on
|
// agendaQueryBuild — shared Build for the agenda grammars. Confidence 1.0 on
|
||||||
// the intent only: the utterance travels intact and the query chain's own
|
// the intent only: the utterance travels intact and the query chain's own
|
||||||
// matchers decide the rest.
|
// matchers decide the rest.
|
||||||
|
|||||||
@@ -208,17 +208,6 @@ ALTER TABLE reminders ADD COLUMN next_fire_ts INTEGER;`, // #2
|
|||||||
// list_tasks into something that writes without the row changing by one
|
// list_tasks into something that writes without the row changing by one
|
||||||
// byte. The fingerprint is the declared shape at approval time, so a
|
// byte. The fingerprint is the declared shape at approval time, so a
|
||||||
// redefinition is a re-approval instead of a silent upgrade.
|
// redefinition is a re-approval instead of a silent upgrade.
|
||||||
`DELETE FROM facts
|
|
||||||
WHERE key LIKE 'calendar_event_%'
|
|
||||||
AND replace(substr(key, 25), '-', '') = '';`,
|
|
||||||
// #18 — drop the calendar keys written while safeKey dropped Cyrillic
|
|
||||||
// (Vikunja #443). Everything after the date prefix was punctuation, so
|
|
||||||
// every Russian event on one day shared one key and only the last one
|
|
||||||
// survived. Deleting rather than rewriting: a calendar fact is derived
|
|
||||||
// data, the next poll writes the day again under keys that identify the
|
|
||||||
// event, and the old rows would otherwise be recited as extra meetings.
|
|
||||||
// The filter is exact — it keeps any key whose summary part still has a
|
|
||||||
// letter or a digit in it.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// migrate applies every migration with a number greater than the DB's current
|
// migrate applies every migration with a number greater than the DB's current
|
||||||
|
|||||||
@@ -47,36 +47,3 @@ func TestMigrateAppliesOnceAndIsIdempotent(t *testing.T) {
|
|||||||
t.Fatalf("after re-migrate user_version = %d, want %d", v, want)
|
t.Fatalf("after re-migrate user_version = %d, want %d", v, want)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Migration #18 clears the calendar keys written while safeKey dropped
|
|
||||||
// Cyrillic. Those rows are indistinguishable from real events on read, so
|
|
||||||
// leaving them would recite one meeting as several (Vikunja #443).
|
|
||||||
func TestCollapsedCalendarKeysAreDropped(t *testing.T) {
|
|
||||||
ctx := context.Background()
|
|
||||||
s := newTestStore(t)
|
|
||||||
|
|
||||||
rows := []string{
|
|
||||||
"calendar_event_20260804_--", // "Встреча с Аней" under the old rule
|
|
||||||
"calendar_event_20260804_", // a one-word Russian summary
|
|
||||||
"calendar_event_20260804_Встреча-с-Аней", // the new format
|
|
||||||
"calendar_event_20260804_Standup", // an ASCII summary, always fine
|
|
||||||
}
|
|
||||||
for _, key := range rows {
|
|
||||||
if _, err := s.db.ExecContext(ctx,
|
|
||||||
`INSERT INTO facts (ts, kind, key, value, source, confidence) VALUES (0, 'env', ?, 'x', 'poll:caldav', 1.0)`,
|
|
||||||
key); err != nil {
|
|
||||||
t.Fatalf("seed %q: %v", key, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if _, err := s.db.ExecContext(ctx, migrations[17]); err != nil {
|
|
||||||
t.Fatalf("migration 18: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var got int
|
|
||||||
if err := s.db.QueryRowContext(ctx, `SELECT count(*) FROM facts WHERE key LIKE 'calendar_event_%'`).Scan(&got); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if got != 2 {
|
|
||||||
t.Fatalf("%d calendar rows left, want the 2 that identify their event", got)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user