Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c5e993fc55 | |||
| a2081d8227 | |||
| 82bd160c0d | |||
| 1fe03f7a51 | |||
| 77206f298e | |||
| a1f811d4c8 | |||
| 7ab38cd7f7 | |||
| 6a85e71077 | |||
| bf6c2bf1a6 |
@@ -189,6 +189,18 @@ fixture had said `query` since ru-query-019 was written. Measured: **full accura
|
|||||||
Go's `\b` is ASCII-only and never fires after a Cyrillic letter; the pattern needs an
|
Go's `\b` is ASCII-only and never fires after a Cyrillic letter; the pattern needs an
|
||||||
explicit `(\s|[?!.]|$)`.
|
explicit `(\s|[?!.]|$)`.
|
||||||
|
|
||||||
|
Two more shapes taken off the model, 04-08-2026 (V-498). `rest-of-day-query` inside
|
||||||
|
`AgendaQueryGrammars` claims "что дальше?" / "what's next", and `NarrativeQueryGrammar`
|
||||||
|
(`stage0.go`, wired **last** in `buildRouter`, after the capture marker) claims "расскажи про
|
||||||
|
X", "объясни X", "опиши X". Neither carries a question mark or an interrogative, so the model
|
||||||
|
called both `IntentFact`; the write was caught downstream by `IsQuestionShaped`, so this was a
|
||||||
|
latency and fixture defect, not a correctness one. The narrative rule reads the same
|
||||||
|
`narrativeRequests` lexicon `IsQuestionShaped` reads, and declines `chatNarrativeTopics` — a
|
||||||
|
joke, a bedtime story, herself — because the query chain has no source that answers those.
|
||||||
|
New fixture cases ru-query-024 and ru-query-025. Classifier + ONNX baseline **56/80 (70.0%) →
|
||||||
|
58/82 (70.7%)**, no case regressed, no new false clarify. The LLM arm was not measured (no
|
||||||
|
llama-server in that run), so judge it again before quoting a cascade number.
|
||||||
|
|
||||||
## LLM output contract
|
## LLM output contract
|
||||||
|
|
||||||
All phrasing paths emit `{"response":"...","mood":"..."}` (parsed in `replier_llm.go` and
|
All phrasing paths emit `{"response":"...","mood":"..."}` (parsed in `replier_llm.go` and
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"log"
|
"log"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/dialogue"
|
||||||
"github.com/kami/maven/internal/ipc"
|
"github.com/kami/maven/internal/ipc"
|
||||||
"github.com/kami/maven/internal/router"
|
"github.com/kami/maven/internal/router"
|
||||||
"github.com/kami/maven/internal/store"
|
"github.com/kami/maven/internal/store"
|
||||||
@@ -70,7 +71,16 @@ func (h *reactiveHandler) queryTasks(ctx context.Context, t *queryTurn) (string,
|
|||||||
log.Printf("voice: list tasks: %v", err)
|
log.Printf("voice: list tasks: %v", err)
|
||||||
return "не получилось посмотреть задачи.", true
|
return "не получилось посмотреть задачи.", true
|
||||||
}
|
}
|
||||||
return tasks.FormatRU(tasks.Rank(taskItems(live), h.now())), true
|
ranked := tasks.Rank(taskItems(live), h.now())
|
||||||
|
// Bind what she is about to say, in the order she says it, so "второй"
|
||||||
|
// means the second task he heard (ordinal.go).
|
||||||
|
spoken := tasks.Spoken(ranked)
|
||||||
|
cands := make([]dialogue.Candidate, 0, len(spoken))
|
||||||
|
for _, r := range spoken {
|
||||||
|
cands = append(cands, dialogue.Candidate{Kind: "task", Ref: r.ID, Label: r.Text})
|
||||||
|
}
|
||||||
|
h.offerCandidates(cands)
|
||||||
|
return tasks.FormatRU(ranked), true
|
||||||
}
|
}
|
||||||
|
|
||||||
// taskItems maps wire rows onto the ranker's input. Written here rather than in
|
// taskItems maps wire rows onto the ranker's input. Written here rather than in
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/dialogue"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ordinal selection over a list she just read (Vikunja #448).
|
||||||
|
//
|
||||||
|
// The dialogue session already carried the intent, the slots and the history.
|
||||||
|
// What it did not carry was the list: she recited five tasks, he said "второй",
|
||||||
|
// and there was nothing for that word to point at, so it routed as a fresh
|
||||||
|
// utterance and meant nothing.
|
||||||
|
//
|
||||||
|
// Candidates are bound when she speaks the list, in the order she spoke it (see
|
||||||
|
// tasks.Spoken). Binding afterwards would resolve "второй" against a fresh
|
||||||
|
// query, and the list can change between two turns.
|
||||||
|
//
|
||||||
|
// An ordinal with no verb is read back, not acted on: "второй" names a task, it
|
||||||
|
// does not say what to do with it. Acting on the bare word would guess, and a
|
||||||
|
// wrong guess here closes work he never finished.
|
||||||
|
|
||||||
|
// candidateOrdinals — the words that pick a position, by index. Prefix match,
|
||||||
|
// because Russian declines them: "первый", "первую", "первое".
|
||||||
|
var candidateOrdinals = []struct {
|
||||||
|
word string
|
||||||
|
nth int
|
||||||
|
}{
|
||||||
|
{"перв", 1}, {"втор", 2}, {"трет", 3}, {"четв", 4}, {"пят", 5},
|
||||||
|
{"first", 1}, {"second", 2}, {"third", 3},
|
||||||
|
}
|
||||||
|
|
||||||
|
// candidateDigits — "второй" said as a number. Matched whole, never by prefix:
|
||||||
|
// "15" starts with "1" and is a time, not a position.
|
||||||
|
var candidateDigits = map[string]int{"1": 1, "2": 2, "3": 3, "4": 4, "5": 5}
|
||||||
|
|
||||||
|
// candidateLast — "последний" picks the end of the list whatever its length.
|
||||||
|
var candidateLast = []string{"последн", "last"}
|
||||||
|
|
||||||
|
// parseOrdinal reads which position he named. 0 and false when he named none.
|
||||||
|
// A negative result means the last one.
|
||||||
|
func parseOrdinal(text string) (int, bool) {
|
||||||
|
// Token by token, not substring: " 1" would otherwise match inside
|
||||||
|
// "напомни в 15:00" and turn a reminder into a selection.
|
||||||
|
for _, tok := range strings.FieldsFunc(strings.ToLower(text), func(r rune) bool {
|
||||||
|
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
|
||||||
|
}) {
|
||||||
|
for _, w := range candidateLast {
|
||||||
|
if strings.HasPrefix(tok, w) {
|
||||||
|
return -1, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if n, ok := candidateDigits[tok]; ok {
|
||||||
|
return n, true
|
||||||
|
}
|
||||||
|
for _, o := range candidateOrdinals {
|
||||||
|
// Prefix, because Russian declines them: "первый", "первую".
|
||||||
|
if strings.HasPrefix(tok, o.word) {
|
||||||
|
return o.nth, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// candidateVerbs — what he wants done with the one he picked. Nothing here is
|
||||||
|
// destructive: a task moves forward or is dropped, and both are recorded with a
|
||||||
|
// provenance the /tasks page shows.
|
||||||
|
var candidateVerbs = []struct {
|
||||||
|
words []string
|
||||||
|
status string
|
||||||
|
say string
|
||||||
|
}{
|
||||||
|
{[]string{"готов", "сделал", "выполнил", "закрыл", "done"}, store.TaskDone, "закрыла"},
|
||||||
|
{[]string{"не надо", "убери", "отмени", "не буду", "drop"}, store.TaskDropped, "убрала"},
|
||||||
|
{[]string{"подтвержда", "беру", "да,", "буду делать"}, store.TaskOpen, "взяла в работу"},
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseCandidateVerb(text string) (status, say string, ok bool) {
|
||||||
|
s := strings.ToLower(strings.TrimSpace(text))
|
||||||
|
for _, v := range candidateVerbs {
|
||||||
|
for _, w := range v.words {
|
||||||
|
if strings.Contains(s, w) {
|
||||||
|
return v.status, v.say, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
// offerCandidates records the list she just read, so his next words can pick
|
||||||
|
// from it. Best effort: no session store, or a session that expired between the
|
||||||
|
// question and the answer, means the words route normally.
|
||||||
|
func (h *reactiveHandler) offerCandidates(cands []dialogue.Candidate) {
|
||||||
|
if h.dialogueSessions == nil || len(cands) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.dialogueSessions.SetCandidates(voiceDialogueID, h.now(), cands)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveCandidate handles "второй", "первую сделал", "последнюю убери" against
|
||||||
|
// the list she just read.
|
||||||
|
func (h *reactiveHandler) resolveCandidate(ctx context.Context, text string) (string, bool) {
|
||||||
|
if h.dialogueSessions == nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
sess := h.dialogueSessions.Get(voiceDialogueID, h.now())
|
||||||
|
if sess == nil || len(sess.Candidates) == 0 {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
nth, ok := parseOrdinal(text)
|
||||||
|
if !ok {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if nth < 0 {
|
||||||
|
nth = len(sess.Candidates)
|
||||||
|
}
|
||||||
|
if nth > len(sess.Candidates) {
|
||||||
|
// Claim the turn: he is picking from her list and named a position she
|
||||||
|
// did not read. Routing it fresh would answer something else entirely.
|
||||||
|
return fmt.Sprintf("я назвала только %d.", len(sess.Candidates)), true
|
||||||
|
}
|
||||||
|
pick := sess.Candidates[nth-1]
|
||||||
|
status, say, hasVerb := parseCandidateVerb(text)
|
||||||
|
if !hasVerb || pick.Kind != "task" {
|
||||||
|
// Read it back and keep the list: naming one is often the first half of
|
||||||
|
// a sentence, and the second half is the next turn.
|
||||||
|
return pick.Label, true
|
||||||
|
}
|
||||||
|
if err := h.api.SetTaskStatus(ctx, pick.Ref, status, h.now(), "tap:voice"); err != nil {
|
||||||
|
log.Printf("voice: candidate %d → %s: %v", pick.Ref, status, err)
|
||||||
|
return "не получилось изменить задачу.", true
|
||||||
|
}
|
||||||
|
// Spent: the list she read is no longer the list, and a second ordinal
|
||||||
|
// against it would close the wrong task.
|
||||||
|
h.dialogueSessions.SetCandidates(voiceDialogueID, h.now(), nil)
|
||||||
|
log.Printf("voice: candidate %d (%q) → %s", pick.Ref, pick.Label, status)
|
||||||
|
return say + ": " + pick.Label, true
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/dialogue"
|
||||||
|
"github.com/kami/maven/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseOrdinalReadsThePosition(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
text string
|
||||||
|
want int
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{"второй", 2, true},
|
||||||
|
{"вторую сделал", 2, true},
|
||||||
|
{"первую убери", 1, true},
|
||||||
|
{"последнюю не надо", -1, true},
|
||||||
|
{"3", 3, true},
|
||||||
|
{"the second one", 2, true},
|
||||||
|
// No position named.
|
||||||
|
{"какие у меня задачи", 0, false},
|
||||||
|
{"", 0, false},
|
||||||
|
// A digit inside a time is not a position.
|
||||||
|
{"напомни в 15:00", 0, false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got, ok := parseOrdinal(c.text)
|
||||||
|
if ok != c.ok || (ok && got != c.want) {
|
||||||
|
t.Errorf("parseOrdinal(%q) = %d,%v; want %d,%v", c.text, got, ok, c.want, c.ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrdinalPassesWithNothingOffered(t *testing.T) {
|
||||||
|
h, _, _ := newClarifyHandler(t)
|
||||||
|
if _, handled := h.resolveCandidate(context.Background(), "второй"); handled {
|
||||||
|
t.Error("an ordinal with no list behind it was claimed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrdinalReadsBackWithoutAVerb(t *testing.T) {
|
||||||
|
h, st, _ := newClarifyHandler(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
ids := seedTasks(t, st, "купить хлеб", "позвонить маме")
|
||||||
|
putCandidates(h, ids, "купить хлеб", "позвонить маме")
|
||||||
|
|
||||||
|
reply, handled := h.resolveCandidate(ctx, "второй")
|
||||||
|
if !handled || !strings.Contains(reply, "позвонить маме") {
|
||||||
|
t.Fatalf("a bare ordinal did not read the task back: %q handled=%v", reply, handled)
|
||||||
|
}
|
||||||
|
// Still live: naming one is often the first half of a sentence.
|
||||||
|
if _, handled := h.resolveCandidate(ctx, "первый"); !handled {
|
||||||
|
t.Error("the list was spent by a read-back")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrdinalWithAVerbMovesTheTask(t *testing.T) {
|
||||||
|
h, st, _ := newClarifyHandler(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
ids := seedTasks(t, st, "купить хлеб", "позвонить маме")
|
||||||
|
putCandidates(h, ids, "купить хлеб", "позвонить маме")
|
||||||
|
|
||||||
|
reply, handled := h.resolveCandidate(ctx, "первую сделал")
|
||||||
|
if !handled || !strings.Contains(reply, "купить хлеб") {
|
||||||
|
t.Fatalf("the pick was not acted on: %q handled=%v", reply, handled)
|
||||||
|
}
|
||||||
|
live, err := st.ListTasks(ctx, "live")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list tasks: %v", err)
|
||||||
|
}
|
||||||
|
for _, task := range live {
|
||||||
|
if task.ID == ids[0] {
|
||||||
|
t.Fatalf("task %d is still live after he closed it", task.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Spent: a second ordinal against a list that no longer holds would close
|
||||||
|
// the wrong task.
|
||||||
|
if _, handled := h.resolveCandidate(ctx, "второй"); handled {
|
||||||
|
t.Error("the list survived the pick it was spent on")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOrdinalPastTheEndSaysHowMany(t *testing.T) {
|
||||||
|
h, st, _ := newClarifyHandler(t)
|
||||||
|
ids := seedTasks(t, st, "купить хлеб")
|
||||||
|
putCandidates(h, ids, "купить хлеб")
|
||||||
|
|
||||||
|
reply, handled := h.resolveCandidate(context.Background(), "третий")
|
||||||
|
if !handled || !strings.Contains(reply, "1") {
|
||||||
|
t.Fatalf("a position she never read was not answered: %q handled=%v", reply, handled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ordinalNow — a fixed capture time; the ranker only needs the rows to exist.
|
||||||
|
var ordinalNow = time.Date(2026, 8, 4, 9, 0, 0, 0, time.UTC)
|
||||||
|
|
||||||
|
func seedTasks(t *testing.T, st *store.Store, texts ...string) []int64 {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
var ids []int64
|
||||||
|
for _, text := range texts {
|
||||||
|
res, err := st.CaptureTask(ctx, store.Task{
|
||||||
|
Text: text,
|
||||||
|
Source: "tap:voice",
|
||||||
|
Status: store.TaskOpen,
|
||||||
|
CreatedTs: ordinalNow,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("capture task: %v", err)
|
||||||
|
}
|
||||||
|
ids = append(ids, res.ID)
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
func putCandidates(h *reactiveHandler, ids []int64, labels ...string) {
|
||||||
|
cands := make([]dialogue.Candidate, 0, len(ids))
|
||||||
|
for i, id := range ids {
|
||||||
|
cands = append(cands, dialogue.Candidate{Kind: "task", Ref: id, Label: labels[i]})
|
||||||
|
}
|
||||||
|
h.dialogueSessions.Put(voiceDialogueID, &dialogue.Session{Timestamp: h.now()})
|
||||||
|
h.offerCandidates(cands)
|
||||||
|
}
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Conversation repair (Vikunja #455).
|
||||||
|
//
|
||||||
|
// The classifier has been able to learn from a correction since it was
|
||||||
|
// written — CorrectMisroute appends the utterance as a new example for the
|
||||||
|
// intent he names, append-only, no retrain. Nothing in the daemon could reach
|
||||||
|
// it: the only caller was a test. So the mechanism existed and the behaviour
|
||||||
|
// did not.
|
||||||
|
//
|
||||||
|
// This is the reachable half. He says she got it wrong and names what it
|
||||||
|
// should have been, she redoes the previous utterance under that intent, and
|
||||||
|
// she says out loud that the correction landed — because a correction he
|
||||||
|
// cannot see is indistinguishable from one that was dropped.
|
||||||
|
//
|
||||||
|
// Taken before routing, like the confirm and clarify turns: "нет, это была
|
||||||
|
// заметка" is an answer to the previous turn, not a fresh command, and routing
|
||||||
|
// it as one files the correction itself as a note.
|
||||||
|
|
||||||
|
// routedTurn — the previous utterance and where it went, which is all a
|
||||||
|
// correction needs to point at.
|
||||||
|
type routedTurn struct {
|
||||||
|
utterance string
|
||||||
|
intent router.Intent
|
||||||
|
at time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
// repairWindow — how long a turn stays correctable. Long enough that he can
|
||||||
|
// hear the wrong answer, think, and say so; short enough that "это заметка"
|
||||||
|
// half an hour later is a fresh sentence and not a verdict on something he has
|
||||||
|
// forgotten.
|
||||||
|
const repairWindow = 5 * time.Minute
|
||||||
|
|
||||||
|
// repairMarkers — the ways he says she got it wrong. One of these must appear:
|
||||||
|
// naming an intent alone is an ordinary sentence ("напиши заметку"), and
|
||||||
|
// treating it as a correction would rewrite the last turn every time he used
|
||||||
|
// the word.
|
||||||
|
var repairMarkers = []string{
|
||||||
|
"не так поняла",
|
||||||
|
"неправильно поняла",
|
||||||
|
"ты не поняла",
|
||||||
|
"не поняла меня",
|
||||||
|
"ты ошиблась",
|
||||||
|
"это не",
|
||||||
|
"а не",
|
||||||
|
"не про то",
|
||||||
|
"got it wrong",
|
||||||
|
"not a ",
|
||||||
|
"that was wrong",
|
||||||
|
}
|
||||||
|
|
||||||
|
// repairIntents — the words he uses for each intent. Prefixes, because Russian
|
||||||
|
// declines them: "заметка", "заметку", "заметкой".
|
||||||
|
var repairIntents = []struct {
|
||||||
|
word string
|
||||||
|
intent router.Intent
|
||||||
|
say string
|
||||||
|
}{
|
||||||
|
{"заметк", router.IntentNote, "заметка"},
|
||||||
|
{"напоминани", router.IntentReminder, "напоминание"},
|
||||||
|
{"напомнить", router.IntentReminder, "напоминание"},
|
||||||
|
{"факт", router.IntentFact, "факт"},
|
||||||
|
{"вопрос", router.IntentQuery, "вопрос"},
|
||||||
|
{"команд", router.IntentAct, "команда"},
|
||||||
|
{"note", router.IntentNote, "заметка"},
|
||||||
|
{"reminder", router.IntentReminder, "напоминание"},
|
||||||
|
{"fact", router.IntentFact, "факт"},
|
||||||
|
{"question", router.IntentQuery, "вопрос"},
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseRepair reads a spoken correction: a marker saying she was wrong, plus
|
||||||
|
// the intent it should have been.
|
||||||
|
//
|
||||||
|
// The negated half is skipped. "это заметка, а не напоминание" names both
|
||||||
|
// intents, and the one he is correcting TO is the one he did not put "не" in
|
||||||
|
// front of.
|
||||||
|
func parseRepair(utterance string) (router.Intent, string, bool) {
|
||||||
|
s := strings.ToLower(strings.TrimSpace(utterance))
|
||||||
|
if s == "" {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
// A leading "нет" is a marker on its own — "нет, это заметка" is the
|
||||||
|
// shortest correction he actually says. Only leading: "нет" in the middle
|
||||||
|
// of a sentence is an ordinary word.
|
||||||
|
marked := strings.HasPrefix(s, "нет") || strings.HasPrefix(s, "no,")
|
||||||
|
for _, m := range repairMarkers {
|
||||||
|
if marked || strings.Contains(s, m) {
|
||||||
|
marked = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !marked {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
best, say, at := router.Intent(""), "", -1
|
||||||
|
for _, w := range repairIntents {
|
||||||
|
i := strings.Index(s, w.word)
|
||||||
|
if i < 0 || negatedAt(s, i) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Leftmost wins: "это заметка, а не напоминание" corrects to the first.
|
||||||
|
if at < 0 || i < at {
|
||||||
|
best, say, at = w.intent, w.say, i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if at < 0 {
|
||||||
|
return "", "", false
|
||||||
|
}
|
||||||
|
return best, say, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// negatedAt reports whether the word at i is the one he is ruling out. Only
|
||||||
|
// the words immediately before it are read, so "не напоминание, а заметка"
|
||||||
|
// negates the first and leaves the second alone.
|
||||||
|
func negatedAt(s string, i int) bool {
|
||||||
|
before := strings.TrimSpace(s[:i])
|
||||||
|
return strings.HasSuffix(before, "не") || strings.HasSuffix(before, "not")
|
||||||
|
}
|
||||||
|
|
||||||
|
// recordTurn keeps the utterance a correction would point at. Only turns she
|
||||||
|
// acted on: a clarify asked instead of acting, so there is nothing yet to be
|
||||||
|
// wrong about.
|
||||||
|
func (h *reactiveHandler) recordTurn(utterance string, intent router.Intent) {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
h.lastRouted = &routedTurn{utterance: utterance, intent: intent, at: h.now()}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *reactiveHandler) takeLastTurn() *routedTurn {
|
||||||
|
h.mu.Lock()
|
||||||
|
defer h.mu.Unlock()
|
||||||
|
last := h.lastRouted
|
||||||
|
// Taken, not read: one utterance is corrected once. Saying "нет, не так"
|
||||||
|
// twice would otherwise redo the same request twice.
|
||||||
|
h.lastRouted = nil
|
||||||
|
return last
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveRepair handles a spoken correction of the previous turn: teach the
|
||||||
|
// classifier, redo the request under the corrected intent, and say so.
|
||||||
|
func (h *reactiveHandler) resolveRepair(ctx context.Context, text string) (string, bool) {
|
||||||
|
corrected, say, ok := parseRepair(text)
|
||||||
|
if !ok || h.router == nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
last := h.takeLastTurn()
|
||||||
|
if last == nil || h.now().Sub(last.at) > repairWindow {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
if last.intent == corrected {
|
||||||
|
// She already did what he is asking for. Correcting the classifier
|
||||||
|
// here would teach it the label it produced, and redoing the request
|
||||||
|
// would file it twice.
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
learned := true
|
||||||
|
if err := h.router.CorrectMisroute(ctx, last.utterance, corrected); err != nil {
|
||||||
|
// The redo is still worth doing: he asked for something and it did not
|
||||||
|
// happen. Only the learning half is lost, and he is told so.
|
||||||
|
log.Printf("voice: repair: could not learn %q as %s: %v", last.utterance, corrected, err)
|
||||||
|
learned = false
|
||||||
|
}
|
||||||
|
log.Printf("voice: repair — %q was %s, corrected to %s (learned=%v)", last.utterance, last.intent, corrected, learned)
|
||||||
|
|
||||||
|
dec := router.Decision{
|
||||||
|
Utterance: last.utterance,
|
||||||
|
Stage: 2,
|
||||||
|
Intent: corrected,
|
||||||
|
Slots: h.extractor.Extract(ctx, corrected, last.utterance, h.now()),
|
||||||
|
}
|
||||||
|
// A reminder's Text is what she says at the hour and stays empty when it
|
||||||
|
// was not spoken, so the gap is asked about rather than filled with the
|
||||||
|
// whole sentence. Everywhere else the utterance IS the payload.
|
||||||
|
if dec.Slots.Text == "" && corrected != router.IntentReminder {
|
||||||
|
dec.Slots.Text = last.utterance
|
||||||
|
}
|
||||||
|
return repairLine(say, learned) + " " + h.finishClarified(ctx, dec), true
|
||||||
|
}
|
||||||
|
|
||||||
|
// repairLine — what she says before redoing it, so the correction is visible
|
||||||
|
// and not just filed. Feminine, informal, no apology: he corrected a routing
|
||||||
|
// call, he did not complain about her.
|
||||||
|
func repairLine(say string, learned bool) string {
|
||||||
|
if !learned {
|
||||||
|
return "поняла, это " + say + " — переделываю, но запомнить поправку не вышло."
|
||||||
|
}
|
||||||
|
return "поняла, это " + say + " — запомнила."
|
||||||
|
}
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/kami/maven/internal/router"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseRepairReadsTheCorrectedIntent(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
utterance string
|
||||||
|
want router.Intent
|
||||||
|
ok bool
|
||||||
|
}{
|
||||||
|
{"нет, ты не поняла, это заметка", router.IntentNote, true},
|
||||||
|
{"нет, это заметка", router.IntentNote, true},
|
||||||
|
{"это не напоминание, а заметка", router.IntentNote, true},
|
||||||
|
{"это заметка, а не напоминание", router.IntentNote, true},
|
||||||
|
{"ты не так поняла — это факт", router.IntentFact, true},
|
||||||
|
{"неправильно поняла, это был вопрос", router.IntentQuery, true},
|
||||||
|
{"you got it wrong, that was a note", router.IntentNote, true},
|
||||||
|
// No marker: an ordinary request that happens to name an intent.
|
||||||
|
{"запиши заметку купить хлеб", "", false},
|
||||||
|
{"напомни мне про заметку", "", false},
|
||||||
|
// A marker with no intent named: nothing to correct to.
|
||||||
|
{"ты не так поняла", "", false},
|
||||||
|
{"", "", false},
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
got, _, ok := parseRepair(c.utterance)
|
||||||
|
if ok != c.ok || (ok && got != c.want) {
|
||||||
|
t.Errorf("parseRepair(%q) = %q,%v; want %q,%v", c.utterance, got, ok, c.want, c.ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRepairTeachesTheClassifierAndRedoesTheTurn is the whole feature: the
|
||||||
|
// previous utterance is filed under the intent he named, the classifier keeps
|
||||||
|
// it as an example, and he hears that it landed.
|
||||||
|
func TestRepairTeachesTheClassifierAndRedoesTheTurn(t *testing.T) {
|
||||||
|
h, st, now := newClarifyHandler(t)
|
||||||
|
emb := router.NewHashEmbedder(256)
|
||||||
|
cls := router.NewClassifier(emb)
|
||||||
|
h.embedder = emb
|
||||||
|
h.router = router.New(router.Config{Classifier: cls, Extractor: h.extractor})
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
h.recordTurn("купить хлеб", router.IntentFact)
|
||||||
|
reply, handled := h.resolveRepair(ctx, "нет, ты не поняла, это заметка")
|
||||||
|
if !handled {
|
||||||
|
t.Fatal("a spoken correction was not handled")
|
||||||
|
}
|
||||||
|
if !strings.Contains(reply, "заметка") {
|
||||||
|
t.Errorf("the correction is not named out loud: %q", reply)
|
||||||
|
}
|
||||||
|
if strings.Contains(reply, "не вышло") {
|
||||||
|
t.Errorf("learning failed unexpectedly: %q", reply)
|
||||||
|
}
|
||||||
|
|
||||||
|
ex := cls.Examples(router.IntentNote)
|
||||||
|
if len(ex) != 1 || ex[0].Text != "купить хлеб" {
|
||||||
|
t.Fatalf("the classifier did not learn the correction: %+v", ex)
|
||||||
|
}
|
||||||
|
notes, err := st.RecentNotes(ctx, 5)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("recent notes: %v", err)
|
||||||
|
}
|
||||||
|
if len(notes) != 1 || !strings.Contains(notes[0].Text, "купить хлеб") {
|
||||||
|
t.Fatalf("the request was not redone as a note: %+v", notes)
|
||||||
|
}
|
||||||
|
_ = now
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepairNeedsARecentTurnToPointAt(t *testing.T) {
|
||||||
|
h, _, now := newClarifyHandler(t)
|
||||||
|
h.router = router.New(router.Config{Classifier: router.NewClassifier(router.NewHashEmbedder(256))})
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Nothing said yet.
|
||||||
|
if _, handled := h.resolveRepair(ctx, "нет, это заметка"); handled {
|
||||||
|
t.Error("a correction with no previous turn was handled")
|
||||||
|
}
|
||||||
|
// Said, but long ago.
|
||||||
|
h.recordTurn("купить хлеб", router.IntentFact)
|
||||||
|
*now = now.Add(repairWindow + time.Minute)
|
||||||
|
if _, handled := h.resolveRepair(ctx, "нет, это заметка"); handled {
|
||||||
|
t.Error("a correction outside the window was handled")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRepairIsSpentOnce(t *testing.T) {
|
||||||
|
h, _, _ := newClarifyHandler(t)
|
||||||
|
emb := router.NewHashEmbedder(256)
|
||||||
|
h.embedder = emb
|
||||||
|
h.router = router.New(router.Config{Classifier: router.NewClassifier(emb), Extractor: h.extractor})
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
h.recordTurn("купить хлеб", router.IntentFact)
|
||||||
|
if _, handled := h.resolveRepair(ctx, "нет, это заметка"); !handled {
|
||||||
|
t.Fatal("the first correction was not handled")
|
||||||
|
}
|
||||||
|
if _, handled := h.resolveRepair(ctx, "нет, это заметка"); handled {
|
||||||
|
t.Error("the same turn was corrected twice")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRepairPassesWhenSheAlreadyDidThat — he names the intent she used. There
|
||||||
|
// is nothing to teach and redoing it would file the request a second time.
|
||||||
|
func TestRepairPassesWhenSheAlreadyDidThat(t *testing.T) {
|
||||||
|
h, _, _ := newClarifyHandler(t)
|
||||||
|
h.router = router.New(router.Config{Classifier: router.NewClassifier(router.NewHashEmbedder(256))})
|
||||||
|
h.recordTurn("купить хлеб", router.IntentNote)
|
||||||
|
if _, handled := h.resolveRepair(context.Background(), "нет, это заметка"); handled {
|
||||||
|
t.Error("a correction to the intent she already used was handled")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -161,6 +161,7 @@ type reactiveHandler struct {
|
|||||||
// the y/n answer. ponytail: single slot, single-user box — a second act
|
// the y/n answer. ponytail: single slot, single-user box — a second act
|
||||||
// while one waits overwrites it (last-asked wins); expires after confirmTTL.
|
// while one waits overwrites it (last-asked wins); expires after confirmTTL.
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
|
lastRouted *routedTurn // the previous acted turn, for a spoken correction (repair.go)
|
||||||
pending *pendingAct
|
pending *pendingAct
|
||||||
pendingRoutine *pendingRoutineConfirm // routine proposal awaiting y/n
|
pendingRoutine *pendingRoutineConfirm // routine proposal awaiting y/n
|
||||||
pendingHexis *pendingHexisExec // mutating Hexis capability awaiting y/n
|
pendingHexis *pendingHexisExec // mutating Hexis capability awaiting y/n
|
||||||
@@ -299,6 +300,22 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
|
|||||||
return withNotice(expiredNotice, reply)
|
return withNotice(expiredNotice, reply)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 4d. spoken correction — "нет, это была заметка" points at the previous
|
||||||
|
// turn and names what it should have been (repair.go). Before routing,
|
||||||
|
// like the confirm and clarify turns: routing the correction as a fresh
|
||||||
|
// utterance files the correction itself instead of fixing anything.
|
||||||
|
if reply, handled := h.resolveRepair(ctx, text); handled {
|
||||||
|
return withNotice(expiredNotice, reply)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4e. ordinal selection — "второй", "первую сделал" pick from the list she
|
||||||
|
// just read (ordinal.go). Before routing, and only when a list is actually
|
||||||
|
// bound to the session: with nothing offered, "второй" is an ordinary word
|
||||||
|
// and keeps routing.
|
||||||
|
if reply, handled := h.resolveCandidate(ctx, text); handled {
|
||||||
|
return withNotice(expiredNotice, reply)
|
||||||
|
}
|
||||||
|
|
||||||
// 5. route. An elliptical follow-up — "а завтра?" — is answered from the
|
// 5. route. An elliptical follow-up — "а завтра?" — is answered from the
|
||||||
// previous turn instead (continuation.go): the intent is the part it is
|
// previous turn instead (continuation.go): the intent is the part it is
|
||||||
// missing, so no amount of routing recovers it, and the model's guess
|
// missing, so no amount of routing recovers it, and the model's guess
|
||||||
@@ -359,6 +376,13 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSour
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remember what this turn was routed as, so the next utterance can correct
|
||||||
|
// it. Only turns she acts on: a clarify asked instead of acting, so there
|
||||||
|
// is nothing yet to be wrong about.
|
||||||
|
if !dec.Clarify {
|
||||||
|
h.recordTurn(text, dec.Intent)
|
||||||
|
}
|
||||||
|
|
||||||
// 8. action — execute the decision's intent. errors here surface as
|
// 8. action — execute the decision's intent. errors here surface as
|
||||||
// short reply text (the user wants to know the action didn't land);
|
// short reply text (the user wants to know the action didn't land);
|
||||||
// the round-trip stays alive.
|
// the round-trip stays alive.
|
||||||
|
|||||||
@@ -385,12 +385,19 @@ func buildRouter(emb router.Embedder, acts router.ActMatcher, threshold float64,
|
|||||||
// Same reason as the agenda rules, for the feeds: "что нового в лентах?"
|
// Same reason as the agenda rules, for the feeds: "что нового в лентах?"
|
||||||
// routed system and answered "пока не умею" (Vikunja #474).
|
// routed system and answered "пока не умею" (Vikunja #474).
|
||||||
grammars = append(grammars, router.FeedQueryGrammar())
|
grammars = append(grammars, router.FeedQueryGrammar())
|
||||||
|
// The list side of the same exposure: a phrasing with no possessive in it
|
||||||
|
// ("список дел") routed system and never reached queryTasks (Vikunja #467).
|
||||||
|
grammars = append(grammars, router.TaskListGrammar())
|
||||||
grammars = append(grammars, router.ReminderGrammar())
|
grammars = append(grammars, router.ReminderGrammar())
|
||||||
// Last, and it matches any utterance shape — its Build is the filter. An
|
// Last, and it matches any utterance shape — its Build is the filter. An
|
||||||
// explicit capture marker beats the model, which called it an act and
|
// explicit capture marker beats the model, which called it an act and
|
||||||
// rewrote the task text (Vikunja #467). After the rules above because a
|
// rewrote the task text (Vikunja #467). After the rules above because a
|
||||||
// marker never collides with a clock or agenda question.
|
// marker never collides with a clock or agenda question.
|
||||||
grammars = append(grammars, router.TaskCaptureGrammar())
|
grammars = append(grammars, router.TaskCaptureGrammar())
|
||||||
|
// After the capture marker, so "запиши" still wins over "расскажи", and
|
||||||
|
// last overall because it matches on the first word alone: "расскажи про
|
||||||
|
// X" is a world question the model called a fact (Vikunja #498).
|
||||||
|
grammars = append(grammars, router.NarrativeQueryGrammar())
|
||||||
return router.New(router.Config{
|
return router.New(router.Config{
|
||||||
Grammars: grammars,
|
Grammars: grammars,
|
||||||
Classifier: cls,
|
Classifier: cls,
|
||||||
|
|||||||
@@ -139,7 +139,13 @@ func TestHandleAmbientIgnoresNonMeetings(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestHandleAmbientAuth(t *testing.T) {
|
func TestHandleAmbientAuth(t *testing.T) {
|
||||||
body := `{"title":"Планёрка 10:00","posted_at":"2026-08-03T09:40:00Z"}`
|
// posted_at carries the local offset, and the clock reading inside the text
|
||||||
|
// sits twenty minutes after it. A bare "Z" here would make the reading
|
||||||
|
// stale by the test machine's own offset and the handler would answer 202
|
||||||
|
// no-meeting, which says nothing about the auth this test is checking
|
||||||
|
// (Vikunja #482).
|
||||||
|
posted := time.Date(2026, 8, 3, 9, 40, 0, 0, time.Local)
|
||||||
|
body := fmt.Sprintf(`{"title":"Планёрка 10:00","posted_at":%q}`, posted.Format(time.RFC3339))
|
||||||
|
|
||||||
newReq := func(hdr, val string) *http.Request {
|
newReq := func(hdr, val string) *http.Request {
|
||||||
r := httptest.NewRequest(http.MethodPost, "/api/ambient", strings.NewReader(body))
|
r := httptest.NewRequest(http.MethodPost, "/api/ambient", strings.NewReader(body))
|
||||||
|
|||||||
@@ -223,6 +223,41 @@ Not alternatives — layers:
|
|||||||
Router contract: `[{"intent":<enum>, key?, value?, text?, verb?}, ...]` over
|
Router contract: `[{"intent":<enum>, key?, value?, text?, verb?}, ...]` over
|
||||||
7 intents (`fact, reminder, note, query, act, chat, system`).
|
7 intents (`fact, reminder, note, query, act, chat, system`).
|
||||||
|
|
||||||
|
#### "второй" points at the list she just read
|
||||||
|
|
||||||
|
Landed 2026-08-04 (Vikunja #448). The dialogue session carried the intent, the
|
||||||
|
slots and the history, and not the list. She recited five tasks, he said
|
||||||
|
"второй", and the word had nothing to point at.
|
||||||
|
|
||||||
|
`Session.Candidates` holds what she just offered, bound at the moment she speaks
|
||||||
|
it and in the order she speaks it (`tasks.Spoken`). Binding afterwards would
|
||||||
|
resolve the word against a fresh query, and the list changes between two turns.
|
||||||
|
`cmd/mavend/ordinal.go` reads the position before routing and dispatches on the
|
||||||
|
candidate's kind.
|
||||||
|
|
||||||
|
An ordinal with no verb is read back, not acted on — "второй" names a task, it
|
||||||
|
does not say what to do with it. With a verb ("первую сделал", "последнюю
|
||||||
|
убери") the task moves and the list is spent, because a second ordinal against a
|
||||||
|
list that no longer holds closes the wrong work. A position she never read is
|
||||||
|
answered with how many she did read, not routed as a fresh sentence.
|
||||||
|
|
||||||
|
#### Saying she got it wrong is a feature
|
||||||
|
|
||||||
|
Landed 2026-08-04 (Vikunja #455). `Router.CorrectMisroute` could always append a
|
||||||
|
corrected utterance as a new classifier example, and until now nothing in the
|
||||||
|
daemon called it, so the mechanism existed and the behaviour did not.
|
||||||
|
|
||||||
|
`cmd/mavend/repair.go` reaches it. He says she got it wrong and names what it
|
||||||
|
should have been — "нет, это заметка", "это не напоминание, а факт" — and three
|
||||||
|
things happen in one turn: the classifier learns the utterance under the named
|
||||||
|
intent, the request is redone under it, and she says the correction landed. The
|
||||||
|
utterance he is correcting TO is the one with no "не" in front of it.
|
||||||
|
|
||||||
|
Read before routing, next to the confirm and clarify turns, because a correction
|
||||||
|
routed as a fresh utterance files the correction itself. One turn is correctable
|
||||||
|
once, inside five minutes, and only turns she acted on — a clarify asked instead
|
||||||
|
of acting, so there is nothing yet to be wrong about.
|
||||||
|
|
||||||
#### A restart expires a parked question
|
#### A restart expires a parked question
|
||||||
|
|
||||||
Decided 2026-08-04 (Vikunja #385). The follow-up dialogue session survives a
|
Decided 2026-08-04 (Vikunja #385). The follow-up dialogue session survives a
|
||||||
|
|||||||
@@ -68,10 +68,19 @@ var dayWords = map[string]int{
|
|||||||
// word ("завтра", "tomorrow") when the notification carries one, and the result
|
// word ("завтра", "tomorrow") when the notification carries one, and the result
|
||||||
// is refused if it lands more than ambientPastGrace in the past. A bare start
|
// is refused if it lands more than ambientPastGrace in the past. A bare start
|
||||||
// time gets DefaultReminderDuration.
|
// time gets DefaultReminderDuration.
|
||||||
|
//
|
||||||
|
// The clock reading is read in the daemon's zone (Vikunja #482). Posted is an
|
||||||
|
// instant and carries an offset; "созвон в 14:30" is a wall clock and carries
|
||||||
|
// none, so the zone has to come from somewhere else. A relay that posts
|
||||||
|
// "2026-08-02T09:00:00Z" used to make that 14:30 UTC, which stored an 18:30
|
||||||
|
// meeting on a UTC+4 box — wrong by the deploy's own offset, and invisible on a
|
||||||
|
// UTC box. The owner's phone and the box share a zone, so the box's zone is the
|
||||||
|
// honest reading of a bare wall clock.
|
||||||
func EventFromNotification(n Notification) (Event, bool) {
|
func EventFromNotification(n Notification) (Event, bool) {
|
||||||
if n.Posted.IsZero() {
|
if n.Posted.IsZero() {
|
||||||
return Event{}, false
|
return Event{}, false
|
||||||
}
|
}
|
||||||
|
n.Posted = n.Posted.In(time.Local)
|
||||||
line := strings.TrimSpace(n.Title + " " + n.Text)
|
line := strings.TrimSpace(n.Title + " " + n.Text)
|
||||||
start, end, ok := parseTimeRange(line)
|
start, end, ok := parseTimeRange(line)
|
||||||
if !ok {
|
if !ok {
|
||||||
|
|||||||
@@ -1,12 +1,22 @@
|
|||||||
package calendar
|
package calendar
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"os"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// A bare clock reading in a notification is read in the daemon's zone, so every
|
||||||
|
// test here needs a known one. UTC+4 is the deploy's (Europe/Samara) and it is
|
||||||
|
// the offset the 18:30 bug was measured at, so a regression shows up as four
|
||||||
|
// hours rather than as nothing at all on a UTC runner.
|
||||||
|
func TestMain(m *testing.M) {
|
||||||
|
time.Local = time.FixedZone("+04", 4*3600)
|
||||||
|
os.Exit(m.Run())
|
||||||
|
}
|
||||||
|
|
||||||
func TestEventFromNotification(t *testing.T) {
|
func TestEventFromNotification(t *testing.T) {
|
||||||
posted := time.Date(2026, 8, 3, 9, 40, 0, 0, time.FixedZone("+04", 4*3600))
|
posted := time.Date(2026, 8, 3, 9, 40, 0, 0, time.Local)
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -98,10 +108,10 @@ func TestEventFromNotification(t *testing.T) {
|
|||||||
if !ev.End.After(ev.Start) {
|
if !ev.End.After(ev.Start) {
|
||||||
t.Errorf("end %v must be after start %v", ev.End, ev.Start)
|
t.Errorf("end %v must be after start %v", ev.End, ev.Start)
|
||||||
}
|
}
|
||||||
// The event lands on the day the phone showed it, in the phone's
|
// The event lands on the day the phone showed it, in the daemon's
|
||||||
// location — not shifted into UTC.
|
// zone — the clock reading is a wall clock, not an instant.
|
||||||
if ev.Start.Location() != posted.Location() {
|
if ev.Start.Location() != time.Local {
|
||||||
t.Errorf("location = %v, want %v", ev.Start.Location(), posted.Location())
|
t.Errorf("location = %v, want %v", ev.Start.Location(), time.Local)
|
||||||
}
|
}
|
||||||
if y, m, d := ev.Start.Date(); y != 2026 || m != time.August || d != 3 {
|
if y, m, d := ev.Start.Date(); y != 2026 || m != time.August || d != 3 {
|
||||||
t.Errorf("date = %d-%02d-%02d, want 2026-08-03", y, m, d)
|
t.Errorf("date = %d-%02d-%02d, want 2026-08-03", y, m, d)
|
||||||
@@ -115,8 +125,7 @@ func TestEventFromNotification(t *testing.T) {
|
|||||||
// the meeting twelve hours in the past and filed it under today in FactKey. A
|
// the meeting twelve hours in the past and filed it under today in FactKey. A
|
||||||
// wrong meeting stored is worse than nothing stored.
|
// wrong meeting stored is worse than nothing stored.
|
||||||
func TestEventFromNotificationDayWords(t *testing.T) {
|
func TestEventFromNotificationDayWords(t *testing.T) {
|
||||||
loc := time.FixedZone("+04", 4*3600)
|
evening := time.Date(2026, 8, 3, 21, 0, 0, 0, time.Local)
|
||||||
evening := time.Date(2026, 8, 3, 21, 0, 0, 0, loc)
|
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -191,7 +200,7 @@ func TestEventFromNotificationDayWords(t *testing.T) {
|
|||||||
func TestEventFromNotificationDropsDayWordFromSummary(t *testing.T) {
|
func TestEventFromNotificationDropsDayWordFromSummary(t *testing.T) {
|
||||||
ev, ok := EventFromNotification(Notification{
|
ev, ok := EventFromNotification(Notification{
|
||||||
Title: "Завтра Планёрка 09:00",
|
Title: "Завтра Планёрка 09:00",
|
||||||
Posted: time.Date(2026, 8, 3, 21, 0, 0, 0, time.UTC),
|
Posted: time.Date(2026, 8, 3, 21, 0, 0, 0, time.Local),
|
||||||
})
|
})
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("expected an event")
|
t.Fatal("expected an event")
|
||||||
@@ -201,6 +210,31 @@ func TestEventFromNotificationDropsDayWordFromSummary(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Vikunja #482. A relay that posts its instant as UTC used to hand the wall
|
||||||
|
// clock inside the text the same zone, so "созвон в 14:30" was stored as 14:30Z
|
||||||
|
// and read back as 18:30 on a UTC+4 box — late by exactly the deploy's offset,
|
||||||
|
// and correct-looking on a UTC one. Nobody writes a notification meaning 14:30Z.
|
||||||
|
func TestEventFromNotificationReadsTheClockAsLocalTime(t *testing.T) {
|
||||||
|
ev, ok := EventFromNotification(Notification{
|
||||||
|
Package: "com.slack",
|
||||||
|
Title: "Standup",
|
||||||
|
Text: "созвон в 14:30",
|
||||||
|
Posted: time.Date(2026, 8, 2, 9, 0, 0, 0, time.UTC), // 13:00 local
|
||||||
|
})
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected an event")
|
||||||
|
}
|
||||||
|
if got := ev.Start.Format("15:04"); got != "14:30" {
|
||||||
|
t.Errorf("start = %s, want 14:30 local", got)
|
||||||
|
}
|
||||||
|
if ev.Start.Location() != time.Local {
|
||||||
|
t.Errorf("location = %v, want %v", ev.Start.Location(), time.Local)
|
||||||
|
}
|
||||||
|
if got, want := FactKey(ev), "calendar_event_20260802_Standup"; got != want {
|
||||||
|
t.Errorf("fact key = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestEventFromNotificationNeedsPostedAt(t *testing.T) {
|
func TestEventFromNotificationNeedsPostedAt(t *testing.T) {
|
||||||
if _, ok := EventFromNotification(Notification{Title: "Планёрка 10:00"}); ok {
|
if _, ok := EventFromNotification(Notification{Title: "Планёрка 10:00"}); ok {
|
||||||
t.Error("a notification with no posted_at has no date to sit on")
|
t.Error("a notification with no posted_at has no date to sit on")
|
||||||
@@ -211,7 +245,7 @@ func TestEventFromNotificationNeedsPostedAt(t *testing.T) {
|
|||||||
func TestAmbientEventsAreStoredAtReducedConfidence(t *testing.T) {
|
func TestAmbientEventsAreStoredAtReducedConfidence(t *testing.T) {
|
||||||
ev, ok := EventFromNotification(Notification{
|
ev, ok := EventFromNotification(Notification{
|
||||||
Title: "Планёрка 10:00-10:30",
|
Title: "Планёрка 10:00-10:30",
|
||||||
Posted: time.Date(2026, 8, 3, 9, 0, 0, 0, time.UTC),
|
Posted: time.Date(2026, 8, 3, 9, 0, 0, 0, time.Local),
|
||||||
})
|
})
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Fatal("expected an event")
|
t.Fatal("expected an event")
|
||||||
|
|||||||
@@ -42,12 +42,27 @@ type Turn struct {
|
|||||||
Text string // raw utterance
|
Text string // raw utterance
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Candidate — one item she just read out loud, kept so his next words can
|
||||||
|
// pick it ("второй", "первую сделал"). Vikunja #448.
|
||||||
|
//
|
||||||
|
// Bound at the moment she speaks the list, not resolved afterwards: the list
|
||||||
|
// can change between two turns, and "второй" means the second thing she said,
|
||||||
|
// not the second row of a fresh query.
|
||||||
|
type Candidate struct {
|
||||||
|
Kind string // what it is, e.g. "task" — the resolver dispatches on this
|
||||||
|
Ref int64 // the row it points at
|
||||||
|
Label string // what she called it, so she can repeat it back
|
||||||
|
}
|
||||||
|
|
||||||
type Session struct {
|
type Session struct {
|
||||||
Intent Intent
|
Intent Intent
|
||||||
Slots Slots
|
Slots Slots
|
||||||
Timestamp time.Time
|
Timestamp time.Time
|
||||||
TTL time.Duration
|
TTL time.Duration
|
||||||
History []Turn // most recent turns, newest last; used for anaphora + cross-intent
|
History []Turn // most recent turns, newest last; used for anaphora + cross-intent
|
||||||
|
// Candidates — the list she just offered, in the order she said it. Empty
|
||||||
|
// on every turn that offered no choice, which is most of them.
|
||||||
|
Candidates []Candidate
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Session) IsExpired(now time.Time) bool {
|
func (s *Session) IsExpired(now time.Time) bool {
|
||||||
@@ -143,6 +158,26 @@ func (s *SessionStore) Put(id string, sess *Session) {
|
|||||||
s.save(id, sess)
|
s.save(id, sess)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetCandidates attaches a just-spoken list to the live session.
|
||||||
|
//
|
||||||
|
// In place rather than through Put, because the turn was already remembered by
|
||||||
|
// the time the answer was built: replacing the session here would drop the
|
||||||
|
// slots the next follow-up inherits. No session, no candidates — a choice with
|
||||||
|
// no turn behind it has nothing to be a choice about.
|
||||||
|
func (s *SessionStore) SetCandidates(id string, now time.Time, cands []Candidate) {
|
||||||
|
s.mu.Lock()
|
||||||
|
sess, ok := s.sessions[id]
|
||||||
|
if ok && !sess.IsExpired(now) {
|
||||||
|
sess.Candidates = cands
|
||||||
|
} else {
|
||||||
|
ok = false
|
||||||
|
}
|
||||||
|
s.mu.Unlock()
|
||||||
|
if ok {
|
||||||
|
s.save(id, sess)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (s *SessionStore) Delete(id string) {
|
func (s *SessionStore) Delete(id string) {
|
||||||
s.mu.Lock()
|
s.mu.Lock()
|
||||||
delete(s.sessions, id)
|
delete(s.sessions, id)
|
||||||
|
|||||||
@@ -85,6 +85,15 @@ type Config struct {
|
|||||||
NCtx int
|
NCtx int
|
||||||
Timeout time.Duration
|
Timeout time.Duration
|
||||||
|
|
||||||
|
// StartupTimeout bounds the wait for llama-server to print the address it
|
||||||
|
// listens on. A config field and not a constant because the box may
|
||||||
|
// legitimately need longer: a cold 1.7B loading off a spinning disk can
|
||||||
|
// outrun a minute, and until this existed that returned "server did not
|
||||||
|
// start within 60s" with no way to raise it.
|
||||||
|
//
|
||||||
|
// 0 ⇒ defaultStartupTimeout.
|
||||||
|
StartupTimeout time.Duration
|
||||||
|
|
||||||
// CacheRAMMiB bounds llama-server's prompt cache, which is what actually ate
|
// CacheRAMMiB bounds llama-server's prompt cache, which is what actually ate
|
||||||
// this box. Measured on homesrv 2026-08-03: the server's own default limit is
|
// this box. Measured on homesrv 2026-08-03: the server's own default limit is
|
||||||
// 8192 MiB, it stores the full KV state of every idle slot it evicts (112 kiB
|
// 8192 MiB, it stores the full KV state of every idle slot it evicts (112 kiB
|
||||||
@@ -134,6 +143,8 @@ func DefaultConfig(modelPath string) Config {
|
|||||||
// 512 MiB caps total RSS near 1 GB and still holds several recent prompts.
|
// 512 MiB caps total RSS near 1 GB and still holds several recent prompts.
|
||||||
CacheRAMMiB: 512,
|
CacheRAMMiB: 512,
|
||||||
Timeout: 30 * time.Second,
|
Timeout: 30 * time.Second,
|
||||||
|
|
||||||
|
StartupTimeout: defaultStartupTimeout,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -260,7 +271,15 @@ func llamaArgs(cfg Config) []string {
|
|||||||
return args
|
return args
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// defaultStartupTimeout — the wait for llama-server's listen line when Config
|
||||||
|
// does not set one. A cold model load off disk is the slow part.
|
||||||
|
const defaultStartupTimeout = 60 * time.Second
|
||||||
|
|
||||||
func startLlamaProc(ctx context.Context, cfg Config) (*llamaProc, error) {
|
func startLlamaProc(ctx context.Context, cfg Config) (*llamaProc, error) {
|
||||||
|
startupTimeout := cfg.StartupTimeout
|
||||||
|
if startupTimeout <= 0 {
|
||||||
|
startupTimeout = defaultStartupTimeout
|
||||||
|
}
|
||||||
p := &llamaProc{}
|
p := &llamaProc{}
|
||||||
cmd := exec.CommandContext(ctx, cfg.BinPath, llamaArgs(cfg)...)
|
cmd := exec.CommandContext(ctx, cfg.BinPath, llamaArgs(cfg)...)
|
||||||
// Pdeathsig: the kernel SIGKILLs llama-server the moment mavend dies — by
|
// Pdeathsig: the kernel SIGKILLs llama-server the moment mavend dies — by
|
||||||
@@ -338,8 +357,8 @@ func startLlamaProc(ctx context.Context, cfg Config) (*llamaProc, error) {
|
|||||||
return fail(fmt.Errorf("llm: server output: %w; last output: %s", err, tail.String()))
|
return fail(fmt.Errorf("llm: server output: %w; last output: %s", err, tail.String()))
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
return fail(ctx.Err())
|
return fail(ctx.Err())
|
||||||
case <-time.After(60 * time.Second):
|
case <-time.After(startupTimeout):
|
||||||
return fail(fmt.Errorf("llm: server did not start within 60s; last output: %s", tail.String()))
|
return fail(fmt.Errorf("llm: server did not start within %s; last output: %s", startupTimeout, tail.String()))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -181,6 +181,37 @@ exit 1`)
|
|||||||
t.Fatalf("err = %v, want context.Canceled", err)
|
t.Fatalf("err = %v, want context.Canceled", err)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// The last arm of the startup race, and the one most likely to leak: a
|
||||||
|
// llama-server still loading a model is alive, so giving up on it without
|
||||||
|
// killing and reaping it orphans a process holding the GPU. Testable at all
|
||||||
|
// because Config.StartupTimeout replaced a hardcoded 60s (Vikunja #323).
|
||||||
|
t.Run("startup timeout", func(t *testing.T) {
|
||||||
|
pidPath := filepath.Join(t.TempDir(), "pid")
|
||||||
|
bin := fakeLlama(t, fmt.Sprintf(`echo $$ > %s
|
||||||
|
while : ; do sleep 1 ; done`, pidPath))
|
||||||
|
cfg := testCfg(bin)
|
||||||
|
cfg.StartupTimeout = 200 * time.Millisecond
|
||||||
|
|
||||||
|
_, err := startLlamaProc(context.Background(), cfg)
|
||||||
|
if err == nil || !strings.Contains(err.Error(), "did not start within 200ms") {
|
||||||
|
t.Fatalf("err = %v, want the startup-timeout arm naming the timeout", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, readErr := os.ReadFile(pidPath)
|
||||||
|
if readErr != nil {
|
||||||
|
t.Fatalf("fake server never recorded its pid: %v", readErr)
|
||||||
|
}
|
||||||
|
pid, convErr := strconv.Atoi(strings.TrimSpace(string(raw)))
|
||||||
|
if convErr != nil {
|
||||||
|
t.Fatalf("pid file = %q: %v", raw, convErr)
|
||||||
|
}
|
||||||
|
// Killed, and reaped: a zombie still answers signal 0, so this asserts
|
||||||
|
// the Wait ran too.
|
||||||
|
if err := syscall.Kill(pid, 0); err == nil {
|
||||||
|
t.Errorf("llama-server %d survived the startup timeout", pid)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestNewLLMPhraserSpawns(t *testing.T) {
|
func TestNewLLMPhraserSpawns(t *testing.T) {
|
||||||
|
|||||||
@@ -237,7 +237,14 @@ func newBaselineRouter(t *testing.T, emb router.Embedder, llmR *router.LLMRouter
|
|||||||
// anything while its grammar set is the daemon's grammar set.
|
// anything while its grammar set is the daemon's grammar set.
|
||||||
grammars = append(grammars, router.AgendaQueryGrammars()...)
|
grammars = append(grammars, router.AgendaQueryGrammars()...)
|
||||||
grammars = append(grammars, router.FeedQueryGrammar())
|
grammars = append(grammars, router.FeedQueryGrammar())
|
||||||
|
// The list side of the same exposure: a phrasing with no possessive in it
|
||||||
|
// ("список дел") routed system and never reached queryTasks (Vikunja #467).
|
||||||
|
grammars = append(grammars, router.TaskListGrammar())
|
||||||
grammars = append(grammars, router.ReminderGrammar())
|
grammars = append(grammars, router.ReminderGrammar())
|
||||||
|
grammars = append(grammars, router.TaskCaptureGrammar())
|
||||||
|
// "расскажи про X" is a world question the model called a fact, and the
|
||||||
|
// rule goes last because it matches on the first word alone (Vikunja #498).
|
||||||
|
grammars = append(grammars, router.NarrativeQueryGrammar())
|
||||||
return router.New(router.Config{
|
return router.New(router.Config{
|
||||||
Grammars: grammars,
|
Grammars: grammars,
|
||||||
Classifier: cls,
|
Classifier: cls,
|
||||||
|
|||||||
@@ -25,6 +25,8 @@
|
|||||||
{ "id": "ru-query-019", "utterance": "что у меня стоит в календаре на послезавтра", "lang": "ru", "intent": "query", "tags": ["calendar", "hard"], "note": "agenda, not the clock: the daemon answers this from CalendarEvents inside the query branch, so the clock/date system rule must not swallow it" },
|
{ "id": "ru-query-019", "utterance": "что у меня стоит в календаре на послезавтра", "lang": "ru", "intent": "query", "tags": ["calendar", "hard"], "note": "agenda, not the clock: the daemon answers this from CalendarEvents inside the query branch, so the clock/date system rule must not swallow it" },
|
||||||
{ "id": "ru-query-022", "utterance": "какие планы на завтра?", "lang": "ru", "intent": "query", "tags": ["calendar"], "note": "the same agenda question as ru-query-019 aimed at another day; it answered \u043f\u043e\u043a\u0430 \u043d\u0435 \u0443\u043c\u0435\u044e on the deployed daemon while the today form worked (Vikunja #471)" },
|
{ "id": "ru-query-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-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-024", "utterance": "что дальше?", "lang": "ru", "intent": "query", "tags": ["calendar", "no-question-word"], "note": "the rest of the day, with no possessive and no plan word to anchor on; the model called it a fact and the write had to be caught downstream (Vikunja #498)" },
|
||||||
|
{ "id": "ru-query-025", "utterance": "расскажи про битву при Ватерлоо", "lang": "ru", "intent": "query", "tags": ["world", "no-question-word"], "note": "a narrative request carries no question mark and no interrogative, so it routed fact; contrast ru-chat-003, where the same verb asks for a joke" },
|
||||||
{ "id": "ru-query-014", "utterance": "я успеваю до дедлайна", "lang": "ru", "intent": "query", "tags": ["hard", "no-question-word"] },
|
{ "id": "ru-query-014", "utterance": "я успеваю до дедлайна", "lang": "ru", "intent": "query", "tags": ["hard", "no-question-word"] },
|
||||||
{ "id": "ru-query-015", "utterance": "сколько я прошёл шагов", "lang": "ru", "intent": "query", "tags": ["aggregate"] },
|
{ "id": "ru-query-015", "utterance": "сколько я прошёл шагов", "lang": "ru", "intent": "query", "tags": ["aggregate"] },
|
||||||
{ "id": "ru-query-016", "utterance": "покажи давление за неделю", "lang": "ru", "intent": "query", "tags": ["hard", "imperative"], "note": "imperative form but a read — must not route to act" },
|
{ "id": "ru-query-016", "utterance": "покажи давление за неделю", "lang": "ru", "intent": "query", "tags": ["hard", "imperative"], "note": "imperative form but a read — must not route to act" },
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package router
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// narrativeRouter wires the grammars in the order the daemon wires them
|
||||||
|
// (voicewire.go), with the narrative rule last — so a test that passes here is
|
||||||
|
// a test of the deployed precedence, not of the rule in isolation.
|
||||||
|
func narrativeRouter(t *testing.T) *Router {
|
||||||
|
t.Helper()
|
||||||
|
r := newTestRouter(t, 0.0)
|
||||||
|
r.grammars = append(r.grammars, SystemTimeDateGrammars()...)
|
||||||
|
r.grammars = append(r.grammars, AgendaQueryGrammars()...)
|
||||||
|
r.grammars = append(r.grammars, TaskListGrammar())
|
||||||
|
r.grammars = append(r.grammars, TaskCaptureGrammar())
|
||||||
|
r.grammars = append(r.grammars, NarrativeQueryGrammar())
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// "расскажи про X" and "что дальше?" carried no question mark and no
|
||||||
|
// interrogative, so nothing at stage 0 claimed them and the model called both
|
||||||
|
// facts (Vikunja #498, point 1 of #470). The fact write is contained now, but
|
||||||
|
// the round trip and the wrong fixture score are not.
|
||||||
|
func TestNarrativeAndRestOfDayRouteToQueryAtStageZero(t *testing.T) {
|
||||||
|
r := narrativeRouter(t)
|
||||||
|
for _, u := range []string{
|
||||||
|
"расскажи про битву при Ватерлоо",
|
||||||
|
"расскажи мне про Юникод",
|
||||||
|
"объясни как работает tcp",
|
||||||
|
"опиши Самару",
|
||||||
|
"перечисли планеты",
|
||||||
|
"tell me about the fall of Rome",
|
||||||
|
"что дальше?",
|
||||||
|
"и что там дальше",
|
||||||
|
"что дальше",
|
||||||
|
"what's next?",
|
||||||
|
} {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
if d.Stage != 0 {
|
||||||
|
t.Errorf("route(%q) decided at stage %d, want 0 — the point is to skip the model", u, d.Stage)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The narrative rule must not take a turn that belongs to something else. A
|
||||||
|
// capture marker wins because it is what he said, and asking her for a joke is
|
||||||
|
// chat: the query chain has no source that answers it.
|
||||||
|
func TestNarrativeGrammarLeavesOtherTurnsAlone(t *testing.T) {
|
||||||
|
r := narrativeRouter(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 && d.Intent == IntentQuery {
|
||||||
|
t.Errorf("route(%q) was claimed as a world question at stage 0", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The topic reaches the query chain without the verb that introduced it: the
|
||||||
|
// search leg wants "битву при Ватерлоо", not "расскажи про битву при Ватерлоо".
|
||||||
|
func TestNarrativeGrammarKeepsTheTopic(t *testing.T) {
|
||||||
|
r := narrativeRouter(t)
|
||||||
|
d, err := r.Route(context.Background(), "расскажи про битву при Ватерлоо", refNow())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got, want := d.Slots.Text, "битву при Ватерлоо"; got != want {
|
||||||
|
t.Errorf("text = %q, want %q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ package router
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"log"
|
"log"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
@@ -201,5 +202,10 @@ func (r *Router) gateLLMDecision(d *Decision) {
|
|||||||
// retrain). Same shape as nudges.outcome tuning cooldowns: more reliable over
|
// retrain). Same shape as nudges.outcome tuning cooldowns: more reliable over
|
||||||
// time, introspectable, no model surgery.
|
// time, introspectable, no model surgery.
|
||||||
func (r *Router) CorrectMisroute(ctx context.Context, utterance string, corrected Intent) error {
|
func (r *Router) CorrectMisroute(ctx context.Context, utterance string, corrected Intent) error {
|
||||||
|
if r == nil || r.classifier == nil {
|
||||||
|
// The LLM router can run with no classifier wired. The correction has
|
||||||
|
// nowhere to land then, and the caller redoes the request anyway.
|
||||||
|
return errors.New("router: no classifier to correct")
|
||||||
|
}
|
||||||
return r.classifier.AddExample(ctx, corrected, utterance)
|
return r.classifier.AddExample(ctx, corrected, utterance)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -196,6 +196,20 @@ func AgendaQueryGrammars() []Grammar {
|
|||||||
Pattern: regexp.MustCompile(`(?i)(^|\s)(план|дел)[а-я]*\s+(на|в|во|по)\s+` + dayWordPattern + `(\s|[?!.]|$)`),
|
Pattern: regexp.MustCompile(`(?i)(^|\s)(план|дел)[а-я]*\s+(на|в|во|по)\s+` + dayWordPattern + `(\s|[?!.]|$)`),
|
||||||
Build: agendaQueryBuild,
|
Build: agendaQueryBuild,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// "что дальше?" — the rest of the day, with no possessive and no
|
||||||
|
// plan word for the rules above to anchor on, so neither claimed
|
||||||
|
// it and the model called it a fact (Vikunja #498). The predicate
|
||||||
|
// for the same utterance already exists as IsRestOfDayQuery, one
|
||||||
|
// layer down in the query chain; this is what gets the turn there.
|
||||||
|
//
|
||||||
|
// "и что там дальше" and "что потом дальше" are the same question,
|
||||||
|
// and "what's next" splits into two tokens, hence the optional
|
||||||
|
// middles rather than plain adjacency.
|
||||||
|
Name: "rest-of-day-query",
|
||||||
|
Pattern: regexp.MustCompile(`(?i)^\s*(и\s+)?(что|чего|what'?s?)\s+(там\s+|ещё\s+|еще\s+|потом\s+|у\s+меня\s+)?(дальше|next)(\s|[?!.]|$)`),
|
||||||
|
Build: agendaQueryBuild,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
// A named event with no calendar word at all: "когда планёрка?",
|
// A named event with no calendar word at all: "когда планёрка?",
|
||||||
// "во сколько созвон". He is asking when something on his calendar
|
// "во сколько созвон". He is asking when something on his calendar
|
||||||
@@ -208,6 +222,55 @@ func AgendaQueryGrammars() []Grammar {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// chatNarrativeTopics — the things "расскажи X" asks for that are not
|
||||||
|
// questions about the world. She is being asked to entertain or to describe
|
||||||
|
// herself, and the query chain has no source for either.
|
||||||
|
var chatNarrativeTopics = regexp.MustCompile(`(?i)(анекдот|шутк|сказк|истори[юи]\s+на\s+ночь|о\s+себе|про\s+себя|о\s+нас|про\s+нас)`)
|
||||||
|
|
||||||
|
// NarrativeQueryGrammar — stage-0 rule for "расскажи про X", "объясни X",
|
||||||
|
// "опиши X", routed to IntentQuery.
|
||||||
|
//
|
||||||
|
// It carries no question mark and no interrogative, so the model called
|
||||||
|
// "расскажи про битву при Ватерлоо" a fact and tried to store the answer it
|
||||||
|
// invented (Vikunja #470, point 1). The write is contained now — actions_fact
|
||||||
|
// refuses a question-shaped write and re-runs the turn as a query — but every
|
||||||
|
// such utterance still paid a model round trip to reach a decision one regex
|
||||||
|
// can make, and the fixture still scored the routing as wrong (Vikunja #498).
|
||||||
|
//
|
||||||
|
// The lexicon is narrativeRequests in question.go, which IsQuestionShaped
|
||||||
|
// already uses. One list, two callers: a word that marks an utterance as
|
||||||
|
// asking must not mark it here and not there.
|
||||||
|
//
|
||||||
|
// Routing, not answering. Which source claims the turn is still the query
|
||||||
|
// chain's decision, and the personal boundary still sits where it sat.
|
||||||
|
func NarrativeQueryGrammar() Grammar {
|
||||||
|
return Grammar{
|
||||||
|
Name: "narrative-query",
|
||||||
|
// (\s|[?!.]|$) rather than \b: Go's \b is ASCII-only and never fires
|
||||||
|
// after a Cyrillic letter, so the pattern would silently never match.
|
||||||
|
Pattern: regexp.MustCompile(`(?is)^\s*(` + strings.Join(narrativeRequests, "|") + `)(?:\s+(?:мне|нам|us|me))?(?:\s+(?:про|о|об|about))?(\s+.+)$`),
|
||||||
|
Build: func(m []string) (Decision, bool) {
|
||||||
|
topic := strings.TrimSpace(m[2])
|
||||||
|
// "расскажи" with nothing after it is a conversational opener,
|
||||||
|
// and there is no topic to look up.
|
||||||
|
if topic == "" {
|
||||||
|
return Decision{}, false
|
||||||
|
}
|
||||||
|
// Against the whole utterance, not the topic: "о себе" has its
|
||||||
|
// preposition eaten by the pattern, leaving a bare "себе".
|
||||||
|
if chatNarrativeTopics.MatchString(m[0]) {
|
||||||
|
return Decision{}, false
|
||||||
|
}
|
||||||
|
return Decision{
|
||||||
|
Stage: 0,
|
||||||
|
Intent: IntentQuery,
|
||||||
|
Confidence: 1.0,
|
||||||
|
Slots: Slots{Text: topic},
|
||||||
|
}, true
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// FeedQueryGrammar — stage-0 rule for "что нового в лентах?", routed to
|
// FeedQueryGrammar — stage-0 rule for "что нового в лентах?", routed to
|
||||||
// IntentQuery so it reaches queryFeeds.
|
// IntentQuery so it reaches queryFeeds.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -248,3 +248,32 @@ func TaskCaptureGrammar() Grammar {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TaskListGrammar — stage 0 for "какие у меня задачи", "список дел", "что мне
|
||||||
|
// нужно сделать" (Vikunja #467).
|
||||||
|
//
|
||||||
|
// The same exposure the capture marker had, pointed the other way. IsTaskListQuery
|
||||||
|
// is a deterministic lookup that lives inside a query source, so it is only
|
||||||
|
// consulted once the turn is already IntentQuery. A phrasing the model calls
|
||||||
|
// system or note never reaches it, and "пока не умею" is what he hears — the
|
||||||
|
// failure the agenda and feed rules were written for.
|
||||||
|
//
|
||||||
|
// Placed after the agenda rules, which already send "какие у меня задачи" to
|
||||||
|
// query. What this adds is the phrasings with no possessive in them.
|
||||||
|
func TaskListGrammar() Grammar {
|
||||||
|
return Grammar{
|
||||||
|
Name: "task-list-query",
|
||||||
|
Pattern: regexp.MustCompile(`(?s)^\s*(.+)$`),
|
||||||
|
Build: func(m []string) (Decision, bool) {
|
||||||
|
if !IsTaskListQuery(m[1]) {
|
||||||
|
return Decision{}, false
|
||||||
|
}
|
||||||
|
return Decision{
|
||||||
|
Stage: 0,
|
||||||
|
Intent: IntentQuery,
|
||||||
|
Confidence: 1.0,
|
||||||
|
Slots: Slots{Text: strings.TrimSpace(m[1])},
|
||||||
|
}, true
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -119,3 +119,27 @@ func TestTaskCaptureGrammarClaimsTheMarker(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestTaskListGrammarClaimsTheAsk — a list question answered before the model,
|
||||||
|
// including the phrasings with no possessive that used to route elsewhere.
|
||||||
|
func TestTaskListGrammarClaimsTheAsk(t *testing.T) {
|
||||||
|
g := TaskListGrammar()
|
||||||
|
claimed := []string{"какие у меня задачи", "список дел", "что мне нужно сделать"}
|
||||||
|
for _, u := range claimed {
|
||||||
|
m := g.Pattern.FindStringSubmatch(u)
|
||||||
|
if m == nil {
|
||||||
|
t.Fatalf("%q did not match the grammar pattern", u)
|
||||||
|
}
|
||||||
|
d, ok := g.Build(m)
|
||||||
|
if !ok || d.Intent != IntentQuery {
|
||||||
|
t.Errorf("%q built %+v ok=%v; want a query", u, d, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
passed := []string{"как дела", "напомни купить хлеб", "что docker делает"}
|
||||||
|
for _, u := range passed {
|
||||||
|
m := g.Pattern.FindStringSubmatch(u)
|
||||||
|
if _, ok := g.Build(m); ok {
|
||||||
|
t.Errorf("%q was claimed as a task list", u)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -268,3 +268,26 @@ func pluralTasksRU(n int) string {
|
|||||||
}
|
}
|
||||||
return "задач"
|
return "задач"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Spoken — the tasks FormatRU actually named, in the order it named them
|
||||||
|
// (Vikunja #448). "второй" has to mean the second thing she said, so the list
|
||||||
|
// an ordinal resolves against is built here and not by a caller guessing how
|
||||||
|
// the renderer split and truncated it.
|
||||||
|
func Spoken(ranked []Ranked) []Ranked {
|
||||||
|
var open, cands []Ranked
|
||||||
|
for _, r := range ranked {
|
||||||
|
if r.Status == StatusCandidate {
|
||||||
|
cands = append(cands, r)
|
||||||
|
} else {
|
||||||
|
open = append(open, r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out := make([]Ranked, 0, 2*SpokenLimit)
|
||||||
|
for _, group := range [][]Ranked{open, cands} {
|
||||||
|
if len(group) > SpokenLimit {
|
||||||
|
group = group[:SpokenLimit]
|
||||||
|
}
|
||||||
|
out = append(out, group...)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user