Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 16d94894b7 | |||
| ae8d38fc31 | |||
| b2521988e1 | |||
| dae123adac | |||
| 1c9ddbbea2 | |||
| 3f2782f5b7 | |||
| 865623ef3e | |||
| 4fdce3ca2c | |||
| c47881106e | |||
| 9a70f7378b | |||
| b18f608594 | |||
| d1f8a734c5 |
+3
-2
@@ -16,6 +16,7 @@ import (
|
||||
"log"
|
||||
|
||||
"github.com/kami/maven/internal/loop"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
@@ -35,10 +36,10 @@ func (h *reactiveHandler) resolveAck(ctx context.Context, text string, src turnS
|
||||
}
|
||||
if err := h.api.ResolveNudge(ctx, target.ID, store.NudgeActed, now); err != nil {
|
||||
log.Printf("voice: ack nudge %d (%s, %s): %v", target.ID, target.Rule, src, err)
|
||||
return "не получилось отметить.", true
|
||||
return phraser.Ack(phraser.FailAck, nil), true
|
||||
}
|
||||
log.Printf("voice: acked nudge %d (rule %s) from %s", target.ID, target.Rule, src)
|
||||
return "отлично, отметила.", true
|
||||
return phraser.Ack(phraser.AckNudge, nil), true
|
||||
}
|
||||
|
||||
// ackFromFact — post-action hook, called once the turn's decision has been
|
||||
|
||||
@@ -40,6 +40,7 @@ import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"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)
|
||||
// and let the LLM respond from general knowledge + context.
|
||||
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)
|
||||
if err != nil {
|
||||
log.Printf("voice: chat: %v", err)
|
||||
return "поговорили."
|
||||
}
|
||||
if reply == "" {
|
||||
return phraser.ChatFallback()
|
||||
}
|
||||
return reply
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"strconv"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
@@ -14,7 +15,7 @@ import (
|
||||
// it for recall, and let pattern detection propose a routine.
|
||||
func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) string {
|
||||
if !dec.Slots.HasKey {
|
||||
return "не разобрала, что записать — попробуй иначе."
|
||||
return phraser.Ack(phraser.FailFactUnparsed, nil)
|
||||
}
|
||||
// A question is never a fact about him (#470). "какая последняя версия
|
||||
// языка Go?" used to land here, and the value stored was whatever the
|
||||
@@ -62,7 +63,7 @@ func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) s
|
||||
factID, err := h.api.WriteFact(ctx, req)
|
||||
if err != nil {
|
||||
log.Printf("voice: write fact: %v", err)
|
||||
return "не получилось сохранить факт."
|
||||
return phraser.Ack(phraser.FailFact, nil)
|
||||
}
|
||||
// Index the fact in long-term memory (best-effort, must not fail the fact
|
||||
// write). Facts aren't in the notes table, so this is the only recall path
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"log"
|
||||
"strconv"
|
||||
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
@@ -23,13 +24,13 @@ func (h *reactiveHandler) actionNote(ctx context.Context, dec router.Decision) s
|
||||
vec, err := router.EmbedPassage(ctx, h.embedder, dec.Utterance)
|
||||
if err != nil {
|
||||
log.Printf("voice: embed note: %v", err)
|
||||
return "не получилось сохранить заметку."
|
||||
return phraser.Ack(phraser.FailNote, nil)
|
||||
}
|
||||
noteTs := h.now()
|
||||
noteID, err := h.api.WriteNote(ctx, noteTs, dec.Utterance, vec, "tap:voice")
|
||||
if err != nil {
|
||||
log.Printf("voice: write note: %v", err)
|
||||
return "не получилось сохранить заметку."
|
||||
return phraser.Ack(phraser.FailNote, nil)
|
||||
}
|
||||
// Insert into long-term memory (best-effort, must not fail the note write).
|
||||
// text/ts in the meta make a Search hit self-describing (see bestRecall).
|
||||
|
||||
+40
-30
@@ -147,9 +147,9 @@ func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision)
|
||||
// The previous question cannot be re-asked for another day. Saying so
|
||||
// beats "не знаю", which reads as "no data for tomorrow" when the
|
||||
// truth is that she never looked.
|
||||
return "про другой день так не отвечу — спроси целиком."
|
||||
return phraser.Q(phraser.QueryOtherDay, nil)
|
||||
}
|
||||
return "не знаю."
|
||||
return phraser.Q(phraser.QueryUnknown, nil)
|
||||
}
|
||||
|
||||
// queryFactByKey — when the dialogue layer resolved an anaphoric reference to
|
||||
@@ -167,11 +167,11 @@ func (h *reactiveHandler) queryFactByKey(ctx context.Context, t *queryTurn) (str
|
||||
if dec.Slots.HasTime {
|
||||
// The query asks about timing — the fact's own timestamp is the
|
||||
// answer it's looking for. Format as a natural reply.
|
||||
return fmt.Sprintf("я записала это %s", formatTime(f.Ts)), true
|
||||
return phraser.Q(phraser.QueryFactWhen, map[string]string{"when": formatTime(f.Ts)}), true
|
||||
}
|
||||
// General fact reference: describe what we know.
|
||||
if dec.Utterance == "" {
|
||||
return fmt.Sprintf("вот что я знаю: %s — %s", dec.Slots.Key, f.Value), true
|
||||
return phraser.Q(phraser.QueryFactValue, map[string]string{"key": dec.Slots.Key, "value": f.Value}), true
|
||||
}
|
||||
// The utterance still carries the question; fall through to normal RAG
|
||||
// with the resolved key in context.
|
||||
@@ -197,7 +197,7 @@ func (h *reactiveHandler) queryDayPlan(ctx context.Context, t *queryTurn) (strin
|
||||
plan, err := h.api.DayPlan(ctx)
|
||||
if err != nil {
|
||||
log.Printf("voice: day plan: %v", err)
|
||||
return "не получилось собрать план.", true
|
||||
return phraser.Q(phraser.QueryFailPlan, nil), true
|
||||
}
|
||||
if !router.IsRestOfDayQuery(t.dec.Utterance) {
|
||||
return plan.Spoken, true
|
||||
@@ -242,7 +242,7 @@ func (h *reactiveHandler) queryHabits(ctx context.Context, t *queryTurn) (string
|
||||
facts, err := h.api.RecentActiveFactsByKind(ctx, string(store.KindSelf), habitFactWindow)
|
||||
if err != nil {
|
||||
log.Printf("voice: habits: recent facts: %v", err)
|
||||
return "не получилось посмотреть записи.", true
|
||||
return phraser.Q(phraser.QueryFailNotes, nil), true
|
||||
}
|
||||
obs := make([]memory.Observation, 0, len(facts))
|
||||
for _, f := range facts {
|
||||
@@ -281,7 +281,7 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string,
|
||||
// Claim the turn rather than fall through: "не читаю ленты" is true, and
|
||||
// letting general knowledge answer "что нового?" would be an invented
|
||||
// news bulletin.
|
||||
return "я пока не читаю ленты — они не настроены.", true
|
||||
return phraser.Q(phraser.QueryFeedsOff, nil), true
|
||||
}
|
||||
// By source, not the last 200 notes of any kind: a busy day of voice notes
|
||||
// used to push the newest headline out of the window, and she answered "в
|
||||
@@ -289,7 +289,7 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string,
|
||||
notes, err := h.api.RecentNotesFromSource(ctx, rss.SourcePrefix, feedNoteWindow)
|
||||
if err != nil {
|
||||
log.Printf("voice: feeds: recent notes: %v", err)
|
||||
return "не получилось посмотреть ленты.", true
|
||||
return phraser.Q(phraser.QueryFailFeeds, nil), true
|
||||
}
|
||||
var picked []string
|
||||
for _, n := range notes {
|
||||
@@ -306,11 +306,11 @@ func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string,
|
||||
}
|
||||
if len(picked) == 0 {
|
||||
if q.Category != "" {
|
||||
return "по этой теме в лентах пока ничего.", true
|
||||
return phraser.Q(phraser.QueryFeedsTopic, nil), true
|
||||
}
|
||||
return "в лентах пока ничего нового.", true
|
||||
return phraser.Q(phraser.QueryFeedsEmpty, nil), true
|
||||
}
|
||||
return "вот что нового: " + strings.Join(picked, "; "), true
|
||||
return phraser.Q(phraser.QueryFeedsNew, map[string]string{"items": strings.Join(picked, "; ")}), true
|
||||
}
|
||||
|
||||
// queryCalendar — "что у меня сегодня?", "планы на завтра?"
|
||||
@@ -324,7 +324,7 @@ func (h *reactiveHandler) queryCalendar(ctx context.Context, t *queryTurn) (stri
|
||||
events, err := h.api.CalendarEvents(ctx, date, date.Add(24*time.Hour))
|
||||
if err != nil {
|
||||
log.Printf("voice: calendar events: %v", err)
|
||||
return "не получилось проверить календарь.", true
|
||||
return phraser.Q(phraser.QueryFailCalendar, nil), true
|
||||
}
|
||||
// Provenance travels with each event. A work meeting relayed off a phone
|
||||
// notification (source ambient:notif, #126) is stored below full confidence
|
||||
@@ -382,19 +382,23 @@ func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (strin
|
||||
if loc == "" {
|
||||
// He named no city and voice.weather.default_location is unset. Saying
|
||||
// so is the only honest answer; picking a city would be inventing one.
|
||||
return "не знаю, для какого города — задай voice.weather.default_location или назови город.", true
|
||||
return phraser.Q(phraser.QueryWeatherWhere, nil), true
|
||||
}
|
||||
ctxWT, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||||
defer cancel()
|
||||
w, err := h.weatherProvider.CurrentWeather(ctxWT, loc)
|
||||
if errors.Is(err, weather.ErrNotConfigured) {
|
||||
return "погода не настроена.", true
|
||||
return phraser.Q(phraser.QueryWeatherOff, nil), true
|
||||
}
|
||||
if err != nil {
|
||||
log.Printf("voice: weather: %v", err)
|
||||
return "не получилось узнать погоду.", true
|
||||
return phraser.Q(phraser.QueryFailWeather, nil), true
|
||||
}
|
||||
return fmt.Sprintf("в %s сейчас %.0f градусов, %s.", w.Location, w.Temperature, w.Condition), true
|
||||
return phraser.Q(phraser.QueryWeatherNow, map[string]string{
|
||||
"location": w.Location,
|
||||
"temp": fmt.Sprintf("%.0f", w.Temperature),
|
||||
"condition": w.Condition,
|
||||
}), true
|
||||
}
|
||||
|
||||
// queryEmbed isn't an answer source — it's the shared cost the two recall
|
||||
@@ -404,7 +408,7 @@ func (h *reactiveHandler) queryEmbed(ctx context.Context, t *queryTurn) (string,
|
||||
vec, err := router.EmbedQuery(ctx, h.embedder, t.dec.Utterance)
|
||||
if err != nil {
|
||||
log.Printf("voice: embed query: %v", err)
|
||||
return "не получилось найти ответ.", true
|
||||
return phraser.Q(phraser.QueryFailAnswer, nil), true
|
||||
}
|
||||
t.vec = vec
|
||||
return "", false
|
||||
@@ -445,7 +449,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
|
||||
// stored.
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -467,7 +477,7 @@ func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string,
|
||||
notes, err := h.api.QueryNotes(ctx, t.vec, 5)
|
||||
if err != nil {
|
||||
log.Printf("voice: query notes: %v", err)
|
||||
return "не получилось найти ответ.", true
|
||||
return phraser.Q(phraser.QueryFailAnswer, nil), true
|
||||
}
|
||||
t.notes = notes
|
||||
noteScores := make([]float64, len(notes))
|
||||
@@ -492,7 +502,7 @@ func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string,
|
||||
log.Printf("voice: phrase query: %v", err)
|
||||
}
|
||||
if reply == "" {
|
||||
reply = "вот что я нашла: " + texts[0]
|
||||
reply = phraser.Q(phraser.QueryFound, map[string]string{"text": texts[0]})
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
@@ -526,13 +536,13 @@ func (h *reactiveHandler) queryWeb(ctx context.Context, t *queryTurn) (string, b
|
||||
page, err := h.crawler.Page(ctxFetch, link)
|
||||
if err != nil {
|
||||
if errors.Is(err, crawl.ErrRobots) {
|
||||
return "эта страница закрыта для чтения — robots.txt не разрешает.", true
|
||||
return phraser.Q(phraser.QueryPageBlocked, nil), true
|
||||
}
|
||||
log.Printf("voice: web: %v", err)
|
||||
return "не получилось прочитать страницу.", true
|
||||
return phraser.Q(phraser.QueryFailPage, nil), true
|
||||
}
|
||||
if page.Text == "" {
|
||||
return "страница открылась, но читать там нечего.", true
|
||||
return phraser.Q(phraser.QueryPageEmpty, nil), true
|
||||
}
|
||||
// The page is handed to the phraser the same way a note is: as context for
|
||||
// the question he actually asked. She answers the question, she does not
|
||||
@@ -542,7 +552,7 @@ func (h *reactiveHandler) queryWeb(ctx context.Context, t *queryTurn) (string, b
|
||||
if reply == "" {
|
||||
// No phraser (or it failed): read back the top of the page rather than
|
||||
// pretend the fetch did not happen.
|
||||
return "вот что на странице: " + crawl.TrimRunes(page.Text, 300), true
|
||||
return phraser.Q(phraser.QueryPageText, map[string]string{"text": crawl.TrimRunes(page.Text, 300)}), true
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
@@ -608,7 +618,7 @@ func (h *reactiveHandler) querySearch(ctx context.Context, t *queryTurn) (string
|
||||
if reply == "" {
|
||||
// No phraser, or it failed. Read back the best evidence rather than
|
||||
// pretend the search did not happen.
|
||||
return "вот что я нашла: " + crawl.TrimRunes(resp.Snippets()[0], 300), true
|
||||
return phraser.Q(phraser.QueryFound, map[string]string{"text": crawl.TrimRunes(resp.Snippets()[0], 300)}), true
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
@@ -689,7 +699,7 @@ func (h *reactiveHandler) queryKiwix(ctx context.Context, t *queryTurn) (string,
|
||||
if reply == "" {
|
||||
// No phraser, or it failed. Read back the best hit rather than pretend
|
||||
// the search did not happen.
|
||||
return "вот что я нашла: " + crawl.TrimRunes(top.Title+" — "+page.Text, 300), true
|
||||
return phraser.Q(phraser.QueryFound, map[string]string{"text": crawl.TrimRunes(top.Title+" — "+page.Text, 300)}), true
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
@@ -719,7 +729,7 @@ func (h *reactiveHandler) queryPersonal(ctx context.Context, t *queryTurn) (stri
|
||||
return "", false
|
||||
}
|
||||
log.Printf("voice: %q is about him and his own data did not answer it; not asking the world", t.dec.Utterance)
|
||||
return "не знаю — не нашла у тебя такой записи.", true
|
||||
return phraser.Q(phraser.QueryPersonalNone, nil), true
|
||||
}
|
||||
|
||||
// personalMarkers — first-person POSSESSION, not first person generally.
|
||||
@@ -787,15 +797,15 @@ func (h *reactiveHandler) queryGeneral(ctx context.Context, t *queryTurn) (strin
|
||||
if h.phraser == nil {
|
||||
// No model of any size. That is not the workstation being asleep, so it
|
||||
// is not that gap: it is simply not knowing.
|
||||
return "не знаю.", true
|
||||
return phraser.Q(phraser.QueryUnknown, nil), true
|
||||
}
|
||||
reply, err := h.phraseWorld(ctx, t.dec.Utterance, nil)
|
||||
if errors.Is(err, phraser.ErrNoWorldModel) {
|
||||
log.Printf("voice: %q needs the world model and it is not available", t.dec.Utterance)
|
||||
return worldGap, true
|
||||
return worldGap(), true
|
||||
}
|
||||
if err != nil || reply == "" {
|
||||
return "не знаю.", true
|
||||
return phraser.Q(phraser.QueryUnknown, nil), true
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
@@ -21,13 +22,13 @@ func (h *reactiveHandler) actionReminder(ctx context.Context, dec router.Decisio
|
||||
}
|
||||
}
|
||||
if !dec.Slots.HasTime {
|
||||
return "не получилось разобрать время напоминания."
|
||||
return phraser.Ack(phraser.FailReminderTime, nil)
|
||||
}
|
||||
}
|
||||
payload := `{"text":` + jsonString(dec.Utterance) + `}`
|
||||
if _, err := h.api.CreateReminder(ctx, dec.Slots.Time, payload, ""); err != nil {
|
||||
log.Printf("voice: create reminder: %v", err)
|
||||
return "не получилось поставить напоминание."
|
||||
return phraser.Ack(phraser.FailReminder, nil)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"log"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
"github.com/kami/maven/internal/tasks"
|
||||
@@ -40,18 +41,18 @@ func (h *reactiveHandler) captureTaskFromNote(ctx context.Context, dec router.De
|
||||
})
|
||||
if err != nil {
|
||||
log.Printf("voice: capture task: %v", err)
|
||||
return "не получилось записать задачу.", true
|
||||
return phraser.Ack(phraser.FailTask, nil), true
|
||||
}
|
||||
if resp.Promoted {
|
||||
// It was a candidate Maven derived from something she read, and he has
|
||||
// now said it himself. Saying "уже в списке" here would be answering a
|
||||
// confirmation with a shrug.
|
||||
return "поняла, беру в работу: " + cap.Text, true
|
||||
return phraser.Ack(phraser.AckTaskUrgent, map[string]string{"text": cap.Text}), true
|
||||
}
|
||||
if !resp.Created {
|
||||
return "это уже в списке.", true
|
||||
return phraser.Ack(phraser.AckTaskDuplicate, nil), true
|
||||
}
|
||||
return "записала: " + cap.Text, true
|
||||
return phraser.Ack(phraser.AckTask, map[string]string{"text": cap.Text}), true
|
||||
}
|
||||
|
||||
// queryTasks — "какие у меня задачи?", "что мне нужно сделать?".
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
@@ -108,7 +109,7 @@ func TestCaptureTaskFromNoteReportsStoreFailure(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatal("a failed capture still claims the turn — the note path must not double-write")
|
||||
}
|
||||
if !strings.Contains(reply, "не получилось") {
|
||||
if !phraser.IsAck(phraser.FailTask, nil, reply) {
|
||||
t.Errorf("reply = %q, want an honest failure", reply)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -465,39 +465,3 @@ func TestExpiryNoticeSurvivesAConfirmTurn(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -156,7 +156,7 @@ func TestQueryWebRefusesNonHTML(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatal("the web source did not claim a question with a URL")
|
||||
}
|
||||
if !strings.Contains(reply, "не получилось") {
|
||||
if !phraser.IsQ(phraser.QueryFailPage, nil, reply) {
|
||||
t.Errorf("reply = %q, want the read-failed answer", reply)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/kami/maven/internal/calendar"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
@@ -147,8 +148,8 @@ func TestQueryDayPlanCoreFailure(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatal("a failed plan read must still answer, not fall through to RAG")
|
||||
}
|
||||
if reply != "не получилось собрать план." {
|
||||
t.Errorf("reply = %q", reply)
|
||||
if !phraser.IsQ(phraser.QueryFailPlan, nil, reply) {
|
||||
t.Errorf("reply = %q, want the honest failure", reply)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ func TestQueryFeedsByCategory(t *testing.T) {
|
||||
t.Fatalf("reply = %q, want only the технологии item", reply)
|
||||
}
|
||||
reply, _ = askFeeds(t, h, "что нового по спорту?")
|
||||
if !strings.Contains(reply, "ничего") {
|
||||
if !phraser.IsQ(phraser.QueryFeedsTopic, nil, reply) {
|
||||
t.Fatalf("reply = %q, want an honest empty answer for an unread category", reply)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"github.com/kami/maven/internal/config"
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/netscan"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
)
|
||||
|
||||
// scanBudget — the whole spoken scan, end to end. A voice turn that takes
|
||||
@@ -106,7 +107,7 @@ func (w *netWiring) scanSummary(ctx context.Context) (string, bool) {
|
||||
res, err := w.scan(ctx)
|
||||
if err != nil {
|
||||
log.Printf("netscan: scan: %v", err)
|
||||
return "не получилось просканировать сеть.", true
|
||||
return phraser.Q(phraser.QueryFailNetscan, nil), true
|
||||
}
|
||||
// A truncated run is not a statement about the LAN. Saying "нашла 6
|
||||
// устройств" after stopping two thirds of the way through the range is a
|
||||
@@ -116,7 +117,7 @@ func (w *netWiring) scanSummary(ctx context.Context) (string, bool) {
|
||||
tail = ", но успела посмотреть не всю сеть"
|
||||
}
|
||||
if len(res.Hosts) == 0 {
|
||||
return "в сети никого не нашла" + tail + ".", true
|
||||
return phraser.Q(phraser.QueryNetEmpty, map[string]string{"tail": tail}), true
|
||||
}
|
||||
out := fmt.Sprintf("нашла %d %s", len(res.Hosts), hostWord(len(res.Hosts)))
|
||||
if shape := scanShape(res.Hosts); shape != "" {
|
||||
|
||||
@@ -121,8 +121,8 @@ func TestQueryRecallNoteCanWin(t *testing.T) {
|
||||
{text: "выучил пару аккордов", score: 0.50, kind: "note"},
|
||||
})
|
||||
reply := askQuery(t, h, q)
|
||||
if want := "вот что я нашла: молоко стоит в холодильнике"; reply != want {
|
||||
t.Errorf("reply %q, want %q", reply, want)
|
||||
if !phraser.IsSourcesFallback(reply, "молоко стоит в холодильнике") {
|
||||
t.Errorf("reply %q, want the note read back", reply)
|
||||
}
|
||||
// One text, the winning memory's — the answer came from the memory
|
||||
// pass, not from handing the phraser every note in the table.
|
||||
@@ -151,7 +151,7 @@ func TestQueryRecallNoteCanWin(t *testing.T) {
|
||||
{text: "молоко стоит в холодильнике", score: 0.860, kind: "note"},
|
||||
{text: "молоко закончилось", score: 0.858, kind: "note"},
|
||||
})
|
||||
if reply := askQuery(t, h, q); reply != "не знаю." {
|
||||
if reply := askQuery(t, h, q); !phraser.IsUnknownFallback(reply) {
|
||||
t.Errorf("reply %q, want silence", reply)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"unicode"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
)
|
||||
|
||||
// resolveQuietToggle — pre-route keyword check. Returns (reply, true) when
|
||||
@@ -32,10 +33,10 @@ func (h *reactiveHandler) resolveQuietToggle(ctx context.Context, text string, s
|
||||
return "", false
|
||||
}
|
||||
val := "false"
|
||||
reply := "тихий режим выключен."
|
||||
reply := phraser.Ack(phraser.AckQuietOff, nil)
|
||||
if on {
|
||||
val = "true"
|
||||
reply = "тихий режим включён. буду реже напоминать."
|
||||
reply = phraser.Ack(phraser.AckQuietOn, nil)
|
||||
}
|
||||
if _, err := h.api.WriteFact(ctx, ipc.WriteFactReq{
|
||||
Ts: h.now(),
|
||||
@@ -46,7 +47,7 @@ func (h *reactiveHandler) resolveQuietToggle(ctx context.Context, text string, s
|
||||
Confidence: 1.0,
|
||||
}); err != nil {
|
||||
log.Printf("voice: write quiet_hours: %v", err)
|
||||
return "не получилось переключить тихий режим.", true
|
||||
return phraser.Ack(phraser.FailQuiet, nil), true
|
||||
}
|
||||
return reply, true
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/llm"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
"github.com/kami/maven/internal/voice"
|
||||
)
|
||||
@@ -29,12 +30,12 @@ func TestLLMReplierPassesTheModelReplyThrough(t *testing.T) {
|
||||
|
||||
func TestLLMReplierFallsBackToStubOnError(t *testing.T) {
|
||||
r := newLLMReplier(stubCompleter{err: errReplierTest}, nil)
|
||||
assertStub(t, r, router.Decision{Intent: router.IntentNote}, "llm error")
|
||||
assertAck(t, r, router.Decision{Intent: router.IntentNote}, phraser.AckNote, "llm error")
|
||||
}
|
||||
|
||||
func TestLLMReplierFallsBackToStubOnEmpty(t *testing.T) {
|
||||
r := newLLMReplier(stubCompleter{out: ""}, nil)
|
||||
assertStub(t, r, router.Decision{Intent: router.IntentNote}, "empty llm")
|
||||
assertAck(t, r, router.Decision{Intent: router.IntentNote}, phraser.AckNote, "empty llm")
|
||||
}
|
||||
|
||||
func TestLLMReplierClarifyUsesStub(t *testing.T) {
|
||||
@@ -42,6 +43,16 @@ func TestLLMReplierClarifyUsesStub(t *testing.T) {
|
||||
assertStub(t, r, router.Decision{Clarify: true}, "clarify")
|
||||
}
|
||||
|
||||
// assertAck — the stub picks between variants now, so two calls to it are not
|
||||
// expected to match. What must hold is that the reply is a line that entry can
|
||||
// produce, which is the same claim without pinning one wording.
|
||||
func assertAck(t *testing.T, r *llmReplier, d router.Decision, key, what string) {
|
||||
t.Helper()
|
||||
if got := r.Reply(d); !phraser.IsAck(key, nil, got) {
|
||||
t.Errorf("on %s: got %q, want a %q line", what, got, key)
|
||||
}
|
||||
}
|
||||
|
||||
func assertStub(t *testing.T, r *llmReplier, d router.Decision, what string) {
|
||||
t.Helper()
|
||||
got, want := r.Reply(d), voice.NewStubReplier().Reply(d)
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/kami/maven/internal/ipc"
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/store"
|
||||
)
|
||||
|
||||
@@ -51,10 +52,10 @@ func (h *reactiveHandler) resolveSnooze(ctx context.Context, text string, src tu
|
||||
}
|
||||
if err := h.api.ResolveNudge(ctx, target.ID, store.NudgeSnoozed, now); err != nil {
|
||||
log.Printf("voice: snooze nudge %d (%s, %s): %v", target.ID, target.Rule, src, err)
|
||||
return "не получилось отложить.", true
|
||||
return phraser.Ack(phraser.FailSnooze, nil), true
|
||||
}
|
||||
log.Printf("voice: snoozed nudge %d (rule %s) from %s", target.ID, target.Rule, src)
|
||||
return "хорошо, вернусь к этому позже.", true
|
||||
return phraser.Ack(phraser.AckSnooze, nil), true
|
||||
}
|
||||
|
||||
// pendingNudge — the newest still-pending nudge sent inside snoozeWindow.
|
||||
|
||||
@@ -23,7 +23,11 @@ type worldPhraser interface {
|
||||
// question about his meeting came back as a swimming competition in Nottingham.
|
||||
// Naming the gap is the rule CLAUDE.md already applies to a sibling service
|
||||
// being down.
|
||||
const worldGap = "сейчас не могу ответить — большая модель недоступна, а придумывать не хочу."
|
||||
//
|
||||
// The wording lives in fallbacks_ru_v1.json and is fixed there, not picked from
|
||||
// variants: this sentence names one specific gap and must not drift into a
|
||||
// general "I don't know".
|
||||
func worldGap() string { return phraser.WorldGap() }
|
||||
|
||||
// phraseWorld asks the world model, or reports the gap.
|
||||
//
|
||||
@@ -54,7 +58,11 @@ func (h *reactiveHandler) phraseSource(ctx context.Context, name, utterance stri
|
||||
log.Printf("voice: %s: no world model, reading the source back instead", name)
|
||||
return ""
|
||||
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)
|
||||
return ""
|
||||
}
|
||||
return reply
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ func TestQueryGeneralNamesTheGap(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatal("queryGeneral passed on the last source in the chain")
|
||||
}
|
||||
if reply != worldGap {
|
||||
if reply != worldGap() {
|
||||
t.Fatalf("reply = %q, want the named gap", reply)
|
||||
}
|
||||
if g.worldCalls != 1 {
|
||||
@@ -51,7 +51,7 @@ func TestQueryGeneralWithoutAWorldModelIsUnchanged(t *testing.T) {
|
||||
if !ok {
|
||||
t.Fatal("queryGeneral passed on the last source in the chain")
|
||||
}
|
||||
if reply != "не знаю." {
|
||||
if !phraser.IsUnknownFallback(reply) {
|
||||
t.Fatalf("reply = %q, want the Stub's answer", reply)
|
||||
}
|
||||
}
|
||||
@@ -61,12 +61,12 @@ func TestQueryGeneralWithoutAWorldModelIsUnchanged(t *testing.T) {
|
||||
// English in it.
|
||||
func TestWorldGapIsInPersona(t *testing.T) {
|
||||
for _, bad := range []string{"вы", "ваш", "рад ", "дорогой", "милый"} {
|
||||
if strings.Contains(worldGap, bad) {
|
||||
t.Errorf("the gap phrase contains %q: %s", bad, worldGap)
|
||||
if strings.Contains(worldGap(), bad) {
|
||||
t.Errorf("the gap phrase contains %q: %s", bad, worldGap())
|
||||
}
|
||||
}
|
||||
if strings.ContainsAny(worldGap, "abcdefghijklmnopqrstuvwxyz") {
|
||||
t.Errorf("the gap phrase has Latin letters in it: %s", worldGap)
|
||||
if strings.ContainsAny(worldGap(), "abcdefghijklmnopqrstuvwxyz") {
|
||||
t.Errorf("the gap phrase has Latin letters in it: %s", worldGap())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+3
-17
@@ -24,12 +24,7 @@ type runner struct {
|
||||
mu sync.Mutex
|
||||
cmd *exec.Cmd
|
||||
ready bool
|
||||
// yielding — stop() has sent the signal and the exit that follows is ours.
|
||||
// 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
|
||||
http *http.Client
|
||||
}
|
||||
|
||||
func newRunner(bin string, args []string, readyURL string) *runner {
|
||||
@@ -75,18 +70,13 @@ func (r *runner) start() error {
|
||||
if err := cmd.Start(); err != nil {
|
||||
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)
|
||||
go func() {
|
||||
err := cmd.Wait()
|
||||
r.mu.Lock()
|
||||
yielded := r.yielding
|
||||
r.cmd, r.ready, r.yielding = nil, false, false
|
||||
r.cmd, r.ready = nil, false
|
||||
r.mu.Unlock()
|
||||
if yielded {
|
||||
log.Printf("mavgpud: llama-server stopped, card yielded (%v)", err)
|
||||
return
|
||||
}
|
||||
log.Printf("mavgpud: llama-server exited: %v", err)
|
||||
}()
|
||||
return nil
|
||||
@@ -100,10 +90,6 @@ func (r *runner) stop(grace time.Duration) {
|
||||
r.mu.Lock()
|
||||
cmd := r.cmd
|
||||
r.ready = false
|
||||
if cmd != nil && cmd.Process != nil {
|
||||
// The exit that follows is ours, not a crash.
|
||||
r.yielding = true
|
||||
}
|
||||
r.mu.Unlock()
|
||||
if cmd == nil || cmd.Process == nil {
|
||||
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.
|
||||
KillSignal=SIGTERM
|
||||
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]
|
||||
WantedBy=default.target
|
||||
|
||||
@@ -18,7 +18,6 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// safeKey makes a summary safe to use inside a fact key: letters and digits in
|
||||
// any script, plus dashes, with space and underscore folded to a dash.
|
||||
//
|
||||
// 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.
|
||||
// safeKey makes a summary safe to use inside a fact key (ASCII alphanumerics
|
||||
// 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.
|
||||
func safeKey(s string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range s {
|
||||
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)
|
||||
case r == ' ' || r == '_':
|
||||
b.WriteRune('-')
|
||||
|
||||
@@ -139,9 +139,6 @@ func TestSafeKey(t *testing.T) {
|
||||
{"Hello_World", "Hello-World"},
|
||||
{"special@#$chars!!", "specialchars"},
|
||||
{"ALL_CAPS_123", "ALL-CAPS-123"},
|
||||
// His calendar is Russian. These reduced to "--" and "--" (Vikunja #443).
|
||||
{"Встреча с Аней", "Встреча-с-Аней"},
|
||||
{"Обед с мамой", "Обед-с-мамой"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := safeKey(tt.in); got != tt.want {
|
||||
@@ -266,19 +263,3 @@ func TestSourceTrust(t *testing.T) {
|
||||
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.
|
||||
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
|
||||
// forms a stable recurring pattern. Returns a ProposedRoutine when:
|
||||
// - At least MinEvents events exist (≥3 intervals)
|
||||
// - The median interval is at least MinIntervalDays
|
||||
// - At least MinOnPatternFraction of the intervals sit within
|
||||
// MaxIntervalRatio of the median interval
|
||||
//
|
||||
@@ -110,8 +88,8 @@ func Detect(events []Event) (*ProposedRoutine, error) {
|
||||
}
|
||||
|
||||
center := medianFloat(intervals)
|
||||
if center <= 0 || center < MinIntervalDays {
|
||||
return nil, nil // a burst, not a rhythm — see MinIntervalDays
|
||||
if center <= 0 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
// 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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"name": "russian capture acknowledgements v1",
|
||||
"notes": [
|
||||
"What she says after storing something he said, and what she says when storing it failed. Edit the wording here, no Go changes needed.",
|
||||
"Rules: she is feminine about herself, he is a man addressed as ты. Never вы/вас/ваш, never он/его about him. No pet names.",
|
||||
"He hears these many times a day, so most entries carry variants: identical wording is what makes a confirmation stop registering as one.",
|
||||
"Placeholders: {key} {value} the fact he stated, {fn} the action, {text} the task title. His data is interpolated Go-side — the file holds the frame, never his words.",
|
||||
"An acknowledgement confirms and stops. It does not ask a follow-up question and it does not editorialise about what he stored."
|
||||
],
|
||||
"entries": {
|
||||
"ack_fact": {
|
||||
"variants": ["записала факт.", "записала.", "запомнила."]
|
||||
},
|
||||
"ack_fact_key": {
|
||||
"variants": ["отметила: {key}", "записала: {key}", "запомнила: {key}"]
|
||||
},
|
||||
"ack_fact_kv": {
|
||||
"variants": ["отметила: {key} = {value}", "записала: {key} — {value}", "запомнила: {key} — {value}"]
|
||||
},
|
||||
"ack_note": {
|
||||
"variants": ["сохранила заметку.", "заметка сохранена.", "записала в заметки."]
|
||||
},
|
||||
"ack_reminder": {
|
||||
"variants": ["напомню.", "напомню, не забуду.", "хорошо, напомню."]
|
||||
},
|
||||
"ack_act": {
|
||||
"variants": ["ок, записала действие: {fn}", "приняла действие: {fn}"]
|
||||
},
|
||||
"ack_task": {
|
||||
"variants": ["записала: {text}", "добавила в задачи: {text}", "внесла в список: {text}"]
|
||||
},
|
||||
"ack_task_urgent": {
|
||||
"variants": ["поняла, беру в работу: {text}", "поняла, это срочно: {text}"]
|
||||
},
|
||||
"ack_task_duplicate": {
|
||||
"variants": ["это уже в списке.", "такое уже есть в задачах."]
|
||||
},
|
||||
"ack_nudge": {
|
||||
"variants": ["отлично, отметила.", "отметила.", "хорошо, отметила."]
|
||||
},
|
||||
"ack_snooze": {
|
||||
"variants": ["хорошо, вернусь к этому позже.", "ладно, напомню попозже.", "хорошо, отложила."]
|
||||
},
|
||||
"ack_generic": {
|
||||
"variants": ["приняла.", "поняла."]
|
||||
},
|
||||
"quiet_on": {
|
||||
"fixed": true,
|
||||
"variants": ["тихий режим включён. буду реже напоминать."]
|
||||
},
|
||||
"quiet_off": {
|
||||
"fixed": true,
|
||||
"variants": ["тихий режим выключен."]
|
||||
},
|
||||
"fail_fact": {
|
||||
"variants": ["не получилось сохранить факт.", "факт не сохранился."]
|
||||
},
|
||||
"fail_note": {
|
||||
"variants": ["не получилось сохранить заметку.", "заметка не сохранилась."]
|
||||
},
|
||||
"fail_reminder": {
|
||||
"variants": ["не получилось поставить напоминание.", "напоминание не поставилось."]
|
||||
},
|
||||
"fail_reminder_time": {
|
||||
"variants": ["не получилось разобрать время напоминания.", "не поняла, на когда напомнить."]
|
||||
},
|
||||
"fail_task": {
|
||||
"variants": ["не получилось записать задачу.", "задача не записалась."]
|
||||
},
|
||||
"fail_ack": {
|
||||
"variants": ["не получилось отметить.", "не смогла отметить."]
|
||||
},
|
||||
"fail_snooze": {
|
||||
"variants": ["не получилось отложить.", "не смогла отложить."]
|
||||
},
|
||||
"fail_quiet": {
|
||||
"variants": ["не получилось переключить тихий режим.", "тихий режим не переключился."]
|
||||
},
|
||||
"fail_fact_unparsed": {
|
||||
"variants": ["не разобрала, что записать — попробуй иначе.", "не поняла, что записать. скажи иначе?"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
package phraser
|
||||
|
||||
// The capture acknowledgements — what she says after storing something he said,
|
||||
// and what she says when storing it failed.
|
||||
//
|
||||
// They were string literals in eight files under cmd/mavend plus the stub
|
||||
// replier in internal/voice. He hears them many times a day, which is exactly
|
||||
// why they need variants and exactly why rewording one must not be a rebuild.
|
||||
// Same shape as fallbacks_ru_v1.json, on the shared deck (deck.go).
|
||||
//
|
||||
// His data stays Go-side. The file holds "отметила: {key} = {value}"; the key
|
||||
// and the value are interpolated here, so nothing he said lives in the copy.
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"log"
|
||||
"math/rand"
|
||||
"sync"
|
||||
)
|
||||
|
||||
//go:embed ack_ru_v1.json
|
||||
var ackJSON []byte
|
||||
|
||||
// AckSchemaVersion — this family's own version. A file that changes on a
|
||||
// different day than the fallbacks cannot share their number (Vikunja #397).
|
||||
const AckSchemaVersion = 1
|
||||
|
||||
// The entry keys. Namespaced by family, because the floor map behind a nil deck
|
||||
// is process-wide.
|
||||
const (
|
||||
AckFact = "ack_fact"
|
||||
AckFactKey = "ack_fact_key"
|
||||
AckFactValue = "ack_fact_kv"
|
||||
AckNote = "ack_note"
|
||||
AckReminder = "ack_reminder"
|
||||
AckAct = "ack_act"
|
||||
AckTask = "ack_task"
|
||||
AckTaskUrgent = "ack_task_urgent"
|
||||
AckTaskDuplicate = "ack_task_duplicate"
|
||||
AckNudge = "ack_nudge"
|
||||
AckSnooze = "ack_snooze"
|
||||
AckGeneric = "ack_generic"
|
||||
AckQuietOn = "quiet_on"
|
||||
AckQuietOff = "quiet_off"
|
||||
|
||||
FailFact = "fail_fact"
|
||||
FailFactUnparsed = "fail_fact_unparsed"
|
||||
FailNote = "fail_note"
|
||||
FailReminder = "fail_reminder"
|
||||
FailReminderTime = "fail_reminder_time"
|
||||
FailTask = "fail_task"
|
||||
FailAck = "fail_ack"
|
||||
FailSnooze = "fail_snooze"
|
||||
FailQuiet = "fail_quiet"
|
||||
)
|
||||
|
||||
// ackKeys — every key the code requires the file to define.
|
||||
var ackKeys = []string{
|
||||
AckFact, AckFactKey, AckFactValue, AckNote, AckReminder, AckAct,
|
||||
AckTask, AckTaskUrgent, AckTaskDuplicate, AckNudge, AckSnooze, AckGeneric,
|
||||
AckQuietOn, AckQuietOff,
|
||||
FailFact, FailFactUnparsed, FailNote, FailReminder, FailReminderTime,
|
||||
FailTask, FailAck, FailSnooze, FailQuiet,
|
||||
}
|
||||
|
||||
// ackFloor — the literal each key falls back to when the file is unusable.
|
||||
// These are the exact strings that lived in Go before this file existed.
|
||||
var ackFloor = registerFloor(map[string]string{
|
||||
AckFact: "записала факт.",
|
||||
AckFactKey: "отметила: {key}",
|
||||
AckFactValue: "отметила: {key} = {value}",
|
||||
AckNote: "сохранила заметку.",
|
||||
AckReminder: "напомню.",
|
||||
AckAct: "ок, записала действие: {fn}",
|
||||
AckTask: "записала: {text}",
|
||||
AckTaskUrgent: "поняла, беру в работу: {text}",
|
||||
AckTaskDuplicate: "это уже в списке.",
|
||||
AckNudge: "отлично, отметила.",
|
||||
AckSnooze: "хорошо, вернусь к этому позже.",
|
||||
AckGeneric: "приняла.",
|
||||
AckQuietOn: "тихий режим включён. буду реже напоминать.",
|
||||
AckQuietOff: "тихий режим выключен.",
|
||||
|
||||
FailFact: "не получилось сохранить факт.",
|
||||
FailFactUnparsed: "не разобрала, что записать — попробуй иначе.",
|
||||
FailNote: "не получилось сохранить заметку.",
|
||||
FailReminder: "не получилось поставить напоминание.",
|
||||
FailReminderTime: "не получилось разобрать время напоминания.",
|
||||
FailTask: "не получилось записать задачу.",
|
||||
FailAck: "не получилось отметить.",
|
||||
FailSnooze: "не получилось отложить.",
|
||||
FailQuiet: "не получилось переключить тихий режим.",
|
||||
})
|
||||
|
||||
// Acks picks a hand-written Russian acknowledgement. Safe for concurrent use.
|
||||
type Acks struct{ d *deck }
|
||||
|
||||
// LoadAcks reads the embedded file. Pass a source to make the picking
|
||||
// reproducible in tests; nil seeds from the clock.
|
||||
func LoadAcks(src rand.Source) (*Acks, error) {
|
||||
d, err := loadDeck(ackJSON, AckSchemaVersion, ackKeys, ackFloor, src)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The three entries that exist to read his own words back. A variant
|
||||
// without the placeholder would confirm the capture and drop what was
|
||||
// captured, which reads as a successful save of nothing.
|
||||
for _, req := range []struct{ key, ph string }{
|
||||
{AckFactKey, "{key}"}, {AckFactValue, "{key}"}, {AckFactValue, "{value}"},
|
||||
{AckAct, "{fn}"}, {AckTask, "{text}"}, {AckTaskUrgent, "{text}"},
|
||||
} {
|
||||
if err := d.requirePlaceholder(req.key, req.ph); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &Acks{d: d}, nil
|
||||
}
|
||||
|
||||
// deck reads through a nil *Acks, which is the unloadable-file case.
|
||||
func (a *Acks) deck() *deck {
|
||||
if a == nil {
|
||||
return nil
|
||||
}
|
||||
return a.d
|
||||
}
|
||||
|
||||
// Say returns one line for key, with his data filled into the frame. Pass nil
|
||||
// when the entry takes none.
|
||||
func (a *Acks) Say(key string, vars map[string]string) string {
|
||||
return a.deck().text(key, vars)
|
||||
}
|
||||
|
||||
// Variants returns every line the file can produce, for the persona scorer.
|
||||
func (a *Acks) Variants() []string { return a.deck().variants() }
|
||||
|
||||
var (
|
||||
ackOnce sync.Once
|
||||
acks *Acks
|
||||
)
|
||||
|
||||
// DefaultAcks returns the shared instance, loading it on first use. A broken
|
||||
// file logs once and leaves a nil *Acks, which still answers from ackFloor.
|
||||
func DefaultAcks() *Acks {
|
||||
ackOnce.Do(func() {
|
||||
a, err := LoadAcks(nil)
|
||||
if err != nil {
|
||||
log.Printf("phraser: acknowledgements unavailable, using the built-in lines: %v", err)
|
||||
return
|
||||
}
|
||||
acks = a
|
||||
})
|
||||
return acks
|
||||
}
|
||||
|
||||
// Ack — one acknowledgement line, the way every caller says it.
|
||||
func Ack(key string, vars map[string]string) string { return DefaultAcks().Say(key, vars) }
|
||||
|
||||
// IsAck reports whether text is a line key could have produced. For the daemon
|
||||
// tests, which can no longer compare against one literal.
|
||||
func IsAck(key string, vars map[string]string, text string) bool {
|
||||
return DefaultAcks().deck().matches(key, vars, text)
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
package phraser
|
||||
|
||||
// deck — the mechanics every family of hand-written Russian lines shares.
|
||||
//
|
||||
// A family is one embedded JSON file: schema-versioned, several variants per
|
||||
// entry, never the same variant twice running, and a hard floor of Go literals
|
||||
// under it so a broken file cannot take her words away. fallbacks.go was the
|
||||
// first family (Vikunja #501) and acks.go the second, at which point copying
|
||||
// eighty lines of loader per family stopped being defensible.
|
||||
//
|
||||
// What stays per family: the file, the keys, the floor literals, the accessor
|
||||
// names, and any validation only that family can state.
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// deckEntry — one line she can say, in as many wordings as the file gives.
|
||||
type deckEntry struct {
|
||||
// Fixed — one variant, never picked between. For wording that must not
|
||||
// drift from turn to turn, like a phrase naming one specific gap.
|
||||
Fixed bool `json:"fixed"`
|
||||
Variants []string `json:"variants"`
|
||||
}
|
||||
|
||||
type deckFile struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
Name string `json:"name"`
|
||||
Notes []string `json:"notes"`
|
||||
Entries map[string]deckEntry `json:"entries"`
|
||||
}
|
||||
|
||||
// deck picks a line. Safe for concurrent use. A nil *deck answers from the
|
||||
// floor, which is what an unloadable file leaves behind.
|
||||
type deck struct {
|
||||
mu sync.Mutex
|
||||
rnd *rand.Rand
|
||||
last map[string]string
|
||||
file deckFile
|
||||
keys []string
|
||||
floor map[string]string
|
||||
}
|
||||
|
||||
// loadDeck parses raw, checks the version and every required key, and seeds the
|
||||
// picker. Pass a source to make the picking reproducible in tests; nil seeds
|
||||
// from the clock.
|
||||
func loadDeck(raw []byte, version int, keys []string, floor map[string]string, src rand.Source) (*deck, error) {
|
||||
var f deckFile
|
||||
if err := json.Unmarshal(raw, &f); err != nil {
|
||||
return nil, fmt.Errorf("parse: %w", err)
|
||||
}
|
||||
if f.SchemaVersion != version {
|
||||
return nil, fmt.Errorf("schema_version %d, want %d", f.SchemaVersion, version)
|
||||
}
|
||||
for _, k := range keys {
|
||||
e, ok := f.Entries[k]
|
||||
if !ok || len(e.Variants) == 0 {
|
||||
return nil, fmt.Errorf("entry %q is missing or empty", k)
|
||||
}
|
||||
if e.Fixed && len(e.Variants) != 1 {
|
||||
return nil, fmt.Errorf("entry %q is fixed but has %d variants", k, len(e.Variants))
|
||||
}
|
||||
}
|
||||
if src == nil {
|
||||
src = rand.NewSource(time.Now().UnixNano())
|
||||
}
|
||||
return &deck{rnd: rand.New(src), last: map[string]string{}, file: f, keys: keys, floor: floor}, nil
|
||||
}
|
||||
|
||||
// requirePlaceholder fails the load when a variant of key does not use ph. For
|
||||
// an entry whose whole job is to read something back, a variant without the
|
||||
// placeholder silently drops it.
|
||||
func (d *deck) requirePlaceholder(key, ph string) error {
|
||||
for _, v := range d.file.Entries[key].Variants {
|
||||
if !strings.Contains(v, ph) {
|
||||
return fmt.Errorf("%q variant %q does not use %s", key, v, ph)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// text returns one variant for key with the placeholders filled in. A nil
|
||||
// receiver answers from the floor, so no caller checks whether the file loaded.
|
||||
func (d *deck) text(key string, vars map[string]string) string {
|
||||
tmpl := ""
|
||||
if d != nil {
|
||||
if e, ok := d.file.Entries[key]; ok && len(e.Variants) > 0 {
|
||||
tmpl = d.pick(key, e)
|
||||
}
|
||||
}
|
||||
if tmpl == "" {
|
||||
tmpl = floorOf(d, key)
|
||||
}
|
||||
return fill(tmpl, vars)
|
||||
}
|
||||
|
||||
// matches reports whether text is a line key could have produced. A caller that
|
||||
// has to recognise one of these lines cannot compare against a literal any more.
|
||||
func (d *deck) matches(key string, vars map[string]string, text string) bool {
|
||||
if fill(floorOf(d, key), vars) == text {
|
||||
return true
|
||||
}
|
||||
if d == nil {
|
||||
return false
|
||||
}
|
||||
for _, v := range d.file.Entries[key].Variants {
|
||||
if fill(v, vars) == text {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// variants returns every line the file can produce, in key order, for the
|
||||
// persona scorer. Stable order so a failure names the same variant twice.
|
||||
func (d *deck) variants() []string {
|
||||
if d == nil {
|
||||
return nil
|
||||
}
|
||||
var out []string
|
||||
for _, k := range d.keys {
|
||||
out = append(out, d.file.Entries[k].Variants...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// pick chooses at random, skipping whatever this entry said last time.
|
||||
func (d *deck) pick(key string, e deckEntry) string {
|
||||
d.mu.Lock()
|
||||
defer d.mu.Unlock()
|
||||
|
||||
choices := e.Variants
|
||||
if len(choices) > 1 {
|
||||
fresh := make([]string, 0, len(choices))
|
||||
for _, v := range choices {
|
||||
if v != d.last[key] {
|
||||
fresh = append(fresh, v)
|
||||
}
|
||||
}
|
||||
if len(fresh) > 0 {
|
||||
choices = fresh
|
||||
}
|
||||
}
|
||||
got := choices[d.rnd.Intn(len(choices))]
|
||||
d.last[key] = got
|
||||
return got
|
||||
}
|
||||
|
||||
// floorOf reads the Go literal behind key, and works on a nil deck because that
|
||||
// is exactly the case it exists for. The per-family map is the source of truth.
|
||||
func floorOf(d *deck, key string) string {
|
||||
if d != nil && d.floor != nil {
|
||||
return d.floor[key]
|
||||
}
|
||||
return deckFloors[key]
|
||||
}
|
||||
|
||||
// deckFloors — every family's floor literals in one map, so a nil deck still
|
||||
// finds them. Families register at init; the keys are namespaced by family.
|
||||
var deckFloors = map[string]string{}
|
||||
|
||||
func registerFloor(floor map[string]string) map[string]string {
|
||||
for k, v := range floor {
|
||||
deckFloors[k] = v
|
||||
}
|
||||
return floor
|
||||
}
|
||||
|
||||
// fill substitutes {name} for each var. A placeholder with no value is left
|
||||
// alone rather than blanked, so a missing value is visible instead of silent.
|
||||
func fill(tmpl string, vars map[string]string) string {
|
||||
for k, v := range vars {
|
||||
tmpl = strings.ReplaceAll(tmpl, "{"+k+"}", v)
|
||||
}
|
||||
return tmpl
|
||||
}
|
||||
@@ -173,23 +173,12 @@ func checkFeminine(body string) Result {
|
||||
// Second pass: self-reference with the pronoun dropped — "напомнил тебе",
|
||||
// "проверил за тебя". A masculine past-tense verb whose object is HIM can
|
||||
// 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 {
|
||||
if !masculinePast(w) || i+1 >= len(words) || governedByYou(words, i) {
|
||||
if !masculinePast(w) || i+1 >= len(words) {
|
||||
continue
|
||||
}
|
||||
next := words[i+1]
|
||||
aboutHim := next == "тебе" || next == "тебя"
|
||||
if next == "за" && i+2 < len(words) && (words[i+2] == "тебя" || words[i+2] == "тебе") {
|
||||
aboutHim = true
|
||||
}
|
||||
if aboutHim {
|
||||
if next == "тебе" || next == "тебя" || next == "за" {
|
||||
return Result{CheckFeminine, false,
|
||||
fmt.Sprintf("masculine self-reference %q before %q", w, next)}
|
||||
}
|
||||
@@ -663,19 +652,3 @@ func checkEllipsis(body string) Result {
|
||||
}
|
||||
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},
|
||||
// The other direction: HE is male, so second-person masculine is right.
|
||||
{"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.
|
||||
{"feminine second person", "ты давно не отдыхала — попей воды.", CheckHisGender},
|
||||
{"feminine second person no dash", "ты пила воду четыре часа назад.", CheckHisGender},
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
package eval
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
)
|
||||
|
||||
// TestFallbackPersona scores every line in fallbacks_ru_v1.json, ack_ru_v1.json and
|
||||
// query_ru_v1.json on the persona checks the nudges already pass. These lines are
|
||||
// heard out loud and they live in a JSON file now, so a reworded variant that
|
||||
// says "рад" or "вы" would otherwise reach him with nothing in between.
|
||||
//
|
||||
// Only the persona checks run. Mood and topic belong to a nudge, and these are
|
||||
// not nudges.
|
||||
func TestFallbackPersona(t *testing.T) {
|
||||
fb, err := phraser.LoadFallbacks(rand.NewSource(20260804))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFallbacks: %v", err)
|
||||
}
|
||||
// No CheckHisGender. It reads a feminine verb near a second-person pronoun
|
||||
// as addressing him as a woman, which is right for a nudge and wrong here:
|
||||
// "не знаю — не нашла у тебя такой записи" is her own verb in her own
|
||||
// sentence. CheckFeminine still holds her side of the rule.
|
||||
persona := map[string]bool{
|
||||
CheckLang: true, CheckFeminine: true,
|
||||
CheckAddress: true, CheckCringe: true, CheckLength: true,
|
||||
}
|
||||
ack, err := phraser.LoadAcks(rand.NewSource(20260804))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAcks: %v", err)
|
||||
}
|
||||
qry, err := phraser.LoadQueries(rand.NewSource(20260804))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadQueries: %v", err)
|
||||
}
|
||||
variants := append(fb.Variants(), ack.Variants()...)
|
||||
variants = append(variants, qry.Variants()...)
|
||||
if len(variants) == 0 {
|
||||
t.Fatal("no variants — the file loaded empty")
|
||||
}
|
||||
for _, v := range variants {
|
||||
// The placeholders stand for his own words and carry no persona.
|
||||
body := v
|
||||
for _, ph := range []string{"{sources}", "{key}", "{value}", "{fn}", "{text}", "{when}", "{items}",
|
||||
"{location}", "{temp}", "{condition}", "{tail}"} {
|
||||
body = strings.ReplaceAll(body, ph, "вода")
|
||||
}
|
||||
for _, r := range RunChecks(Case{}, body, "neutral") {
|
||||
if persona[r.Name] && !r.Pass {
|
||||
t.Errorf("%q fails %s: %s", v, r.Name, r.Detail)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,12 @@ type TalkCase struct {
|
||||
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.
|
||||
type TalkFixture struct {
|
||||
SchemaVersion int `json:"schema_version"`
|
||||
@@ -92,8 +98,8 @@ func LoadTalk() (TalkFixture, error) {
|
||||
if err := json.Unmarshal(talkFixtureJSON, &f); err != nil {
|
||||
return TalkFixture{}, fmt.Errorf("parse talk fixture: %w", err)
|
||||
}
|
||||
if f.SchemaVersion != SchemaVersion {
|
||||
return TalkFixture{}, fmt.Errorf("talk fixture schema_version %d, want %d", f.SchemaVersion, SchemaVersion)
|
||||
if f.SchemaVersion != TalkSchemaVersion {
|
||||
return TalkFixture{}, fmt.Errorf("talk fixture schema_version %d, want %d", f.SchemaVersion, TalkSchemaVersion)
|
||||
}
|
||||
if len(f.Cases) == 0 {
|
||||
return TalkFixture{}, fmt.Errorf("talk fixture has no cases")
|
||||
|
||||
@@ -142,19 +142,13 @@ func TestLLMTalkBaseline(t *testing.T) {
|
||||
p := phraser.NewLLMPhraserAt(base, cfg)
|
||||
defer p.Close()
|
||||
|
||||
// Unreachable server is fatal here, not a logged warning, and that differs
|
||||
// from the nudge test on purpose. PhraseNudge returns its errors, so a dead
|
||||
// server there shows up honestly in the Errors column. PhraseChat and
|
||||
// PhraseQuery do NOT: they swallow every failure and return a canned string
|
||||
// ("поговорили.", "не знаю.", "вот что я нашла: …"). 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).
|
||||
// The model id names the run in the report. Since Vikunja #397 every path
|
||||
// returns its errors, so a server that dies mid-run shows up in the Errors
|
||||
// column instead of scoring as bad phrasing — the before-and-after probe that
|
||||
// used to stand in for that is gone.
|
||||
model, err := llm.ModelID(ctx, base)
|
||||
if err != nil {
|
||||
t.Fatalf("no model at %s: %v — refusing to score, these paths hide their errors "+
|
||||
"and would report a plausible-looking result off a dead server", base, err)
|
||||
t.Fatalf("no model at %s: %v", base, err)
|
||||
}
|
||||
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())
|
||||
|
||||
// And again afterwards: the run takes minutes, and a server that died or got
|
||||
// OOM-killed halfway through would leave the first cases scored and the rest
|
||||
// silently canned. Checking only at the start would not catch that.
|
||||
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)
|
||||
// A run where nothing was phrased is not a low score, it is no measurement.
|
||||
if rep.Errors == rep.Total {
|
||||
t.Fatalf("every case errored — nothing was measured, the score above is not a phrasing result")
|
||||
}
|
||||
if rep.Errors > 0 {
|
||||
t.Logf("%d/%d cases errored — those are model failures, not phrasing failures", rep.Errors, rep.Total)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
package phraser
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// isFallback — the text she says is picked from that entry's variants, so a test
|
||||
// pins the entry rather than the wording. Pinning one line would make editing
|
||||
// fallbacks_ru_v1.json break Go tests, which is the coupling this file removed.
|
||||
func isFallback(t *testing.T, key, sources, got string) bool {
|
||||
t.Helper()
|
||||
return DefaultFallbacks().deck().matches(key, map[string]string{"sources": sources}, got)
|
||||
}
|
||||
|
||||
// A dead server must be distinguishable from bad phrasing. Both PhraseChat and
|
||||
// PhraseQuery keep the turn alive with canned text — and every one of those
|
||||
// lines 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)
|
||||
key string
|
||||
sources string
|
||||
}{
|
||||
{"chat", func() (string, error) {
|
||||
return p.PhraseChat(context.Background(), "как дела", nil)
|
||||
}, fbChat, ""},
|
||||
{"knowledge", func() (string, error) {
|
||||
return p.PhraseQuery(context.Background(), "кто написал войну и мир", nil)
|
||||
}, fbQueryUnknown, ""},
|
||||
{"evidence", func() (string, error) {
|
||||
return p.PhraseQuery(context.Background(), "сколько воды я выпил", []string{"два литра"})
|
||||
}, fbQuerySources, "два литра"},
|
||||
}
|
||||
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 !isFallback(t, c.key, c.sources, got) {
|
||||
t.Errorf("fallback text = %q, want a %q variant — the daemon still has to say something", got, c.key)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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 !isFallback(t, fbQueryUnknown, "", got) {
|
||||
t.Errorf("fallback text = %q, want a %q variant", got, fbQueryUnknown)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "empty") {
|
||||
t.Errorf("error = %v; want it to name the empty response", err)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
package phraser
|
||||
|
||||
// The phrasing fallbacks — what she says when the model gave her nothing usable.
|
||||
//
|
||||
// They were four string literals spread across phraser.go, llmphraser.go and
|
||||
// cmd/mavend/worldmodel.go. Every one of them is a line he hears out loud, so
|
||||
// rewording one was a Go edit, a rebuild and a redeploy for what is product copy.
|
||||
//
|
||||
// The floor under the floor is deliberate. These strings exist because something
|
||||
// already failed, so a broken template file must not be able to take the last
|
||||
// words she has: every accessor falls back to the literal it replaced.
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"log"
|
||||
"math/rand"
|
||||
"sync"
|
||||
)
|
||||
|
||||
//go:embed fallbacks_ru_v1.json
|
||||
var fallbackJSON []byte
|
||||
|
||||
// FallbackSchemaVersion — the version this code understands. Its own constant,
|
||||
// not shared with the nudge templates or the eval fixtures: two files that change
|
||||
// on different days cannot be versioned by one number (Vikunja #397).
|
||||
const FallbackSchemaVersion = 1
|
||||
|
||||
// The entry keys. Every one of them is read by a method below, so a typo in the
|
||||
// file is caught at load rather than at the moment she needs the words.
|
||||
const (
|
||||
fbChat = "chat"
|
||||
fbQueryUnknown = "query_unknown"
|
||||
fbQuerySources = "query_sources"
|
||||
fbWorldGap = "world_gap"
|
||||
)
|
||||
|
||||
// fbKeys — every key the code requires the file to define.
|
||||
var fbKeys = []string{fbChat, fbQueryUnknown, fbQuerySources, fbWorldGap}
|
||||
|
||||
// hardFloor — the literal each key falls back to when the file is unusable.
|
||||
// These are the exact strings that lived in Go before this file existed.
|
||||
var hardFloor = registerFloor(map[string]string{
|
||||
fbChat: "даже не знаю, что сказать.",
|
||||
fbQueryUnknown: "не знаю.",
|
||||
fbQuerySources: "вот что я нашла: {sources}",
|
||||
fbWorldGap: "сейчас не могу ответить — большая модель недоступна, а придумывать не хочу.",
|
||||
})
|
||||
|
||||
// Fallbacks picks a hand-written Russian fallback line. Safe for concurrent use.
|
||||
type Fallbacks struct{ d *deck }
|
||||
|
||||
// LoadFallbacks reads the embedded file. Pass a source to make the picking
|
||||
// reproducible in tests; nil seeds from the clock.
|
||||
func LoadFallbacks(src rand.Source) (*Fallbacks, error) {
|
||||
d, err := loadDeck(fallbackJSON, FallbackSchemaVersion, fbKeys, hardFloor, src)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// query_sources is the one entry whose whole job is to read something back.
|
||||
if err := d.requirePlaceholder(fbQuerySources, "{sources}"); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Fallbacks{d: d}, nil
|
||||
}
|
||||
|
||||
// deck reads through a nil *Fallbacks, which is the unloadable-file case.
|
||||
func (f *Fallbacks) deck() *deck {
|
||||
if f == nil {
|
||||
return nil
|
||||
}
|
||||
return f.d
|
||||
}
|
||||
|
||||
// Chat — nothing usable came back on the chat path.
|
||||
func (f *Fallbacks) Chat() string { return f.deck().text(fbChat, nil) }
|
||||
|
||||
// Unknown — a question she cannot answer and will not guess at.
|
||||
func (f *Fallbacks) Unknown() string { return f.deck().text(fbQueryUnknown, nil) }
|
||||
|
||||
// FromSources — read back what she was handed, because phrasing it failed.
|
||||
func (f *Fallbacks) FromSources(sources string) string {
|
||||
return f.deck().text(fbQuerySources, map[string]string{"sources": sources})
|
||||
}
|
||||
|
||||
// WorldGap — the world model is the one configured to answer and it is not
|
||||
// answering. Fixed wording: it names a specific gap, and a variant set here
|
||||
// would let "the big model is asleep" drift into "I don't know".
|
||||
func (f *Fallbacks) WorldGap() string { return f.deck().text(fbWorldGap, nil) }
|
||||
|
||||
// Variants returns every line the file can produce, for the persona scorer.
|
||||
func (f *Fallbacks) Variants() []string { return f.deck().variants() }
|
||||
|
||||
// The process-wide instance. Package-level because these lines are needed on
|
||||
// paths that have no phraser to hand — cmd/mavend names the world gap without
|
||||
// one — and because a template file that is embedded and validated at load has
|
||||
// nothing per-instance to configure.
|
||||
var (
|
||||
fallbackOnce sync.Once
|
||||
fallbacks *Fallbacks
|
||||
)
|
||||
|
||||
// DefaultFallbacks returns the shared instance, loading it on first use. A
|
||||
// broken file logs once and leaves a nil *Fallbacks, which still answers from
|
||||
// hardFloor — a daemon must not fail to boot over its own copy deck.
|
||||
func DefaultFallbacks() *Fallbacks {
|
||||
fallbackOnce.Do(func() {
|
||||
fb, err := LoadFallbacks(nil)
|
||||
if err != nil {
|
||||
log.Printf("phraser: fallbacks unavailable, using the built-in lines: %v", err)
|
||||
return
|
||||
}
|
||||
fallbacks = fb
|
||||
})
|
||||
return fallbacks
|
||||
}
|
||||
|
||||
// ChatFallback — what she says when the chat path produced nothing.
|
||||
func ChatFallback() string { return DefaultFallbacks().Chat() }
|
||||
|
||||
// UnknownFallback — what she says when she has no answer and will not invent one.
|
||||
func UnknownFallback() string { return DefaultFallbacks().Unknown() }
|
||||
|
||||
// SourcesFallback — read the sources back rather than ship a broken fragment.
|
||||
func SourcesFallback(sources string) string { return DefaultFallbacks().FromSources(sources) }
|
||||
|
||||
// WorldGap — what he hears when the world model is configured and unreachable.
|
||||
func WorldGap() string { return DefaultFallbacks().WorldGap() }
|
||||
|
||||
// IsUnknownFallback reports whether text is one of her "I do not know" lines.
|
||||
// The daemon tests read it to tell an answer from a shrug.
|
||||
func IsUnknownFallback(text string) bool {
|
||||
return DefaultFallbacks().deck().matches(fbQueryUnknown, nil, text)
|
||||
}
|
||||
|
||||
// IsSourcesFallback reports whether text is sources read back verbatim.
|
||||
func IsSourcesFallback(text, sources string) bool {
|
||||
return DefaultFallbacks().deck().matches(fbQuerySources, map[string]string{"sources": sources}, text)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"name": "russian phrasing fallbacks v1",
|
||||
"notes": [
|
||||
"What she says when the model gave her nothing usable. Edit the wording here, no Go changes needed.",
|
||||
"Rules: she is feminine about herself, he is a man addressed as ты. Never вы/вас/ваш, never plural imperatives, never он/его about him. No pet names.",
|
||||
"These are heard after a failure, so they stay short and admit the gap. None of them may claim knowledge she does not have.",
|
||||
"Placeholders: {sources} the notes or passages she was handed. A variant whose placeholder has no value is skipped, so every entry needs at least one variant with no placeholder — except query_sources, which exists only to read sources back.",
|
||||
"fixed: true means exactly one variant and no picking. Used where the wording is load-bearing and must not drift between turns."
|
||||
],
|
||||
"entries": {
|
||||
"chat": {
|
||||
"variants": [
|
||||
"даже не знаю, что сказать.",
|
||||
"не могу найти слов.",
|
||||
"мысль ускользнула, повтори?",
|
||||
"у меня сейчас пусто в голове."
|
||||
]
|
||||
},
|
||||
"query_unknown": {
|
||||
"variants": [
|
||||
"не знаю.",
|
||||
"не знаю, честно.",
|
||||
"тут я пас.",
|
||||
"не скажу, не знаю."
|
||||
]
|
||||
},
|
||||
"query_sources": {
|
||||
"variants": [
|
||||
"вот что я нашла: {sources}",
|
||||
"нашла вот это: {sources}",
|
||||
"есть только это: {sources}"
|
||||
]
|
||||
},
|
||||
"world_gap": {
|
||||
"fixed": true,
|
||||
"variants": [
|
||||
"сейчас не могу ответить — большая модель недоступна, а придумывать не хочу."
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package phraser
|
||||
|
||||
import (
|
||||
"math/rand"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The embedded file must load, or the daemon speaks from hardFloor and nobody
|
||||
// finds out until he hears the wrong words.
|
||||
func TestFallbacksLoad(t *testing.T) {
|
||||
fb, err := LoadFallbacks(rand.NewSource(1))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFallbacks: %v", err)
|
||||
}
|
||||
if got := fb.FromSources("два литра"); !strings.Contains(got, "два литра") {
|
||||
t.Errorf("FromSources = %q, want the sources in it", got)
|
||||
}
|
||||
if fb.WorldGap() != hardFloor[fbWorldGap] {
|
||||
t.Errorf("WorldGap = %q, want the fixed wording %q", fb.WorldGap(), hardFloor[fbWorldGap])
|
||||
}
|
||||
}
|
||||
|
||||
// A broken or missing file must not take her last words away: every accessor
|
||||
// answers from the literal it replaced.
|
||||
func TestNilFallbacksAnswerFromTheHardFloor(t *testing.T) {
|
||||
var fb *Fallbacks
|
||||
if got := fb.Chat(); got != hardFloor[fbChat] {
|
||||
t.Errorf("Chat = %q, want %q", got, hardFloor[fbChat])
|
||||
}
|
||||
if got := fb.Unknown(); got != hardFloor[fbQueryUnknown] {
|
||||
t.Errorf("Unknown = %q, want %q", got, hardFloor[fbQueryUnknown])
|
||||
}
|
||||
if got := fb.FromSources("два литра"); got != "вот что я нашла: два литра" {
|
||||
t.Errorf("FromSources = %q", got)
|
||||
}
|
||||
if got := fb.WorldGap(); got != hardFloor[fbWorldGap] {
|
||||
t.Errorf("WorldGap = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Hearing the identical words every time a request fails is how a failure stops
|
||||
// registering as one.
|
||||
func TestFallbacksDoNotRepeat(t *testing.T) {
|
||||
fb, err := LoadFallbacks(rand.NewSource(7))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadFallbacks: %v", err)
|
||||
}
|
||||
prev := fb.Chat()
|
||||
for i := 0; i < 20; i++ {
|
||||
got := fb.Chat()
|
||||
if got == prev {
|
||||
t.Fatalf("chat repeated %q on turn %d", got, i)
|
||||
}
|
||||
prev = got
|
||||
}
|
||||
}
|
||||
|
||||
// The acknowledgements load, fill his words into the frame, and answer from the
|
||||
// floor when the file is gone.
|
||||
func TestAcksLoad(t *testing.T) {
|
||||
a, err := LoadAcks(rand.NewSource(1))
|
||||
if err != nil {
|
||||
t.Fatalf("LoadAcks: %v", err)
|
||||
}
|
||||
got := a.Say(AckFactValue, map[string]string{"key": "вода", "value": "2л"})
|
||||
if !strings.Contains(got, "вода") || !strings.Contains(got, "2л") {
|
||||
t.Errorf("Say(%s) = %q, want his key and value in it", AckFactValue, got)
|
||||
}
|
||||
var nilAcks *Acks
|
||||
if got := nilAcks.Say(AckNote, nil); got != ackFloor[AckNote] {
|
||||
t.Errorf("nil Acks said %q, want the floor %q", got, ackFloor[AckNote])
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
@@ -26,6 +27,11 @@ import (
|
||||
|
||||
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 {
|
||||
cfg Config
|
||||
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
|
||||
// compose a natural answer. Falls back to "вот что я нашла: <notes>" on any
|
||||
// LLM error — better to give the raw data than silence.
|
||||
// compose a natural answer. On any LLM error it returns the fallback text —
|
||||
// "вот что я нашла: <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) {
|
||||
// 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
|
||||
@@ -439,13 +448,15 @@ func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes []
|
||||
if len(notes) == 0 {
|
||||
sys, prompt := p.knowledgePrompt(utterance)
|
||||
resp, err := p.chatWithSystem(ctx, sys, prompt, 768)
|
||||
if err != nil || resp == "" {
|
||||
return "не знаю.", nil
|
||||
if err != nil {
|
||||
return UnknownFallback(), fmt.Errorf("phrase query (knowledge): %w", err)
|
||||
}
|
||||
if resp == "" {
|
||||
return UnknownFallback(), errEmptyResponse
|
||||
}
|
||||
text, _, perr := parseResponseMood(resp)
|
||||
if perr != nil {
|
||||
log.Printf("phraser: PhraseQuery: %v", perr)
|
||||
return "не знаю.", nil
|
||||
return UnknownFallback(), fmt.Errorf("phrase query (knowledge): %w", perr)
|
||||
}
|
||||
if text != "" {
|
||||
return text, nil
|
||||
@@ -457,13 +468,12 @@ func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes []
|
||||
text, _, perr := parseResponseMood(resp)
|
||||
if err != nil || perr != nil {
|
||||
// Read the notes out rather than ship a broken fragment.
|
||||
if perr != nil {
|
||||
log.Printf("phraser: PhraseQuery: %v", perr)
|
||||
cause := err
|
||||
if cause == nil {
|
||||
cause = perr
|
||||
}
|
||||
if len(notes) == 1 {
|
||||
return "вот что я нашла: " + notes[0], nil
|
||||
}
|
||||
return "вот что я нашла: " + strings.Join(notes, "; "), nil
|
||||
return SourcesFallback(strings.Join(notes, "; ")),
|
||||
fmt.Errorf("phrase query (evidence): %w", cause)
|
||||
}
|
||||
if text != "" {
|
||||
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
|
||||
// message array from dialogue history + the current user utterance. Falls back
|
||||
// to a simple greeting on any LLM error — better to say something than nothing.
|
||||
// message array from dialogue history + the current user utterance. On any LLM
|
||||
// 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) {
|
||||
sys := chatSystemPrompt(p.cfg.ContextBlock)
|
||||
msgs := []chatMsg{
|
||||
@@ -490,13 +501,11 @@ func (p *LLMPhraser) PhraseChat(ctx context.Context, utterance string, history [
|
||||
|
||||
resp, err := p.chatWithMessages(ctx, msgs, 768)
|
||||
if err != nil {
|
||||
log.Printf("phraser: PhraseChat: %v", err)
|
||||
return "поговорили.", nil
|
||||
return ChatFallback(), fmt.Errorf("phrase chat: %w", err)
|
||||
}
|
||||
text, _, perr := parseResponseMood(resp)
|
||||
if perr != nil {
|
||||
log.Printf("phraser: PhraseChat: %v", perr)
|
||||
return "поговорили.", nil
|
||||
return ChatFallback(), fmt.Errorf("phrase chat: %w", perr)
|
||||
}
|
||||
if text != "" {
|
||||
return text, nil
|
||||
|
||||
@@ -70,18 +70,15 @@ func NewStub() *Stub { return &Stub{} }
|
||||
// prompted response from the model. The history parameter is accepted but
|
||||
// ignored at the stub level (the production impl uses it for multi-turn).
|
||||
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.
|
||||
func (s *Stub) PhraseQuery(_ context.Context, _ string, notes []string) (string, error) {
|
||||
if len(notes) == 0 {
|
||||
return "не знаю.", nil
|
||||
return UnknownFallback(), nil
|
||||
}
|
||||
if len(notes) == 1 {
|
||||
return "вот что я нашла: " + notes[0], nil
|
||||
}
|
||||
return "вот что я нашла: " + strings.Join(notes, "; "), nil
|
||||
return SourcesFallback(strings.Join(notes, "; ")), nil
|
||||
}
|
||||
|
||||
// Close implements Phraser.Close (no-op for the stub).
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
package phraser
|
||||
|
||||
// The query answers and gaps — what a query source says when it answers from
|
||||
// something other than the model, and what it says when it has nothing.
|
||||
//
|
||||
// Third family on the shared deck (deck.go), after the fallbacks and the
|
||||
// acknowledgements. They were literals spread across actions_query.go and
|
||||
// netscan.go, where the largest single site held two dozen of them.
|
||||
//
|
||||
// QueryUnknown is not the phraser's UnknownFallback, even though the two read
|
||||
// the same today. Here she looked and found nothing; there she failed to phrase
|
||||
// an answer she had. Two files, two entries, so rewording one leaves the other.
|
||||
|
||||
import (
|
||||
_ "embed"
|
||||
"log"
|
||||
"math/rand"
|
||||
"sync"
|
||||
)
|
||||
|
||||
//go:embed query_ru_v1.json
|
||||
var queryJSON []byte
|
||||
|
||||
// QuerySchemaVersion — this family's own version.
|
||||
const QuerySchemaVersion = 1
|
||||
|
||||
// The entry keys.
|
||||
const (
|
||||
QueryUnknown = "query_unknown"
|
||||
QueryOtherDay = "other_day"
|
||||
QueryPersonalNone = "personal_none"
|
||||
QueryFactWhen = "fact_when"
|
||||
QueryFactValue = "fact_value"
|
||||
QueryFound = "found"
|
||||
QueryPageText = "page_text"
|
||||
QueryPageBlocked = "page_blocked"
|
||||
QueryPageEmpty = "page_empty"
|
||||
QueryFeedsOff = "feeds_off"
|
||||
QueryFeedsNew = "feeds_new"
|
||||
QueryFeedsEmpty = "feeds_empty"
|
||||
QueryFeedsTopic = "feeds_empty_topic"
|
||||
QueryWeatherNow = "weather_now"
|
||||
QueryWeatherOff = "weather_off"
|
||||
QueryWeatherWhere = "weather_nolocation"
|
||||
QueryNetEmpty = "net_empty"
|
||||
|
||||
QueryFailPlan = "fail_plan"
|
||||
QueryFailNotes = "fail_notes"
|
||||
QueryFailFeeds = "fail_feeds"
|
||||
QueryFailCalendar = "fail_calendar"
|
||||
QueryFailWeather = "fail_weather"
|
||||
QueryFailAnswer = "fail_answer"
|
||||
QueryFailPage = "fail_page"
|
||||
QueryFailNetscan = "fail_netscan"
|
||||
)
|
||||
|
||||
var queryKeys = []string{
|
||||
QueryUnknown, QueryOtherDay, QueryPersonalNone, QueryFactWhen, QueryFactValue,
|
||||
QueryFound, QueryPageText, QueryPageBlocked, QueryPageEmpty,
|
||||
QueryFeedsOff, QueryFeedsNew, QueryFeedsEmpty, QueryFeedsTopic,
|
||||
QueryWeatherNow, QueryWeatherOff, QueryWeatherWhere, QueryNetEmpty,
|
||||
QueryFailPlan, QueryFailNotes, QueryFailFeeds, QueryFailCalendar,
|
||||
QueryFailWeather, QueryFailAnswer, QueryFailPage, QueryFailNetscan,
|
||||
}
|
||||
|
||||
// queryFloor — the literal each key falls back to when the file is unusable.
|
||||
// These are the exact strings that lived in Go before this file existed.
|
||||
var queryFloor = registerFloor(map[string]string{
|
||||
QueryUnknown: "не знаю.",
|
||||
QueryOtherDay: "про другой день так не отвечу — спроси целиком.",
|
||||
QueryPersonalNone: "не знаю — не нашла у тебя такой записи.",
|
||||
QueryFactWhen: "я записала это {when}",
|
||||
QueryFactValue: "вот что я знаю: {key} — {value}",
|
||||
QueryFound: "вот что я нашла: {text}",
|
||||
QueryPageText: "вот что на странице: {text}",
|
||||
QueryPageBlocked: "эта страница закрыта для чтения — robots.txt не разрешает.",
|
||||
QueryPageEmpty: "страница открылась, но читать там нечего.",
|
||||
QueryFeedsOff: "я пока не читаю ленты — они не настроены.",
|
||||
QueryFeedsNew: "вот что нового: {items}",
|
||||
QueryFeedsEmpty: "в лентах пока ничего нового.",
|
||||
QueryFeedsTopic: "по этой теме в лентах пока ничего.",
|
||||
QueryWeatherNow: "в {location} сейчас {temp} градусов, {condition}.",
|
||||
QueryWeatherOff: "погода не настроена.",
|
||||
QueryWeatherWhere: "не знаю, для какого города — задай voice.weather.default_location или назови город.",
|
||||
QueryNetEmpty: "в сети никого не нашла{tail}.",
|
||||
|
||||
QueryFailPlan: "не получилось собрать план.",
|
||||
QueryFailNotes: "не получилось посмотреть записи.",
|
||||
QueryFailFeeds: "не получилось посмотреть ленты.",
|
||||
QueryFailCalendar: "не получилось проверить календарь.",
|
||||
QueryFailWeather: "не получилось узнать погоду.",
|
||||
QueryFailAnswer: "не получилось найти ответ.",
|
||||
QueryFailPage: "не получилось прочитать страницу.",
|
||||
QueryFailNetscan: "не получилось просканировать сеть.",
|
||||
})
|
||||
|
||||
// Queries picks a hand-written Russian query line. Safe for concurrent use.
|
||||
type Queries struct{ d *deck }
|
||||
|
||||
// LoadQueries reads the embedded file. Pass a source to make the picking
|
||||
// reproducible in tests; nil seeds from the clock.
|
||||
func LoadQueries(src rand.Source) (*Queries, error) {
|
||||
d, err := loadDeck(queryJSON, QuerySchemaVersion, queryKeys, queryFloor, src)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// The entries that exist to read something back. A variant without the
|
||||
// placeholder would answer the question by dropping the answer.
|
||||
for _, req := range []struct{ key, ph string }{
|
||||
{QueryFactWhen, "{when}"}, {QueryFactValue, "{key}"}, {QueryFactValue, "{value}"},
|
||||
{QueryFound, "{text}"}, {QueryPageText, "{text}"}, {QueryFeedsNew, "{items}"},
|
||||
{QueryWeatherNow, "{location}"}, {QueryWeatherNow, "{temp}"}, {QueryWeatherNow, "{condition}"},
|
||||
} {
|
||||
if err := d.requirePlaceholder(req.key, req.ph); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &Queries{d: d}, nil
|
||||
}
|
||||
|
||||
// deck reads through a nil *Queries, which is the unloadable-file case.
|
||||
func (q *Queries) deck() *deck {
|
||||
if q == nil {
|
||||
return nil
|
||||
}
|
||||
return q.d
|
||||
}
|
||||
|
||||
// Say returns one line for key, with the values filled into the frame.
|
||||
func (q *Queries) Say(key string, vars map[string]string) string {
|
||||
return q.deck().text(key, vars)
|
||||
}
|
||||
|
||||
// Variants returns every line the file can produce, for the persona scorer.
|
||||
func (q *Queries) Variants() []string { return q.deck().variants() }
|
||||
|
||||
var (
|
||||
queryOnce sync.Once
|
||||
queries *Queries
|
||||
)
|
||||
|
||||
// DefaultQueries returns the shared instance, loading it on first use. A broken
|
||||
// file logs once and leaves a nil *Queries, which still answers from queryFloor.
|
||||
func DefaultQueries() *Queries {
|
||||
queryOnce.Do(func() {
|
||||
q, err := LoadQueries(nil)
|
||||
if err != nil {
|
||||
log.Printf("phraser: query lines unavailable, using the built-in ones: %v", err)
|
||||
return
|
||||
}
|
||||
queries = q
|
||||
})
|
||||
return queries
|
||||
}
|
||||
|
||||
// Q — one query line, the way every caller says it.
|
||||
func Q(key string, vars map[string]string) string { return DefaultQueries().Say(key, vars) }
|
||||
|
||||
// IsQ reports whether text is a line key could have produced, for the tests.
|
||||
func IsQ(key string, vars map[string]string, text string) bool {
|
||||
return DefaultQueries().deck().matches(key, vars, text)
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"name": "russian query answers and gaps v1",
|
||||
"notes": [
|
||||
"What a query source says when it answers from something other than the model, and what it says when it has nothing. Edit the wording here, no Go changes needed.",
|
||||
"Rules: she is feminine about herself, he is a man addressed as ты. Never вы/вас/ваш, never он/его about him. No pet names.",
|
||||
"A gap names its own gap. \"the feeds are not configured\", \"the search failed\" and \"I do not know\" are different sentences and must never collapse into one entry.",
|
||||
"query_unknown here is a real answer — she looked and found nothing. The phraser's identical-looking line in fallbacks_ru_v1.json is a failure to phrase. Two files, two entries, on purpose.",
|
||||
"Placeholders: {key} {value} a stored fact, {when} when she wrote it, {items} what she found, {text} a passage, {location} {temp} {condition} the weather.",
|
||||
"fixed: true means exactly one variant and no picking. Used where the wording is load-bearing: the personal boundary, and the refusal to re-ask a question for another day."
|
||||
],
|
||||
"entries": {
|
||||
"query_unknown": {
|
||||
"variants": ["не знаю.", "не нашла ничего.", "ничего не нашла."]
|
||||
},
|
||||
"other_day": {
|
||||
"fixed": true,
|
||||
"variants": ["про другой день так не отвечу — спроси целиком."]
|
||||
},
|
||||
"personal_none": {
|
||||
"fixed": true,
|
||||
"variants": ["не знаю — не нашла у тебя такой записи."]
|
||||
},
|
||||
"fact_when": {
|
||||
"variants": ["я записала это {when}", "записала это {when}"]
|
||||
},
|
||||
"fact_value": {
|
||||
"variants": ["вот что я знаю: {key} — {value}", "у меня записано: {key} — {value}"]
|
||||
},
|
||||
"found": {
|
||||
"variants": ["вот что я нашла: {text}", "нашла вот это: {text}", "есть такое: {text}"]
|
||||
},
|
||||
"page_text": {
|
||||
"variants": ["вот что на странице: {text}", "на странице вот это: {text}"]
|
||||
},
|
||||
"page_blocked": {
|
||||
"fixed": true,
|
||||
"variants": ["эта страница закрыта для чтения — robots.txt не разрешает."]
|
||||
},
|
||||
"page_empty": {
|
||||
"variants": ["страница открылась, но читать там нечего.", "страница пустая, читать нечего."]
|
||||
},
|
||||
"feeds_off": {
|
||||
"variants": ["я пока не читаю ленты — они не настроены."]
|
||||
},
|
||||
"feeds_new": {
|
||||
"variants": ["вот что нового: {items}", "нового вот что: {items}"]
|
||||
},
|
||||
"feeds_empty": {
|
||||
"variants": ["в лентах пока ничего нового.", "в лентах тихо."]
|
||||
},
|
||||
"feeds_empty_topic": {
|
||||
"variants": ["по этой теме в лентах пока ничего.", "по этой теме в лентах тихо."]
|
||||
},
|
||||
"weather_now": {
|
||||
"variants": ["в {location} сейчас {temp} градусов, {condition}.", "{location}: {temp} градусов, {condition}."]
|
||||
},
|
||||
"weather_off": {
|
||||
"variants": ["погода не настроена."]
|
||||
},
|
||||
"weather_nolocation": {
|
||||
"fixed": true,
|
||||
"variants": ["не знаю, для какого города — задай voice.weather.default_location или назови город."]
|
||||
},
|
||||
"net_empty": {
|
||||
"variants": ["в сети никого не нашла{tail}.", "никого в сети не видно{tail}."]
|
||||
},
|
||||
"fail_plan": {
|
||||
"variants": ["не получилось собрать план.", "план не собрался."]
|
||||
},
|
||||
"fail_notes": {
|
||||
"variants": ["не получилось посмотреть записи.", "записи не открылись."]
|
||||
},
|
||||
"fail_feeds": {
|
||||
"variants": ["не получилось посмотреть ленты.", "ленты не открылись."]
|
||||
},
|
||||
"fail_calendar": {
|
||||
"variants": ["не получилось проверить календарь.", "календарь не открылся."]
|
||||
},
|
||||
"fail_weather": {
|
||||
"variants": ["не получилось узнать погоду.", "погода не пришла."]
|
||||
},
|
||||
"fail_answer": {
|
||||
"variants": ["не получилось найти ответ.", "ответ не нашёлся."]
|
||||
},
|
||||
"fail_page": {
|
||||
"variants": ["не получилось прочитать страницу.", "страница не прочиталась."]
|
||||
},
|
||||
"fail_netscan": {
|
||||
"variants": ["не получилось просканировать сеть.", "сеть не просканировалась."]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -215,10 +215,12 @@ func TestSwap_RollbackFailureLeavesNoBackendAndDegrades(t *testing.T) {
|
||||
if _, _, aerr := p.acquire(); !errors.Is(aerr, ErrNoBackend) {
|
||||
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)
|
||||
if err != nil {
|
||||
t.Fatalf("PhraseChat after a total failure returned an error: %v", err)
|
||||
if !errors.Is(err, ErrNoBackend) {
|
||||
t.Errorf("PhraseChat error = %v; want ErrNoBackend alongside the fallback", err)
|
||||
}
|
||||
if got == "" {
|
||||
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-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-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-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" },
|
||||
|
||||
@@ -210,11 +210,7 @@ func (lr *LLMRouter) Route(ctx context.Context, utterance string, now time.Time)
|
||||
d.Slots.HasKey = a.Key != ""
|
||||
case IntentReminder:
|
||||
d.Intent = IntentReminder
|
||||
// No utterance fallback here, unlike every other intent below. The
|
||||
// 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
|
||||
d.Slots.Text = firstNonEmpty(a.Text, utterance)
|
||||
case IntentNote:
|
||||
d.Intent = IntentNote
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
// The extractor's Text is the raw utterance, which is the payload for a
|
||||
// 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 {
|
||||
if d.Slots.Text == "" {
|
||||
d.Slots.Text = ex.Text
|
||||
}
|
||||
// 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 {
|
||||
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 {
|
||||
d.Clarify = true
|
||||
}
|
||||
|
||||
@@ -182,38 +182,9 @@ func AgendaQueryGrammars() []Grammar {
|
||||
Pattern: regexp.MustCompile(`(?i)^\s*(что|чего|какие|сколько|во\s+сколько|когда)\s+у\s+меня(\s|[?!.]|$)`),
|
||||
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
|
||||
// the intent only: the utterance travels intact and the query chain's own
|
||||
// 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
|
||||
// byte. The fingerprint is the declared shape at approval time, so a
|
||||
// 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
|
||||
|
||||
@@ -47,36 +47,3 @@ func TestMigrateAppliesOnceAndIsIdempotent(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
+11
-11
@@ -25,7 +25,10 @@
|
||||
// the daemon seam (config wiring, no CoreAPI or voice-package change).
|
||||
package voice
|
||||
|
||||
import "github.com/kami/maven/internal/router"
|
||||
import (
|
||||
"github.com/kami/maven/internal/phraser"
|
||||
"github.com/kami/maven/internal/router"
|
||||
)
|
||||
|
||||
// Replier — the reactive reply phrasing seam. The daemon's reactive handler
|
||||
// calls Reply with the router's Decision; the impl produces a terse reply
|
||||
@@ -60,27 +63,24 @@ func (s *StubReplier) Reply(d router.Decision) string {
|
||||
if !d.Slots.HasFn {
|
||||
return "не могу это сделать — не разобрала действие."
|
||||
}
|
||||
return "ок, записала действие: " + d.Slots.Fn
|
||||
return phraser.Ack(phraser.AckAct, map[string]string{"fn": d.Slots.Fn})
|
||||
case router.IntentReminder:
|
||||
if d.Slots.HasTime {
|
||||
return "напомню."
|
||||
}
|
||||
return "напомню."
|
||||
return phraser.Ack(phraser.AckReminder, nil)
|
||||
case router.IntentFact:
|
||||
if d.Slots.HasKey {
|
||||
if d.Slots.Value != "" {
|
||||
return "отметила: " + d.Slots.Key + " = " + d.Slots.Value
|
||||
return phraser.Ack(phraser.AckFactValue, map[string]string{"key": d.Slots.Key, "value": d.Slots.Value})
|
||||
}
|
||||
return "отметила: " + d.Slots.Key
|
||||
return phraser.Ack(phraser.AckFactKey, map[string]string{"key": d.Slots.Key})
|
||||
}
|
||||
return "записала факт."
|
||||
return phraser.Ack(phraser.AckFact, nil)
|
||||
case router.IntentNote:
|
||||
return "сохранила заметку."
|
||||
return phraser.Ack(phraser.AckNote, nil)
|
||||
case router.IntentQuery:
|
||||
return "поискала в заметках — ничего не нашла."
|
||||
case router.IntentChat:
|
||||
return "поговорили." // stub — LLMReplier replaces this
|
||||
default:
|
||||
return "приняла."
|
||||
return phraser.Ack(phraser.AckGeneric, nil)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user