Merge task/467 so the sweep tail can reach the three mechanisms (V-528)
attentionq.go, repair.go and internal/router/complaint.go carry the last
hand-written Russian patterns of the V-522 sweep, and they live on task/467.
internal/lexicon, internal/morph and cmd/mavend/topics.go live here. One of
the two had to move.
Four conflicts, and one of them is a real collision rather than a mechanical
one. Both branches wrote the narrative stage 0 rule. This side had
NarrativeQueryGrammars, plural, with the rest-of-day rule beside it and the
verb alternation built from the lexicon; task/467 had NarrativeQueryGrammar,
singular, which extracts the topic into Slots.Text, refuses a bare "расскажи",
and excludes the shapes that are chat ("расскажи о себе", "историю на ночь").
Resolved by keeping this side's container and this side's lexicon-built
pattern, and taking every behaviour only the other side had: the topic slot,
the empty-topic refusal, chatNarrativeTopics, and its wiring position after
TaskCaptureGrammar so "запиши" still beats "расскажи".
The rest: queryFeeds keeps task/467's conditional claim (V-474 supersedes the
unconditional one), rank.go keeps Spoken and drops pluralTasksRU because
say.CountWord is the one copy of Russian count agreement, and vendor/ was
re-vendored — the merged modules.txt claimed replaces for nexus and praxis
that neither go.mod has.
Routing fixture 58/82, unchanged from both sides.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -58,10 +58,19 @@ const ambientPastGrace = 2 * time.Hour
|
||||
// word ("завтра", "tomorrow") when the notification carries one, and the result
|
||||
// is refused if it lands more than ambientPastGrace in the past. A bare start
|
||||
// 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) {
|
||||
if n.Posted.IsZero() {
|
||||
return Event{}, false
|
||||
}
|
||||
n.Posted = n.Posted.In(time.Local)
|
||||
line := strings.TrimSpace(n.Title + " " + n.Text)
|
||||
start, end, ok := parseTimeRange(line)
|
||||
if !ok {
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
package calendar
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
"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) {
|
||||
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 {
|
||||
name string
|
||||
@@ -98,10 +108,10 @@ func TestEventFromNotification(t *testing.T) {
|
||||
if !ev.End.After(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
|
||||
// location — not shifted into UTC.
|
||||
if ev.Start.Location() != posted.Location() {
|
||||
t.Errorf("location = %v, want %v", ev.Start.Location(), posted.Location())
|
||||
// The event lands on the day the phone showed it, in the daemon's
|
||||
// zone — the clock reading is a wall clock, not an instant.
|
||||
if ev.Start.Location() != time.Local {
|
||||
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 {
|
||||
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
|
||||
// wrong meeting stored is worse than nothing stored.
|
||||
func TestEventFromNotificationDayWords(t *testing.T) {
|
||||
loc := time.FixedZone("+04", 4*3600)
|
||||
evening := time.Date(2026, 8, 3, 21, 0, 0, 0, loc)
|
||||
evening := time.Date(2026, 8, 3, 21, 0, 0, 0, time.Local)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
@@ -191,7 +200,7 @@ func TestEventFromNotificationDayWords(t *testing.T) {
|
||||
func TestEventFromNotificationDropsDayWordFromSummary(t *testing.T) {
|
||||
ev, ok := EventFromNotification(Notification{
|
||||
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 {
|
||||
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) {
|
||||
if _, ok := EventFromNotification(Notification{Title: "Планёрка 10:00"}); ok {
|
||||
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) {
|
||||
ev, ok := EventFromNotification(Notification{
|
||||
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 {
|
||||
t.Fatal("expected an event")
|
||||
|
||||
@@ -42,12 +42,27 @@ type Turn struct {
|
||||
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 {
|
||||
Intent Intent
|
||||
Slots Slots
|
||||
Timestamp time.Time
|
||||
TTL time.Duration
|
||||
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 {
|
||||
@@ -143,6 +158,26 @@ func (s *SessionStore) Put(id string, sess *Session) {
|
||||
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) {
|
||||
s.mu.Lock()
|
||||
delete(s.sessions, id)
|
||||
|
||||
@@ -91,6 +91,15 @@ type Config struct {
|
||||
NCtx int
|
||||
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
|
||||
// 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
|
||||
@@ -140,6 +149,8 @@ func DefaultConfig(modelPath string) Config {
|
||||
// 512 MiB caps total RSS near 1 GB and still holds several recent prompts.
|
||||
CacheRAMMiB: 512,
|
||||
Timeout: 30 * time.Second,
|
||||
|
||||
StartupTimeout: defaultStartupTimeout,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -266,7 +277,15 @@ func llamaArgs(cfg Config) []string {
|
||||
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) {
|
||||
startupTimeout := cfg.StartupTimeout
|
||||
if startupTimeout <= 0 {
|
||||
startupTimeout = defaultStartupTimeout
|
||||
}
|
||||
p := &llamaProc{}
|
||||
cmd := exec.CommandContext(ctx, cfg.BinPath, llamaArgs(cfg)...)
|
||||
// Pdeathsig: the kernel SIGKILLs llama-server the moment mavend dies — by
|
||||
@@ -344,8 +363,8 @@ func startLlamaProc(ctx context.Context, cfg Config) (*llamaProc, error) {
|
||||
return fail(fmt.Errorf("llm: server output: %w; last output: %s", err, tail.String()))
|
||||
case <-ctx.Done():
|
||||
return fail(ctx.Err())
|
||||
case <-time.After(60 * time.Second):
|
||||
return fail(fmt.Errorf("llm: server did not start within 60s; last output: %s", tail.String()))
|
||||
case <-time.After(startupTimeout):
|
||||
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)
|
||||
}
|
||||
})
|
||||
|
||||
// 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) {
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
package router
|
||||
|
||||
import "strings"
|
||||
|
||||
// transientStems — the states a thing is in for an afternoon. Compared as
|
||||
// prefixes because Russian inflects the ending: "медленн" covers "медленная",
|
||||
// "медленный" and "медленно" without listing them.
|
||||
var transientStems = []string{
|
||||
"медленн", "тормоз", "лаг", "завис", "виснет", "глюч", "барахл",
|
||||
"отвал", "падает", "упал", "сдох", "греется", "перегре",
|
||||
"slow", "laggy", "stuck", "frozen", "flaky", "broken", "down",
|
||||
}
|
||||
|
||||
// brokenVerbs — what "не ..." is denying when the sentence is a complaint.
|
||||
// "не работает", "не грузит", "не открывается". Prefixes again.
|
||||
var brokenVerbs = []string{
|
||||
"работ", "пашет", "груз", "открыва", "включа", "коннект", "подключ",
|
||||
"work", "load", "connect", "respond",
|
||||
}
|
||||
|
||||
// selfMarkers — the words that make a sentence about him rather than about a
|
||||
// thing. Their presence turns the test off, because losing a fact he meant to
|
||||
// store is worse than keeping a complaint: "я сломал руку" is durable, and
|
||||
// "интернет не работает" is not.
|
||||
var selfMarkers = []string{"я", "мне", "меня", "мной", "i", "me", "my"}
|
||||
|
||||
// IsTransientComplaint reports whether text observes a passing state of some
|
||||
// thing rather than recording a fact.
|
||||
//
|
||||
// It exists because "сеть какая-то медленная" and "интернет не работает" were
|
||||
// written to the fact store as `self` rows at confidence 1.00 (Vikunja #481),
|
||||
// where recall reads them back later as if they were still true. A complaint
|
||||
// describes a moment; the fact store describes him.
|
||||
//
|
||||
// Deterministic, offline, and shaped exactly like IsQuestionShaped: an
|
||||
// explicit capture verb wins over everything, because "запомни что интернет
|
||||
// не работает" is an instruction and not a passing remark. A first-person
|
||||
// marker also turns it off — the test is meant to catch a sentence about a
|
||||
// thing, and it errs toward storing.
|
||||
func IsTransientComplaint(text string) bool {
|
||||
t := strings.TrimSpace(text)
|
||||
if t == "" {
|
||||
return false
|
||||
}
|
||||
toks := planTokens(strings.ToLower(t))
|
||||
for _, v := range captureVerbs {
|
||||
if hasTok(toks, v) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, m := range selfMarkers {
|
||||
if hasTok(toks, m) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, tok := range toks {
|
||||
for _, stem := range transientStems {
|
||||
if strings.HasPrefix(tok, stem) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
// "не" plus a verb of working, in either order of the two tokens that
|
||||
// follow it — "не работает" and "не очень работает" both deny the same
|
||||
// thing.
|
||||
for i, tok := range toks {
|
||||
if tok != "не" && tok != "not" && tok != "isn" {
|
||||
continue
|
||||
}
|
||||
for j := i + 1; j < len(toks) && j <= i+2; j++ {
|
||||
for _, v := range brokenVerbs {
|
||||
if strings.HasPrefix(toks[j], v) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
package router
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestIsTransientComplaint(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
text string
|
||||
want bool
|
||||
}{
|
||||
// The two rows from the QA run that named this bug.
|
||||
{"сеть какая-то медленная", true},
|
||||
{"интернет не работает", true},
|
||||
{"вайфай тормозит", true},
|
||||
{"сервер завис", true},
|
||||
{"the wifi is slow", true},
|
||||
|
||||
// An instruction wins: he asked for it to be written down.
|
||||
{"запомни что интернет не работает", false},
|
||||
{"запиши что сеть медленная", false},
|
||||
|
||||
// About him, so it stays a fact even when it sounds like a complaint.
|
||||
{"я сломал руку", false},
|
||||
{"мне медленно думается", false},
|
||||
|
||||
// Ordinary captures must not be touched.
|
||||
{"поужинал", false},
|
||||
{"выпил воды", false},
|
||||
{"машина на парковке", false},
|
||||
{"", false},
|
||||
} {
|
||||
if got := IsTransientComplaint(tc.text); got != tc.want {
|
||||
t.Errorf("IsTransientComplaint(%q) = %v, want %v", tc.text, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -81,6 +81,9 @@ func NewPythonDateParser() *PythonDateParser {
|
||||
// or dateparser is unavailable, falls back to the stub parser. Returns
|
||||
// (time, true, nil) on success; (zero, false, nil) when no date is found.
|
||||
func (p *PythonDateParser) Parse(ctx context.Context, text string, now time.Time) (time.Time, bool, error) {
|
||||
// Speech says the hour in words, and neither this parser nor the stub
|
||||
// reads "в семь вечера" (Vikunja #469). Both see the digits instead.
|
||||
text = SpellOutDigits(text)
|
||||
t, ok, err := p.parseWithPython(ctx, text, now)
|
||||
if err != nil {
|
||||
// python3 missing, dateparser not installed, or process failure —
|
||||
|
||||
@@ -236,8 +236,15 @@ func newBaselineRouter(t *testing.T, emb router.Embedder, llmR *router.LLMRouter
|
||||
// Same order as buildRouter (voicewire.go). The fixture is only worth
|
||||
// anything while its grammar set is the daemon's grammar set.
|
||||
grammars = append(grammars, router.AgendaQueryGrammars()...)
|
||||
grammars = append(grammars, router.NarrativeQueryGrammars()...)
|
||||
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.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.NarrativeQueryGrammars()...)
|
||||
return router.New(router.Config{
|
||||
Grammars: grammars,
|
||||
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-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-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-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" },
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func feedRouter(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, FeedQueryGrammar())
|
||||
return r
|
||||
}
|
||||
|
||||
// The documented utterance of task 258 step 1 routed system and answered
|
||||
// "пока не умею отвечать на этот вопрос.", while the same question worded with
|
||||
// "новостях" worked (Vikunja #474).
|
||||
func TestFeedQuestionsRouteToQuery(t *testing.T) {
|
||||
r := feedRouter(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 greeting and the statement keep their intents. "что нового?" is the most
|
||||
// common opener in the language, and a rule that claimed it would answer hello
|
||||
// with a configuration status.
|
||||
func TestFeedGrammarLeavesTheGreetingAlone(t *testing.T) {
|
||||
r := feedRouter(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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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, NarrativeQueryGrammars()[1])
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package router
|
||||
|
||||
import "strings"
|
||||
|
||||
// ruNumerals — spoken numbers as digits, for the clock hours and the minutes
|
||||
// that follow them. Every case ending he might say is listed rather than
|
||||
// stemmed: "в семь", "к семи", "около семи" are three forms of one hour, and a
|
||||
// prefix rule short enough to cover them also matches "семья".
|
||||
//
|
||||
// Stops at thirty, which is as far as a spoken time goes ("без двадцати
|
||||
// восемь", "в половине шестого"). Anything larger is said in digits.
|
||||
var ruNumerals = map[string]string{
|
||||
"один": "1", "одного": "1", "одну": "1", "час": "1", "часу": "1",
|
||||
"два": "2", "две": "2", "двух": "2",
|
||||
"три": "3", "трёх": "3", "трех": "3",
|
||||
"четыре": "4", "четырёх": "4", "четырех": "4",
|
||||
"пять": "5", "пяти": "5",
|
||||
"шесть": "6", "шести": "6",
|
||||
"семь": "7", "семи": "7",
|
||||
"восемь": "8", "восьми": "8",
|
||||
"девять": "9", "девяти": "9",
|
||||
"десять": "10", "десяти": "10",
|
||||
"одиннадцать": "11", "одиннадцати": "11",
|
||||
"двенадцать": "12", "двенадцати": "12",
|
||||
"тринадцать": "13", "тринадцати": "13",
|
||||
"четырнадцать": "14", "четырнадцати": "14",
|
||||
"пятнадцать": "15", "пятнадцати": "15",
|
||||
"шестнадцать": "16", "шестнадцати": "16",
|
||||
"семнадцать": "17", "семнадцати": "17",
|
||||
"восемнадцать": "18", "восемнадцати": "18",
|
||||
"девятнадцать": "19", "девятнадцати": "19",
|
||||
"двадцать": "20", "двадцати": "20",
|
||||
"тридцать": "30", "тридцати": "30",
|
||||
"сорок": "40", "сорока": "40",
|
||||
"пятьдесят": "50", "пятидесяти": "50",
|
||||
}
|
||||
|
||||
// numeralContext — the words that make a numeral a time. A numeral is only
|
||||
// rewritten when one of these sits next to it, so "три яблока" in a note is
|
||||
// left alone and "в три часа" is not.
|
||||
var numeralContext = map[string]bool{
|
||||
"в": true, "во": true, "к": true, "около": true, "на": true,
|
||||
"часа": true, "часов": true, "час": true, "часу": true,
|
||||
"утра": true, "вечера": true, "дня": true, "ночи": true,
|
||||
"минут": true, "минуты": true, "минуту": true,
|
||||
"at": true, "by": true,
|
||||
}
|
||||
|
||||
// SpellOutDigits rewrites spoken numbers as digits so the date parsers see the
|
||||
// shape they know.
|
||||
//
|
||||
// "напомни мне позвонить маме в семь вечера" parsed to nothing, while "в 19:00"
|
||||
// parsed fine (Vikunja #469). Speech is where reminders come from, and speech
|
||||
// says the hour in words, so this is not a long-tail case — it is the ordinary
|
||||
// one. dateparser reads "в 7 вечера" through the qualifier rewrite the python
|
||||
// script already does; it does not read "в семь вечера".
|
||||
//
|
||||
// Conservative by construction: a numeral is only rewritten when a time word
|
||||
// stands beside it. "три часа" becomes "3 часа"; "три яблока" stays as it is,
|
||||
// and a note or a fact carrying a spoken number is untouched.
|
||||
func SpellOutDigits(text string) string {
|
||||
toks := strings.Fields(text)
|
||||
if len(toks) == 0 {
|
||||
return text
|
||||
}
|
||||
out := make([]string, len(toks))
|
||||
copy(out, toks)
|
||||
for i, tok := range toks {
|
||||
key := strings.ToLower(strings.Trim(tok, ".,!?;:«»\"'"))
|
||||
digit, ok := ruNumerals[key]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// "час" and "часу" are the hour noun as often as they are the number
|
||||
// one, and rewriting "в час дня" to "в 1 дня" is right either way. What
|
||||
// must not happen is rewriting the noun that gives another numeral its
|
||||
// context: "в семь часов" must keep "часов".
|
||||
if !hasTimeNeighbour(toks, i) {
|
||||
continue
|
||||
}
|
||||
out[i] = digit
|
||||
}
|
||||
return strings.Join(out, " ")
|
||||
}
|
||||
|
||||
// hasTimeNeighbour reports whether the token before or after i is a time word.
|
||||
func hasTimeNeighbour(toks []string, i int) bool {
|
||||
for _, j := range []int{i - 1, i + 1} {
|
||||
if j < 0 || j >= len(toks) {
|
||||
continue
|
||||
}
|
||||
if numeralContext[strings.ToLower(strings.Trim(toks[j], ".,!?;:«»\"'"))] {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestSpellOutDigits(t *testing.T) {
|
||||
for _, tc := range []struct{ in, want string }{
|
||||
{"напомни мне позвонить маме в семь вечера", "напомни мне позвонить маме в 7 вечера"},
|
||||
{"в три часа дня", "в 3 часа дня"},
|
||||
{"напомни в половине шестого", "напомни в половине шестого"},
|
||||
{"через двадцать минут", "через 20 минут"},
|
||||
// Untouched: no time word stands beside the number.
|
||||
{"купить три яблока", "купить три яблока"},
|
||||
{"семь раз отмерь", "семь раз отмерь"},
|
||||
{"напомни в 19:00", "напомни в 19:00"},
|
||||
{"", ""},
|
||||
} {
|
||||
if got := SpellOutDigits(tc.in); got != tc.want {
|
||||
t.Errorf("SpellOutDigits(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The utterance from the QA sitting that named this bug: the numeric form
|
||||
// parsed and the spoken form did not.
|
||||
func TestStubParsesASpokenHour(t *testing.T) {
|
||||
now := time.Date(2026, 8, 2, 9, 0, 0, 0, time.Local)
|
||||
got, ok, err := StubDateTimeParser{}.Parse(context.Background(), "напомни мне позвонить маме в семь вечера", now)
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("Parse ok=%v err=%v, want a time", ok, err)
|
||||
}
|
||||
if got.Hour() != 19 {
|
||||
t.Fatalf("hour = %d, want 19", got.Hour())
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ package router
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log"
|
||||
"time"
|
||||
)
|
||||
@@ -201,5 +202,10 @@ func (r *Router) gateLLMDecision(d *Decision) {
|
||||
// retrain). Same shape as nudges.outcome tuning cooldowns: more reliable over
|
||||
// time, introspectable, no model surgery.
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -178,7 +178,7 @@ func afterWord(s, w string) string {
|
||||
type StubDateTimeParser struct{}
|
||||
|
||||
func (StubDateTimeParser) Parse(_ context.Context, text string, now time.Time) (time.Time, bool, error) {
|
||||
s := strings.ToLower(strings.TrimSpace(text))
|
||||
s := strings.ToLower(strings.TrimSpace(SpellOutDigits(text)))
|
||||
toks := strings.Fields(s)
|
||||
// scan for "in <num> <unit>" anywhere — dateparser extracts the datetime
|
||||
// expression from surrounding text; the stub does the same naively.
|
||||
@@ -206,14 +206,22 @@ func (StubDateTimeParser) Parse(_ context.Context, text string, now time.Time) (
|
||||
|
||||
// --- Russian time expressions (stub floor; dateparser replaces) ---
|
||||
|
||||
// "в <clock>" anywhere — mirror of the English "at" scan.
|
||||
// "в <clock>" anywhere — mirror of the English "at" scan. A qualifier
|
||||
// after the hour moves it into the afternoon: "в 7 вечера" is 19:00, and
|
||||
// with SpellOutDigits in front of this that is what "в семь вечера" reads
|
||||
// as too (Vikunja #469).
|
||||
for i := 0; i+1 < len(toks); i++ {
|
||||
if toks[i] != "в" {
|
||||
continue
|
||||
}
|
||||
if t, ok := parseClock(toks[i+1], now); ok {
|
||||
return t, true, nil
|
||||
t, ok := parseClock(toks[i+1], now)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if i+2 < len(toks) {
|
||||
t = applyRuQualifier(t, toks[i+2], now)
|
||||
}
|
||||
return t, true, nil
|
||||
}
|
||||
|
||||
// "через <N> <unit>" / "через <unit>" (bare = 1) / "через полчаса".
|
||||
@@ -465,3 +473,30 @@ func midnight(now time.Time, days int) time.Time {
|
||||
y, m, d := now.AddDate(0, 0, days).Date()
|
||||
return time.Date(y, m, d, 0, 0, 0, 0, now.Location())
|
||||
}
|
||||
|
||||
// applyRuQualifier moves an hour into the afternoon when he said "вечера" or
|
||||
// "дня" after it. Noon-crossing only: 7 becomes 19, and 19 stays 19. Morning
|
||||
// qualifiers need no arithmetic, they only confirm the hour as spoken.
|
||||
//
|
||||
// The date is recomputed rather than shifted, so an hour that parseClock
|
||||
// already pushed to tomorrow does not land two days out.
|
||||
func applyRuQualifier(t time.Time, qualifier string, now time.Time) time.Time {
|
||||
h := t.Hour()
|
||||
switch strings.Trim(strings.ToLower(qualifier), ".,!?;:") {
|
||||
case "вечера", "дня":
|
||||
if h < 12 {
|
||||
h += 12
|
||||
}
|
||||
case "утра", "ночи":
|
||||
if h == 12 {
|
||||
h = 0
|
||||
}
|
||||
default:
|
||||
return t
|
||||
}
|
||||
out := time.Date(now.Year(), now.Month(), now.Day(), h, t.Minute(), 0, 0, now.Location())
|
||||
if !out.After(now) {
|
||||
out = out.Add(24 * time.Hour)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
+76
-11
@@ -199,6 +199,20 @@ func AgendaQueryGrammars() []Grammar {
|
||||
Pattern: regexp.MustCompile(`(?i)(^|\s)(план|дел)[а-я]*\s+(на|в|во|по)\s+` + dayWordPattern + `(\s|[?!.]|$)`),
|
||||
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: "когда планёрка?",
|
||||
// "во сколько созвон". He is asking when something on his calendar
|
||||
@@ -211,6 +225,11 @@ 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+нас)`)
|
||||
|
||||
// NarrativeQueryGrammars — stage-0 grammars for the two question shapes that
|
||||
// carry no question mark and no interrogative, and so reached the resident
|
||||
// model with nothing deterministic in front of them (Vikunja #498).
|
||||
@@ -255,15 +274,27 @@ var entertainmentNouns = []string{
|
||||
"joke", "jokes", "story",
|
||||
}
|
||||
|
||||
// narrativeQueryBuild — the narrative shape is a query unless he also said one
|
||||
// of the capture verbs, or asked for entertainment. "расскажи и запиши" is him
|
||||
// asking for a note, and stage 0 must not take either off the cascade.
|
||||
// narrativeQueryBuild — the narrative shape is a query carrying its topic,
|
||||
// unless he also said one of the capture verbs, asked for entertainment, or
|
||||
// asked about her. "расскажи и запиши" is him asking for a note, and stage 0
|
||||
// must not take either off the cascade.
|
||||
//
|
||||
// The topic goes into Slots.Text rather than the whole utterance: the query
|
||||
// chain looks things up by it, and "расскажи мне про Ватерлоо" is a question
|
||||
// about Ватерлоо.
|
||||
func narrativeQueryBuild(m []string) (Decision, bool) {
|
||||
rest := ""
|
||||
if len(m) > 3 {
|
||||
rest = m[3]
|
||||
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
|
||||
}
|
||||
for _, t := range planTokens(rest) {
|
||||
// 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
|
||||
}
|
||||
for _, t := range planTokens(topic) {
|
||||
for _, v := range captureVerbs {
|
||||
if t == v {
|
||||
return Decision{}, false
|
||||
@@ -275,7 +306,12 @@ func narrativeQueryBuild(m []string) (Decision, bool) {
|
||||
}
|
||||
}
|
||||
}
|
||||
return agendaQueryBuild(m)
|
||||
return Decision{
|
||||
Stage: 0,
|
||||
Intent: IntentQuery,
|
||||
Confidence: 1.0,
|
||||
Slots: Slots{Text: topic},
|
||||
}, true
|
||||
}
|
||||
|
||||
// narrativeQueryPattern — "расскажи про X", built from the lexicon rather than
|
||||
@@ -285,9 +321,36 @@ func narrativeQueryBuild(m []string) (Decision, bool) {
|
||||
//
|
||||
// Anchored at the start, which was the point of the old literal and still is:
|
||||
// "запиши что мне рассказали" is a capture, and a narrative verb buried
|
||||
// mid-utterance is not the shape.
|
||||
// mid-utterance is not the shape. The dative and the preposition are eaten so
|
||||
// the topic slot comes out clean: "расскажи мне про Ватерлоо" leaves
|
||||
// "Ватерлоо".
|
||||
var narrativeQueryPattern = regexp.MustCompile(
|
||||
`(?i)^\s*(` + strings.Join(lexicon.NarrativeRequests(), "|") + `)(\s+(.*))?$`)
|
||||
`(?is)^\s*(` + strings.Join(lexicon.NarrativeRequests(), "|") +
|
||||
`)(?:\s+(?:мне|нам|us|me))?(?:\s+(?:про|о|об|about))?(\s+.+)$`)
|
||||
|
||||
// FeedQueryGrammar — stage-0 rule for "что нового в лентах?", routed to
|
||||
// IntentQuery so it reaches queryFeeds.
|
||||
//
|
||||
// Same shape of defect as the agenda grammars: the model calls it system, and
|
||||
// replySystem has no feeds arm, so the documented utterance of task 258 step 1
|
||||
// answered "пока не умею отвечать на этот вопрос." while the same question
|
||||
// worded with "новостях" worked (Vikunja #474).
|
||||
//
|
||||
// An ask word at the front and a feed noun after it are both required, which
|
||||
// is the same pair ParseFeedQuery wants. "что нового?" on its own is a greeting
|
||||
// — the most common opener in the language — and vagueNouns in feeds.go exists
|
||||
// to keep it out of the feed reader; routing it to query here would put it
|
||||
// back. "у меня новая лента в инстаграме" carries the noun without the ask and
|
||||
// stays the statement it is.
|
||||
func FeedQueryGrammar() Grammar {
|
||||
return Grammar{
|
||||
Name: "feed-query",
|
||||
// (\s|[?!.]|$) rather than \b, which is ASCII-only and never fires next
|
||||
// to a Cyrillic letter.
|
||||
Pattern: regexp.MustCompile(`(?i)^\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 ("в
|
||||
@@ -345,7 +408,9 @@ func isCyrillic(s string) bool {
|
||||
return s != ""
|
||||
}
|
||||
|
||||
// agendaQueryBuild — shared Build for the agenda grammars. Confidence 1.0 on
|
||||
// agendaQueryBuild — shared Build for the agenda grammars and the feed one,
|
||||
// which all do the same single thing: keep the utterance out of IntentSystem
|
||||
// and let the query chain decide who answers it. Confidence 1.0 on
|
||||
// the intent only: the utterance travels intact and the query chain's own
|
||||
// matchers decide the rest.
|
||||
func agendaQueryBuild(m []string) (Decision, bool) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -267,3 +267,26 @@ func joinRU(rs []Ranked, limit int, withReasons bool) string {
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
package tts
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Pronunciation dictionary (Vikunja #458).
|
||||
//
|
||||
// piper reads a Russian sentence with a Russian voice, and a Latin service id
|
||||
// inside that sentence comes out as letters or as noise: "Vikunja" is spelled
|
||||
// out, "SearXNG" is unreadable, and "homesrv" is read as if it were a word. The
|
||||
// fix is not a code change per name — it is a file of replacements applied to
|
||||
// the text before piper sees it.
|
||||
//
|
||||
// Spelling, not phonemes. piper has no lexicon input of its own here, so the
|
||||
// only lever is the text, and the entry for a name is how it should be spelled
|
||||
// in Russian for the voice to say it right. That also means a wrong entry is
|
||||
// visible: it is a word, and it is read out loud.
|
||||
//
|
||||
// The dictionary is data, so it ships as a file rather than a table in Go. A
|
||||
// name added to it needs no rebuild and no deploy of the daemon that owns the
|
||||
// text — only a restart of mavttsd, which is the process that reads it.
|
||||
|
||||
// Lexicon rewrites names into the spelling the voice reads correctly.
|
||||
//
|
||||
// The zero value is usable and rewrites nothing, so a daemon with no dictionary
|
||||
// configured behaves exactly as it did before this existed.
|
||||
type Lexicon struct {
|
||||
// Rules are held in one alternation rather than as a map, so a text is
|
||||
// scanned once however many entries there are, and the longest name wins
|
||||
// where two overlap ("Home Assistant" before "Home").
|
||||
re *regexp.Regexp
|
||||
// by lower-cased name, because the match is case-insensitive and the
|
||||
// replacement is not derived from what was matched.
|
||||
by map[string]string
|
||||
}
|
||||
|
||||
// LoadLexicon reads a dictionary file: a flat JSON object of name to spelling.
|
||||
//
|
||||
// {"Vikunja": "Викунья", "SearXNG": "сёрчиксэнджи"}
|
||||
//
|
||||
// An empty path returns an empty Lexicon and no error — the dictionary is off
|
||||
// unless configured, like every other optional capability. A path that is set
|
||||
// and unreadable IS an error: he asked for it, and silently saying names wrong
|
||||
// is the failure this exists to remove.
|
||||
func LoadLexicon(path string) (*Lexicon, error) {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return &Lexicon{}, nil
|
||||
}
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("tts: lexicon %s: %w", path, err)
|
||||
}
|
||||
var entries map[string]string
|
||||
if err := json.Unmarshal(raw, &entries); err != nil {
|
||||
return nil, fmt.Errorf("tts: lexicon %s: %w", path, err)
|
||||
}
|
||||
return NewLexicon(entries), nil
|
||||
}
|
||||
|
||||
// NewLexicon builds a lexicon from entries already in memory.
|
||||
func NewLexicon(entries map[string]string) *Lexicon {
|
||||
names := make([]string, 0, len(entries))
|
||||
by := make(map[string]string, len(entries))
|
||||
for name, say := range entries {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || strings.TrimSpace(say) == "" {
|
||||
continue
|
||||
}
|
||||
names = append(names, name)
|
||||
by[strings.ToLower(name)] = say
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return &Lexicon{}
|
||||
}
|
||||
// Longest first: "Home Assistant" must match before "Home" does, and Go's
|
||||
// regexp alternation is leftmost-first, not longest-match.
|
||||
sort.Slice(names, func(i, j int) bool { return len(names[i]) > len(names[j]) })
|
||||
quoted := make([]string, len(names))
|
||||
for i, n := range names {
|
||||
quoted[i] = regexp.QuoteMeta(n)
|
||||
}
|
||||
// The boundaries are written out rather than left to \b, which is ASCII-only
|
||||
// and never fires next to a Cyrillic letter — so "в Vikunja," would not
|
||||
// match with \b on the left in a Russian sentence.
|
||||
pattern := `(?i)(^|[^\p{L}\p{N}_])(` + strings.Join(quoted, "|") + `)($|[^\p{L}\p{N}_])`
|
||||
return &Lexicon{re: regexp.MustCompile(pattern), by: by}
|
||||
}
|
||||
|
||||
// Apply rewrites every name in the text. Text with no name in it comes back
|
||||
// unchanged and untouched.
|
||||
func (l *Lexicon) Apply(text string) string {
|
||||
if l == nil || l.re == nil || text == "" {
|
||||
return text
|
||||
}
|
||||
// Twice, because two names separated by a single space share the character
|
||||
// between them and one pass consumes it: "Nexus Praxis" would leave the
|
||||
// second name alone otherwise.
|
||||
out := l.replaceOnce(text)
|
||||
return l.replaceOnce(out)
|
||||
}
|
||||
|
||||
func (l *Lexicon) replaceOnce(text string) string {
|
||||
return l.re.ReplaceAllStringFunc(text, func(m string) string {
|
||||
groups := l.re.FindStringSubmatch(m)
|
||||
if groups == nil {
|
||||
return m
|
||||
}
|
||||
say, ok := l.by[strings.ToLower(groups[2])]
|
||||
if !ok {
|
||||
return m
|
||||
}
|
||||
return groups[1] + say + groups[3]
|
||||
})
|
||||
}
|
||||
|
||||
// Size reports how many names are loaded, for the startup log line.
|
||||
func (l *Lexicon) Size() int {
|
||||
if l == nil {
|
||||
return 0
|
||||
}
|
||||
return len(l.by)
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
package tts
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLexiconRewritesNames(t *testing.T) {
|
||||
lex := NewLexicon(map[string]string{
|
||||
"Vikunja": "Викунья",
|
||||
"Home Assistant": "Хоум Ассистент",
|
||||
"Home": "Хоум",
|
||||
"GPU": "джи-пи-ю",
|
||||
})
|
||||
for _, tc := range []struct{ in, want string }{
|
||||
{"задача в Vikunja готова", "задача в Викунья готова"},
|
||||
// Case-insensitive: the router and the model both change the case of a
|
||||
// name on the way through.
|
||||
{"открой vikunja.", "открой Викунья."},
|
||||
// Longest first, or "Home Assistant" is read as "Хоум Assistant".
|
||||
{"Home Assistant не отвечает", "Хоум Ассистент не отвечает"},
|
||||
// Two names in a row share the space between them, which one pass
|
||||
// would consume.
|
||||
{"GPU GPU", "джи-пи-ю джи-пи-ю"},
|
||||
// Not a word boundary: a name inside a longer token is left alone.
|
||||
{"vikunjaless", "vikunjaless"},
|
||||
{"ничего не совпало", "ничего не совпало"},
|
||||
{"", ""},
|
||||
} {
|
||||
if got := lex.Apply(tc.in); got != tc.want {
|
||||
t.Errorf("Apply(%q) = %q, want %q", tc.in, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The zero value and an unconfigured path rewrite nothing, so a daemon with no
|
||||
// dictionary behaves as it did before this existed.
|
||||
func TestLexiconOffByDefault(t *testing.T) {
|
||||
var zero *Lexicon
|
||||
if got := zero.Apply("Vikunja"); got != "Vikunja" {
|
||||
t.Errorf("nil lexicon rewrote %q", got)
|
||||
}
|
||||
lex, err := LoadLexicon("")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLexicon(\"\"): %v", err)
|
||||
}
|
||||
if lex.Size() != 0 || lex.Apply("Vikunja") != "Vikunja" {
|
||||
t.Errorf("empty path produced a live lexicon of %d names", lex.Size())
|
||||
}
|
||||
}
|
||||
|
||||
// A path he set and that cannot be read is a startup failure. Saying names
|
||||
// wrong in silence is what the dictionary exists to stop.
|
||||
func TestLexiconLoadErrors(t *testing.T) {
|
||||
if _, err := LoadLexicon(filepath.Join(t.TempDir(), "nope.json")); err == nil {
|
||||
t.Error("a missing dictionary must be an error")
|
||||
}
|
||||
bad := filepath.Join(t.TempDir(), "bad.json")
|
||||
if err := os.WriteFile(bad, []byte("{not json"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := LoadLexicon(bad); err == nil {
|
||||
t.Error("an unparseable dictionary must be an error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLexiconRoundTripsAFile(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "lex.json")
|
||||
if err := os.WriteFile(path, []byte(`{"Praxis":"Праксис"," ":"skipped","Nexus":""}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lex, err := LoadLexicon(path)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLexicon: %v", err)
|
||||
}
|
||||
// Blank names and blank spellings are dropped: an entry that says nothing
|
||||
// would delete the word it matched.
|
||||
if lex.Size() != 1 {
|
||||
t.Fatalf("Size = %d, want 1", lex.Size())
|
||||
}
|
||||
if got := lex.Apply("Praxis молчит"); got != "Праксис молчит" {
|
||||
t.Errorf("Apply = %q", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user