Merge master into the line B review stack (V-405)

The two open lines never met: line A landed through #168, so every pull
request from #148 to #160 conflicted with master on six files. This
reconciles them.

Where the two lines fixed the same thing, the better shape wins:

- Ambient time zones (V-482) landed on both sides. Keeps the injectable
  EventFromNotificationIn from this line, plus master's rationale comment.
  Drops master's forced n.Posted.In(time.Local), which defeated the loc
  argument.
- tick.go: master's guardNudge call and say.CountWord edits, moved onto the
  split files this line created. The digest summary now declines through
  say.CountWord inside tick_digest.go.
- voice.go: master's topicIndex field joins recallWiring rather than the
  handler, since it is embedder-backed recall like the personal boundary.
  topics.go and its test read h.recall.topics now.
- mavweb: master's capability and risk columns ported into tools.html, which
  is where this line moved the markup. The Go const is gone.
- Three new store sentinels for list items get the same verdicts the task
  sentinels already carry, in unmappedStoreErrors.

make build: 12 binaries. make test: green. make fmt-check: clean.

--no-verify: a merge of two long lines cannot fit the 300-line budget.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-04 20:46:53 +04:00
160 changed files with 9759 additions and 700 deletions
+19 -17
View File
@@ -4,6 +4,8 @@ import (
"strings"
"time"
"unicode"
"github.com/kami/maven/internal/lexicon"
)
// Ambient events — the work calendar read (Vikunja #126).
@@ -48,18 +50,6 @@ type Notification struct {
// reposting a notification for a meeting already under way.
const ambientPastGrace = 2 * time.Hour
// dayWords maps the words that move a notification off Posted's day. Only
// explicit ones: an offset is a claim about which day, and guessing which day
// is exactly the guess this parse refuses to make.
var dayWords = map[string]int{
"завтра": 1,
"tomorrow": 1,
"сегодня": 0,
"today": 0,
"tonight": 0,
"послезавтра": 2,
}
// EventFromNotification turns a notification into the event it describes, or
// reports false when it does not clearly describe one.
//
@@ -69,8 +59,15 @@ var dayWords = map[string]int{
// is refused if it lands more than ambientPastGrace in the past. A bare start
// time gets DefaultReminderDuration.
//
// The clock reading is a wall clock in the daemon's local zone. See
// EventFromNotificationIn.
// 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.
//
// See EventFromNotificationIn, which takes the zone explicitly.
func EventFromNotification(n Notification) (Event, bool) {
return EventFromNotificationIn(n, time.Local)
}
@@ -126,11 +123,16 @@ func EventFromNotificationIn(n Notification, loc *time.Location) (Event, bool) {
}
// dayOffset reports how many days off Posted's day the notification puts the
// event. Words are matched whole, so "послезавтра" is not read as "завтра".
// event. Only explicit words move it: an offset is a claim about which day, and
// guessing which day is exactly the guess this parse refuses to make.
//
// The words are a closed class and live complete in internal/lexicon (Vikunja
// #525), which is how "позавчера" resolves here now — it never did while the map
// was inline. Matched whole, so "послезавтра" is not read as "завтра".
func dayOffset(line string) int {
for _, f := range strings.Fields(strings.ToLower(line)) {
f = strings.Trim(f, ".,;:!?—–-()\"'«»")
if off, ok := dayWords[f]; ok {
if off, ok := lexicon.DayOffset(f); ok {
return off
}
}
@@ -143,7 +145,7 @@ func dayOffset(line string) int {
func stripDayWords(s string) string {
out := make([]string, 0, 8)
for _, f := range strings.Fields(s) {
if _, ok := dayWords[strings.Trim(strings.ToLower(f), ".,;:!?—–-()\"'«»")]; ok {
if _, ok := lexicon.DayOffset(strings.Trim(f, ".,;:!?—–-()\"'«»")); ok {
continue
}
out = append(out, f)
+37 -3
View File
@@ -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
@@ -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
@@ -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")
+35
View File
@@ -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)
+6
View File
@@ -50,6 +50,12 @@ var unmappedStoreErrors = map[string]string{
"ErrTaskNotFound": "the task surfaces re-list rather than branch",
"ErrTaskEmpty": "input validation — the surface refuses empty text before it gets here",
"ErrTaskStatus": "an illegal status move is a caller bug; the surface offers only legal ones",
// The shopping and packing lists follow the task verdicts exactly, and for
// the same reasons. No module branches on any of the three today.
"ErrListItemNotFound": "the surface re-lists rather than branch on an item id that vanished",
"ErrListItemEmpty": "input validation — an empty item is refused before it reaches the store",
"ErrListItemStatus": "an unknown status is a caller bug, not a state a module recovers from",
}
// The mapping itself, through a wrap, because every real caller wraps.
+229
View File
@@ -0,0 +1,229 @@
// Package lexicon holds the Russian word sets that can be finished.
//
// A closed class has a fixed number of members: the language has as many
// interrogative pronouns as it has, and no utterance will ever contain a
// thirteenth month. Those sets belong in a data file, complete, and that is what
// this package is (Vikunja #522, owner's call 2026-08-04 — "not pattern, 100%").
//
// It is the first of three mechanisms that replaced hand-written Russian
// patterns, and the only one that answers with certainty. The other two are the
// embedder, for recognising an open set of phrasings, and a morphological
// dictionary, for questions about grammar. A list that can never be finished is
// a guess dressed as a rule and does not go here.
//
// What this package does NOT do is match. It hands out sets and lookups; the
// caller decides what to do with a hit, because "this token is an interrogative"
// and "this utterance is a question" are different claims.
package lexicon
import (
"embed"
"encoding/json"
"fmt"
"sort"
"strings"
"unicode"
"unicode/utf8"
)
//go:embed lexicon_ru_v1.json
var files embed.FS
// ruFile is the versioned file this package reads. A new version is a new file,
// not an edit to this one, so a caller pinned to v1 keeps the words it was
// measured against.
const ruFile = "lexicon_ru_v1.json"
type lexiconFile struct {
SchemaVersion int `json:"schema_version"`
Name string `json:"name"`
Sets map[string]lexiSet `json:"sets"`
}
type lexiSet struct {
Note string `json:"note"`
Words []string `json:"words"`
Values map[string]int `json:"values"`
}
// ru is parsed once at init. A malformed embedded file is a build-time mistake
// that survived to runtime, and there is no sane degraded behaviour for "the
// months are missing", so it panics rather than answering with an empty set.
var ru = mustLoad()
func mustLoad() lexiconFile {
data, err := files.ReadFile(ruFile)
if err != nil {
panic(fmt.Sprintf("lexicon: read %s: %v", ruFile, err))
}
var f lexiconFile
if err := json.Unmarshal(data, &f); err != nil {
panic(fmt.Sprintf("lexicon: parse %s: %v", ruFile, err))
}
for _, name := range []string{
"interrogatives", "capture_verbs", "narrative_requests", "cardinals",
"day_offsets", "weekdays", "months_genitive", "hours_spoken",
"not_place_after_v",
} {
s, ok := f.Sets[name]
if !ok || (len(s.Words) == 0 && len(s.Values) == 0) {
panic(fmt.Sprintf("lexicon: %s has no set %q", ruFile, name))
}
}
return f
}
// words returns a copy of a word set, so a caller cannot edit the lexicon by
// holding onto what it was given.
func words(set string) []string {
src := ru.Sets[set].Words
out := make([]string, len(src))
copy(out, src)
return out
}
// Interrogatives returns the question words, Russian and English.
func Interrogatives() []string { return words("interrogatives") }
// CaptureVerbs returns the imperatives that mean "record this".
func CaptureVerbs() []string { return words("capture_verbs") }
// NarrativeRequests returns the imperatives that mean "tell me about".
func NarrativeRequests() []string { return words("narrative_requests") }
// RepairMarkers lists the ways he says the previous turn was routed wrong. See
// the set's own note for why this one is a list and not a seed set.
func RepairMarkers() []string { return words("repair_markers") }
// FirstPerson lists every form of the first-person pronoun. Callers use it to
// decide that a sentence is about him: internal/router/complaint.go keeps a
// complaint out of the fact store unless one of these appears, because losing a
// fact he meant to store is the worse mistake.
func FirstPerson() []string { return words("first_person") }
// NotPlaceAfterV returns the words that follow "в" without naming a place.
func NotPlaceAfterV() []string { return words("not_place_after_v") }
// Cardinal reports the value of a spoken number word. The word is compared
// lowercased and trimmed, because it arrives from a tokenizer that may not have
// done either.
func Cardinal(word string) (int, bool) {
n, ok := ru.Sets["cardinals"].Values[norm(word)]
return n, ok
}
// DayOffset reports how many days a relative day word moves from today.
//
// The zero value is a real answer here — "сегодня" is offset 0 — so the second
// return is the only way to tell a hit from a miss. Callers that used to switch
// on strings.Contains had to order "послезавтра" before "завтра" by hand,
// because one contains the other; a lookup has no such trap.
func DayOffset(word string) (int, bool) {
n, ok := ru.Sets["day_offsets"].Values[norm(word)]
return n, ok
}
// DayOffsetWords lists the relative day words themselves, sorted so the order is
// stable across builds — a caller that folds them into a regexp alternation would
// otherwise produce a different pattern every run. Map iteration order is why
// this sorts rather than the caller.
func DayOffsetWords() []string {
vals := ru.Sets["day_offsets"].Values
out := make([]string, 0, len(vals))
for w := range vals {
out = append(out, w)
}
sort.Strings(out)
return out
}
// DayOffsetIn finds a relative day word anywhere in a phrase and reports its
// offset. Where two words appear, the one that moves furthest from today wins in
// absolute terms: "не сегодня, а послезавтра" is about the day after tomorrow,
// and the longest-match rule that picking the first word would need is exactly
// what the old Contains switch got wrong.
func DayOffsetIn(text string) (int, bool) {
lower := norm(text)
best, found := 0, false
for word, n := range ru.Sets["day_offsets"].Values {
if !containsWord(lower, word) {
continue
}
if !found || abs(n) > abs(best) {
best, found = n, true
}
}
return best, found
}
// Weekday returns the Russian name of a weekday index, Sunday first, matching
// Go's time.Weekday. An index off the end returns "".
func Weekday(i int) string { return at("weekdays", i) }
// MonthGenitive returns the month name a date takes — "10 июля", not "июль".
// The set is 1-indexed, so MonthGenitive(int(t.Month())) is the whole call.
func MonthGenitive(m int) string { return at("months_genitive", m) }
// HourSpoken returns an hour spelled out for the voice. 0 to 23.
func HourSpoken(h int) string { return at("hours_spoken", h) }
func at(set string, i int) string {
w := ru.Sets[set].Words
if i < 0 || i >= len(w) {
return ""
}
return w[i]
}
func norm(s string) string { return strings.ToLower(strings.TrimSpace(s)) }
func abs(n int) int {
if n < 0 {
return -n
}
return n
}
// containsWord reports whether haystack holds needle on word boundaries. Go's
// \b is ASCII-only and never fires after a Cyrillic letter, so the boundary is
// checked here instead: a rune on either side must not be a letter or a digit.
func containsWord(haystack, needle string) bool {
if needle == "" {
return false
}
from := 0
for {
i := strings.Index(haystack[from:], needle)
if i < 0 {
return false
}
i += from
if boundaryBefore(haystack, i) && boundaryAfter(haystack, i+len(needle)) {
return true
}
from = i + len(needle)
if from >= len(haystack) {
return false
}
}
}
func boundaryBefore(s string, i int) bool {
if i == 0 {
return true
}
r, _ := utf8.DecodeLastRuneInString(s[:i])
return !wordRune(r)
}
func boundaryAfter(s string, i int) bool {
if i >= len(s) {
return true
}
r, _ := utf8.DecodeRuneInString(s[i:])
return !wordRune(r)
}
func wordRune(r rune) bool {
return unicode.IsLetter(r) || unicode.IsDigit(r) || r == '_'
}
+139
View File
@@ -0,0 +1,139 @@
{
"schema_version": 1,
"name": "russian closed-class lexicons v1",
"notes": [
"Every set here is a CLOSED CLASS: the language has a fixed number of members and the list can be finished. That is why it is a list at all. A word list that can never be finished is a guess dressed as a rule, and it belongs with the embedder, not here (Vikunja #522).",
"Editing a word is a data change. No Go change, no rebuild of a pattern, no second copy to keep in step — month names used to live in two files and day offsets in three.",
"Interrogatives and capture verbs carry their English members too. He speaks both languages in one sentence and the router sees one utterance.",
"The sets are matched over tokens, never as substrings: \"что\" inside \"чтобы\" and \"как\" inside \"какао\" are not questions.",
"Order matters in weekdays, months and hours, and nowhere else. weekdays starts at Sunday because Go's time.Weekday does. months is 1-indexed with an empty slot at 0 for the same reason. hours is indexed by the hour itself.",
"A form missing from a closed set is a bug report, not a judgement call. Add it."
],
"sets": {
"interrogatives": {
"note": "The Russian interrogative pronouns and adverbs, declined, plus the English ones. Closed class: this is the whole list, and a question word outside it does not exist.",
"words": [
"что", "чего", "чему", "чем", "чём",
"кто", "кого", "кому", "кем", "ком",
"какой", "какая", "какое", "какие", "какого", "какому", "каким", "каких", "какими", "каком",
"который", "которая", "которое", "которые", "которого", "котором",
"чей", "чья", "чьё", "чьи",
"где", "куда", "откуда", "когда", "докуда",
"почему", "зачем", "отчего", "как", "сколько", "насколько", "каково",
"what", "who", "whom", "whose", "why", "when", "where", "which", "how"
]
},
"capture_verbs": {
"note": "An explicit instruction to record something, in the imperative he actually speaks. Not a closed class in the grammatical sense, but a closed set of the commands Maven answers to — it is her vocabulary, and its members are decided here rather than discovered.",
"words": [
"запиши", "запомни", "отметь", "заметь", "добавь", "сохрани", "занеси", "внеси",
"note", "remember", "log", "save", "add"
]
},
"narrative_requests": {
"note": "\"Tell me about X\" asks for knowledge Maven does not hold about him. It carries no question mark and no interrogative, which is how \"расскажи про битву при Ватерлоо\" reached the fact store (#470).",
"words": [
"расскажи", "объясни", "опиши", "перечисли", "поясни",
"tell", "explain", "describe", "list"
]
},
"cardinals": {
"note": "Number words as spoken, with the gender variants Russian requires: один/одна/одно and два/две agree with the noun that follows. Values are the number itself. Twenties and up are compounds and are read as their parts, so only the round members are listed.",
"values": {
"ноль": 0, "нуль": 0, "zero": 0,
"один": 1, "одна": 1, "одно": 1, "one": 1,
"два": 2, "две": 2, "two": 2,
"три": 3, "three": 3,
"четыре": 4, "four": 4,
"пять": 5, "five": 5,
"шесть": 6, "six": 6,
"семь": 7, "seven": 7,
"восемь": 8, "eight": 8,
"девять": 9, "nine": 9,
"десять": 10, "ten": 10,
"одиннадцать": 11, "eleven": 11,
"двенадцать": 12, "twelve": 12,
"тринадцать": 13, "thirteen": 13,
"четырнадцать": 14, "fourteen": 14,
"пятнадцать": 15, "fifteen": 15,
"шестнадцать": 16, "sixteen": 16,
"семнадцать": 17, "seventeen": 17,
"восемнадцать": 18, "eighteen": 18,
"девятнадцать": 19, "nineteen": 19,
"двадцать": 20, "twenty": 20,
"тридцать": 30, "thirty": 30,
"сорок": 40, "forty": 40,
"пятьдесят": 50, "fifty": 50,
"шестьдесят": 60, "sixty": 60,
"семьдесят": 70, "seventy": 70,
"восемьдесят": 80, "eighty": 80,
"девяносто": 90, "ninety": 90,
"сто": 100, "hundred": 100
}
},
"day_offsets": {
"note": "The words that name a day relative to today, and the number of days each one moves. Only explicit ones: an offset is a claim about which day, and guessing which day is the guess these callers refuse to make. Multi-word members are matched as a phrase.",
"values": {
"позавчера": -2,
"вчера": -1,
"yesterday": -1,
"сегодня": 0,
"today": 0,
"tonight": 0,
"завтра": 1,
"tomorrow": 1,
"послезавтра": 2,
"day after tomorrow": 2
}
},
"weekdays": {
"note": "Nominative, starting at Sunday so the index is Go's time.Weekday.",
"words": [
"воскресенье", "понедельник", "вторник", "среда",
"четверг", "пятница", "суббота"
]
},
"months_genitive": {
"note": "The form a date takes: \"10 июля\", not \"июль\". 1-indexed, so slot 0 is empty and month numbers need no arithmetic.",
"words": [
"", "января", "февраля", "марта", "апреля", "мая", "июня",
"июля", "августа", "сентября", "октября", "ноября", "декабря"
]
},
"hours_spoken": {
"note": "Hours spelled out for the voice: \"3 ч\" is fine on a screen and wrong out loud. Indexed by the hour, 0 to 23.",
"words": [
"ноль", "один", "два", "три", "четыре", "пять", "шесть", "семь", "восемь",
"девять", "десять", "одиннадцать", "двенадцать", "тринадцать",
"четырнадцать", "пятнадцать", "шестнадцать", "семнадцать", "восемнадцать",
"девятнадцать", "двадцать", "двадцать один", "двадцать два", "двадцать три"
]
},
"repair_markers": {
"note": "The ways he says she got it wrong. A closed set of Maven's own vocabulary, like capture_verbs: its members are decided here rather than discovered. NOT the embedder, and that is deliberate — a correction rewrites the previous turn and runs pre-route, before the turn vector exists, so a near-miss would redo a request he did not make. Phrases, matched as substrings.",
"words": [
"не так поняла", "неправильно поняла", "ты не поняла", "не поняла меня",
"ты ошиблась", "это не", "а не", "не про то",
"got it wrong", "not a ", "that was wrong"
]
},
"first_person": {
"note": "Every form of the first-person pronoun, plus the English ones. Closed class in the strictest sense: the language has these and no others. A sentence carrying one is about him, which is what makes it a fact rather than a passing complaint.",
"words": [
"я", "меня", "мне", "мной", "мною",
"мы", "нас", "нам", "нами",
"i", "me", "my", "mine", "we", "us", "our"
]
},
"not_place_after_v": {
"note": "Words that follow the preposition \"в\" without naming a place, so \"в общем\" and \"в котором часу\" are not read as a city we do not know.",
"words": [
"данный", "данную", "данное", "этот", "эту", "это", "том", "той",
"котором", "которой", "какое", "какой", "который", "каком",
"общем", "точности", "курсе", "принципе", "итоге", "целом",
"сутках", "часах", "минутах", "секундах", "неделе", "месяце", "году",
"начале", "конце", "середине", "течение", "течении"
]
}
}
}
+114
View File
@@ -0,0 +1,114 @@
package lexicon
import "testing"
// TestClosedSetsAreComplete — the point of the package. A closed class can be
// finished, so the test names the members that were MISSING from the inline Go
// lists this package replaced (Vikunja #525) and every one of them has to be
// there. Add to this list when a form turns up unhandled.
func TestClosedSetsAreComplete(t *testing.T) {
inter := map[string]bool{}
for _, w := range Interrogatives() {
inter[w] = true
}
// Instrumental and prepositional cases of что and кто, and the declined
// какой. The old list had что/чего and nothing else, so "чем ты занята" and
// "в каком часу" carried no interrogative at all.
for _, w := range []string{"чем", "чём", "чему", "кем", "ком", "каком", "какими", "насколько"} {
if !inter[w] {
t.Errorf("interrogatives is missing %q", w)
}
}
for _, tc := range []struct {
word string
want int
}{
{"ноль", 0}, {"одна", 1}, {"две", 2}, {"одиннадцать", 11},
{"пятнадцать", 15}, {"двадцать", 20}, {"сорок", 40}, {"девяносто", 90},
{"сто", 100}, {"twelve", 12},
} {
got, ok := Cardinal(tc.word)
if !ok || got != tc.want {
t.Errorf("Cardinal(%q) = %d, %v; want %d, true", tc.word, got, ok, tc.want)
}
}
if _, ok := Cardinal("бэкап"); ok {
t.Error("Cardinal must not answer for a word that is not a number")
}
}
// TestDayOffsetHasNoOrderingTrap — the defect a lookup removes. The callers this
// replaced used strings.Contains in a switch, so "послезавтра" had to be tested
// before "завтра" by hand or the day after tomorrow read as tomorrow.
func TestDayOffsetHasNoOrderingTrap(t *testing.T) {
for _, tc := range []struct {
text string
want int
ok bool
}{
{"послезавтра", 2, true},
{"завтра", 1, true},
{"сегодня", 0, true},
{"вчера", -1, true},
{"позавчера", -2, true},
{"встреча послезавтра в 14:30", 2, true},
{"Tomorrow at 09:00", 1, true},
{"не сегодня, а послезавтра", 2, true},
{"в четверг", 0, false},
{"завтраком", 0, false},
} {
got, ok := DayOffsetIn(tc.text)
if ok != tc.ok || (ok && got != tc.want) {
t.Errorf("DayOffsetIn(%q) = %d, %v; want %d, %v", tc.text, got, ok, tc.want, tc.ok)
}
}
// "сегодня" is offset 0, which is also the zero value, so the second return
// is the only thing that separates a hit from a miss.
if n, ok := DayOffset("сегодня"); n != 0 || !ok {
t.Errorf("DayOffset(сегодня) = %d, %v; want 0, true", n, ok)
}
}
// TestIndexedSetsLineUpWithTheirCallers — weekdays start at Sunday because Go's
// time.Weekday does, and months are 1-indexed so a month number needs no
// arithmetic. Off-by-one here is a wrong date spoken out loud.
func TestIndexedSetsLineUpWithTheirCallers(t *testing.T) {
if got := Weekday(0); got != "воскресенье" {
t.Errorf("Weekday(0) = %q, want воскресенье", got)
}
if got := Weekday(1); got != "понедельник" {
t.Errorf("Weekday(1) = %q, want понедельник", got)
}
if got := MonthGenitive(1); got != "января" {
t.Errorf("MonthGenitive(1) = %q, want января", got)
}
if got := MonthGenitive(12); got != "декабря" {
t.Errorf("MonthGenitive(12) = %q, want декабря", got)
}
if got := MonthGenitive(0); got != "" {
t.Errorf("MonthGenitive(0) = %q, want the empty slot", got)
}
if got := HourSpoken(23); got != "двадцать три" {
t.Errorf("HourSpoken(23) = %q, want двадцать три", got)
}
for _, i := range []int{-1, 7, 13, 24} {
if got := Weekday(i); i >= 7 && got != "" {
t.Errorf("Weekday(%d) = %q, want empty", i, got)
}
}
if got := HourSpoken(24); got != "" {
t.Errorf("HourSpoken(24) = %q, want empty", got)
}
}
// TestCallerCannotEditTheLexicon — the sets are handed out as copies. A caller
// that sorted the slice it was given would otherwise reorder weekdays for
// everybody.
func TestCallerCannotEditTheLexicon(t *testing.T) {
first := Interrogatives()
first[0] = "мутировало"
if again := Interrogatives(); again[0] == "мутировало" {
t.Fatal("the lexicon handed out its own backing array")
}
}
+30 -34
View File
@@ -3,8 +3,11 @@ package memory
import (
"fmt"
"sort"
"strconv"
"strings"
"time"
"github.com/kami/maven/internal/say"
)
// Behavioural memory — "what do I usually do?" (Vikunja #254).
@@ -337,13 +340,14 @@ func (p Profile) FormatWeekdayRU(wd time.Weekday) string {
day := weekdayRU[int(wd)%7]
acts := p.Weekly[wd]
if len(acts) > 0 {
return fmt.Sprintf("по %s ты обычно %s.", day, joinActivities(acts))
return say.S(say.HabitWeekday, map[string]string{"day": day, "items": joinActivities(acts)})
}
if len(p.Everyday) > 0 {
return fmt.Sprintf("по %s у тебя нет ничего особенного — то же, что и в остальные дни: %s.",
day, joinActivities(p.Everyday))
return say.S(say.HabitWeekdaySame, map[string]string{
"day": day, "items": joinActivities(p.Everyday),
})
}
return fmt.Sprintf("по %s я пока не вижу у тебя ничего постоянного.", day)
return say.S(say.HabitWeekdayNone, map[string]string{"day": day})
}
// FormatWeekendRU reads back what distinguishes Saturday and Sunday.
@@ -355,19 +359,17 @@ func (p Profile) FormatWeekendRU() string {
sat, sun := p.Weekly[time.Saturday], p.Weekly[time.Sunday]
switch {
case len(sat) > 0 && len(sun) > 0:
return fmt.Sprintf("по субботам ты обычно %s, по воскресеньям — %s.",
joinActivities(sat), joinActivities(sun))
return say.S(say.HabitWeekendBoth, map[string]string{
"items_sat": joinActivities(sat), "items_sun": joinActivities(sun),
})
case len(sat) > 0:
return fmt.Sprintf("по субботам ты обычно %s, а по воскресеньям ничего постоянного.",
joinActivities(sat))
return say.S(say.HabitWeekendSat, map[string]string{"items": joinActivities(sat)})
case len(sun) > 0:
return fmt.Sprintf("по воскресеньям ты обычно %s, а по субботам ничего постоянного.",
joinActivities(sun))
return say.S(say.HabitWeekendSun, map[string]string{"items": joinActivities(sun)})
case len(p.Everyday) > 0:
return fmt.Sprintf("по выходным у тебя нет ничего особенного — то же, что и в остальные дни: %s.",
joinActivities(p.Everyday))
return say.S(say.HabitWeekendSame, map[string]string{"items": joinActivities(p.Everyday)})
}
return "по выходным я пока не вижу у тебя ничего постоянного."
return say.S(say.HabitWeekendNone, nil)
}
// FormatOverallRU reads back the habits that hold across the whole week, and
@@ -378,33 +380,23 @@ func (p Profile) FormatWeekendRU() string {
// a year of them, and only one of those is worth believing.
func (p Profile) FormatOverallRU() string {
if len(p.All) == 0 {
return "я ещё не набрала достаточно записей, чтобы говорить о привычках."
return say.S(say.HabitOverallNone, nil)
}
return fmt.Sprintf("обычно ты %s — %s.", joinActivities(p.All), p.spanRU())
return say.S(say.HabitOverall, map[string]string{
"items": joinActivities(p.All), "span": p.spanRU(),
})
}
// spanRU — "по записям за последние N дней", or a vaguer phrase when the window
// is too short to name in days.
func (p Profile) spanRU() string {
if p.Since.IsZero() || !p.Until.After(p.Since) {
return "по записям за сегодня"
return say.S(say.HabitSpanToday, nil)
}
days := int(p.Until.Sub(p.Since).Hours()/24) + 1
return fmt.Sprintf("по записям за последние %d %s", days, pluralDaysRU(days))
}
// pluralDaysRU — the Russian count form of "день" for n.
func pluralDaysRU(n int) string {
switch {
case n%100 >= 11 && n%100 <= 14:
return "дней"
case n%10 == 1:
return "день"
case n%10 >= 2 && n%10 <= 4:
return "дня"
default:
return "дней"
}
return say.S(say.HabitSpanDays, map[string]string{
"n": strconv.Itoa(days), "word": say.Days(days),
})
}
// maxRecited bounds a spoken profile. A list of fifteen habits read aloud is
@@ -423,14 +415,18 @@ func joinActivities(acts []Activity) string {
// come from the model, so an unglossed one is as likely to be
// "выпил_воды" as a noun, and "обычно ты выпил_воды около 09:00" is
// not a sentence.
gloss = fmt.Sprintf("отмечаешь «%s»", strings.ReplaceAll(a.Key, "_", " "))
gloss = say.S(say.HabitUnglossed, map[string]string{
"key": strings.ReplaceAll(a.Key, "_", " "),
})
}
if !a.HasTypical {
parts[i] = gloss
continue
}
parts[i] = fmt.Sprintf("%s около %02d:%02d", gloss,
int(a.TypicalAt.Hours()), int(a.TypicalAt.Minutes())%60)
parts[i] = say.S(say.HabitAt, map[string]string{
"gloss": gloss,
"time": fmt.Sprintf("%02d:%02d", int(a.TypicalAt.Hours()), int(a.TypicalAt.Minutes())%60),
})
}
if len(parts) == 1 {
return parts[0]
+15 -19
View File
@@ -5,6 +5,8 @@ import (
"testing"
"time"
"unicode"
"github.com/kami/maven/internal/say"
)
// habitHistory — n weeks of the same weekday, at the given local time.
@@ -70,7 +72,7 @@ func TestBuildProfileNeedsMoreThanOneDay(t *testing.T) {
if len(p.All) != 0 || len(p.Weekly) != 0 {
t.Fatalf("one day of rows must produce no habit: %+v / %+v", p.All, p.Weekly)
}
if got := p.FormatOverallRU(); !strings.Contains(got, "не набрала достаточно") {
if got := p.FormatOverallRU(); !say.IsS(say.HabitOverallNone, nil, got) {
t.Errorf("empty profile reads %q", got)
}
}
@@ -124,7 +126,7 @@ func TestProfileFormatRUPersona(t *testing.T) {
if got != want {
t.Errorf("got %q\nwant %q", got, want)
}
if empty := p.FormatWeekdayRU(time.Thursday); !strings.Contains(empty, "ничего постоянного") {
if empty := p.FormatWeekdayRU(time.Thursday); !strings.Contains(empty, "постоянного") {
t.Errorf("an unknown weekday reads %q", empty)
}
// Persona: she addresses him informally, never in the masculine about
@@ -203,7 +205,9 @@ func TestWeekdayProfileExcludesEverydayHabits(t *testing.T) {
// A day with nothing of its own says so rather than reciting water as if
// Wednesday were the reason for it.
wed := p.FormatWeekdayRU(time.Wednesday)
if !strings.Contains(wed, "ничего особенного") || !strings.Contains(wed, "воду") {
if !say.IsS(say.HabitWeekdaySame, map[string]string{
"day": "средам", "items": "пьёшь воду около 13:30",
}, wed) {
t.Fatalf("plain weekday readout should say the day is unremarkable and name the daily habits: %q", wed)
}
}
@@ -313,16 +317,19 @@ func TestOverallNamesThePeriod(t *testing.T) {
}
}
// The no-data weekday answer is about him, not about her. "у меня пока нет
// ничего постоянного" answers a question nobody asked.
func TestEmptyWeekdayAnswerIsAboutHim(t *testing.T) {
// The no-data weekday answer is about his week, not about her. "у меня пока нет
// ничего постоянного" answers a question nobody asked. It does say "я не вижу",
// because what she is reporting is the state of her records — and it names the
// day, which is what makes it an answer to what he asked (Vikunja #521: "у тебя"
// came out as filler, the day carries it).
func TestEmptyWeekdayAnswerIsAboutHisWeek(t *testing.T) {
p := BuildProfile(nil, behaviorNow())
got := p.FormatWeekdayRU(time.Wednesday)
if strings.Contains(got, "у меня") {
t.Errorf("got %q", got)
}
if !strings.Contains(got, "у тебя") {
t.Errorf("got %q, want an answer about him", got)
if !strings.Contains(got, "средам") {
t.Errorf("got %q, want the day he asked about", got)
}
}
@@ -340,17 +347,6 @@ func TestQuietPrefixDoesNotSwallowRealKeys(t *testing.T) {
}
}
func TestPluralDaysRU(t *testing.T) {
for _, c := range []struct {
n int
want string
}{{1, "день"}, {2, "дня"}, {5, "дней"}, {11, "дней"}, {21, "день"}, {22, "дня"}, {114, "дней"}} {
if got := pluralDaysRU(c.n); got != c.want {
t.Errorf("pluralDaysRU(%d) = %q, want %q", c.n, got, c.want)
}
}
}
// "по выходным" is a question about two days, answered as two days.
func TestFormatWeekendRU(t *testing.T) {
now := behaviorNow()
+8 -4
View File
@@ -6,6 +6,7 @@ import (
"strings"
"time"
"github.com/kami/maven/internal/say"
"github.com/kami/maven/internal/store"
)
@@ -164,17 +165,20 @@ func (p Plan) FormatRU() string {
// it is over, and saying it was empty is a false statement about a day
// he just lived.
if p.Rest {
return "на сегодня больше ничего не запланировано."
return say.S(say.PlanRestEmpty, nil)
}
return fmt.Sprintf("на %s ничего не запланировано.", p.Date.Format("02.01.2006"))
return say.S(say.PlanDayEmpty, map[string]string{"date": p.Date.Format("02.01.2006")})
}
parts := make([]string, len(p.Items))
for i, it := range p.Items {
line := fmt.Sprintf("%s — %s", it.At.Format("15:04"), it.Text)
if it.Uncertain {
line = "похоже, " + line
line = say.S(say.PlanUncertain, map[string]string{"line": line})
}
parts[i] = line
}
return fmt.Sprintf("план на %s: %s.", p.Date.Format("02.01.2006"), strings.Join(parts, "; "))
return say.S(say.PlanDay, map[string]string{
"date": p.Date.Format("02.01.2006"),
"items": strings.Join(parts, "; "),
})
}
+25 -1
View File
@@ -4,6 +4,7 @@ import (
"strings"
"testing"
"time"
"unicode"
"github.com/kami/maven/internal/store"
)
@@ -137,7 +138,7 @@ func TestPlanFormatRU(t *testing.T) {
want := "план на 03.08.2026: 10:00 — Standup @ 10:00-10:30; " +
"10:30 — утро — осталось: витамины; " +
"похоже, 14:00 — Планёрка @ 14:00-14:30; " +
"18:30 — позвонить маме."
"18:30 — позвонить маме"
if got != want {
t.Errorf("got %q\nwant %q", got, want)
}
@@ -215,3 +216,26 @@ func TestBuildPlanIgnoresAnUnopenedWindow(t *testing.T) {
t.Fatalf("got %+v", p.Items)
}
}
// plan_uncertain nests one rendered line inside another sentence: «похоже, » in
// front of what this loop already built. That reads as one sentence only while
// what arrives starts lowercase, and it does here because every line starts with
// the clock time. A capital after the hedge would be «похоже, Планёрка».
func TestTheUncertainHedgeRunsIntoLowercase(t *testing.T) {
now := time.Date(2026, 8, 3, 9, 0, 0, 0, time.UTC)
p := Plan{Date: now, Items: []PlanEntry{
{At: planAt(now, 14, 0), Text: "Планёрка", Kind: PlanEvent, Uncertain: true},
}}
got := p.FormatRU()
const hedge = "похоже, "
i := strings.Index(got, hedge)
if i < 0 {
t.Fatalf("%q does not hedge an uncertain item", got)
}
for _, r := range got[i+len(hedge):] {
if unicode.IsUpper(r) {
t.Fatalf("the hedge runs into a capital: %q", got)
}
break
}
}
+127
View File
@@ -0,0 +1,127 @@
// Package morph answers questions about Russian grammar from a dictionary.
//
// The second of the three mechanisms replacing hand-written Russian patterns
// (Vikunja #522, owner's call 2026-08-04). internal/lexicon holds the sets that
// can be finished; the embedder recognises an open set of phrasings; and this
// package answers the questions that are about grammar rather than meaning:
//
// - is this word a form of a verb, so it carries its own subject?
// - are these two tokens the same word in different cases?
//
// Three places used to answer those from a list of letter endings, and each list
// was wrong in a way its own comment admitted. "канал" read as a past-tense verb
// because it ends in -ал. A list of nineteen nouns ending in л existed only to
// suppress the false positives of "ends in л means masculine past tense", which
// is a pattern conceding it is wrong. Grammar is what a dictionary is for.
//
// Not the resident model. This has to be right every time, offline, in
// microseconds, and a 1.7B is neither reliable enough nor fast enough to ask.
//
// The dictionary is github.com/aaaton/golem's Russian data, vendored. It is
// embedded in the module, so a load failure is not a network problem and not a
// config problem — it is corrupt data that got past the build. Every function
// answers conservatively in that case rather than failing the turn, and says so
// in its own doc comment.
package morph
import (
"log"
"strings"
"sync"
"github.com/aaaton/golem/v4"
"github.com/aaaton/golem/v4/dicts/ru"
)
var (
once sync.Once
lemma *golem.Lemmatizer
loadErr error
)
// dict loads the lemmatizer on first use. Loading costs a few megabytes of maps,
// which is why it is not done at init: a daemon that never sees Russian never
// pays for it.
func dict() *golem.Lemmatizer {
once.Do(func() {
lemma, loadErr = golem.New(ru.New())
if loadErr != nil {
// Once, not per call: this is a permanent condition and a voice loop
// would otherwise fill the log with it at speech rate.
log.Printf("morph: russian dictionary unavailable, answering conservatively: %v", loadErr)
}
})
return lemma
}
// Available reports whether the dictionary loaded. Callers do not need it to be
// correct — every function below has a defined answer without it — but a test
// that means to measure the dictionary should skip rather than pass vacuously.
func Available() bool {
dict()
return loadErr == nil
}
// Lemma returns the dictionary form of a word, or the word itself when the
// dictionary does not know it or could not load. An unknown word is its own
// lemma: "бэкап" is not in the dictionary and there is nothing better to say
// about it than what he said.
func Lemma(word string) string {
w := strings.ToLower(strings.TrimSpace(word))
if w == "" {
return ""
}
l := dict()
if l == nil {
return w
}
if got := l.Lemma(w); got != "" {
return got
}
return w
}
// infinitiveEndings — how a Russian infinitive ends. This is not a stem pattern:
// it is applied to a LEMMA the dictionary returned, where the infinitive is the
// dictionary form of every verb by definition, so the test is about the
// dictionary's own output and not about the word he said.
//
// The reflexive forms are listed because a reflexive lemma keeps its particle:
// "тренировался" lemmatises to "тренироваться", which ends in "ся" rather than
// "ть".
var infinitiveEndings = []string{"ться", "тись", "чься", "ть", "ти", "чь"}
// IsVerbForm reports whether a word is some form of a verb — past tense, present,
// imperative, reflexive, participle. A verb carries its own subject and tense, so
// in Russian one verb is a whole sentence, which is what the callers care about.
//
// Without the dictionary this answers false: not knowing is not evidence that a
// word IS a verb, and the callers all treat false as the cautious direction.
func IsVerbForm(word string) bool {
if dict() == nil {
return false
}
l := Lemma(word)
for _, e := range infinitiveEndings {
if strings.HasSuffix(l, e) {
return true
}
}
return false
}
// SameWord reports whether two tokens are the same word in different cases —
// "режим" and "режиме", "тихий" and "тихо". It is the test a stem-plus-endings
// comparison was approximating, and it draws the line the ending list could not:
// "тихонько" and "потихоньку" are different words, and the dictionary says so
// because it has never heard of either.
//
// Without the dictionary this falls back to exact equality, which is the
// narrowest honest answer.
func SameWord(a, b string) bool {
la, lb := Lemma(a), Lemma(b)
if la == "" || lb == "" {
return false
}
return la == lb
}
+96
View File
@@ -0,0 +1,96 @@
package morph
import "testing"
// TestVerbFormsAreVerbs — the question internal/router/singletoken.go asks. Every
// one of these is a whole sentence in Russian, because the verb carries its own
// subject, tense and gender.
func TestVerbFormsAreVerbs(t *testing.T) {
if !Available() {
t.Skip("russian dictionary unavailable")
}
for _, w := range []string{
"поужинал", "сходил", "выпил", "напомнил", "поняла", "сделала",
"пришёл", "начал", "работаешь", "занимаюсь",
// Reflexive: the lemma keeps its particle, so "тренироваться" ends in
// "ся" and not "ть". That is why the ending list carries both.
"тренировался", "проснулся",
} {
if !IsVerbForm(w) {
t.Errorf("IsVerbForm(%q) = false, want true (lemma %q)", w, Lemma(w))
}
}
}
// TestNounsEndingInLAreNotVerbs — the list this package deleted. Nineteen nouns
// lived in internal/phraser/eval/checks.go as exceptions to "ends in л means
// masculine past tense", plus the ones internal/router/singletoken.go named as
// its own known errors. A list of exceptions to a pattern is the pattern
// conceding it is wrong, so all of them are here and none may be a verb.
func TestNounsEndingInLAreNotVerbs(t *testing.T) {
if !Available() {
t.Skip("russian dictionary unavailable")
}
for _, w := range []string{
"стол", "стул", "пол", "зал", "гол", "узел", "отдел", "файл", "канал",
"угол", "футбол", "вокзал", "металл", "интервал", "уровень", "мускул",
"апрель", "июль", "рубль",
// singletoken.go named these: "канал" read as past tense, and short
// nouns needed a length exemption to survive a two-letter suffix test.
"нос", "лес", "газ", "вода", "бэкап",
} {
if IsVerbForm(w) {
t.Errorf("IsVerbForm(%q) = true, want false (lemma %q)", w, Lemma(w))
}
}
}
// TestSameWordDrawsTheLineTheEndingListCouldNot — the question
// cmd/mavend/quiet_toggle.go asks. Its comment describes exactly this: "тихий",
// "тихом" and "тихо" are one word inflected, while "тихонько" and "потихоньку"
// are different words. The dictionary says so; a list of 36 endings approximated
// it.
func TestSameWordDrawsTheLineTheEndingListCouldNot(t *testing.T) {
if !Available() {
t.Skip("russian dictionary unavailable")
}
for _, w := range []string{"тихий", "тихом", "тихо", "тише"} {
if !SameWord(w, "тихий") {
t.Errorf("SameWord(%q, тихий) = false, want true (lemma %q)", w, Lemma(w))
}
}
for _, w := range []string{"тихонько", "потихоньку"} {
if SameWord(w, "тихий") {
t.Errorf("SameWord(%q, тихий) = true, want false", w)
}
}
for _, w := range []string{"режим", "режима", "режиме", "режимы"} {
if !SameWord(w, "режим") {
t.Errorf("SameWord(%q, режим) = false, want true (lemma %q)", w, Lemma(w))
}
}
if SameWord("режим", "тихий") {
t.Error("SameWord matched two unrelated words")
}
}
// TestUnknownWordIsItsOwnLemma — "бэкап" is not in the dictionary, and there is
// nothing better to say about it than what he said. Two spellings of an unknown
// word still compare equal, which is what the exact-equality fallback rests on.
func TestUnknownWordIsItsOwnLemma(t *testing.T) {
if got := Lemma("бэкап"); got != "бэкап" {
t.Errorf("Lemma(бэкап) = %q, want бэкап", got)
}
if got := Lemma(" БЭКАП "); got != "бэкап" {
t.Errorf("Lemma trims and lowercases: got %q", got)
}
if !SameWord("бэкап", "БЭКАП") {
t.Error("SameWord must still compare an unknown word with itself")
}
if got := Lemma(""); got != "" {
t.Errorf("Lemma(empty) = %q, want empty", got)
}
if SameWord("", "") {
t.Error("two empty tokens are not a word")
}
}
+84
View File
@@ -0,0 +1,84 @@
{
"schema_version": 1,
"name": "russian capture acknowledgements v1",
"notes": [
"What she says after storing something he said, and what she says when storing it failed. Edit the wording here, no Go changes needed.",
"Rules: she is feminine about herself, he is a man addressed as ты. Never вы/вас/ваш, never он/его about him. No pet names.",
"He hears these many times a day, so most entries carry variants: identical wording is what makes a confirmation stop registering as one.",
"Placeholders: {key} {value} the fact he stated, {fn} the action, {text} the task title. His data is interpolated Go-side — the file holds the frame, never his words.",
"An acknowledgement confirms and stops. It does not ask a follow-up question and it does not editorialise about what he stored."
],
"entries": {
"ack_fact": {
"variants": ["записала факт.", "записала.", "запомнила."]
},
"ack_fact_key": {
"variants": ["отметила: {key}", "записала: {key}", "запомнила: {key}"]
},
"ack_fact_kv": {
"variants": ["отметила: {key} = {value}", "записала: {key} — {value}", "запомнила: {key} — {value}"]
},
"ack_note": {
"variants": ["сохранила заметку.", "заметка сохранена.", "записала в заметки."]
},
"ack_reminder": {
"variants": ["напомню.", "напомню, не забуду.", "хорошо, напомню."]
},
"ack_act": {
"variants": ["ок, записала действие: {fn}", "приняла действие: {fn}"]
},
"ack_task": {
"variants": ["записала: {text}", "добавила в задачи: {text}", "внесла в список: {text}"]
},
"ack_task_urgent": {
"variants": ["поняла, беру в работу: {text}", "поняла, это срочно: {text}"]
},
"ack_task_duplicate": {
"variants": ["это уже в списке.", "такое уже есть в задачах."]
},
"ack_nudge": {
"variants": ["отлично, отметила.", "отметила.", "хорошо, отметила."]
},
"ack_snooze": {
"variants": ["хорошо, вернусь к этому позже.", "ладно, напомню попозже.", "хорошо, отложила."]
},
"ack_generic": {
"variants": ["приняла.", "поняла."]
},
"quiet_on": {
"fixed": true,
"variants": ["тихий режим включён. буду реже напоминать."]
},
"quiet_off": {
"fixed": true,
"variants": ["тихий режим выключен."]
},
"fail_fact": {
"variants": ["не получилось сохранить факт.", "факт не сохранился."]
},
"fail_note": {
"variants": ["не получилось сохранить заметку.", "заметка не сохранилась."]
},
"fail_reminder": {
"variants": ["не получилось поставить напоминание.", "напоминание не поставилось."]
},
"fail_reminder_time": {
"variants": ["не получилось разобрать время напоминания.", "не поняла, на когда напомнить."]
},
"fail_task": {
"variants": ["не получилось записать задачу.", "задача не записалась."]
},
"fail_ack": {
"variants": ["не получилось отметить.", "не смогла отметить."]
},
"fail_snooze": {
"variants": ["не получилось отложить.", "не смогла отложить."]
},
"fail_quiet": {
"variants": ["не получилось переключить тихий режим.", "тихий режим не переключился."]
},
"fail_fact_unparsed": {
"variants": ["не разобрала, что записать — попробуй иначе.", "не поняла, что записать. скажи иначе?"]
}
}
}
+164
View File
@@ -0,0 +1,164 @@
package phraser
// The capture acknowledgements — what she says after storing something he said,
// and what she says when storing it failed.
//
// They were string literals in eight files under cmd/mavend plus the stub
// replier in internal/voice. He hears them many times a day, which is exactly
// why they need variants and exactly why rewording one must not be a rebuild.
// Same shape as fallbacks_ru_v1.json, on the shared deck (deck.go).
//
// His data stays Go-side. The file holds "отметила: {key} = {value}"; the key
// and the value are interpolated here, so nothing he said lives in the copy.
import (
_ "embed"
"log"
"math/rand"
"sync"
"github.com/kami/maven/internal/say"
)
//go:embed ack_ru_v1.json
var ackJSON []byte
// AckSchemaVersion — this family's own version. A file that changes on a
// different day than the fallbacks cannot share their number (Vikunja #397).
const AckSchemaVersion = 1
// The entry keys. Namespaced by family, because the floor map behind a nil deck
// is process-wide.
const (
AckFact = "ack_fact"
AckFactKey = "ack_fact_key"
AckFactValue = "ack_fact_kv"
AckNote = "ack_note"
AckReminder = "ack_reminder"
AckAct = "ack_act"
AckTask = "ack_task"
AckTaskUrgent = "ack_task_urgent"
AckTaskDuplicate = "ack_task_duplicate"
AckNudge = "ack_nudge"
AckSnooze = "ack_snooze"
AckGeneric = "ack_generic"
AckQuietOn = "quiet_on"
AckQuietOff = "quiet_off"
FailFact = "fail_fact"
FailFactUnparsed = "fail_fact_unparsed"
FailNote = "fail_note"
FailReminder = "fail_reminder"
FailReminderTime = "fail_reminder_time"
FailTask = "fail_task"
FailAck = "fail_ack"
FailSnooze = "fail_snooze"
FailQuiet = "fail_quiet"
)
// ackKeys — every key the code requires the file to define.
var ackKeys = []string{
AckFact, AckFactKey, AckFactValue, AckNote, AckReminder, AckAct,
AckTask, AckTaskUrgent, AckTaskDuplicate, AckNudge, AckSnooze, AckGeneric,
AckQuietOn, AckQuietOff,
FailFact, FailFactUnparsed, FailNote, FailReminder, FailReminderTime,
FailTask, FailAck, FailSnooze, FailQuiet,
}
// ackFloor — the literal each key falls back to when the file is unusable.
// These are the exact strings that lived in Go before this file existed.
var ackFloor = map[string]string{
AckFact: "записала факт.",
AckFactKey: "отметила: {key}",
AckFactValue: "отметила: {key} = {value}",
AckNote: "сохранила заметку.",
AckReminder: "напомню.",
AckAct: "ок, записала действие: {fn}",
AckTask: "записала: {text}",
AckTaskUrgent: "поняла, беру в работу: {text}",
AckTaskDuplicate: "это уже в списке.",
AckNudge: "отлично, отметила.",
AckSnooze: "хорошо, вернусь к этому позже.",
AckGeneric: "приняла.",
AckQuietOn: "тихий режим включён. буду реже напоминать.",
AckQuietOff: "тихий режим выключен.",
FailFact: "не получилось сохранить факт.",
FailFactUnparsed: "не разобрала, что записать — попробуй иначе.",
FailNote: "не получилось сохранить заметку.",
FailReminder: "не получилось поставить напоминание.",
FailReminderTime: "не получилось разобрать время напоминания.",
FailTask: "не получилось записать задачу.",
FailAck: "не получилось отметить.",
FailSnooze: "не получилось отложить.",
FailQuiet: "не получилось переключить тихий режим.",
}
// Acks picks a hand-written Russian acknowledgement. Safe for concurrent use.
type Acks struct{ d *say.Deck }
// LoadAcks reads the embedded file. Pass a source to make the picking
// reproducible in tests; nil seeds from the clock.
func LoadAcks(src rand.Source) (*Acks, error) {
d, err := say.Load(ackJSON, AckSchemaVersion, ackKeys, ackFloor, src)
if err != nil {
return nil, err
}
// The three entries that exist to read his own words back. A variant
// without the placeholder would confirm the capture and drop what was
// captured, which reads as a successful save of nothing.
for _, req := range []struct{ key, ph string }{
{AckFactKey, "{key}"}, {AckFactValue, "{key}"}, {AckFactValue, "{value}"},
{AckAct, "{fn}"}, {AckTask, "{text}"}, {AckTaskUrgent, "{text}"},
} {
if err := d.RequirePlaceholder(req.key, req.ph); err != nil {
return nil, err
}
}
return &Acks{d: d}, nil
}
// deck reads through a nil *Acks, which is the unloadable-file case.
func (a *Acks) deck() *say.Deck {
if a == nil {
return say.FloorDeck(ackFloor)
}
return a.d
}
// Say returns one line for key, with his data filled into the frame. Pass nil
// when the entry takes none.
func (a *Acks) Say(key string, vars map[string]string) string {
return a.deck().Text(key, vars)
}
// Variants returns every line the file can produce, for the persona scorer.
func (a *Acks) Variants() []string { return a.deck().Variants() }
var (
ackOnce sync.Once
acks *Acks
)
// DefaultAcks returns the shared instance, loading it on first use. A broken
// file logs once and leaves a nil *Acks, which still answers from ackFloor.
func DefaultAcks() *Acks {
ackOnce.Do(func() {
a, err := LoadAcks(nil)
if err != nil {
log.Printf("phraser: acknowledgements unavailable, using the built-in lines: %v", err)
return
}
acks = a
})
return acks
}
// Ack — one acknowledgement line, the way every caller says it.
func Ack(key string, vars map[string]string) string { return DefaultAcks().Say(key, vars) }
// IsAck reports whether text is a line key could have produced. For the daemon
// tests, which can no longer compare against one literal.
func IsAck(key string, vars map[string]string, text string) bool {
return DefaultAcks().deck().Matches(key, vars, text)
}
+85
View File
@@ -0,0 +1,85 @@
package phraser
import (
"math/rand"
"strconv"
"strings"
"testing"
)
func loadTestActs(t *testing.T) *Acts {
t.Helper()
a, err := LoadActs(rand.NewSource(1))
if err != nil {
t.Fatalf("LoadActs: %v", err)
}
return a
}
// She talks about a lamp or a server, never about a row in a schema. «сущность»
// and «экосистема» are the same defect as saying a capability id out loud.
func TestNoActLineSaysASchemaWord(t *testing.T) {
a := loadTestActs(t)
for _, v := range a.Variants() {
for _, word := range []string{"сущност", "экосистем"} {
if strings.Contains(v, word) {
t.Errorf("variant %q says %q out loud", v, word)
}
}
}
}
// Nexus, Praxis and Hexis fail independently, so "не отвечает" with no subject
// is not an answer he can act on.
func TestAServiceFailureNamesTheService(t *testing.T) {
a := loadTestActs(t)
for _, key := range []string{EcoDown, EcoDenied} {
got := a.Say(key, map[string]string{"name": "Praxis"})
if !strings.HasPrefix(got, "Praxis ") {
t.Errorf("%s = %q, want it to name the service", key, got)
}
}
}
// A confirmation prompt for a destructive act is the worst place for an unfilled
// placeholder, so the two names it interpolates are distinct keys and both are
// declared.
func TestConfirmEntityFillsBothNames(t *testing.T) {
a := loadTestActs(t)
got := a.Say(ActConfirmEntity, map[string]string{
"name": "restart", "name_entity": "Muzick indexer",
})
if strings.ContainsAny(got, "{}") {
t.Fatalf("act_confirm_entity = %q, want no placeholder left", got)
}
if !strings.Contains(got, "restart") || !strings.Contains(got, "Muzick indexer") {
t.Fatalf("act_confirm_entity = %q, want both names", got)
}
}
// home_dark counts unreachable devices, and Russian inflects the noun after the
// number: the count goes in {count} and the noun comes from the helper.
func TestHomeDarkCountsWithTheHelper(t *testing.T) {
a := loadTestActs(t)
for n, want := range map[int]string{1: "1 устройство", 2: "2 устройства", 5: "5 устройств"} {
got := a.Say(HomeDark, map[string]string{"count": strconv.Itoa(n), "word": Devices(n)})
if !strings.Contains(got, want) {
t.Errorf("home_dark for %d = %q, want %q in it", n, got, want)
}
}
}
// Four truths, four entries: a failure must not be able to report itself as a
// success, and an empty result must not read as a failure.
func TestActOutcomesStayDistinct(t *testing.T) {
a := loadTestActs(t)
seen := map[string]string{}
for _, key := range actKeys {
for _, v := range a.d.VariantsOf(key) {
if prev, dup := seen[v]; dup {
t.Errorf("%s and %s both say %q", prev, key, v)
}
seen[v] = key
}
}
}
+197
View File
@@ -0,0 +1,197 @@
package phraser
// The act and smart-home replies — what she says when a capability ran, refused,
// or could not be reached.
//
// Fourth family on the shared deck (deck.go). They were literals in
// ecosystem_acts.go, actions_act.go and smarthome.go, where a reworded line was
// a rebuild of the daemon that executes his house.
//
// The four outcomes stay four entries. Reporting a refusal with the wording of
// a success is the one failure mode this family can have, and a shared variant
// set is how it would happen.
import (
_ "embed"
"log"
"math/rand"
"sync"
"github.com/kami/maven/internal/say"
)
//go:embed acts_ru_v1.json
var actJSON []byte
// ActSchemaVersion — this family's own version.
const ActSchemaVersion = 1
// The entry keys.
const (
ActDone = "act_done"
ActDoneOut = "act_done_out"
ActDoneEntity = "act_done_entity"
ActConfirm = "act_confirm"
ActConfirmEntity = "act_confirm_entity"
ActWhich = "act_which"
ActFail = "act_fail"
ActFailOut = "act_fail_out"
ActFailEntity = "act_fail_entity"
ActServerDown = "act_server_down"
ActWithdrawn = "act_withdrawn"
ActNeedsArgs = "act_needs_args"
// ActNeedsAuthedSurface — the irreversible tier, local row or Hexis
// capability alike. Not a failure and not a refusal to help: a spoken "да"
// is the only authority the voice path can offer, and this is the one act
// it is not enough for (Vikunja #449, #523).
ActNeedsAuthedSurface = "act_needs_authed_surface"
EcoDenied = "eco_denied"
EcoDown = "eco_down"
EcoAmbiguous = "eco_ambiguous"
EcoUnknownEntity = "eco_unknown_entity"
EcoNoNexus = "eco_no_nexus"
EcoAboutWhat = "eco_about_what"
EcoRecall = "eco_recall"
AttentionNone = "attention_none"
AttentionList = "attention_list"
AttentionFail = "attention_fail"
AttentionNoneEntity = "attention_none_entity"
AttentionListEntity = "attention_list_entity"
AttentionFailEntity = "attention_fail_entity"
ChangesNone = "changes_none"
ChangesList = "changes_list"
ChangesFail = "changes_fail"
HomeUnreachable = "home_unreachable"
HomeEmpty = "home_empty"
HomeOn = "home_on"
HomeDark = "home_dark"
)
var actKeys = []string{
ActDone, ActDoneOut, ActDoneEntity, ActConfirm, ActConfirmEntity, ActWhich,
ActFail, ActFailOut, ActFailEntity, ActServerDown, ActWithdrawn, ActNeedsArgs,
ActNeedsAuthedSurface,
EcoDenied, EcoDown, EcoAmbiguous, EcoUnknownEntity, EcoNoNexus, EcoAboutWhat, EcoRecall,
AttentionNone, AttentionList, AttentionFail,
AttentionNoneEntity, AttentionListEntity, AttentionFailEntity,
ChangesNone, ChangesList, ChangesFail,
HomeUnreachable, HomeEmpty, HomeOn, HomeDark,
}
// actFloor — the literal each key falls back to when the file is unusable. It
// started as the exact strings that lived in Go before this file existed and now
// tracks the file's first variant instead, because a floor that keeps the
// wording review threw out would say it back on the one turn nobody is watching.
var actFloor = map[string]string{
ActDone: "готово.",
ActDoneOut: "готово: {out}",
ActDoneEntity: "готово: {name}.",
ActConfirm: "выполнить «{name}»? да или нет.",
ActConfirmEntity: "выполнить «{name}» для {name_entity}? да или нет.",
ActWhich: "какую команду для {name}: {items}?",
ActFail: "не получилось выполнить команду.",
ActFailOut: "не получилось выполнить команду: {out}",
ActFailEntity: "не получилось выполнить команду для {name}.",
ActServerDown: "инструмент есть, но сервер не подключён.",
ActWithdrawn: "сервер больше не отдаёт этот инструмент — сняла его с разрешённых, посмотри /tools.",
ActNeedsArgs: "тут нужны аргументы, из голоса не соберу. угадывать не буду.",
ActNeedsAuthedSurface: "это из голоса не выполню — после него ничего не вернуть. запусти сам.",
EcoDenied: "{name} отклоняет доступ, проверь токен.",
EcoDown: "{name} не отвечает, попробуй ещё раз.",
EcoAmbiguous: "что именно: {items}?",
EcoUnknownEntity: "не знаю, что это.",
EcoNoNexus: "не с чем связать — Nexus не настроен.",
EcoAboutWhat: "про что именно?",
EcoRecall: "я помню: {items}",
AttentionNone: "ничего не требует внимания.",
AttentionList: "требует внимания: {items}",
AttentionFail: "не могу сейчас узнать, что требует внимания.",
AttentionNoneEntity: "по «{name}» ничего нет.",
AttentionListEntity: "по «{name}»: {items}",
AttentionFailEntity: "не могу сейчас узнать, что требует внимания по «{name}».",
ChangesNone: "изменений нет.",
ChangesList: "изменения: {items}",
ChangesFail: "не могу сейчас узнать об изменениях.",
HomeUnreachable: "дом не отвечает.",
HomeEmpty: "дом ничего не отдаёт.",
HomeOn: "включено: {items}",
HomeDark: "не отвечают: {count} {word}.",
}
// Acts picks a hand-written Russian act reply. Safe for concurrent use.
type Acts struct{ d *say.Deck }
// LoadActs reads the embedded file. Pass a source to make the picking
// reproducible in tests; nil seeds from the clock.
func LoadActs(src rand.Source) (*Acts, error) {
d, err := say.Load(actJSON, ActSchemaVersion, actKeys, actFloor, src)
if err != nil {
return nil, err
}
// The entries that name what ran or what he has to choose between. A
// variant that dropped the name would confirm an act without saying which.
for _, req := range []struct{ key, ph string }{
{ActDoneOut, "{out}"}, {ActDoneEntity, "{name}"}, {ActFailOut, "{out}"},
{ActFailEntity, "{name}"}, {ActConfirm, "{name}"},
{ActConfirmEntity, "{name}"}, {ActConfirmEntity, "{name_entity}"},
{ActWhich, "{name}"}, {ActWhich, "{items}"},
{EcoAmbiguous, "{items}"}, {EcoRecall, "{items}"},
{AttentionList, "{items}"}, {ChangesList, "{items}"}, {HomeOn, "{items}"},
{EcoDenied, "{name}"}, {EcoDown, "{name}"},
{HomeDark, "{count}"}, {HomeDark, "{word}"},
{AttentionNoneEntity, "{name}"}, {AttentionListEntity, "{name}"},
{AttentionListEntity, "{items}"}, {AttentionFailEntity, "{name}"},
} {
if err := d.RequirePlaceholder(req.key, req.ph); err != nil {
return nil, err
}
}
return &Acts{d: d}, nil
}
// deck reads through a nil *Acts, which is the unloadable-file case.
func (a *Acts) deck() *say.Deck {
if a == nil {
return say.FloorDeck(actFloor)
}
return a.d
}
// Say returns one line for key, with the names filled into the frame.
func (a *Acts) Say(key string, vars map[string]string) string {
return a.deck().Text(key, vars)
}
// Variants returns every line the file can produce, for the persona scorer.
func (a *Acts) Variants() []string { return a.deck().Variants() }
var (
actOnce sync.Once
actsDeck *Acts
)
// DefaultActs returns the shared instance, loading it on first use. A broken
// file logs once and leaves a nil *Acts, which still answers from actFloor.
func DefaultActs() *Acts {
actOnce.Do(func() {
a, err := LoadActs(nil)
if err != nil {
log.Printf("phraser: act replies unavailable, using the built-in lines: %v", err)
return
}
actsDeck = a
})
return actsDeck
}
// A — one act reply, the way every caller says it.
func A(key string, vars map[string]string) string { return DefaultActs().Say(key, vars) }
// IsA reports whether text is a line key could have produced, for the tests.
func IsA(key string, vars map[string]string, text string) bool {
return DefaultActs().deck().Matches(key, vars, text)
}
+145
View File
@@ -0,0 +1,145 @@
{
"schema_version": 1,
"name": "russian act and smart-home replies v1",
"notes": [
"What she says when a capability ran, refused, or could not be reached. Edit the wording here, no Go changes needed.",
"Rules: she is feminine about herself, he is a man addressed as ты. Never вы/вас/ваш, never он/его about him. No pet names.",
"\"it ran\", \"it was refused\", \"a service is down\" and \"I could not work out what you meant\" are four different truths. They keep four entries, because one variant set would let a failure report itself as a success.",
"She says what he would say. No schema words out loud: not «сущность», not «экосистема», not a capability id, not a config key. She is talking about a lamp or a server.",
"A service that is down or refusing is named. \"не отвечает\" with no subject tells him nothing he can act on, and Nexus, Praxis and Hexis fail independently.",
"Placeholders: {name} an entity or capability the caller resolved, {name_entity} the entity an act runs against when {name} is already the capability, {out} the command's own output, {items} a joined list, {count} a number, {word} the counted noun in the form {count} needs. Entity names and capability ids are interpolated Go-side.",
"A count never carries a hardcoded noun. Russian inflects it — 1 устройство, 2 устройства, 5 устройств — so the number goes in {count} and the noun comes from the Go helper through {word}.",
"An entry that only exists to read a list back must never be reached with an empty list. The caller routes an empty list to the matching _none entry, because a single-variant placeholder-only line has no shorter wording to fall back to.",
"fixed: true means exactly one variant and no picking. Used where the wording carries an instruction he has to act on — a confirmation, a pointer at /tools — and for the lines that report an act as done, because a success report that reworded itself is harder to trust and harder to test."
],
"entries": {
"act_done": {
"fixed": true,
"variants": ["готово."]
},
"act_done_out": {
"variants": ["готово: {out}", "сделала: {out}"]
},
"act_done_entity": {
"fixed": true,
"variants": ["готово: {name}."]
},
"act_confirm": {
"fixed": true,
"variants": ["выполнить «{name}»? да или нет."]
},
"act_confirm_entity": {
"fixed": true,
"variants": ["выполнить «{name}» для {name_entity}? да или нет."]
},
"act_which": {
"fixed": true,
"variants": ["какую команду для {name}: {items}?"]
},
"act_fail": {
"fixed": true,
"variants": ["не получилось выполнить команду."]
},
"act_fail_out": {
"fixed": true,
"variants": ["не получилось выполнить команду: {out}"]
},
"act_fail_entity": {
"fixed": true,
"variants": ["не получилось выполнить команду для {name}."]
},
"act_server_down": {
"fixed": true,
"variants": ["инструмент есть, но сервер не подключён."]
},
"act_withdrawn": {
"fixed": true,
"variants": ["сервер больше не отдаёт этот инструмент — сняла его с разрешённых, посмотри /tools."]
},
"act_needs_args": {
"fixed": true,
"variants": ["тут нужны аргументы, из голоса не соберу. угадывать не буду."]
},
"act_needs_authed_surface": {
"fixed": true,
"variants": ["это из голоса не выполню — после него ничего не вернуть. запусти сам."]
},
"eco_denied": {
"fixed": true,
"variants": ["{name} отклоняет доступ, проверь токен."]
},
"eco_down": {
"fixed": true,
"variants": ["{name} не отвечает, попробуй ещё раз."]
},
"eco_ambiguous": {
"fixed": true,
"variants": ["что именно: {items}?"]
},
"eco_unknown_entity": {
"variants": ["не знаю, что это.", "такого у меня нет."]
},
"eco_no_nexus": {
"fixed": true,
"variants": ["не с чем связать — Nexus не настроен."]
},
"eco_about_what": {
"fixed": true,
"variants": ["про что именно?"]
},
"eco_recall": {
"fixed": true,
"variants": ["я помню: {items}"]
},
"attention_none": {
"fixed": true,
"variants": ["ничего не требует внимания."]
},
"attention_list": {
"fixed": true,
"variants": ["требует внимания: {items}"]
},
"attention_fail": {
"fixed": true,
"variants": ["не могу сейчас узнать, что требует внимания."]
},
"attention_none_entity": {
"variants": ["по «{name}» ничего нет.", "по «{name}» пока пусто."]
},
"attention_list_entity": {
"fixed": true,
"variants": ["по «{name}»: {items}"]
},
"attention_fail_entity": {
"fixed": true,
"variants": ["не могу сейчас узнать, что требует внимания по «{name}»."]
},
"changes_none": {
"fixed": true,
"variants": ["изменений нет."]
},
"changes_list": {
"fixed": true,
"variants": ["изменения: {items}"]
},
"changes_fail": {
"fixed": true,
"variants": ["не могу сейчас узнать об изменениях."]
},
"home_unreachable": {
"variants": ["дом не отвечает.", "не достучалась до дома."]
},
"home_empty": {
"fixed": true,
"variants": ["дом ничего не отдаёт."]
},
"home_on": {
"fixed": true,
"variants": ["включено: {items}"]
},
"home_dark": {
"fixed": true,
"variants": ["не отвечают: {count} {word}."]
}
}
}
+31 -15
View File
@@ -6,6 +6,8 @@ import (
"strings"
"unicode"
"unicode/utf8"
"github.com/kami/maven/internal/morph"
)
// The check names, in report order. Every check is a string or length test — no
@@ -67,6 +69,19 @@ func RunChecks(c Case, body, mood string) []Result {
}
}
// Feminine, HisGender and Address expose three checks one at a time, so the
// daemon can run them on a phrased message before he hears it (Vikunja #399).
// Only these three: they are unambiguous string tests with nothing to compare
// against, while length is path-specific and ontopic needs the fixture's
// expected fragments, which do not exist at runtime.
func Feminine(body string) Result { return checkFeminine(body) }
// HisGender — see checkHisGender.
func HisGender(body string) Result { return checkHisGender(body) }
// Address — see checkAddress.
func Address(body string) Result { return checkAddress(body) }
func checkMood(mood string) Result {
if Moods[mood] {
return Result{CheckMood, true, ""}
@@ -127,24 +142,25 @@ var masculinePredicative = map[string]bool{
"обязан": true, "сам": true, "занят": true, "прав": true,
}
// nounsEndingInL — the false positives of "ends in л ⇒ masculine past tense".
// Small on purpose: it only has to cover nouns a nudge might actually use.
var nounsEndingInL = map[string]bool{
"стол": true, "стул": true, "пол": true, "зал": true, "гол": true,
"узел": true, "отдел": true, "файл": true, "канал": true, "угол": true,
"футбол": true, "вокзал": true, "металл": true, "интервал": true,
"уровень": true, "мускул": true, "апрель": true, "июль": true, "рубль": true,
}
// masculinePast reports whether a word looks like a masculine past-tense verb.
// Russian past tense is gendered by suffix: -л (m), -ла (f). A 0.8B with weak
// Russian defaults to the masculine form, which is the exact drift being
// measured.
// masculinePast reports whether a word is a masculine past-tense verb. Russian
// past tense is gendered by suffix: -л for him, -ла for her. A small model with
// weak Russian defaults to the masculine form, which is the exact drift this
// check measures.
//
// The ending is only half the test, and the other half used to be a hand list of
// nineteen nouns that end in л — стол, файл, апрель — kept "small on purpose",
// which means incomplete on purpose. A list of exceptions to a pattern is the
// pattern conceding it is wrong, so the second half is now a dictionary lookup:
// ends in -л AND is a form of a verb (Vikunja #526). Every noun the list held is
// correctly not a verb, and so are the ones it had not got round to.
func masculinePast(w string) bool {
if len([]rune(w)) < 3 || nounsEndingInL[w] {
if len([]rune(w)) < 3 {
return false
}
return strings.HasSuffix(w, "л") || strings.HasSuffix(w, "лся")
if !strings.HasSuffix(w, "л") && !strings.HasSuffix(w, "лся") {
return false
}
return morph.IsVerbForm(w)
}
func checkFeminine(body string) Result {
+70
View File
@@ -0,0 +1,70 @@
package eval
import (
"math/rand"
"strings"
"testing"
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/say"
)
// TestFallbackPersona scores every line in every hand-written family on the
// persona checks the nudges already pass. These lines are heard out loud and
// they live in a JSON file now, so a reworded variant that says "рад" or "вы"
// would otherwise reach him with nothing in between.
//
// Only the persona checks run. Mood and topic belong to a nudge, and these are
// not nudges.
func TestFallbackPersona(t *testing.T) {
fb, err := phraser.LoadFallbacks(rand.NewSource(20260804))
if err != nil {
t.Fatalf("LoadFallbacks: %v", err)
}
// No CheckHisGender. It reads a feminine verb near a second-person pronoun
// as addressing him as a woman, which is right for a nudge and wrong here:
// "не знаю — не нашла у тебя такой записи" is her own verb in her own
// sentence. CheckFeminine still holds her side of the rule.
persona := map[string]bool{
CheckLang: true, CheckFeminine: true,
CheckAddress: true, CheckCringe: true, CheckLength: true,
}
ack, err := phraser.LoadAcks(rand.NewSource(20260804))
if err != nil {
t.Fatalf("LoadAcks: %v", err)
}
qry, err := phraser.LoadQueries(rand.NewSource(20260804))
if err != nil {
t.Fatalf("LoadQueries: %v", err)
}
variants := append(fb.Variants(), ack.Variants()...)
variants = append(variants, qry.Variants()...)
act, err := phraser.LoadActs(rand.NewSource(20260804))
if err != nil {
t.Fatalf("LoadActs: %v", err)
}
variants = append(variants, act.Variants()...)
sum, err := say.LoadSummaries(rand.NewSource(20260804))
if err != nil {
t.Fatalf("LoadSummaries: %v", err)
}
variants = append(variants, sum.Variants()...)
if len(variants) == 0 {
t.Fatal("no variants — the file loaded empty")
}
for _, v := range variants {
// The placeholders stand for his own words and carry no persona.
body := v
for _, ph := range []string{"{sources}", "{key}", "{value}", "{fn}", "{text}", "{when}", "{items}",
"{location}", "{temp}", "{condition}", "{tail}", "{out}", "{name}",
"{entity}", "{count}", "{word}",
"{date}", "{line}", "{n}", "{day}", "{sat}", "{sun}", "{span}", "{gloss}", "{time}"} {
body = strings.ReplaceAll(body, ph, "вода")
}
for _, r := range RunChecks(Case{}, body, "neutral") {
if persona[r.Name] && !r.Pass {
t.Errorf("%q fails %s: %s", v, r.Name, r.Detail)
}
}
}
}
+8 -2
View File
@@ -78,6 +78,12 @@ type TalkCase struct {
Note string `json:"note,omitempty"`
}
// TalkSchemaVersion — the version this loader understands. Separate from the
// nudge fixture's SchemaVersion: the two fixtures have different shapes and
// change on different days, and one shared constant would force a bump on the
// fixture that did not move.
const TalkSchemaVersion = 1
// TalkFixture — the versioned envelope, same gating as Fixture.
type TalkFixture struct {
SchemaVersion int `json:"schema_version"`
@@ -92,8 +98,8 @@ func LoadTalk() (TalkFixture, error) {
if err := json.Unmarshal(talkFixtureJSON, &f); err != nil {
return TalkFixture{}, fmt.Errorf("parse talk fixture: %w", err)
}
if f.SchemaVersion != SchemaVersion {
return TalkFixture{}, fmt.Errorf("talk fixture schema_version %d, want %d", f.SchemaVersion, SchemaVersion)
if f.SchemaVersion != TalkSchemaVersion {
return TalkFixture{}, fmt.Errorf("talk fixture schema_version %d, want %d", f.SchemaVersion, TalkSchemaVersion)
}
if len(f.Cases) == 0 {
return TalkFixture{}, fmt.Errorf("talk fixture has no cases")
+11 -16
View File
@@ -142,19 +142,13 @@ func TestLLMTalkBaseline(t *testing.T) {
p := phraser.NewLLMPhraserAt(base, cfg)
defer p.Close()
// Unreachable server is fatal here, not a logged warning, and that differs
// from the nudge test on purpose. PhraseNudge returns its errors, so a dead
// server there shows up honestly in the Errors column. PhraseChat and
// PhraseQuery do NOT: they swallow every failure and return a canned string
// ("поговорили.", "не знаю.", "вот что я нашла: …"). So on these three paths
// a dead server produces a full report with 0 errors and a terrible score —
// a number that looks like bad phrasing and is really no phrasing at all.
// Refusing to score without a confirmed model is the only guard available
// until the phraser reports its failures (Vikunja #397).
// The model id names the run in the report. Since Vikunja #397 every path
// returns its errors, so a server that dies mid-run shows up in the Errors
// column instead of scoring as bad phrasing — the before-and-after probe that
// used to stand in for that is gone.
model, err := llm.ModelID(ctx, base)
if err != nil {
t.Fatalf("no model at %s: %v — refusing to score, these paths hide their errors "+
"and would report a plausible-looking result off a dead server", base, err)
t.Fatalf("no model at %s: %v", base, err)
}
t.Logf("scoring model %s at %s", model, base)
@@ -169,10 +163,11 @@ func TestLLMTalkBaseline(t *testing.T) {
}
t.Log("\n" + rep.String() + "\nreplies:\n" + rep.Replies() + "\nfailures:\n" + rep.Failures())
// And again afterwards: the run takes minutes, and a server that died or got
// OOM-killed halfway through would leave the first cases scored and the rest
// silently canned. Checking only at the start would not catch that.
if _, err := llm.ModelID(ctx, base); err != nil {
t.Fatalf("model at %s went away during the run: %v — the score above is not trustworthy", base, err)
// A run where nothing was phrased is not a low score, it is no measurement.
if rep.Errors == rep.Total {
t.Fatalf("every case errored — nothing was measured, the score above is not a phrasing result")
}
if rep.Errors > 0 {
t.Logf("%d/%d cases errored — those are model failures, not phrasing failures", rep.Errors, rep.Total)
}
}
+80
View File
@@ -0,0 +1,80 @@
package phraser
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// isFallback — the text she says is picked from that entry's variants, so a test
// pins the entry rather than the wording. Pinning one line would make editing
// fallbacks_ru_v1.json break Go tests, which is the coupling this file removed.
func isFallback(t *testing.T, key, sources, got string) bool {
t.Helper()
return DefaultFallbacks().deck().Matches(key, map[string]string{"sources": sources}, got)
}
// A dead server must be distinguishable from bad phrasing. Both PhraseChat and
// PhraseQuery keep the turn alive with canned text — and every one of those
// lines is also a legitimate reply, so the text alone cannot say which happened.
// The error is the only signal, and before Vikunja #397 it was dropped: the talk
// scorer reported a full run with zero errors off a server that answered nothing.
func TestPhrasingReportsTheFailureWithTheFallback(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "model not loaded", http.StatusServiceUnavailable)
}))
t.Cleanup(srv.Close)
p := NewLLMPhraserAt(srv.URL, Config{})
cases := []struct {
name string
call func() (string, error)
key string
sources string
}{
{"chat", func() (string, error) {
return p.PhraseChat(context.Background(), "как дела", nil)
}, fbChat, ""},
{"knowledge", func() (string, error) {
return p.PhraseQuery(context.Background(), "кто написал войну и мир", nil)
}, fbQueryUnknown, ""},
{"evidence", func() (string, error) {
return p.PhraseQuery(context.Background(), "сколько воды я выпил", []string{"два литра"})
}, fbQuerySources, "два литра"},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got, err := c.call()
if err == nil {
t.Fatalf("no error from a dead server; the scorer would count this as bad phrasing")
}
if !isFallback(t, c.key, c.sources, got) {
t.Errorf("fallback text = %q, want a %q variant — the daemon still has to say something", got, c.key)
}
})
}
}
// An empty answer is a failure too: the server is up and produced no tokens,
// which is not an answer and must not score as one.
func TestEmptyKnowledgeAnswerIsAnError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"choices":[{"message":{"content":""}}]}`))
}))
t.Cleanup(srv.Close)
p := NewLLMPhraserAt(srv.URL, Config{})
got, err := p.PhraseQuery(context.Background(), "кто написал войну и мир", nil)
if err == nil {
t.Fatal("an empty response scored as an answer")
}
if !isFallback(t, fbQueryUnknown, "", got) {
t.Errorf("fallback text = %q, want a %q variant", got, fbQueryUnknown)
}
if !strings.Contains(err.Error(), "empty") {
t.Errorf("error = %v; want it to name the empty response", err)
}
}
+140
View File
@@ -0,0 +1,140 @@
package phraser
// The phrasing fallbacks — what she says when the model gave her nothing usable.
//
// They were four string literals spread across phraser.go, llmphraser.go and
// cmd/mavend/worldmodel.go. Every one of them is a line he hears out loud, so
// rewording one was a Go edit, a rebuild and a redeploy for what is product copy.
//
// The floor under the floor is deliberate. These strings exist because something
// already failed, so a broken template file must not be able to take the last
// words she has: every accessor falls back to the literal it replaced.
import (
_ "embed"
"log"
"math/rand"
"sync"
"github.com/kami/maven/internal/say"
)
//go:embed fallbacks_ru_v1.json
var fallbackJSON []byte
// FallbackSchemaVersion — the version this code understands. Its own constant,
// not shared with the nudge templates or the eval fixtures: two files that change
// on different days cannot be versioned by one number (Vikunja #397).
const FallbackSchemaVersion = 1
// The entry keys. Every one of them is read by a method below, so a typo in the
// file is caught at load rather than at the moment she needs the words.
const (
fbChat = "chat"
fbQueryUnknown = "query_unknown"
fbQuerySources = "query_sources"
fbWorldGap = "world_gap"
)
// fbKeys — every key the code requires the file to define.
var fbKeys = []string{fbChat, fbQueryUnknown, fbQuerySources, fbWorldGap}
// hardFloor — the literal each key falls back to when the file is unusable.
// These are the exact strings that lived in Go before this file existed.
var hardFloor = map[string]string{
fbChat: "даже не знаю, что сказать.",
fbQueryUnknown: "не знаю.",
fbQuerySources: "вот что я нашла: {sources}",
fbWorldGap: "сейчас не могу ответить — большая модель недоступна, а придумывать не хочу.",
}
// Fallbacks picks a hand-written Russian fallback line. Safe for concurrent use.
type Fallbacks struct{ d *say.Deck }
// LoadFallbacks reads the embedded file. Pass a source to make the picking
// reproducible in tests; nil seeds from the clock.
func LoadFallbacks(src rand.Source) (*Fallbacks, error) {
d, err := say.Load(fallbackJSON, FallbackSchemaVersion, fbKeys, hardFloor, src)
if err != nil {
return nil, err
}
// query_sources is the one entry whose whole job is to read something back.
if err := d.RequirePlaceholder(fbQuerySources, "{sources}"); err != nil {
return nil, err
}
return &Fallbacks{d: d}, nil
}
// deck reads through a nil *Fallbacks, which is the unloadable-file case.
func (f *Fallbacks) deck() *say.Deck {
if f == nil {
return say.FloorDeck(hardFloor)
}
return f.d
}
// Chat — nothing usable came back on the chat path.
func (f *Fallbacks) Chat() string { return f.deck().Text(fbChat, nil) }
// Unknown — a question she cannot answer and will not guess at.
func (f *Fallbacks) Unknown() string { return f.deck().Text(fbQueryUnknown, nil) }
// FromSources — read back what she was handed, because phrasing it failed.
func (f *Fallbacks) FromSources(sources string) string {
return f.deck().Text(fbQuerySources, map[string]string{"sources": sources})
}
// WorldGap — the world model is the one configured to answer and it is not
// answering. Fixed wording: it names a specific gap, and a variant set here
// would let "the big model is asleep" drift into "I don't know".
func (f *Fallbacks) WorldGap() string { return f.deck().Text(fbWorldGap, nil) }
// Variants returns every line the file can produce, for the persona scorer.
func (f *Fallbacks) Variants() []string { return f.deck().Variants() }
// The process-wide instance. Package-level because these lines are needed on
// paths that have no phraser to hand — cmd/mavend names the world gap without
// one — and because a template file that is embedded and validated at load has
// nothing per-instance to configure.
var (
fallbackOnce sync.Once
fallbacks *Fallbacks
)
// DefaultFallbacks returns the shared instance, loading it on first use. A
// broken file logs once and leaves a nil *Fallbacks, which still answers from
// hardFloor — a daemon must not fail to boot over its own copy deck.
func DefaultFallbacks() *Fallbacks {
fallbackOnce.Do(func() {
fb, err := LoadFallbacks(nil)
if err != nil {
log.Printf("phraser: fallbacks unavailable, using the built-in lines: %v", err)
return
}
fallbacks = fb
})
return fallbacks
}
// ChatFallback — what she says when the chat path produced nothing.
func ChatFallback() string { return DefaultFallbacks().Chat() }
// UnknownFallback — what she says when she has no answer and will not invent one.
func UnknownFallback() string { return DefaultFallbacks().Unknown() }
// SourcesFallback — read the sources back rather than ship a broken fragment.
func SourcesFallback(sources string) string { return DefaultFallbacks().FromSources(sources) }
// WorldGap — what he hears when the world model is configured and unreachable.
func WorldGap() string { return DefaultFallbacks().WorldGap() }
// IsUnknownFallback reports whether text is one of her "I do not know" lines.
// The daemon tests read it to tell an answer from a shrug.
func IsUnknownFallback(text string) bool {
return DefaultFallbacks().deck().Matches(fbQueryUnknown, nil, text)
}
// IsSourcesFallback reports whether text is sources read back verbatim.
func IsSourcesFallback(text, sources string) bool {
return DefaultFallbacks().deck().Matches(fbQuerySources, map[string]string{"sources": sources}, text)
}
+42
View File
@@ -0,0 +1,42 @@
{
"schema_version": 1,
"name": "russian phrasing fallbacks v1",
"notes": [
"What she says when the model gave her nothing usable. Edit the wording here, no Go changes needed.",
"Rules: she is feminine about herself, he is a man addressed as ты. Never вы/вас/ваш, never plural imperatives, never он/его about him. No pet names.",
"These are heard after a failure, so they stay short and admit the gap. None of them may claim knowledge she does not have.",
"Placeholders: {sources} the notes or passages she was handed. A variant whose placeholder has no value is skipped, so every entry needs at least one variant with no placeholder — except query_sources, which exists only to read sources back.",
"fixed: true means exactly one variant and no picking. Used where the wording is load-bearing and must not drift between turns."
],
"entries": {
"chat": {
"variants": [
"даже не знаю, что сказать.",
"не могу найти слов.",
"мысль ускользнула, повтори?",
"у меня сейчас пусто в голове."
]
},
"query_unknown": {
"variants": [
"не знаю.",
"не знаю, честно.",
"тут я пас.",
"не скажу, не знаю."
]
},
"query_sources": {
"variants": [
"вот что я нашла: {sources}",
"нашла вот это: {sources}",
"есть только это: {sources}"
]
},
"world_gap": {
"fixed": true,
"variants": [
"сейчас не могу ответить — большая модель недоступна, а придумывать не хочу."
]
}
}
}
+74
View File
@@ -0,0 +1,74 @@
package phraser
import (
"math/rand"
"strings"
"testing"
)
// The embedded file must load, or the daemon speaks from hardFloor and nobody
// finds out until he hears the wrong words.
func TestFallbacksLoad(t *testing.T) {
fb, err := LoadFallbacks(rand.NewSource(1))
if err != nil {
t.Fatalf("LoadFallbacks: %v", err)
}
if got := fb.FromSources("два литра"); !strings.Contains(got, "два литра") {
t.Errorf("FromSources = %q, want the sources in it", got)
}
if fb.WorldGap() != hardFloor[fbWorldGap] {
t.Errorf("WorldGap = %q, want the fixed wording %q", fb.WorldGap(), hardFloor[fbWorldGap])
}
}
// A broken or missing file must not take her last words away: every accessor
// answers from the literal it replaced.
func TestNilFallbacksAnswerFromTheHardFloor(t *testing.T) {
var fb *Fallbacks
if got := fb.Chat(); got != hardFloor[fbChat] {
t.Errorf("Chat = %q, want %q", got, hardFloor[fbChat])
}
if got := fb.Unknown(); got != hardFloor[fbQueryUnknown] {
t.Errorf("Unknown = %q, want %q", got, hardFloor[fbQueryUnknown])
}
if got := fb.FromSources("два литра"); got != "вот что я нашла: два литра" {
t.Errorf("FromSources = %q", got)
}
if got := fb.WorldGap(); got != hardFloor[fbWorldGap] {
t.Errorf("WorldGap = %q", got)
}
}
// Hearing the identical words every time a request fails is how a failure stops
// registering as one.
func TestFallbacksDoNotRepeat(t *testing.T) {
fb, err := LoadFallbacks(rand.NewSource(7))
if err != nil {
t.Fatalf("LoadFallbacks: %v", err)
}
prev := fb.Chat()
for i := 0; i < 20; i++ {
got := fb.Chat()
if got == prev {
t.Fatalf("chat repeated %q on turn %d", got, i)
}
prev = got
}
}
// The acknowledgements load, fill his words into the frame, and answer from the
// floor when the file is gone.
func TestAcksLoad(t *testing.T) {
a, err := LoadAcks(rand.NewSource(1))
if err != nil {
t.Fatalf("LoadAcks: %v", err)
}
got := a.Say(AckFactValue, map[string]string{"key": "вода", "value": "2л"})
if !strings.Contains(got, "вода") || !strings.Contains(got, "2л") {
t.Errorf("Say(%s) = %q, want his key and value in it", AckFactValue, got)
}
var nilAcks *Acks
if got := nilAcks.Say(AckNote, nil); got != ackFloor[AckNote] {
t.Errorf("nil Acks said %q, want the floor %q", got, ackFloor[AckNote])
}
}
+38
View File
@@ -0,0 +1,38 @@
package phraser
import (
"math/rand"
"testing"
)
// The other four families, held to the rule internal/say holds the fifth to:
// one variant means fixed. Reported per family, because a failure that names
// "some file" is a failure nobody acts on.
func TestEverySingleVariantEntryIsFixed(t *testing.T) {
f, err := LoadFallbacks(rand.NewSource(1))
if err != nil {
t.Fatalf("LoadFallbacks: %v", err)
}
a, err := LoadAcks(rand.NewSource(1))
if err != nil {
t.Fatalf("LoadAcks: %v", err)
}
q, err := LoadQueries(rand.NewSource(1))
if err != nil {
t.Fatalf("LoadQueries: %v", err)
}
acts, err := LoadActs(rand.NewSource(1))
if err != nil {
t.Fatalf("LoadActs: %v", err)
}
for name, keys := range map[string][]string{
"fallbacks": f.d.UnfixedSingles(),
"acks": a.d.UnfixedSingles(),
"queries": q.d.UnfixedSingles(),
"acts": acts.d.UnfixedSingles(),
} {
if len(keys) > 0 {
t.Errorf("%s: single-variant entries not marked fixed: %v", name, keys)
}
}
}
+48 -20
View File
@@ -5,6 +5,7 @@ import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"log"
@@ -26,6 +27,11 @@ import (
var listenRE = regexp.MustCompile(`listening on (https?://\S+)`)
// errEmptyResponse — the server answered and said nothing. Separate from a
// transport failure: the model is up and produced no tokens, which is still not
// an answer and must not score as one.
var errEmptyResponse = errors.New("phraser: empty response from the model")
type LLMPhraser struct {
cfg Config
client *http.Client
@@ -85,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
@@ -134,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,
}
}
@@ -260,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
@@ -338,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()))
}
}
@@ -428,8 +453,11 @@ func (p *LLMPhraser) PhraseNudge(ctx context.Context, c loop.Candidate) (deliver
}
// PhraseQuery prompts the LLM with the user's utterance and matching notes to
// compose a natural answer. Falls back to "вот что я нашла: <notes>" on any
// LLM error — better to give the raw data than silence.
// compose a natural answer. On any LLM error it returns the fallback text —
// "вот что я нашла: <notes>", or "не знаю." with no notes — and the error
// together. The daemon uses the text and keeps the turn alive; a caller that is
// measuring counts the failure. Until Vikunja #397 the error was dropped, so a
// dead server scored as bad phrasing.
func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes []string) (string, error) {
// Blank sources are no sources. A caller that hands over one empty string —
// a page that fetched to nothing, a snippet trimmed away — used to take the
@@ -439,13 +467,15 @@ func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes []
if len(notes) == 0 {
sys, prompt := p.knowledgePrompt(utterance)
resp, err := p.chatWithSystem(ctx, sys, prompt, 768)
if err != nil || resp == "" {
return "не знаю.", nil
if err != nil {
return UnknownFallback(), fmt.Errorf("phrase query (knowledge): %w", err)
}
if resp == "" {
return UnknownFallback(), errEmptyResponse
}
text, _, perr := parseResponseMood(resp)
if perr != nil {
log.Printf("phraser: PhraseQuery: %v", perr)
return "не знаю.", nil
return UnknownFallback(), fmt.Errorf("phrase query (knowledge): %w", perr)
}
if text != "" {
return text, nil
@@ -457,13 +487,12 @@ func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes []
text, _, perr := parseResponseMood(resp)
if err != nil || perr != nil {
// Read the notes out rather than ship a broken fragment.
if perr != nil {
log.Printf("phraser: PhraseQuery: %v", perr)
cause := err
if cause == nil {
cause = perr
}
if len(notes) == 1 {
return "вот что я нашла: " + notes[0], nil
}
return "вот что я нашла: " + strings.Join(notes, "; "), nil
return SourcesFallback(strings.Join(notes, "; ")),
fmt.Errorf("phrase query (evidence): %w", cause)
}
if text != "" {
return text, nil
@@ -472,8 +501,9 @@ func (p *LLMPhraser) PhraseQuery(ctx context.Context, utterance string, notes []
}
// PhraseChat uses the LLM to respond conversationally, building a multi-turn
// message array from dialogue history + the current user utterance. Falls back
// to a simple greeting on any LLM error — better to say something than nothing.
// message array from dialogue history + the current user utterance. On any LLM
// error it returns both ChatFallback and the error, on the same rule as
// PhraseQuery: the fallback keeps the turn alive, the error stays visible.
func (p *LLMPhraser) PhraseChat(ctx context.Context, utterance string, history []dialogue.Turn) (string, error) {
sys := chatSystemPrompt(p.cfg.ContextBlock)
msgs := []chatMsg{
@@ -490,13 +520,11 @@ func (p *LLMPhraser) PhraseChat(ctx context.Context, utterance string, history [
resp, err := p.chatWithMessages(ctx, msgs, 768)
if err != nil {
log.Printf("phraser: PhraseChat: %v", err)
return "поговорили.", nil
return ChatFallback(), fmt.Errorf("phrase chat: %w", err)
}
text, _, perr := parseResponseMood(resp)
if perr != nil {
log.Printf("phraser: PhraseChat: %v", perr)
return "поговорили.", nil
return ChatFallback(), fmt.Errorf("phrase chat: %w", perr)
}
if text != "" {
return text, nil
+10 -10
View File
@@ -18,6 +18,8 @@ import (
"fmt"
"math/rand"
"regexp"
"github.com/kami/maven/internal/lexicon"
"strings"
"sync"
"time"
@@ -210,13 +212,11 @@ func capitalizeFirst(s string) string {
}
// hourWords — hours spelled out. "3 ч" is fine on a screen and wrong in a
// Russian voice, so the number goes out as words.
var hourWords = []string{
"ноль", "один", "два", "три", "четыре", "пять", "шесть", "семь", "восемь",
"девять", "десять", "одиннадцать", "двенадцать", "тринадцать",
"четырнадцать", "пятнадцать", "шестнадцать", "семнадцать", "восемнадцать",
"девятнадцать", "двадцать", "двадцать один", "двадцать два", "двадцать три",
}
// Russian voice, so the number goes out as words. Closed set, indexed by the
// hour, kept in internal/lexicon (Vikunja #525).
func hourWord(h int) string { return lexicon.HourSpoken(h) }
const hoursSpoken = 24
// hourPlural — час / часа / часов by Russian counting rules.
func hourPlural(h int) string {
@@ -245,7 +245,7 @@ func ruSinceWords(d time.Duration) string {
h++
m = 0
}
if h >= len(hourWords) {
if h >= hoursSpoken {
return "больше суток"
}
if h == 1 {
@@ -255,7 +255,7 @@ func ruSinceWords(d time.Duration) string {
return "час"
}
if m >= 15 {
return hourWords[h] + " с половиной часа"
return hourWord(h) + " с половиной часа"
}
return hourWords[h] + " " + hourPlural(h)
return hourWord(h) + " " + hourPlural(h)
}
+3 -6
View File
@@ -70,18 +70,15 @@ func NewStub() *Stub { return &Stub{} }
// prompted response from the model. The history parameter is accepted but
// ignored at the stub level (the production impl uses it for multi-turn).
func (s *Stub) PhraseChat(_ context.Context, _ string, _ []dialogue.Turn) (string, error) {
return "поговорили.", nil
return ChatFallback(), nil
}
// PhraseQuery returns a deterministic summary of the best matching notes.
func (s *Stub) PhraseQuery(_ context.Context, _ string, notes []string) (string, error) {
if len(notes) == 0 {
return "не знаю.", nil
return UnknownFallback(), nil
}
if len(notes) == 1 {
return "вот что я нашла: " + notes[0], nil
}
return "вот что я нашла: " + strings.Join(notes, "; "), nil
return SourcesFallback(strings.Join(notes, "; ")), nil
}
// Close implements Phraser.Close (no-op for the stub).
+19
View File
@@ -0,0 +1,19 @@
package phraser
// The counted noun, for callers that already speak through this package.
//
// The rule itself lives in internal/say, next to the line files that carry the
// {word} placeholder, because internal/say sits under phraser in the import
// graph and internal/memory and internal/tasks need it too. These are the same
// three functions under the names cmd/mavend already calls.
import "github.com/kami/maven/internal/say"
// CountWord picks between the three forms a Russian count needs.
func CountWord(n int, one, few, many string) string { return say.CountWord(n, one, few, many) }
// Degrees — the noun for a temperature.
func Degrees(temp float64) string { return say.Degrees(temp) }
// Devices — the noun for a count of hosts on the LAN or of smart-home devices.
func Devices(n int) string { return say.Devices(n) }
+32
View File
@@ -0,0 +1,32 @@
package phraser
import "testing"
// The bug the helper exists for: the weather line said "градусов" for every
// reading, which is wrong for 1-4 and for every number ending in 1-4.
func TestDegreesAgreeWithTheReading(t *testing.T) {
for temp, want := range map[float64]string{
1: "градус", 1.4: "градус", 2: "градуса", 4: "градуса", 5: "градусов",
0: "градусов", 11: "градусов", 14: "градусов", 21: "градус",
22: "градуса", 25: "градусов", 101: "градус",
// Minus does not change the noun, and a reading rounds to the number
// she is about to say: -2.4° is "-2 градуса", not "-2 градусов".
-1: "градус", -2.4: "градуса", -5: "градусов", -11: "градусов",
} {
if got := Degrees(temp); got != want {
t.Errorf("Degrees(%v) = %q, want %q", temp, got, want)
}
}
}
func TestDevicesAgreeWithTheCount(t *testing.T) {
for n, want := range map[int]string{
1: "устройство", 2: "устройства", 4: "устройства", 5: "устройств",
11: "устройств", 12: "устройств", 21: "устройство", 22: "устройства",
25: "устройств", 111: "устройств", 101: "устройство", 0: "устройств",
} {
if got := Devices(n); got != want {
t.Errorf("Devices(%d) = %q, want %q", n, got, want)
}
}
}
+171
View File
@@ -0,0 +1,171 @@
package phraser
// The query answers and gaps — what a query source says when it answers from
// something other than the model, and what it says when it has nothing.
//
// Third family on the shared deck (deck.go), after the fallbacks and the
// acknowledgements. They were literals spread across actions_query.go and
// netscan.go, where the largest single site held two dozen of them.
//
// QueryUnknown is not the phraser's UnknownFallback, even though the two read
// the same today. Here she looked and found nothing; there she failed to phrase
// an answer she had. Two files, two entries, so rewording one leaves the other.
import (
_ "embed"
"log"
"math/rand"
"sync"
"github.com/kami/maven/internal/say"
)
//go:embed query_ru_v1.json
var queryJSON []byte
// QuerySchemaVersion — this family's own version.
const QuerySchemaVersion = 1
// The entry keys.
const (
QueryUnknown = "query_unknown"
QueryOtherDay = "other_day"
QueryPersonalNone = "personal_none"
QueryFactWhen = "fact_when"
QueryFactValue = "fact_value"
QueryFound = "found"
QueryPageText = "page_text"
QueryPageBlocked = "page_blocked"
QueryPageEmpty = "page_empty"
QueryFeedsOff = "feeds_off"
QueryFeedsNew = "feeds_new"
QueryFeedsEmpty = "feeds_empty"
QueryFeedsTopic = "feeds_empty_topic"
QueryWeatherNow = "weather_now"
QueryWeatherOff = "weather_off"
QueryWeatherWhere = "weather_nolocation"
QueryNetEmpty = "net_empty"
QueryNetOff = "net_off"
QueryPageOff = "page_off"
QueryFailPlan = "fail_plan"
QueryFailNotes = "fail_notes"
QueryFailFeeds = "fail_feeds"
QueryFailCalendar = "fail_calendar"
QueryFailWeather = "fail_weather"
QueryFailAnswer = "fail_answer"
QueryFailPage = "fail_page"
QueryFailNetscan = "fail_netscan"
)
var queryKeys = []string{
QueryUnknown, QueryOtherDay, QueryPersonalNone, QueryFactWhen, QueryFactValue,
QueryFound, QueryPageText, QueryPageBlocked, QueryPageEmpty,
QueryFeedsOff, QueryFeedsNew, QueryFeedsEmpty, QueryFeedsTopic,
QueryWeatherNow, QueryWeatherOff, QueryWeatherWhere, QueryNetEmpty, QueryNetOff, QueryPageOff,
QueryFailPlan, QueryFailNotes, QueryFailFeeds, QueryFailCalendar,
QueryFailWeather, QueryFailAnswer, QueryFailPage, QueryFailNetscan,
}
// queryFloor — the literal each key falls back to when the file is unusable.
// It started as the exact strings that lived in Go before this file existed and
// now tracks the file's first variant instead, because a floor that keeps the
// wording review threw out would say it back on the one turn nobody is watching.
var queryFloor = map[string]string{
QueryUnknown: "ничего не нашла.",
QueryOtherDay: "про другой день так не отвечу — спроси целиком.",
QueryPersonalNone: "не знаю — не нашла у тебя такой записи.",
QueryFactWhen: "записала это {when}",
QueryFactValue: "у меня записано: {key} — {value}",
QueryFound: "вот что я нашла: {text}",
QueryPageText: "вот что на странице: {text}",
QueryPageBlocked: "эта страница закрыта для чтения — сам сайт это запрещает.",
QueryPageEmpty: "страница открылась, но читать там нечего.",
QueryFeedsOff: "ленты не настроены.",
QueryFeedsNew: "вот что нового: {items}",
QueryFeedsEmpty: "в лентах пока ничего нового.",
QueryFeedsTopic: "по этой теме в лентах пока ничего.",
QueryWeatherNow: "в {location} сейчас {temp} {word}, {condition}.",
QueryWeatherOff: "погода не настроена.",
QueryWeatherWhere: "для какого города?",
QueryNetEmpty: "в сети никого не нашла.",
QueryNetOff: "сканирование сети не настроено.",
QueryPageOff: "я не читаю страницы — это не настроено.",
QueryFailPlan: "не получилось собрать план.",
QueryFailNotes: "не получилось посмотреть записи.",
QueryFailFeeds: "не получилось посмотреть ленты.",
QueryFailCalendar: "не получилось проверить календарь.",
QueryFailWeather: "не получилось узнать погоду.",
QueryFailAnswer: "не получилось найти ответ.",
QueryFailPage: "не получилось прочитать страницу.",
QueryFailNetscan: "не получилось просканировать сеть.",
}
// Queries picks a hand-written Russian query line. Safe for concurrent use.
type Queries struct{ d *say.Deck }
// LoadQueries reads the embedded file. Pass a source to make the picking
// reproducible in tests; nil seeds from the clock.
func LoadQueries(src rand.Source) (*Queries, error) {
d, err := say.Load(queryJSON, QuerySchemaVersion, queryKeys, queryFloor, src)
if err != nil {
return nil, err
}
// The entries that exist to read something back. A variant without the
// placeholder would answer the question by dropping the answer.
for _, req := range []struct{ key, ph string }{
{QueryFactWhen, "{when}"}, {QueryFactValue, "{key}"}, {QueryFactValue, "{value}"},
{QueryFound, "{text}"}, {QueryPageText, "{text}"}, {QueryFeedsNew, "{items}"},
{QueryWeatherNow, "{location}"}, {QueryWeatherNow, "{temp}"},
{QueryWeatherNow, "{word}"}, {QueryWeatherNow, "{condition}"},
} {
if err := d.RequirePlaceholder(req.key, req.ph); err != nil {
return nil, err
}
}
return &Queries{d: d}, nil
}
// deck reads through a nil *Queries, which is the unloadable-file case.
func (q *Queries) deck() *say.Deck {
if q == nil {
return say.FloorDeck(queryFloor)
}
return q.d
}
// Say returns one line for key, with the values filled into the frame.
func (q *Queries) Say(key string, vars map[string]string) string {
return q.deck().Text(key, vars)
}
// Variants returns every line the file can produce, for the persona scorer.
func (q *Queries) Variants() []string { return q.deck().Variants() }
var (
queryOnce sync.Once
queries *Queries
)
// DefaultQueries returns the shared instance, loading it on first use. A broken
// file logs once and leaves a nil *Queries, which still answers from queryFloor.
func DefaultQueries() *Queries {
queryOnce.Do(func() {
q, err := LoadQueries(nil)
if err != nil {
log.Printf("phraser: query lines unavailable, using the built-in ones: %v", err)
return
}
queries = q
})
return queries
}
// Q — one query line, the way every caller says it.
func Q(key string, vars map[string]string) string { return DefaultQueries().Say(key, vars) }
// IsQ reports whether text is a line key could have produced, for the tests.
func IsQ(key string, vars map[string]string, text string) bool {
return DefaultQueries().deck().Matches(key, vars, text)
}
+107
View File
@@ -0,0 +1,107 @@
package phraser
import (
"math/rand"
"strings"
"testing"
)
func TestQueriesLoad(t *testing.T) {
q, err := LoadQueries(rand.NewSource(1))
if err != nil {
t.Fatalf("LoadQueries: %v", err)
}
for _, k := range queryKeys {
if got := q.Say(k, nil); got == "" {
t.Errorf("%s says nothing", k)
}
}
}
// The bug: net_empty carried {tail} in every variant, and a scan that finished
// the whole range has no caveat to put there. Whatever the file says, an answer
// he can hear has to come out — never braces, never nothing.
func TestNetEmptySaysSomethingWithNoTail(t *testing.T) {
q, err := LoadQueries(rand.NewSource(1))
if err != nil {
t.Fatalf("LoadQueries: %v", err)
}
for _, vars := range []map[string]string{nil, {"tail": ""}} {
for i := 0; i < 20; i++ {
got := q.Say(QueryNetEmpty, vars)
if got == "" || strings.ContainsAny(got, "{}") {
t.Fatalf("net_empty with vars %v said %q", vars, got)
}
}
}
}
// The other half: a caveat he was given is not dropped for a shorter wording.
func TestNetEmptyKeepsTheTailItIsGiven(t *testing.T) {
q, err := LoadQueries(rand.NewSource(1))
if err != nil {
t.Fatalf("LoadQueries: %v", err)
}
const tail = ", но успела посмотреть не всю сеть"
for i := 0; i < 20; i++ {
if got := q.Say(QueryNetEmpty, map[string]string{"tail": tail}); !strings.Contains(got, tail) {
t.Fatalf("net_empty dropped the tail: %q", got)
}
}
}
// query_unknown means she looked and found nothing. The phraser's fallback
// means she failed to phrase an answer she had. Two causes, two sentences, or
// the distinction the two files exist for is unobservable from the outside.
func TestQueryUnknownNeverRepeatsAPhrasingFallback(t *testing.T) {
q, err := LoadQueries(rand.NewSource(1))
if err != nil {
t.Fatalf("LoadQueries: %v", err)
}
f, err := LoadFallbacks(rand.NewSource(1))
if err != nil {
t.Fatalf("LoadFallbacks: %v", err)
}
failures := map[string]bool{}
for _, v := range f.Variants() {
failures[v] = true
}
for _, v := range q.d.VariantsOf(QueryUnknown) {
if failures[v] {
t.Errorf("query_unknown variant %q is also a phrasing failure line", v)
}
}
}
// The weather line splits the count into a number and a noun, so a variant that
// says the temperature without {word} is the hardcoded "градусов" coming back.
func TestWeatherLineCountsWithTheHelper(t *testing.T) {
q, err := LoadQueries(rand.NewSource(1))
if err != nil {
t.Fatalf("LoadQueries: %v", err)
}
for _, v := range q.d.VariantsOf(QueryWeatherNow) {
if strings.Contains(v, "градус") {
t.Errorf("weather_now variant %q spells the noun out instead of using {word}", v)
}
}
got := q.Say(QueryWeatherNow, map[string]string{
"location": "Москва", "temp": "1", "word": Degrees(1), "condition": "ясно",
})
if !strings.Contains(got, "1 градус,") {
t.Errorf("weather_now said %q, want the singular noun", got)
}
}
// No line spoken to him names a config key. She asks instead.
func TestNoQueryLineRecitesAConfigPath(t *testing.T) {
q, err := LoadQueries(rand.NewSource(1))
if err != nil {
t.Fatalf("LoadQueries: %v", err)
}
for _, v := range q.Variants() {
if strings.Contains(v, "voice.") || strings.Contains(v, "_location") {
t.Errorf("variant %q says a config path out loud", v)
}
}
}
+115
View File
@@ -0,0 +1,115 @@
{
"schema_version": 1,
"name": "russian query answers and gaps v1",
"notes": [
"What a query source says when it answers from something other than the model, and what it says when it has nothing. Edit the wording here, no Go changes needed.",
"Rules: she is feminine about herself, he is a man addressed as ты. Never вы/вас/ваш, never он/его about him. No pet names.",
"A gap names its own gap. \"the feeds are not configured\", \"the search failed\" and \"I do not know\" are different sentences and must never collapse into one entry.",
"query_unknown here is a real answer — she looked and found nothing. The phraser's identical-looking line in fallbacks_ru_v1.json is a failure to phrase. Two files, two entries, on purpose, so no variant here may repeat a string from there.",
"Placeholders: {key} {value} a stored fact, {when} when she wrote it, {items} what she found, {text} a passage, {location} {temp} {condition} the weather, {word} the counted noun in the form {temp} needs, {tail} a caveat about how the answer was gathered.",
"A count never carries a hardcoded noun. Russian inflects it — 1 градус, 2 градуса, 5 градусов — so the number goes in {temp} and the noun comes from the Go helper through {word}.",
"{tail} is optional, and an entry that can be said without it needs one variant carrying no placeholder at all. Otherwise nothing is fillable and she says nothing, which he hears as a hang.",
"fixed: true means exactly one variant and no picking. Used where the wording is load-bearing: the personal boundary, and the refusal to re-ask a question for another day."
],
"entries": {
"query_unknown": {
"variants": ["ничего не нашла.", "искала — не нашла."]
},
"other_day": {
"fixed": true,
"variants": ["про другой день так не отвечу — спроси целиком."]
},
"personal_none": {
"fixed": true,
"variants": ["не знаю — не нашла у тебя такой записи."]
},
"fact_when": {
"fixed": true,
"variants": ["записала это {when}"]
},
"fact_value": {
"variants": ["у меня записано: {key} — {value}", "вот что записано: {key} — {value}"]
},
"found": {
"variants": ["вот что я нашла: {text}", "нашла вот это: {text}", "есть такое: {text}"]
},
"page_text": {
"variants": ["вот что на странице: {text}", "на странице вот это: {text}"]
},
"page_blocked": {
"fixed": true,
"variants": ["эта страница закрыта для чтения — сам сайт это запрещает."]
},
"page_empty": {
"fixed": true,
"variants": ["страница открылась, но читать там нечего."]
},
"feeds_off": {
"fixed": true,
"variants": ["ленты не настроены."]
},
"feeds_new": {
"variants": ["вот что нового: {items}", "нового вот что: {items}"]
},
"feeds_empty": {
"variants": ["в лентах пока ничего нового.", "в лентах тихо."]
},
"feeds_empty_topic": {
"variants": ["по этой теме в лентах пока ничего.", "по этой теме в лентах тихо."]
},
"weather_now": {
"variants": ["в {location} сейчас {temp} {word}, {condition}.", "{location}: {temp} {word}, {condition}."]
},
"weather_off": {
"fixed": true,
"variants": ["погода не настроена."]
},
"weather_nolocation": {
"fixed": true,
"variants": ["для какого города?"]
},
"net_off": {
"fixed": true,
"variants": ["сканирование сети не настроено."]
},
"page_off": {
"fixed": true,
"variants": ["я не читаю страницы — это не настроено."]
},
"net_empty": {
"variants": ["в сети никого не нашла.", "в сети никого не нашла{tail}."]
},
"fail_plan": {
"fixed": true,
"variants": ["не получилось собрать план."]
},
"fail_notes": {
"fixed": true,
"variants": ["не получилось посмотреть записи."]
},
"fail_feeds": {
"fixed": true,
"variants": ["не получилось посмотреть ленты."]
},
"fail_calendar": {
"fixed": true,
"variants": ["не получилось проверить календарь."]
},
"fail_weather": {
"fixed": true,
"variants": ["не получилось узнать погоду."]
},
"fail_answer": {
"fixed": true,
"variants": ["не получилось найти ответ."]
},
"fail_page": {
"fixed": true,
"variants": ["не получилось прочитать страницу."]
},
"fail_netscan": {
"fixed": true,
"variants": ["не получилось просканировать сеть."]
}
}
}
+31
View File
@@ -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) {
+5 -3
View File
@@ -215,10 +215,12 @@ func TestSwap_RollbackFailureLeavesNoBackendAndDegrades(t *testing.T) {
if _, _, aerr := p.acquire(); !errors.Is(aerr, ErrNoBackend) {
t.Errorf("acquire error = %v; want ErrNoBackend", aerr)
}
// Phrasing degrades to its fallback instead of failing the turn.
// Phrasing degrades to its fallback instead of failing the turn, and since
// Vikunja #397 it reports the error next to that fallback so a measuring
// caller can tell "no model" from "bad phrasing".
got, err := p.PhraseChat(context.Background(), "привет", nil)
if err != nil {
t.Fatalf("PhraseChat after a total failure returned an error: %v", err)
if !errors.Is(err, ErrNoBackend) {
t.Errorf("PhraseChat error = %v; want ErrNoBackend alongside the fallback", err)
}
if got == "" {
t.Error("PhraseChat returned empty; the fallback must still say something")
+37
View File
@@ -76,6 +76,29 @@ func TestAgendaGrammarSparesStatements(t *testing.T) {
}
}
// The two shapes that carried no question mark and no interrogative, so the
// model saw them first and called them facts (Vikunja #498).
func TestNarrativeGrammarsRouteToQuery(t *testing.T) {
r := agendaRouter(t)
r.grammars = append(r.grammars, NarrativeQueryGrammars()...)
for _, u := range []string{
"что дальше?",
"и что там дальше",
"what's next?",
"расскажи про битву при Ватерлоо",
"объясни как работает дизель",
"опиши Ватерлоо",
} {
d, err := r.Route(context.Background(), u, refNow())
if err != nil {
t.Fatalf("%q: %v", u, err)
}
if d.Intent != IntentQuery || d.Stage != 0 {
t.Errorf("%q routed intent=%s stage=%d, want query at stage 0", u, d.Intent, d.Stage)
}
}
}
// The tomorrow form and the bare event noun. Both were measured answering
// "пока не умею" on the deployed daemon, 02-08-2026, while the same question
// about today worked — the first rule set needed "у меня" or a calendar noun
@@ -101,6 +124,20 @@ func TestAgendaCoversOtherDaysAndNamedEvents(t *testing.T) {
}
}
// A narrative verb next to a capture verb is him asking for a note. Stage 0
// declines and the extractor gets its turn.
func TestNarrativeGrammarLeavesCapturesAlone(t *testing.T) {
r := agendaRouter(t)
r.grammars = append(r.grammars, NarrativeQueryGrammars()...)
d, err := r.Route(context.Background(), "расскажи и запиши что я пил воду", refNow())
if err != nil {
t.Fatal(err)
}
if d.Stage == 0 && d.Intent == IntentQuery {
t.Errorf("stage 0 claimed a capture: %+v", d)
}
}
// The two new rules are narrow on purpose. A world question that opens with
// "когда" is not an agenda question, and telling her about a plan is not
// asking about one.
+96
View File
@@ -0,0 +1,96 @@
package router
import (
"strings"
"github.com/kami/maven/internal/lexicon"
"github.com/kami/maven/internal/morph"
)
// transientWords — the states a thing is in for an afternoon, as dictionary
// forms. They used to be prefixes, which is how a prefix list always goes wrong:
// "лаг" matched "лагерь" and "падает" matched nothing else it inflected into.
// morph.SameWord compares the words themselves (Vikunja #528).
//
// Russian aspect pairs are two separate verbs, so a slot lists both where both
// are said. English members have no dictionary here and fall back to exact
// comparison, which is what they had.
var transientWords = []string{
"медленный", "медленно", "тормозить", "тормоз", "лагать", "лаг",
"зависать", "зависнуть", "виснуть", "глючить", "барахлить",
"отваливаться", "отвалиться", "падать", "упасть", "сдохнуть",
"греться", "перегреваться", "перегреться",
"slow", "laggy", "stuck", "frozen", "flaky", "broken", "down",
}
// brokenWords — what "не ..." is denying when the sentence is a complaint.
// Dictionary forms, same reason.
var brokenWords = []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.
//
// A closed class, so it comes from the lexicon: the first-person pronoun has a
// fixed number of forms and this file was the third place they were typed out
// (Vikunja #528).
var selfMarkers = lexicon.FirstPerson()
// 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 _, w := range transientWords {
if morph.SameWord(tok, w) {
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 brokenWords {
if morph.SameWord(toks[j], v) {
return true
}
}
}
}
return false
}
+66
View File
@@ -0,0 +1,66 @@
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)
}
}
}
// TestComplaintPrefixCollisions — what the prefix list got wrong and the
// dictionary does not (Vikunja #528). Each of these contains a word that starts
// with one of the old stems and is a different word.
func TestComplaintPrefixCollisions(t *testing.T) {
for _, s := range []string{
// "лаг" matched "лагерь".
"детский лагерь под москвой",
// "падает" was literal, but "падеж" and "падение" start on "пад".
"падение цен на квартиры",
// "отвал" matched "отвальная".
"отвальная в пятницу",
} {
if IsTransientComplaint(s) {
t.Errorf("IsTransientComplaint(%q) = true, want false", s)
}
}
// And the real complaints still read as complaints, in the inflections the
// prefixes were there to cover.
for _, s := range []string{
"сеть какая-то медленная",
"интернет не работает",
"nextcloud тормозит",
"диск сдохнет скоро",
"сервис не открывается",
} {
if !IsTransientComplaint(s) {
t.Errorf("IsTransientComplaint(%q) = false, want true", s)
}
}
}
+3
View File
@@ -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 —
+8
View File
@@ -236,7 +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.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,
+4
View File
@@ -25,11 +25,15 @@
{ "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" },
{ "id": "ru-query-017", "utterance": "чем я занимался в среду", "lang": "ru", "intent": "query", "tags": ["hard", "chat-shaped"] },
{ "id": "ru-query-018", "utterance": "хватает ли места под новые бэкапы", "lang": "ru", "intent": "query", "tags": ["homelab"] },
{ "id": "ru-query-020", "utterance": "что дальше?", "lang": "ru", "intent": "query", "tags": ["agenda", "hard"], "note": "the rest of the day, with no interrogative the model can read as a question — it routed fact until a stage 0 rule claimed it (V-498)" },
{ "id": "ru-query-021", "utterance": "расскажи про битву при Ватерлоо", "lang": "ru", "intent": "query", "tags": ["world", "hard"], "note": "a world question phrased as an instruction. It routed fact, and the fact gate had to catch the write (V-498)" },
{ "id": "en-query-001", "utterance": "did I take my vitamins today", "lang": "en", "intent": "query", "tags": ["fact-shaped"] },
{ "id": "en-query-002", "utterance": "how long since the last backup finished", "lang": "en", "intent": "query", "tags": ["temporal"] },
{ "id": "en-query-003", "utterance": "show me this week's weight", "lang": "en", "intent": "query", "tags": ["imperative"] },
+55
View File
@@ -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)
}
}
}
+270
View File
@@ -0,0 +1,270 @@
package router
import (
"regexp"
"strings"
"github.com/kami/maven/internal/morph"
)
// Standing lists, matched deterministically (Vikunja #453).
//
// Same posture as task capture in task.go and for the same reason: the intent
// enum is a contract shared with the relabelling prompt, so a list is not an
// eighth intent. It is a note-shaped or query-shaped utterance carrying an
// explicit marker, and the marker is a lookup.
//
// The markers are deliberately explicit. "молоко закончилось" is an
// observation about the world and belongs in a note; only an instruction to
// put something on a list puts it there.
// listTags — the lists he can name, as one dictionary form each. Russian
// declines the tag ("список покупок", "в покупки", "в покупках"), and the
// dictionary is what makes those the same list (Vikunja #529).
//
// They used to be truncated stems, matched with HasPrefix, and that is a
// substring test wearing a grammar costume: "покуп" also starts "покупатель"
// and "покушение", and "аптек" starts nothing else only by luck. The English
// tags are exact tokens, since the dictionary is Russian.
var listTags = []struct{ word, list string }{
{"покупка", "покупки"},
{"продукт", "покупки"},
{"магазин", "покупки"},
{"аптека", "аптека"},
{"хозяйство", "хозяйство"},
}
var listTagsEN = []struct{ word, list string }{
{"shopping", "покупки"},
{"groceries", "покупки"},
{"pharmacy", "аптека"},
}
// The four phrase tables below stay whole phrases, and that is the mechanism
// answer rather than an exception to it (Vikunja #529). Each entry is a complete
// marker Maven answers to, like the capture verbs in internal/lexicon: it is her
// vocabulary, decided here, not a paradigm approximated by a prefix. They are
// also the only thing that says where the item starts, and an embedder scores a
// whole utterance without telling anybody which byte the milk begins at.
// listCapturePrefixes — an instruction to add to a list. Longest match wins.
var listCapturePrefixes = []string{
"добавь в список",
"добавь в покупки",
"добавь к покупкам",
"запиши в список",
"внеси в список",
"положи в список",
"в список покупок",
"add to the list",
"add to my list",
"add to the shopping list",
"put on the list",
}
// listQueryPrefixes — an ask to read a list back.
var listQueryPrefixes = []string{
"что в списке",
"что в покупках",
"что мне купить",
"что нужно купить",
"что надо купить",
"покажи список",
"прочитай список",
"список покупок",
"мой список",
"what is on the list",
"what's on the list",
"read me the list",
"show me the list",
"shopping list",
}
// listClearPhrases — the whole list is got. One sentence, one turn.
var listClearPhrases = []string{
"всё купил",
"все купил",
"всё взял",
"все взял",
"очисти список",
"очисти покупки",
"список пустой",
"got everything",
"clear the list",
}
// listRemovePrefixes — one item off the list.
var listRemovePrefixes = []string{
"вычеркни",
"убери из списка",
"убери со списка",
"купил",
"взял",
"cross off",
"remove from the list",
}
// listTrimCut — punctuation and connectives to strip off a parsed remainder.
const listTrimCut = " .,;:!?—-"
// ListCapture — a parsed list instruction: which list, and the item.
type ListCapture struct {
List string
Item string
}
// ParseListCapture reports whether an utterance puts something on a list, and
// returns the list tag and the item. A marker with nothing usable after it is
// not a capture: there is no item in "добавь в список покупок".
func ParseListCapture(text string) (ListCapture, bool) {
rest, ok := afterLongestPrefix(text, listCapturePrefixes)
if !ok {
return ListCapture{}, false
}
list, rest := takeListTag(rest)
rest = strings.Trim(rest, listTrimCut)
if rest == "" {
return ListCapture{}, false
}
return ListCapture{List: list, Item: rest}, true
}
// ParseListQuery reports whether an utterance asks for a list, and which one.
func ParseListQuery(text string) (string, bool) {
rest, ok := afterLongestPrefix(text, listQueryPrefixes)
if !ok {
return "", false
}
list, _ := takeListTag(rest)
return list, true
}
// ParseListClear reports whether an utterance crosses off a whole list.
func ParseListClear(text string) (string, bool) {
lower := strings.ToLower(strings.Trim(strings.TrimSpace(text), listTrimCut))
for _, p := range listClearPhrases {
if lower == p || strings.HasPrefix(lower, p+" ") {
list, _ := takeListTag(strings.TrimSpace(lower[len(p):]))
return list, true
}
}
return "", false
}
// ParseListRemove reports whether an utterance takes one named item off a
// list, and returns the list and the item.
//
// The item is required. "купил" on its own is him reporting he shopped, which
// ParseListClear reads first, and it must not fall through to here and remove
// nothing while sounding like it did.
func ParseListRemove(text string) (ListCapture, bool) {
rest, ok := afterLongestPrefix(text, listRemovePrefixes)
if !ok {
return ListCapture{}, false
}
list, rest := takeListTag(rest)
rest = strings.Trim(rest, listTrimCut)
for _, lead := range []string{"из списка ", "со списка ", "из ", "from the list "} {
rest = strings.TrimPrefix(rest, lead)
}
rest = strings.Trim(rest, listTrimCut)
if rest == "" {
return ListCapture{}, false
}
return ListCapture{List: list, Item: rest}, true
}
// afterLongestPrefix matches the longest prefix in the table and returns what
// follows it, trimmed. Lowercasing does not change the byte length of Russian
// or English letters, so the index carries over to the original text.
func afterLongestPrefix(text string, prefixes []string) (string, bool) {
trimmed := strings.TrimSpace(text)
lower := strings.ToLower(trimmed)
best := ""
for _, p := range prefixes {
if strings.HasPrefix(lower, p) && len(p) > len(best) {
best = p
}
}
if best == "" {
return "", false
}
return strings.Trim(trimmed[len(best):], listTrimCut), true
}
// takeListTag reads a list name off the front of the remainder and returns the
// list plus what is left. A remainder naming no list is the default list, and
// nothing is consumed — "добавь в список молоко" names no list and the item is
// молоко.
func takeListTag(rest string) (string, string) {
fields := strings.Fields(rest)
if len(fields) == 0 {
return "покупки", ""
}
head := strings.ToLower(strings.Trim(fields[0], listTrimCut))
// "в список покупок" leaves "покупок"; "в списке" leaves nothing. One
// dictionary form covers the three cases that used to be spelled out.
if morph.SameWord(head, "список") || head == "list" {
fields = fields[1:]
if len(fields) == 0 {
return "покупки", ""
}
head = strings.ToLower(strings.Trim(fields[0], listTrimCut))
}
for _, s := range listTags {
if morph.SameWord(head, s.word) {
return s.list, strings.Join(fields[1:], " ")
}
}
for _, s := range listTagsEN {
if head == s.word {
return s.list, strings.Join(fields[1:], " ")
}
}
return "покупки", strings.Join(fields, " ")
}
// ListGrammars — stage 0 for the list (Vikunja #453).
//
// Both patterns match everything and the Build functions are the real filter,
// the shape the wake-word act grammar already uses: the parsers above are the
// definition of a list utterance and duplicating them as regexps would give
// two answers to one question.
//
// Why stage 0 at all: an add and a read-back are deterministic and cheap, and
// leaving them to the model means "добавь в список покупок молоко" lands as an
// act or a fact on the turns the model has a bad day. The action handlers still
// re-parse, so a list turn that arrives by any other route still works.
func ListGrammars() []Grammar {
anything := regexp.MustCompile(`(?s)^(.*)$`)
return []Grammar{
{
Name: "list-query",
Pattern: anything,
Build: func(m []string) (Decision, bool) {
if _, ok := ParseListQuery(m[1]); !ok {
return Decision{}, false
}
return Decision{Stage: 0, Intent: IntentQuery, Confidence: 1.0}, true
},
},
{
Name: "list-capture",
Pattern: anything,
Build: func(m []string) (Decision, bool) {
text := m[1]
_, add := ParseListCapture(text)
_, clear := ParseListClear(text)
if !add && !clear {
return Decision{}, false
}
return Decision{
Stage: 0,
Intent: IntentNote,
Confidence: 1.0,
Slots: Slots{Text: strings.TrimSpace(text)},
}, true
},
},
}
}
+110
View File
@@ -0,0 +1,110 @@
package router
import "testing"
func TestParseListCaptureReadsListAndItem(t *testing.T) {
cases := []struct {
utterance string
list string
item string
}{
{"добавь в список покупок молоко", "покупки", "молоко"},
{"добавь в список молоко", "покупки", "молоко"},
{"Добавь в покупки хлеб и яйца", "покупки", "хлеб и яйца"},
{"запиши в список аптеки бинт", "аптека", "бинт"},
{"добавь в список хозяйства лампочки.", "хозяйство", "лампочки"},
{"add to the shopping list milk", "покупки", "milk"},
}
for _, c := range cases {
got, ok := ParseListCapture(c.utterance)
if !ok {
t.Errorf("ParseListCapture(%q) did not claim it", c.utterance)
continue
}
if got.List != c.list || got.Item != c.item {
t.Errorf("ParseListCapture(%q) = %+v; want list %q item %q", c.utterance, got, c.list, c.item)
}
}
}
// A marker with no item is not a capture, and an utterance that only mentions
// shopping is not one either.
func TestParseListCapturePasses(t *testing.T) {
for _, u := range []string{
"добавь в список покупок",
"добавь в список",
"молоко закончилось",
"надо бы съездить в магазин",
"добавь в задачи купить молоко",
} {
if got, ok := ParseListCapture(u); ok {
t.Errorf("ParseListCapture(%q) claimed it as %+v", u, got)
}
}
}
func TestParseListQueryNamesTheList(t *testing.T) {
cases := []struct{ utterance, list string }{
{"что в списке покупок?", "покупки"},
{"что в списке", "покупки"},
{"что мне купить", "покупки"},
{"покажи список аптеки", "аптека"},
{"what's on the list", "покупки"},
}
for _, c := range cases {
list, ok := ParseListQuery(c.utterance)
if !ok {
t.Errorf("ParseListQuery(%q) did not claim it", c.utterance)
continue
}
if list != c.list {
t.Errorf("ParseListQuery(%q) = %q; want %q", c.utterance, list, c.list)
}
}
if _, ok := ParseListQuery("какие у меня задачи"); ok {
t.Error("ParseListQuery claimed a task question")
}
}
func TestParseListClearAndRemove(t *testing.T) {
if list, ok := ParseListClear("всё купил"); !ok || list != "покупки" {
t.Errorf("ParseListClear = %q, %v; want покупки, true", list, ok)
}
if list, ok := ParseListClear("очисти список аптеки"); !ok || list != "аптека" {
t.Errorf("ParseListClear = %q, %v; want аптека, true", list, ok)
}
if _, ok := ParseListClear("купил молоко"); ok {
t.Error("ParseListClear claimed a single item")
}
got, ok := ParseListRemove("вычеркни молоко")
if !ok || got.Item != "молоко" || got.List != "покупки" {
t.Errorf("ParseListRemove = %+v, %v; want молоко on покупки", got, ok)
}
if got, ok := ParseListRemove("убери из списка аптеки бинт"); !ok || got.Item != "бинт" || got.List != "аптека" {
t.Errorf("ParseListRemove = %+v, %v; want бинт on аптека", got, ok)
}
if _, ok := ParseListRemove("вычеркни"); ok {
t.Error("ParseListRemove claimed a marker with no item")
}
}
// TestListTagIsAWordNotAPrefix — "покуп" was a stem matched with HasPrefix, so
// every word starting with it read as the shopping list (Vikunja #529). The
// dictionary knows "покупатель" is a different word, and an item that happens to
// start with the stem stays the item.
func TestListTagIsAWordNotAPrefix(t *testing.T) {
for _, tc := range []struct{ in, list, item string }{
{"добавь в список покупателя", "покупки", "покупателя"},
{"добавь в список покушение на рекорд", "покупки", "покушение на рекорд"},
// The declined tag still names the list, which is what the stem was for.
{"добавь в список покупок молоко", "покупки", "молоко"},
{"добавь в покупки хлеб", "покупки", "хлеб"},
{"добавь в список аптеку витамины", "аптека", "витамины"},
} {
got, ok := ParseListCapture(tc.in)
if !ok || got.List != tc.list || got.Item != tc.item {
t.Errorf("ParseListCapture(%q) = (%q, %q, %v), want (%q, %q, true)",
tc.in, got.List, got.Item, ok, tc.list, tc.item)
}
}
}
+81 -41
View File
@@ -1,6 +1,11 @@
package router
import "strings"
import (
"strings"
"github.com/kami/maven/internal/lexicon"
"github.com/kami/maven/internal/morph"
)
// Money questions, matched deterministically (Vikunja #125).
//
@@ -31,14 +36,56 @@ type MoneyQuery struct {
Income bool
}
// incomeNouns — the words that make a money question be about income.
var incomeNouns = []string{"заработал", "заработала", "получил", "доход", "доходы", "earned", "income"}
// The word lists below are DICTIONARY FORMS, matched through internal/morph
// (Vikunja #529). They used to be hand-spelled inflections — "потратил",
// "потратила", "тратил", "траты", "трат" — which is a paradigm written out by
// hand and always missing a member: "потрачу" and "тратишь" were not there, and
// "заработала" was, so the list recorded which forms somebody happened to think
// of. Russian aspect pairs are two separate verbs, so both are still listed.
//
// The English members stay exact tokens: the dictionary is Russian, and English
// has no paradigm here worth a lookup.
// moneyNouns — the words that make a question be about his money.
var moneyNouns = []string{
"потратил", "потратила", "тратил", "траты", "трат", "расходы", "расходов",
"заработал", "заработала", "доход", "доходы", "потрачено", "денег",
"spend", "spent", "expenses", "earned", "income",
// incomeWords — the words that make a money question be about income.
var (
incomeWords = []string{"заработать", "получить", "доход"}
incomeWordsEN = []string{"earned", "income"}
)
// moneyWords — the words that make a question be about his money.
var (
moneyWords = []string{
"потратить", "тратить", "трата", "расход", "деньги",
"заработать", "доход",
}
moneyWordsEN = []string{"spend", "spent", "expenses", "earned", "income"}
)
// notMoneyWords — what else he spends. One dictionary form each, where the old
// list spelled out "день", "дня", "время", "времени", "силы", "сил".
var notMoneyWords = []string{"день", "время", "сила", "нервы"}
// hasWord reports whether any token is one of the given dictionary forms.
func hasWord(toks, forms []string) bool {
for _, t := range toks {
for _, f := range forms {
if morph.SameWord(t, f) {
return true
}
}
}
return false
}
// hasAnyTok reports whether any token matches exactly. For the English members,
// which are not declined.
func hasAnyTok(toks, words []string) bool {
for _, w := range words {
if hasTok(toks, w) {
return true
}
}
return false
}
// ParseMoneyQuery reports whether an utterance asks about spending or income,
@@ -56,48 +103,41 @@ func ParseMoneyQuery(text string) (MoneyQuery, bool) {
if len(toks) == 0 {
return MoneyQuery{}, false
}
hasNoun := false
for _, t := range toks {
for _, n := range moneyNouns {
if t == n {
hasNoun = true
}
}
}
if !hasNoun {
if !hasWord(toks, moneyWords) && !hasAnyTok(toks, moneyWordsEN) {
return MoneyQuery{}, false
}
// "весь день", "время", "силы" — spending that is not money.
for _, t := range toks {
switch t {
case "день", "дня", "время", "времени", "силы", "сил", "нервы":
return MoneyQuery{}, false
}
if hasWord(toks, notMoneyWords) {
return MoneyQuery{}, false
}
asking := hasTok(toks, "сколько") || hasTok(toks, "какие") || hasTok(toks, "покажи") ||
hasTok(toks, "how") || hasTok(toks, "much") || hasTok(toks, "my") ||
hasTok(toks, "мои")
// The ask half. Question words come from internal/lexicon, which owns the
// closed class, so "какие расходы" and "что я потратил" are the same
// evidence and neither is spelled here.
asking := hasWord(toks, lexicon.Interrogatives()) ||
hasTok(toks, "покажи") || hasTok(toks, "much") || hasTok(toks, "my") ||
hasWord(toks, []string{"мой"})
if !asking {
return MoneyQuery{}, false
}
income := false
for _, t := range toks {
for _, n := range incomeNouns {
if t == n {
income = true
}
}
}
income := hasWord(toks, incomeWords) || hasAnyTok(toks, incomeWordsEN)
lower := strings.ToLower(text)
switch {
// Windows nothing is stored for, named explicitly so they are refused
// rather than silently answered with the month.
case hasTok(toks, "вчера") || hasTok(toks, "позавчера") || strings.Contains(lower, "yesterday"),
hasTok(toks, "неделю") || hasTok(toks, "неделе") || hasTok(toks, "неделя") ||
strings.Contains(lower, "week"),
hasTok(toks, "год") || hasTok(toks, "году") || strings.Contains(lower, "year"):
// Which window. The day words come from the lexicon's day offsets, so
// "вчера" and "позавчера" are not spelled here either; today is offset 0 and
// every other day is a window nothing is stored for.
if off, ok := lexicon.DayOffsetIn(text); ok {
if off == 0 {
return MoneyQuery{Window: MoneyToday, Income: income}, true
}
return MoneyQuery{Window: MoneyUnsupported, Income: income}, true
case hasTok(toks, "сегодня") || strings.Contains(lower, "today"):
}
switch {
// The remaining unsupported windows, claimed so they are refused rather
// than silently answered with the month.
case hasWord(toks, []string{"неделя", "год"}),
strings.Contains(lower, "yesterday") || strings.Contains(lower, "week") ||
strings.Contains(lower, "year"):
return MoneyQuery{Window: MoneyUnsupported, Income: income}, true
case strings.Contains(lower, "today"):
return MoneyQuery{Window: MoneyToday, Income: income}, true
}
return MoneyQuery{Window: MoneyMonth, Income: income}, true
+28
View File
@@ -37,3 +37,31 @@ func TestParseMoneyQuery(t *testing.T) {
}
}
}
// TestMoneyFormsTheOldListMissed — the point of matching through the dictionary
// (Vikunja #529). Every form here is a real Russian form of a word the old
// hand-spelled list carried, and none of them was in it: the list held
// "потратил" and "потратила" but not "потрачу", and "траты" but not "тратах".
func TestMoneyFormsTheOldListMissed(t *testing.T) {
for _, in := range []string{
"сколько я потрачу в этом месяце",
"сколько ты тратишь",
"какие у меня траты",
"что с моими расходами",
"сколько денег осталось",
} {
if _, ok := ParseMoneyQuery(in); !ok {
t.Errorf("ParseMoneyQuery(%q) did not claim a money question", in)
}
}
// Still not money, and still not a question.
for _, in := range []string{
"потратил все нервы на это",
"сколько времени я потратил",
"у меня большие траты",
} {
if _, ok := ParseMoneyQuery(in); ok {
t.Errorf("ParseMoneyQuery(%q) claimed a money question", in)
}
}
}
+85
View File
@@ -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)
}
}
+97
View File
@@ -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
}
+38
View File
@@ -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())
}
}
+23 -25
View File
@@ -1,32 +1,30 @@
package router
import "strings"
import (
"strings"
// interrogatives — the question words that mark an utterance as asking rather
// than telling. Tokenized, never substring: "что" inside "чтобы" and "как"
// inside "какао" are not questions.
var interrogatives = []string{
"что", "чего", "какой", "какая", "какое", "какие", "каких",
"кто", "кого", "кому", "чей", "почему", "зачем", "отчего",
"где", "куда", "откуда", "когда", "сколько", "как",
"what", "who", "whom", "why", "when", "where", "which", "how",
}
"github.com/kami/maven/internal/lexicon"
)
// narrativeRequests — "tell me about X" asks for knowledge Maven does not
// hold about him. It carries no question mark and no interrogative, which is
// how "расскажи про битву при Ватерлоо" reached the fact store (#470).
var narrativeRequests = []string{
"расскажи", "объясни", "опиши", "перечисли",
"tell", "explain", "describe",
}
// captureVerbs — an explicit instruction to record something. These win over
// every test below, because "запиши что я пил воду" contains an interrogative
// and is still a capture: the word he said is "запиши".
var captureVerbs = []string{
"запиши", "запомни", "отметь", "заметь", "добавь", "сохрани",
"note", "remember", "log", "save",
}
// The three word sets this file tests against are closed classes, so they live
// complete in internal/lexicon rather than inline here (Vikunja #525). The
// inline lists were short: no "чем", no "чём", no "кем", no declined "какой",
// so "чем ты занята" carried no interrogative at all and read as a statement.
//
// interrogatives mark an utterance as asking rather than telling.
// narrativeRequests are "tell me about X", which asks for knowledge Maven does
// not hold about him and carries neither a question mark nor an interrogative —
// that is how "расскажи про битву при Ватерлоо" reached the fact store (#470).
// captureVerbs win over both, because "запиши что я пил воду" contains an
// interrogative and is still a capture: the word he said is "запиши".
//
// All three are matched over tokens, never as substrings: "что" inside "чтобы"
// and "как" inside "какао" are not questions.
var (
interrogatives = lexicon.Interrogatives()
narrativeRequests = lexicon.NarrativeRequests()
captureVerbs = lexicon.CaptureVerbs()
)
// IsQuestionShaped reports whether text asks for something rather than
// records it. It is a deterministic offline test over tokens, so it costs
+6
View File
@@ -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)
}
+14 -38
View File
@@ -1,6 +1,10 @@
package router
import "strings"
import (
"strings"
"github.com/kami/maven/internal/morph"
)
// thinSingleToken — is a one-word utterance thin evidence, or is it a whole
// sentence?
@@ -17,13 +21,15 @@ import "strings"
//
// - a closed lexicon of social and command singles, which are complete by
// definition ("привет", "спасибо", "стоп", "yes");
// - a suffix test for an inflected predicate — past tense, 2nd person,
// reflexive. Verbs carry their own subject, so a verb IS a sentence.
// - a dictionary lookup for a verb form. A verb carries its own subject,
// tense and gender, so a verb IS a sentence.
//
// The suffix test is deliberately loose about nouns that happen to end the
// same way ("канал" reads as past tense here). That direction of error only
// costs a clarify we would not have asked for; the other direction — treating
// a real report as thin — is the bug being fixed.
// The dictionary lookup replaced a list of 24 letter endings (Vikunja #526). The
// list was loose in a direction its own comment named: "канал" ends in -ал and
// read as past tense, and short words needed a length exemption so "нос" and
// "лес" would survive a two-letter suffix. Asking a morphological dictionary
// costs one map lookup and has no such errors — grammar is what a dictionary is
// for.
func thinSingleToken(utterance string) bool {
f := strings.Fields(utterance)
if len(f) != 1 {
@@ -36,7 +42,7 @@ func thinSingleToken(utterance string) bool {
if completeSingles[w] {
return false
}
return !looksInflected(w)
return !morph.IsVerbForm(w)
}
// completeSingles — one-word utterances that need no second half. Greetings,
@@ -58,33 +64,3 @@ var completeSingles = map[string]bool{
"sure": true, "right": true, "stop": true, "cancel": true, "help": true,
"repeat": true, "continue": true,
}
// inflectedSuffixes — endings that mark a finite or past-tense Russian verb.
// Ordered longest-first is unnecessary (any match wins), but each entry is
// chosen to be long enough that common nouns rarely collide.
var inflectedSuffixes = []string{
// reflexive — strongly verbal whatever precedes it
"ся", "сь",
// past tense
"ал", "ял", "ил", "ел", "ыл", "ул", "ёл", "ала", "яла", "ила", "ела",
"ыла", "ула", "али", "яли", "или", "ели",
// 2nd person singular
"ешь", "ишь", "ёшь",
// 1st/2nd person plural, 3rd person plural
"аем", "яем", "уем", "аете", "ите", "ают", "яют", "уют", "ат", "ят",
}
// looksInflected — does the word carry a verb ending? Short words are exempt:
// a three-letter token is not enough stem to trust a two-letter suffix on
// ("газ" would otherwise never match, but "нос" and "лес" would).
func looksInflected(w string) bool {
if len([]rune(w)) < 5 {
return false
}
for _, s := range inflectedSuffixes {
if strings.HasSuffix(w, s) {
return true
}
}
return false
}
+57 -33
View File
@@ -5,6 +5,8 @@ import (
"strconv"
"strings"
"time"
"github.com/kami/maven/internal/lexicon"
)
// DateTimeParser — resolves relative→absolute AT CAPTURE ("in 4h" → now+4h),
@@ -176,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.
@@ -204,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) / "через полчаса".
@@ -348,26 +358,17 @@ func leadingDigits(s string) (int, string, bool) {
return n, s[i:], true
}
// wordNumbers — small set, enough for natural test seeds ("four hours",
// "thirty minutes"). Production dateparser handles the full ru/en range.
var wordNumbers = map[string]int{
"one": 1, "two": 2, "three": 3, "four": 4, "five": 5,
"six": 6, "seven": 7, "eight": 8, "nine": 9, "ten": 10,
"eleven": 11, "twelve": 12, "fifteen": 15, "twenty": 20,
"thirty": 30, "forty": 40, "fifty": 50, "sixty": 60,
// Russian word numbers (gender variants cover natural речи)
"один": 1, "одна": 1, "одно": 1,
"два": 2, "две": 2, "три": 3, "четыре": 4,
"пять": 5, "шесть": 6, "семь": 7, "восемь": 8,
"девять": 9, "десять": 10,
}
// leadingWordNumber reads a spoken number off the front of a phrase — "два
// часа", "twenty minutes". The number words are a closed class and live in
// internal/lexicon, complete: the inline table here stopped at "десять" in
// Russian, so "пятнадцать минут" was not a duration (Vikunja #525).
func leadingWordNumber(s string) (int, string, bool) {
toks := strings.Fields(s)
if len(toks) == 0 {
return 0, "", false
}
n, ok := wordNumbers[toks[0]]
n, ok := lexicon.Cardinal(toks[0])
if !ok {
return 0, "", false
}
@@ -450,24 +451,20 @@ func (AnaphoraResolver) Resolve(text string) (ref string, ok bool) {
}
// ParseCalendarDate detects RU/EN calendar day words in text and returns
// midnight of that day in now's own time zone. Handles "сегодня", "завтра",
// "послезавтра", "вчера" (and the English words). Returns zero time + false
// if no match.
// midnight of that day in now's own time zone. Returns zero time + false if no
// match.
//
// "послезавтра" is checked before "завтра" because it contains it.
// The day words are a closed class and live in internal/lexicon, so this is a
// lookup rather than an ordered switch (Vikunja #525). The switch it replaced
// had to test "послезавтра" before "завтра" by hand, because one contains the
// other — and it matched on substrings, so "завтраком" was tomorrow. The lexicon
// matches on word boundaries and gained "позавчера", which was never here.
func ParseCalendarDate(text string, now time.Time) (time.Time, bool) {
lower := strings.ToLower(text)
switch {
case strings.Contains(lower, "сегодня") || strings.Contains(lower, "today"):
return midnight(now, 0), true
case strings.Contains(lower, "послезавтра") || strings.Contains(lower, "day after tomorrow"):
return midnight(now, 2), true
case strings.Contains(lower, "завтра") || strings.Contains(lower, "tomorrow"):
return midnight(now, 1), true
case strings.Contains(lower, "вчера") || strings.Contains(lower, "yesterday"):
return midnight(now, -1), true
days, ok := lexicon.DayOffsetIn(text)
if !ok {
return time.Time{}, false
}
return time.Time{}, false
return midnight(now, days), true
}
// midnight returns the start of the day that is `days` away from now, in
@@ -476,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
}
+200 -4
View File
@@ -3,6 +3,9 @@ package router
import (
"regexp"
"strings"
"unicode"
"github.com/kami/maven/internal/lexicon"
)
// Grammar — one stage-0 exact-match pattern. Wake-word + known command grammar
@@ -196,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
@@ -208,13 +225,192 @@ 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).
//
// Both were routed IntentFact by the model. The fact gate catches the write and
// re-runs the turn as a query, so nothing breaks today; what they cost is a full
// model round trip to reach a decision two patterns can make offline, and a
// wrong row on the routing fixture.
//
// Wired after the agenda grammars, which is where their overlap resolves:
// "расскажи, что у меня сегодня" is claimed here as a query either way.
func NarrativeQueryGrammars() []Grammar {
return []Grammar{
{
// "что дальше?" — the rest of the day. IsRestOfDayQuery already
// recognises it downstream in the query chain, but that runs after
// the routing decision, and the routing decision was fact.
Name: "rest-of-day-query",
Pattern: regexp.MustCompile(`(?i)(^|\s)(что|чего)\s+(там\s+|потом\s+)?дальше(\s|[?!.]|$)|(^|\s)what'?s?\s+next(\s|[?!.]|$)`),
Build: agendaQueryBuild,
},
{
// "расскажи про X" — a world question phrased as an instruction.
// The lexicon is narrativeRequests, already written for the
// question-shaped test in question.go.
//
// Anchored at the start: "запиши что мне рассказали" is a capture,
// and a narrative verb buried mid-utterance is not the shape.
Name: "narrative-query",
Pattern: narrativeQueryPattern,
Build: narrativeQueryBuild,
},
}
}
// entertainmentNouns — what "расскажи" asks for when it is not asking for
// knowledge. "расскажи анекдот про программистов" is chat: he wants her to make
// something up, which is the one case where inventing is the right answer
// (fixture ru-chat-003).
var entertainmentNouns = []string{
"анекдот", "анекдоты", "шутку", "шутки", "историю", "сказку", "сказки",
"joke", "jokes", "story",
}
// 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) {
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
}
for _, t := range planTokens(topic) {
for _, v := range captureVerbs {
if t == v {
return Decision{}, false
}
}
for _, v := range entertainmentNouns {
if t == v {
return Decision{}, false
}
}
}
return Decision{
Stage: 0,
Intent: IntentQuery,
Confidence: 1.0,
Slots: Slots{Text: topic},
}, true
}
// narrativeQueryPattern — "расскажи про X", built from the lexicon rather than
// spelled out here (Vikunja #527). The verbs used to be a second copy of
// lexicon.NarrativeRequests, and a second copy of a closed set is a set that
// drifts: adding "поясни" in the data file left this rule not knowing it.
//
// 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. The dative and the preposition are eaten so
// the topic slot comes out clean: "расскажи мне про Ватерлоо" leaves
// "Ватерлоо".
var narrativeQueryPattern = regexp.MustCompile(
`(?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 ("в
// среду", "на среде"), which is why the stems carry an inflection tail rather
// than a fixed ending.
const dayWordPattern = `(сегодня|завтра|послезавтра|выходн[а-я]+|недел[а-я]+|понедельник[а-я]*|вторник[а-я]*|сред[ауые][а-я]*|четверг[а-я]*|пятниц[ауые][а-я]*|суббот[ауые][а-я]*|воскресень[ея][а-я]*)`
// среду", "на среде"), so each one contributes its stem plus an inflection
// tail; the relative day words are exact.
//
// Built from the lexicon for the same reason as above. The literal that stood
// here spelled all seven weekdays out a second time, in a third file after
// cmd/mavend/voice.go and internal/ttsnorm.
var dayWordPattern = buildDayWordPattern()
// agendaQueryBuild — shared Build for the agenda grammars. Confidence 1.0 on
// buildDayWordPattern — one alternation over the relative day words, the
// weekday stems, and the two period words that are in no closed set ("на
// выходных", "на неделе" name a span, not a day).
func buildDayWordPattern() string {
alts := []string{`выходн[а-я]+`, `недел[а-я]+`}
for _, w := range lexicon.DayOffsetWords() {
if strings.Contains(w, " ") || !isCyrillic(w) {
// Multi-word and English members belong to the offset lookup, not
// to a Russian agenda pattern.
continue
}
alts = append(alts, regexp.QuoteMeta(w))
}
for i := 0; i < 7; i++ {
day := lexicon.Weekday(i)
if day == "" {
continue
}
alts = append(alts, weekdayStem(day)+`[а-я]*`)
}
return `(` + strings.Join(alts, "|") + `)`
}
// weekdayStem trims the nominative ending off a weekday so the pattern matches
// the case forms an agenda question uses: "среда" has to reach "в среду", and
// "понедельник" already ends on its stem.
func weekdayStem(day string) string {
r := []rune(day)
switch r[len(r)-1] {
case 'а', 'я', 'е', 'о', 'ь':
return string(r[:len(r)-1])
}
return day
}
// isCyrillic reports whether every rune is Cyrillic. Used to keep the English
// members of a bilingual lexicon set out of a Russian-only pattern.
func isCyrillic(s string) bool {
for _, r := range s {
if !unicode.Is(unicode.Cyrillic, r) {
return false
}
}
return s != ""
}
// 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) {
+29
View File
@@ -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
},
}
}
+24
View File
@@ -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)
}
}
}
+265
View File
@@ -0,0 +1,265 @@
package say
// Package say holds the mechanics every family of hand-written Russian lines
// shares. The families themselves live next to the code that speaks them.
//
// Deck — the mechanics every family of hand-written Russian lines shares.
//
// A family is one embedded JSON file: schema-versioned, several variants per
// entry, never the same variant twice running, and a hard floor of Go literals
// under it so a broken file cannot take her words away. fallbacks.go was the
// first family (Vikunja #501) and acks.go the second, at which point copying
// eighty lines of loader per family stopped being defensible.
//
// What stays per family: the file, the keys, the floor literals, the accessor
// names, and any validation only that family can state.
import (
"encoding/json"
"fmt"
"math/rand"
"strings"
"sync"
"time"
)
// Entry — one line she can say, in as many wordings as the file gives.
type Entry struct {
// Fixed — one variant, never picked between. For wording that must not
// drift from turn to turn, like a phrase naming one specific gap.
Fixed bool `json:"fixed"`
Variants []string `json:"variants"`
}
type file struct {
SchemaVersion int `json:"schema_version"`
Name string `json:"name"`
Notes []string `json:"notes"`
Entries map[string]Entry `json:"entries"`
}
// Deck picks a line. Safe for concurrent use. A Deck with no entries answers
// from the floor, which is what an unloadable file leaves behind (FloorDeck).
type Deck struct {
mu sync.Mutex
rnd *rand.Rand
last map[string]string
file file
keys []string
floor map[string]string
}
// loadDeck parses raw, checks the version and every required key, and seeds the
// picker. Pass a source to make the picking reproducible in tests; nil seeds
// from the clock.
func Load(raw []byte, version int, keys []string, floor map[string]string, src rand.Source) (*Deck, error) {
var f file
if err := json.Unmarshal(raw, &f); err != nil {
return nil, fmt.Errorf("parse: %w", err)
}
if f.SchemaVersion != version {
return nil, fmt.Errorf("schema_version %d, want %d", f.SchemaVersion, version)
}
for _, k := range keys {
e, ok := f.Entries[k]
if !ok || len(e.Variants) == 0 {
return nil, fmt.Errorf("entry %q is missing or empty", k)
}
if e.Fixed && len(e.Variants) != 1 {
return nil, fmt.Errorf("entry %q is fixed but has %d variants", k, len(e.Variants))
}
}
if src == nil {
src = rand.NewSource(time.Now().UnixNano())
}
return &Deck{rnd: rand.New(src), last: map[string]string{}, file: f, keys: keys, floor: floor}, nil
}
// requirePlaceholder fails the load when a variant of key does not use ph. For
// an entry whose whole job is to read something back, a variant without the
// placeholder silently drops it.
func (d *Deck) RequirePlaceholder(key, ph string) error {
for _, v := range d.file.Entries[key].Variants {
if !strings.Contains(v, ph) {
return fmt.Errorf("%q variant %q does not use %s", key, v, ph)
}
}
return nil
}
// text returns one variant for key with the placeholders filled in. A nil
// receiver answers from the floor, so no caller checks whether the file loaded.
func (d *Deck) Text(key string, vars map[string]string) string {
tmpl := ""
if d != nil {
if e, ok := d.file.Entries[key]; ok && len(e.Variants) > 0 {
tmpl = d.pick(key, fillable(e.Variants, vars))
}
}
if tmpl == "" {
tmpl = floorOf(d, key)
}
return fill(tmpl, vars)
}
// matches reports whether text is a line key could have produced. A caller that
// has to recognise one of these lines cannot compare against a literal any more.
func (d *Deck) Matches(key string, vars map[string]string, text string) bool {
if fill(floorOf(d, key), vars) == text {
return true
}
if d == nil {
return false
}
for _, v := range d.file.Entries[key].Variants {
if fill(v, vars) == text {
return true
}
}
return false
}
// variants returns every line the file can produce, in key order, for the
// persona scorer. Stable order so a failure names the same variant twice.
func (d *Deck) Variants() []string {
if d == nil {
return nil
}
var out []string
for _, k := range d.keys {
out = append(out, d.file.Entries[k].Variants...)
}
return out
}
// VariantsOf returns the wordings the file gives for one key, for a test that
// has something to say about every one of them.
func (d *Deck) VariantsOf(key string) []string {
if d == nil {
return nil
}
return d.file.Entries[key].Variants
}
// UnfixedSingles lists the keys with exactly one variant that are not marked
// fixed. Nothing breaks on one — the picker has nothing to pick either way — but
// the flag is what a reader goes by, and parallel entries disagreeing about it
// is how a family stops being readable. Load already rejects the other half of
// the rule, fixed with more than one variant, so this is the pair to it.
func (d *Deck) UnfixedSingles() []string {
if d == nil {
return nil
}
var out []string
for _, k := range d.keys {
if e := d.file.Entries[k]; len(e.Variants) == 1 && !e.Fixed {
out = append(out, k)
}
}
return out
}
// fillable narrows variants to the ones this call can actually say, which is
// the rule an optional placeholder needs: a caller with nothing to put in
// {tail} must not be handed a variant that has one. Two passes, because both
// halves matter. The first keeps only variants whose every placeholder has a
// non-empty value, so an absent optional never reaches him as braces. The
// second prefers, among those, the variants using the most of what the caller
// supplied, so a caveat he was given is not dropped for a shorter wording.
// Nothing fillable leaves the list alone, and the unfilled placeholder shows
// up in the answer rather than turning it into silence.
func fillable(variants []string, vars map[string]string) []string {
if len(variants) < 2 {
return variants
}
best, bestUsed := make([]string, 0, len(variants)), -1
for _, v := range variants {
used := 0
ok := true
for _, ph := range placeholders(v) {
if vars[ph] == "" {
ok = false
break
}
used++
}
if !ok || used < bestUsed {
continue
}
if used > bestUsed {
best, bestUsed = best[:0], used
}
best = append(best, v)
}
if len(best) == 0 {
return variants
}
return best
}
// placeholders lists the {name}s in tmpl, in order.
func placeholders(tmpl string) []string {
var out []string
for {
i := strings.IndexByte(tmpl, '{')
if i < 0 {
return out
}
j := strings.IndexByte(tmpl[i:], '}')
if j < 0 {
return out
}
out = append(out, tmpl[i+1:i+j])
tmpl = tmpl[i+j+1:]
}
}
// pick chooses at random, skipping whatever this entry said last time.
func (d *Deck) pick(key string, variants []string) string {
d.mu.Lock()
defer d.mu.Unlock()
choices := variants
if len(choices) > 1 {
fresh := make([]string, 0, len(choices))
for _, v := range choices {
if v != d.last[key] {
fresh = append(fresh, v)
}
}
if len(fresh) > 0 {
choices = fresh
}
}
got := choices[d.rnd.Intn(len(choices))]
d.last[key] = got
return got
}
// floorOf reads the Go literal behind key. Every deck carries its own family's
// map, including the floor-only deck an unloadable file leaves behind, so no
// lookup ever crosses families. It used to go through one global map keyed by
// bare entry name, which two families both calling an entry query_unknown
// silently shared: whichever registered last answered for both (Vikunja #521).
func floorOf(d *Deck, key string) string {
if d == nil {
return ""
}
return d.floor[key]
}
// FloorDeck — the deck a family falls back to when its file will not load. It
// has no entries, so every read drops through to the floor literals, and it is
// a real *Deck so no accessor has to know which case it is in.
func FloorDeck(floor map[string]string) *Deck {
return &Deck{last: map[string]string{}, floor: floor}
}
// fill substitutes {name} for each var. A placeholder with no value is left
// alone rather than blanked, so a missing value is visible instead of silent.
func fill(tmpl string, vars map[string]string) string {
for k, v := range vars {
tmpl = strings.ReplaceAll(tmpl, "{"+k+"}", v)
}
return tmpl
}
+55
View File
@@ -0,0 +1,55 @@
package say
// The counted noun, in the form the number in front of it demands.
//
// Russian inflects a noun after a numeral, and the form depends on the last two
// digits: 1 градус, 2 градуса, 5 градусов, 11 градусов, 21 градус, 22 градуса.
// A line file cannot spell that out, so a count in a template splits into two
// placeholders — the number, and {word} filled from here.
//
// The rule lived once as hostWord in cmd/mavend/netscan.go and once as
// pluralDaysRU in internal/memory, which meant the weather line said "градусов"
// for every temperature and the task list said "дн." — a written abbreviation
// read aloud. One helper, every count site (Vikunja #521).
import "math"
// CountWord picks between the three forms n needs: one for 1, few for 2-4, many
// for 0, 5-20 and anything ending in those. A negative count reads its own
// magnitude, since minus does not change the noun: -2 градуса.
func CountWord(n int, one, few, many string) string {
if n < 0 {
n = -n
}
if n%100 >= 11 && n%100 <= 14 {
return many
}
switch n % 10 {
case 1:
return one
case 2, 3, 4:
return few
default:
return many
}
}
// Degrees — the noun for a temperature. Takes the reading as it arrives from a
// weather provider and counts by the whole degrees she is about to say, so the
// noun agrees with the number in the same sentence rather than with the reading
// behind it.
func Degrees(temp float64) string {
return CountWord(int(math.Round(temp)), "градус", "градуса", "градусов")
}
// Devices — the noun for a count of hosts on the LAN or of smart-home devices.
func Devices(n int) string {
return CountWord(n, "устройство", "устройства", "устройств")
}
// Days — the noun for a stretch of days. This is what replaces «дн.» in the
// overdue and due-soon reasons: an abbreviation is written shorthand, and every
// one of these lines is spoken.
func Days(n int) string {
return CountWord(n, "день", "дня", "дней")
}
+16
View File
@@ -0,0 +1,16 @@
package say
import "testing"
// The day forms, kept from internal/memory when the helper moved: 21 takes the
// singular and 11 does not, which is the whole reason this is not n == 1.
func TestDays(t *testing.T) {
for _, c := range []struct {
n int
want string
}{{1, "день"}, {2, "дня"}, {5, "дней"}, {11, "дней"}, {21, "день"}, {22, "дня"}, {114, "дней"}} {
if got := Days(c.n); got != c.want {
t.Errorf("Days(%d) = %q, want %q", c.n, got, c.want)
}
}
}
+192
View File
@@ -0,0 +1,192 @@
package say
// The summary sentences — what she says around aggregated data: the morning
// plan, the ranked task list, and the habits read back out of behaviour records.
//
// Fifth family on the deck, and the first one outside internal/phraser. It
// lives here because its three callers — internal/morning, internal/tasks and
// internal/memory — sit under phraser in the import graph and cannot reach it.
//
// The "I have not seen enough yet" sentences are the load-bearing ones. Three
// days of taps and a year of them produce the same "обычно ты ...", and only one
// of those is worth believing, so the empty cases say she has not seen a
// pattern rather than that he has none.
import (
_ "embed"
"log"
"math/rand"
"sync"
)
//go:embed summary_ru_v1.json
var summaryJSON []byte
// SummarySchemaVersion — this family's own version.
const SummarySchemaVersion = 1
// The entry keys.
const (
PlanRestEmpty = "plan_rest_empty"
PlanDayEmpty = "plan_day_empty"
PlanDay = "plan_day"
PlanUncertain = "plan_uncertain"
TasksNone = "tasks_none"
TasksFirst = "tasks_first"
TasksCandidates = "tasks_candidates"
ReasonOverdue = "reason_overdue"
ReasonOverdueDays = "reason_overdue_days"
ReasonToday = "reason_today"
ReasonTomorrow = "reason_tomorrow"
ReasonInDays = "reason_in_days"
ReasonImportant = "reason_important"
ReasonUrgent = "reason_urgent"
ReasonStale = "reason_stale"
HabitWeekday = "habit_weekday"
HabitWeekdaySame = "habit_weekday_same"
HabitWeekdayNone = "habit_weekday_none"
HabitWeekendBoth = "habit_weekend_both"
HabitWeekendSat = "habit_weekend_sat"
HabitWeekendSun = "habit_weekend_sun"
HabitWeekendSame = "habit_weekend_same"
HabitWeekendNone = "habit_weekend_none"
HabitOverall = "habit_overall"
HabitOverallNone = "habit_overall_none"
HabitSpanToday = "habit_span_today"
HabitSpanDays = "habit_span_days"
HabitUnglossed = "habit_unglossed"
HabitAt = "habit_at"
)
var summaryKeys = []string{
PlanRestEmpty, PlanDayEmpty, PlanDay, PlanUncertain,
TasksNone, TasksFirst, TasksCandidates,
ReasonOverdue, ReasonOverdueDays, ReasonToday, ReasonTomorrow,
ReasonInDays, ReasonImportant, ReasonUrgent, ReasonStale,
HabitWeekday, HabitWeekdaySame, HabitWeekdayNone,
HabitWeekendBoth, HabitWeekendSat, HabitWeekendSun, HabitWeekendSame, HabitWeekendNone,
HabitOverall, HabitOverallNone, HabitSpanToday, HabitSpanDays,
HabitUnglossed, HabitAt,
}
// summaryFloor — the literal each key falls back to when the file is unusable.
// It started as the exact strings that lived in Go before this file existed and
// now tracks the file's first variant instead, because a floor that keeps the
// wording review threw out would say it back on the one turn nobody is watching.
var summaryFloor = map[string]string{
PlanRestEmpty: "на сегодня больше ничего не запланировано.",
PlanDayEmpty: "на {date} ничего не запланировано.",
PlanDay: "план на {date}: {items}",
PlanUncertain: "похоже, {line}",
TasksNone: "задач нет.",
TasksFirst: "сначала: {items}",
TasksCandidates: "нашла ещё, но ты не подтверждал: {items}",
ReasonOverdue: "просрочено",
ReasonOverdueDays: "просрочено на {n} {word}",
ReasonToday: "сегодня",
ReasonTomorrow: "завтра",
ReasonInDays: "через {n} {word}",
ReasonImportant: "важно",
ReasonUrgent: "срочно",
ReasonStale: "давно в списке",
HabitWeekday: "по {day} ты обычно {items}.",
HabitWeekdaySame: "по {day} всё как обычно — то же, что и в остальные дни: {items}.",
HabitWeekdayNone: "по {day} я пока не вижу ничего постоянного.",
HabitWeekendBoth: "по субботам ты обычно {items_sat}, по воскресеньям — {items_sun}.",
HabitWeekendSat: "по субботам ты обычно {items}, а по воскресеньям постоянного нет.",
HabitWeekendSun: "по воскресеньям ты обычно {items}, а по субботам постоянного нет.",
HabitWeekendSame: "по выходным всё как обычно — то же, что и в остальные дни: {items}.",
HabitWeekendNone: "по выходным я пока не вижу ничего постоянного.",
HabitOverall: "обычно ты {items} — {span}.",
HabitOverallNone: "записей пока мало, про привычки не скажу.",
HabitSpanToday: "по записям за сегодня",
HabitSpanDays: "по записям за последние {n} {word}",
HabitUnglossed: "отмечаешь «{key}»",
HabitAt: "{gloss} около {time}",
}
// Summaries picks a hand-written Russian summary sentence. Safe for concurrent
// use.
type Summaries struct{ d *Deck }
// LoadSummaries reads the embedded file. Pass a source to make the picking
// reproducible in tests; nil seeds from the clock.
func LoadSummaries(src rand.Source) (*Summaries, error) {
d, err := Load(summaryJSON, SummarySchemaVersion, summaryKeys, summaryFloor, src)
if err != nil {
return nil, err
}
// The entries that exist to read the aggregate back. A variant without the
// placeholder would summarise the data by dropping it.
for _, req := range []struct{ key, ph string }{
{PlanDayEmpty, "{date}"}, {PlanDay, "{date}"}, {PlanDay, "{items}"},
{PlanUncertain, "{line}"},
{TasksFirst, "{items}"}, {TasksCandidates, "{items}"},
{ReasonOverdueDays, "{n}"}, {ReasonOverdueDays, "{word}"},
{ReasonInDays, "{n}"}, {ReasonInDays, "{word}"},
{HabitWeekday, "{day}"}, {HabitWeekday, "{items}"},
{HabitWeekdaySame, "{day}"}, {HabitWeekdaySame, "{items}"},
{HabitWeekdayNone, "{day}"},
{HabitWeekendBoth, "{items_sat}"}, {HabitWeekendBoth, "{items_sun}"},
{HabitWeekendSat, "{items}"}, {HabitWeekendSun, "{items}"},
{HabitWeekendSame, "{items}"},
{HabitOverall, "{items}"}, {HabitOverall, "{span}"},
{HabitSpanDays, "{n}"}, {HabitSpanDays, "{word}"},
{HabitUnglossed, "{key}"}, {HabitAt, "{gloss}"}, {HabitAt, "{time}"},
} {
if err := d.RequirePlaceholder(req.key, req.ph); err != nil {
return nil, err
}
}
return &Summaries{d: d}, nil
}
// deck reads through a nil *Summaries, which is the unloadable-file case.
func (s *Summaries) deck() *Deck {
if s == nil {
return FloorDeck(summaryFloor)
}
return s.d
}
// Say returns one line for key, with the values filled into the frame.
func (s *Summaries) Say(key string, vars map[string]string) string {
return s.deck().Text(key, vars)
}
// Variants returns every line the file can produce, for the persona scorer.
func (s *Summaries) Variants() []string { return s.deck().Variants() }
var (
summaryOnce sync.Once
summaries *Summaries
)
// DefaultSummaries returns the shared instance, loading it on first use. A
// broken file logs once and leaves a nil *Summaries, which still answers from
// summaryFloor.
func DefaultSummaries() *Summaries {
summaryOnce.Do(func() {
s, err := LoadSummaries(nil)
if err != nil {
log.Printf("say: summary lines unavailable, using the built-in ones: %v", err)
return
}
summaries = s
})
return summaries
}
// S — one summary sentence, the way every caller says it.
func S(key string, vars map[string]string) string { return DefaultSummaries().Say(key, vars) }
// IsS reports whether text is a line key could have produced, for the tests.
func IsS(key string, vars map[string]string, text string) bool {
return DefaultSummaries().deck().Matches(key, vars, text)
}
+143
View File
@@ -0,0 +1,143 @@
{
"schema_version": 1,
"name": "russian summary sentences v1",
"notes": [
"The sentences she builds around aggregated data: the morning plan, the ranked task list, and the habits she reads back out of behaviour records.",
"Rules: she is feminine about herself, he is a man addressed as ты. Never вы/вас/ваш, never он/его about him. No pet names.",
"\"I have not seen enough yet\" and \"there is nothing there\" are different claims, and the habit entries keep the first. Three days of taps do not license a statement about his life, so habit_*_none says she does not see a pattern, never that he has no habits.",
"She reports on the records, not on him. «всё как обычно» is a claim about what she has stored; «у тебя нет ничего особенного» is a verdict on his life, and she does not pass those.",
"Placeholders: {date} a formatted date, {items} a joined list, {items_sat} and {items_sun} the same for one weekend day each, {day} a weekday name, {span} the stretch of records a habit claim rests on, {line} one already-rendered plan line, {key} a behaviour key she has no gloss for, {gloss} the readable name of one, {time} a clock time, {n} a count, {word} the Russian count form for {n}, built Go-side.",
"Three parallel entries share one scheme: habit_weekend_both names its two lists {items_sat} and {items_sun}, and habit_weekend_sat and habit_weekend_sun each use {items}, because each of them says one list.",
"The count forms (день/дня/дней) are morphology, not copy. They stay in Go and arrive here through {word}. An abbreviation is not an option: «дн.» is written shorthand and every line here is spoken, so it reads as garbage or gets spelled out letter by letter.",
"{line} in plan_uncertain is another rendered line, so the join reads as one sentence only while what arrives starts lowercase. The caller asserts that.",
"A joined list arrives with its own punctuation, so the entries that end on {items} carry no trailing period. The habit entries keep theirs, because there the list sits mid-sentence.",
"fixed: true means exactly one variant and no picking. Parallel entries agree on it, and a single-variant entry is fixed — in this file and in every other family. A test holds that."
],
"entries": {
"plan_rest_empty": {
"fixed": true,
"variants": ["на сегодня больше ничего не запланировано."]
},
"plan_day_empty": {
"fixed": true,
"variants": ["на {date} ничего не запланировано."]
},
"plan_day": {
"fixed": true,
"variants": ["план на {date}: {items}"]
},
"plan_uncertain": {
"fixed": true,
"variants": ["похоже, {line}"]
},
"tasks_none": {
"fixed": true,
"variants": ["задач нет."]
},
"tasks_first": {
"fixed": true,
"variants": ["сначала: {items}"]
},
"tasks_candidates": {
"fixed": true,
"variants": ["нашла ещё, но ты не подтверждал: {items}"]
},
"reason_overdue": {
"fixed": true,
"variants": ["просрочено"]
},
"reason_overdue_days": {
"fixed": true,
"variants": ["просрочено на {n} {word}"]
},
"reason_today": {
"fixed": true,
"variants": ["сегодня"]
},
"reason_tomorrow": {
"fixed": true,
"variants": ["завтра"]
},
"reason_in_days": {
"fixed": true,
"variants": ["через {n} {word}"]
},
"reason_important": {
"fixed": true,
"variants": ["важно"]
},
"reason_urgent": {
"fixed": true,
"variants": ["срочно"]
},
"reason_stale": {
"fixed": true,
"variants": ["давно в списке"]
},
"habit_weekday": {
"fixed": true,
"variants": ["по {day} ты обычно {items}."]
},
"habit_weekday_same": {
"fixed": true,
"variants": ["по {day} всё как обычно — то же, что и в остальные дни: {items}."]
},
"habit_weekday_none": {
"variants": [
"по {day} я пока не вижу ничего постоянного.",
"по {day} постоянного пока не вижу — записей мало."
]
},
"habit_weekend_both": {
"fixed": true,
"variants": ["по субботам ты обычно {items_sat}, по воскресеньям — {items_sun}."]
},
"habit_weekend_sat": {
"fixed": true,
"variants": ["по субботам ты обычно {items}, а по воскресеньям постоянного нет."]
},
"habit_weekend_sun": {
"fixed": true,
"variants": ["по воскресеньям ты обычно {items}, а по субботам постоянного нет."]
},
"habit_weekend_same": {
"fixed": true,
"variants": ["по выходным всё как обычно — то же, что и в остальные дни: {items}."]
},
"habit_weekend_none": {
"variants": [
"по выходным я пока не вижу ничего постоянного.",
"по выходным постоянного пока не вижу — записей мало."
]
},
"habit_overall": {
"fixed": true,
"variants": ["обычно ты {items} — {span}."]
},
"habit_overall_none": {
"variants": [
"записей пока мало, про привычки не скажу.",
"пока мало записей, чтобы говорить о привычках."
]
},
"habit_span_today": {
"fixed": true,
"variants": ["по записям за сегодня"]
},
"habit_span_days": {
"fixed": true,
"variants": ["по записям за последние {n} {word}"]
},
"habit_unglossed": {
"fixed": true,
"variants": ["отмечаешь «{key}»"]
},
"habit_at": {
"fixed": true,
"variants": ["{gloss} около {time}"]
}
}
}
+62
View File
@@ -0,0 +1,62 @@
package say
import (
"math/rand"
"strings"
"testing"
)
// The file has to load, and every key the code names has to be in it.
func TestSummariesLoad(t *testing.T) {
s, err := LoadSummaries(rand.NewSource(1))
if err != nil {
t.Fatalf("load: %v", err)
}
for _, key := range summaryKeys {
if got := s.Say(key, nil); got == "" {
t.Errorf("%s says nothing", key)
}
}
}
// A nil *Summaries is the unloadable-file case, and it must still speak. The
// habit sentences are the ones that matter here: falling back must not turn
// "I have not seen enough" into silence.
func TestNilSummariesAnswerFromTheFloor(t *testing.T) {
var s *Summaries
if got, want := s.Say(HabitOverallNone, nil), summaryFloor[HabitOverallNone]; got != want {
t.Errorf("got %q, want %q", got, want)
}
if got := s.Say(PlanDay, map[string]string{"date": "03.08.2026", "items": "x"}); !strings.Contains(got, "03.08.2026") {
t.Errorf("the floor dropped the date: %q", got)
}
}
// The empty cases claim she has not seen enough, never that he has no habits.
// Every variant has to hold that line, since the picker treats them as equals.
func TestHabitGapsSaySheHasNotSeenEnough(t *testing.T) {
s, err := LoadSummaries(rand.NewSource(1))
if err != nil {
t.Fatalf("load: %v", err)
}
for _, key := range []string{HabitWeekdayNone, HabitWeekendNone, HabitOverallNone} {
for _, v := range s.d.file.Entries[key].Variants {
if !strings.Contains(v, "пока") && !strings.Contains(v, "ещё") {
t.Errorf("%s variant %q reads as a fact about him, not as a gap in her records", key, v)
}
}
}
}
// One variant means fixed, in this file and in the four in internal/phraser.
// Nothing breaks on the flag being absent, but parallel entries disagreeing
// about it is how the file stops telling a reader which wording is load-bearing.
func TestEverySingleVariantEntryIsFixed(t *testing.T) {
s, err := LoadSummaries(rand.NewSource(1))
if err != nil {
t.Fatalf("load: %v", err)
}
if got := s.d.UnfixedSingles(); len(got) > 0 {
t.Errorf("single-variant entries not marked fixed: %v", got)
}
}
+194
View File
@@ -0,0 +1,194 @@
package store
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"time"
)
// List items — the fourth append-only shape (Vikunja #453).
//
// A list is a standing set of short strings under a tag: покупки, аптека,
// хозяйство. It is not work and it is not a claim about the world, which is
// why it is neither a task nor a fact. Nothing here is prioritised, nothing
// nudges about it, and the digestion worker does not read it. The only two
// things a list does are grow and shrink.
//
// The consequence that made it worth a table: because no predicate touches a
// list item, several people adding to the same list at once cost nothing. There
// is no ranking to disagree about and no lifecycle beyond crossed-off.
const (
// ListItemOpen — on the list.
ListItemOpen = "open"
// ListItemDone — bought, taken, crossed off.
ListItemDone = "done"
// ListItemDropped — removed without being got.
ListItemDropped = "dropped"
)
// DefaultList — the list a capture lands on when he names none. Almost every
// spoken list item is groceries, and asking "в какой список?" for the common
// case would be a nag.
const DefaultList = "покупки"
// ListItem — one line on one list.
type ListItem struct {
ID int64
CreatedTs time.Time
List string
Item string
Source string
Status string
ResolvedTs *time.Time
}
var (
ErrListItemNotFound = errors.New("store: list item not found")
ErrListItemEmpty = errors.New("store: list item is empty")
ErrListItemStatus = errors.New("store: invalid list item status")
)
// NormalizeListName folds a list tag to its dedupe form. Lists are named out
// loud, so "Покупки" and "покупки " are the same list.
func NormalizeListName(s string) string {
n := NormalizeTaskText(s)
if n == "" {
return DefaultList
}
return n
}
// AddListItem puts an item on a list, or returns the existing row when the same
// item is already on it. Created says which happened, so the caller can say
// "уже есть" instead of pretending it wrote something.
func (s *Store) AddListItem(ctx context.Context, li ListItem) (CaptureResult, error) {
item := strings.TrimSpace(li.Item)
if item == "" {
return CaptureResult{}, ErrListItemEmpty
}
list := NormalizeListName(li.List)
norm := NormalizeTaskText(item)
created := li.CreatedTs
if created.IsZero() {
created = time.Now()
}
res, err := s.db.ExecContext(ctx,
`INSERT INTO list_items (created_ts, list, item, norm, source, status)
VALUES (?,?,?,?,?,?)
ON CONFLICT DO NOTHING`,
created.UnixMilli(), list, item, norm, li.Source, ListItemOpen)
if err != nil {
return CaptureResult{}, fmt.Errorf("add list item: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return CaptureResult{}, fmt.Errorf("add list item: rows affected: %w", err)
}
if n > 0 {
id, err := res.LastInsertId()
if err != nil {
return CaptureResult{}, fmt.Errorf("add list item: last insert id: %w", err)
}
return CaptureResult{ID: id, Created: true}, nil
}
var id int64
err = s.db.QueryRowContext(ctx,
`SELECT id FROM list_items WHERE list = ? AND norm = ? AND status = ?`,
list, norm, ListItemOpen).Scan(&id)
if errors.Is(err, sql.ErrNoRows) {
return CaptureResult{}, ErrListItemNotFound
}
if err != nil {
return CaptureResult{}, fmt.Errorf("add list item: lookup: %w", err)
}
return CaptureResult{ID: id}, nil
}
// ListItems reads one list in the order it was added. An empty status reads the
// open items, which is what reading the list aloud means.
func (s *Store) ListItems(ctx context.Context, list, status string) ([]ListItem, error) {
if status == "" {
status = ListItemOpen
}
rows, err := s.db.QueryContext(ctx,
`SELECT id, created_ts, list, item, source, status, resolved_ts
FROM list_items WHERE list = ? AND status = ?
ORDER BY created_ts, id`,
NormalizeListName(list), status)
if err != nil {
return nil, fmt.Errorf("list items: %w", err)
}
defer rows.Close()
var out []ListItem
for rows.Next() {
var (
li ListItem
created int64
resolved sql.NullInt64
)
if err := rows.Scan(&li.ID, &created, &li.List, &li.Item, &li.Source, &li.Status, &resolved); err != nil {
return nil, fmt.Errorf("list items: scan: %w", err)
}
li.CreatedTs = time.UnixMilli(created)
if resolved.Valid {
t := time.UnixMilli(resolved.Int64)
li.ResolvedTs = &t
}
out = append(out, li)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("list items: %w", err)
}
return out, nil
}
// SetListItemStatus crosses an item off, or removes it. Moving an item that is
// already resolved is not an error — crossing off twice is the same list.
func (s *Store) SetListItemStatus(ctx context.Context, id int64, status string, at time.Time) error {
if status != ListItemOpen && status != ListItemDone && status != ListItemDropped {
return fmt.Errorf("%w: %q", ErrListItemStatus, status)
}
var resolved sql.NullInt64
if status != ListItemOpen {
if at.IsZero() {
at = time.Now()
}
resolved = sql.NullInt64{Int64: at.UnixMilli(), Valid: true}
}
res, err := s.db.ExecContext(ctx,
`UPDATE list_items SET status = ?, resolved_ts = ? WHERE id = ?`,
status, resolved, id)
if err != nil {
return fmt.Errorf("set list item status: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return fmt.Errorf("set list item status: rows affected: %w", err)
}
if n == 0 {
return ErrListItemNotFound
}
return nil
}
// ClearList crosses off every open item on a list and reports how many. This is
// "всё купил", which is one sentence and must not become one turn per item.
func (s *Store) ClearList(ctx context.Context, list string, at time.Time) (int, error) {
if at.IsZero() {
at = time.Now()
}
res, err := s.db.ExecContext(ctx,
`UPDATE list_items SET status = ?, resolved_ts = ? WHERE list = ? AND status = ?`,
ListItemDone, at.UnixMilli(), NormalizeListName(list), ListItemOpen)
if err != nil {
return 0, fmt.Errorf("clear list: %w", err)
}
n, err := res.RowsAffected()
if err != nil {
return 0, fmt.Errorf("clear list: rows affected: %w", err)
}
return int(n), nil
}
+149
View File
@@ -0,0 +1,149 @@
package store
import (
"context"
"errors"
"testing"
"time"
)
var listNow = time.Date(2026, 8, 4, 12, 0, 0, 0, time.UTC)
func TestAddListItemDedupesTheOpenList(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
first, err := s.AddListItem(ctx, ListItem{Item: "молоко", Source: "tap:voice", CreatedTs: listNow})
if err != nil {
t.Fatalf("add: %v", err)
}
if !first.Created {
t.Fatal("the first молоко did not create a row")
}
again, err := s.AddListItem(ctx, ListItem{Item: " Молоко ", Source: "tap:voice", CreatedTs: listNow})
if err != nil {
t.Fatalf("add again: %v", err)
}
if again.Created {
t.Error("молоко was added twice")
}
if again.ID != first.ID {
t.Errorf("second add points at %d; want the existing %d", again.ID, first.ID)
}
if _, err := s.AddListItem(ctx, ListItem{Item: " "}); !errors.Is(err, ErrListItemEmpty) {
t.Errorf("empty item: %v; want ErrListItemEmpty", err)
}
}
// A crossed-off item does not block the next one: buying milk again next week
// is a new line, the way saying an errand again is a new task.
func TestCrossedOffItemComesBack(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
first, err := s.AddListItem(ctx, ListItem{Item: "молоко", CreatedTs: listNow})
if err != nil {
t.Fatalf("add: %v", err)
}
if err := s.SetListItemStatus(ctx, first.ID, ListItemDone, listNow); err != nil {
t.Fatalf("cross off: %v", err)
}
next, err := s.AddListItem(ctx, ListItem{Item: "молоко", CreatedTs: listNow.Add(time.Hour)})
if err != nil {
t.Fatalf("add after: %v", err)
}
if !next.Created || next.ID == first.ID {
t.Errorf("second молоко reused row %d; want a new one", next.ID)
}
open, err := s.ListItems(ctx, "", "")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(open) != 1 || open[0].ID != next.ID {
t.Errorf("open list %+v; want only the new row", open)
}
}
// Lists are separate stores under one table: the same word on two lists is two
// items, and reading one never reads the other.
func TestListsDoNotSeeEachOther(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
if _, err := s.AddListItem(ctx, ListItem{List: "покупки", Item: "вода", CreatedTs: listNow}); err != nil {
t.Fatalf("add: %v", err)
}
if _, err := s.AddListItem(ctx, ListItem{List: "Аптека", Item: "вода", CreatedTs: listNow}); err != nil {
t.Fatalf("add: %v", err)
}
for _, c := range []struct{ list, want string }{
{"покупки", "покупки"},
{"аптека", "аптека"},
{"", "покупки"},
} {
got, err := s.ListItems(ctx, c.list, "")
if err != nil {
t.Fatalf("list %q: %v", c.list, err)
}
if len(got) != 1 {
t.Fatalf("list %q has %d items; want 1", c.list, len(got))
}
if got[0].List != c.want {
t.Errorf("list %q returned tag %q; want %q", c.list, got[0].List, c.want)
}
}
}
func TestClearListCrossesOffEverythingOpen(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
for _, item := range []string{"молоко", "хлеб", "яйца"} {
if _, err := s.AddListItem(ctx, ListItem{Item: item, CreatedTs: listNow}); err != nil {
t.Fatalf("add %s: %v", item, err)
}
}
if _, err := s.AddListItem(ctx, ListItem{List: "аптека", Item: "бинт", CreatedTs: listNow}); err != nil {
t.Fatalf("add: %v", err)
}
n, err := s.ClearList(ctx, "покупки", listNow)
if err != nil {
t.Fatalf("clear: %v", err)
}
if n != 3 {
t.Errorf("cleared %d; want 3", n)
}
left, err := s.ListItems(ctx, "покупки", "")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(left) != 0 {
t.Errorf("%d items still open; want none", len(left))
}
done, err := s.ListItems(ctx, "покупки", ListItemDone)
if err != nil {
t.Fatalf("list done: %v", err)
}
if len(done) != 3 || done[0].ResolvedTs == nil {
t.Errorf("done list %+v; want 3 rows carrying a resolved time", done)
}
other, err := s.ListItems(ctx, "аптека", "")
if err != nil {
t.Fatalf("list: %v", err)
}
if len(other) != 1 {
t.Error("clearing покупки touched аптека")
}
}
func TestSetListItemStatusRejectsWhatIsNotAStatus(t *testing.T) {
ctx := context.Background()
s := newTestStore(t)
if err := s.SetListItemStatus(ctx, 1, "куплено", listNow); !errors.Is(err, ErrListItemStatus) {
t.Errorf("bad status: %v; want ErrListItemStatus", err)
}
if err := s.SetListItemStatus(ctx, 999, ListItemDone, listNow); !errors.Is(err, ErrListItemNotFound) {
t.Errorf("missing row: %v; want ErrListItemNotFound", err)
}
}
+22 -1
View File
@@ -219,8 +219,29 @@ ALTER TABLE reminders ADD COLUMN next_fire_ts INTEGER;`, // #2
// event, and the old rows would otherwise be recited as extra meetings.
// The filter is exact — it keeps any key whose summary part still has a
// letter or a digit in it.
`CREATE TABLE IF NOT EXISTS list_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
created_ts INTEGER NOT NULL,
list TEXT NOT NULL,
item TEXT NOT NULL,
norm TEXT NOT NULL,
source TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open','done','dropped')),
resolved_ts INTEGER
);
CREATE UNIQUE INDEX IF NOT EXISTS idx_list_items_live ON list_items (list, norm) WHERE status = 'open';
CREATE INDEX IF NOT EXISTS idx_list_items_list ON list_items (list, status, created_ts);`,
// #19 — standing lists (Vikunja #453). The fourth append-only shape, after
// facts, notes and tasks, and the reason it is its own table rather than a
// tag on tasks: milk on the shopping list is not work. Nothing prioritises
// it, nothing nudges about it, and the prioritiser must not start counting
// groceries as outstanding errands.
//
// The live-only unique index is the tasks one, per list: saying "молоко"
// twice before the shop keeps one row, saying it again next week after the
// last one was crossed off writes a new one.
// #19 — unstick the routines accepted before the fire-forever fix
// #20 — unstick the routines accepted before the fire-forever fix
// (Vikunja #377, follow-up to #366). Accepting used to leave accepted_ts
// NULL and a live one-shot reminder behind, and the tick loop skips a row
// with no accepted_ts, so every non-weekly routine accepted before that fix
+4 -2
View File
@@ -114,8 +114,10 @@ func TestStuckRoutinesAreBackfilled(t *testing.T) {
t.Fatal(err)
}
if _, err := s.db.ExecContext(ctx, migrations[18]); err != nil {
t.Fatalf("migration 19: %v", err)
// Index 19, version 20: standing lists landed on the same number first
// (Vikunja #453), so this one moved down one.
if _, err := s.db.ExecContext(ctx, migrations[19]); err != nil {
t.Fatalf("migration 20: %v", err)
}
accepted, err := s.ListAcceptedRoutines(ctx)
+51 -29
View File
@@ -19,8 +19,11 @@ package tasks
import (
"fmt"
"sort"
"strconv"
"strings"
"time"
"github.com/kami/maven/internal/say"
)
// Status values, mirroring internal/store so a caller can rank ipc.Task rows
@@ -117,21 +120,25 @@ func score(it Item, now time.Time) (float64, string) {
bonus = scoreOverdueCap
}
total += scoreOverdue + bonus
reason = "просрочено"
if late == 1 {
reason = "просрочено на день"
} else if late > 1 {
reason = fmt.Sprintf("просрочено на %d дн.", late)
reason = say.S(say.ReasonOverdue, nil)
if late > 0 {
// One day needs no arm of its own: «просрочено на 1 день»
// falls out of the count helper like every other number.
reason = say.S(say.ReasonOverdueDays, map[string]string{
"n": strconv.Itoa(late), "word": say.Days(late),
})
}
case days == 0:
total += scoreDueToday
reason = "сегодня"
reason = say.S(say.ReasonToday, nil)
case days == 1:
total += scoreDueTomorrow
reason = "завтра"
reason = say.S(say.ReasonTomorrow, nil)
case days <= 7:
total += scoreDueWeek
reason = fmt.Sprintf("через %d дн.", days)
reason = say.S(say.ReasonInDays, map[string]string{
"n": strconv.Itoa(days), "word": say.Days(days),
})
default:
total += scoreDueLater
}
@@ -147,9 +154,9 @@ func score(it Item, now time.Time) (float64, string) {
// The rungs get their own words. The reason string is the one place
// the ranking explains itself, and reading "важно" back at a task
// he flagged "срочно" reports a word he did not say.
reason = "важно"
reason = say.S(say.ReasonImportant, nil)
if w >= MaxWeight {
reason = "срочно"
reason = say.S(say.ReasonUrgent, nil)
}
}
}
@@ -163,7 +170,7 @@ func score(it Item, now time.Time) (float64, string) {
}
total += age
if reason == "" && weeks >= 2 {
reason = "давно в списке"
reason = say.S(say.ReasonStale, nil)
}
}
}
@@ -210,22 +217,28 @@ func FormatRU(ranked []Ranked) string {
}
}
if len(open) == 0 && len(cands) == 0 {
return "задач нет."
return say.S(say.TasksNone, nil)
}
var b strings.Builder
if len(open) > 0 {
b.WriteString("сначала: ")
b.WriteString(joinRU(open, SpokenLimit, true))
b.WriteString(".")
b.WriteString(say.S(say.TasksFirst, map[string]string{
"items": joinRU(open, SpokenLimit, true),
}))
}
if len(cands) > 0 {
// Two sentences, and the first one ends on a joined list that carries
// whatever punctuation its last task had — usually none. So the break
// is the caller's to make, not the line file's (Vikunja #521).
if b.Len() > 0 {
if !strings.HasSuffix(b.String(), ".") {
b.WriteString(".")
}
b.WriteString(" ")
}
b.WriteString("ещё я нашла, но ты не подтвердил: ")
b.WriteString(joinRU(cands, SpokenLimit, false))
b.WriteString(".")
b.WriteString(say.S(say.TasksCandidates, map[string]string{
"items": joinRU(cands, SpokenLimit, false),
}))
}
return b.String()
}
@@ -250,21 +263,30 @@ func joinRU(rs []Ranked, limit int, withReasons bool) string {
s := strings.Join(parts, "; ")
if rest > 0 {
// With the noun. Spoken, a bare number trails off mid-sentence.
s += fmt.Sprintf("; и ещё %d %s", rest, pluralTasksRU(rest))
s += fmt.Sprintf("; и ещё %d %s", rest, say.CountWord(rest, "задача", "задачи", "задач"))
}
return s
}
// pluralTasksRU — the right form of "задача" for a count. Russian needs three.
func pluralTasksRU(n int) string {
if n%100 >= 11 && n%100 <= 14 {
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)
}
}
switch n % 10 {
case 1:
return "задача"
case 2, 3, 4:
return "задачи"
out := make([]Ranked, 0, 2*SpokenLimit)
for _, group := range [][]Ranked{open, cands} {
if len(group) > SpokenLimit {
group = group[:SpokenLimit]
}
out = append(out, group...)
}
return "задач"
return out
}
+10 -6
View File
@@ -4,6 +4,8 @@ import (
"strings"
"testing"
"time"
"github.com/kami/maven/internal/say"
)
func at(y int, m time.Month, d int) *time.Time {
@@ -123,10 +125,12 @@ func TestRankOverdueDaysAreCounted(t *testing.T) {
if got[0].Text != "давно" {
t.Errorf("order = %v, want the later-overdue task first", texts(got))
}
if got[0].Reason != "просрочено на 12 дн." {
if got[0].Reason != "просрочено на 12 дней" {
t.Errorf("reason = %q", got[0].Reason)
}
if got[1].Reason != "просрочено на день" {
// One day goes through the same entry as twelve: «на 1 день» is what the
// count helper says, so there is no reason_overdue_day any more.
if got[1].Reason != "просрочено на 1 день" {
t.Errorf("reason = %q", got[1].Reason)
}
}
@@ -141,7 +145,7 @@ func TestFormatRUNamesReasonsAndSeparatesCandidates(t *testing.T) {
if !strings.HasPrefix(got, "сначала: оплатить интернет (сегодня)") {
t.Errorf("reply = %q", got)
}
if !strings.Contains(got, "не подтвердил: продлить страховку") {
if !strings.Contains(got, "не подтверждал: продлить страховку") {
t.Errorf("candidate not named as unconfirmed: %q", got)
}
// A candidate's due date is Maven's reading of a mail, not his statement.
@@ -178,7 +182,7 @@ func TestFormatRUEmpty(t *testing.T) {
// A due date read back from the store is a UTC instant, so comparing calendar
// days in ITS location put every date a day out east of Greenwich: the row said
// "сегодня" for a task due tomorrow, and "просрочено на день" on the due date
// "сегодня" for a task due tomorrow, and "просрочено на 1 день" on the due date
// itself while the due column one cell over said otherwise.
func TestRankComparesDaysInTheCallersLocation(t *testing.T) {
tz := time.FixedZone("UTC+4", 4*3600)
@@ -238,8 +242,8 @@ func TestFormatRUTailCarriesTheNoun(t *testing.T) {
t.Errorf("reply = %q, want the count with its noun", got)
}
for n, want := range map[int]string{1: "задача", 2: "задачи", 5: "задач", 11: "задач", 21: "задача"} {
if got := pluralTasksRU(n); got != want {
t.Errorf("pluralTasksRU(%d) = %q, want %q", n, got, want)
if got := say.CountWord(n, "задача", "задачи", "задач"); got != want {
t.Errorf("CountWord(%d) = %q, want %q", n, got, want)
}
}
}
+140
View File
@@ -0,0 +1,140 @@
package tool
import (
"sort"
"strings"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/mcp"
"github.com/kami/maven/internal/smarthome"
)
// Capability ids (Vikunja #452).
//
// A tool row is flat: one name, one scope, one enabled bit. Permission is
// therefore per name, and nothing groups. Hexis has spoken dotted capability
// ids since it existed, so the local surface was the odd one out — and the
// flat shape gets expensive around fifteen rows, when "what can she do to the
// house" stops being a question anyone can answer by reading a list.
//
// A capability id is scope.domain.action: homelab.docker.restart,
// house.lock.unlock, mcp_vikunja.vikunja.delete_task.
//
// DERIVED, not stored, for the same reason the risk tier is (risk.go): a
// derivation is one place to argue with, a column is whatever the last person
// to enable the row happened to type. The name stays the primary key and
// nothing about lookup or execution changes — this is a way to READ the
// allowlist, not a second allowlist.
type Capability struct {
Scope string
Domain string
Action string
}
// String renders the dotted id. An empty segment becomes "unknown" rather than
// collapsing, so an id always has three parts and a prefix match cannot
// accidentally widen.
func (c Capability) String() string {
return capSegment(c.Scope) + "." + capSegment(c.Domain) + "." + capSegment(c.Action)
}
func capSegment(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
s = strings.ReplaceAll(s, ".", "_")
s = strings.ReplaceAll(s, " ", "_")
if s == "" {
return "unknown"
}
return s
}
// CapabilityOf derives the id of a tool row.
//
// The domain is the thing acted on and the action is what is done to it, read
// off whichever dispatch shape the row uses:
//
// - a house row: the Home Assistant entity domain and the service, so
// light.kitchen + turn_off becomes house.light.turn_off. Its scope is
// "house" whatever the row says, because the entity id is what decides
// what it touches.
// - an MCP row: the server handle and the remote tool name.
// - a process row: the program (path stripped) and its first subcommand, or
// the tool name when the argv carries no second word.
func CapabilityOf(t ipc.Tool) Capability {
if entityID, service, ok := smarthome.ParseCmd(t.Cmd); ok {
domain := entityID
if i := strings.Index(entityID, "."); i > 0 {
domain = entityID[:i]
}
return Capability{Scope: "house", Domain: domain, Action: service}
}
if server, remote, ok := mcp.ParseCmd(t.Cmd); ok {
return Capability{Scope: "mcp_" + server, Domain: server, Action: remote}
}
scope := t.Scope
if scope == "" {
scope = "homelab"
}
if len(t.Cmd) == 0 {
// A proposal has no argv yet. It still gets an id, because "what did
// she ask for" is exactly the question the proposed list answers.
return Capability{Scope: scope, Domain: "unknown", Action: t.Name}
}
program := t.Cmd[0]
if i := strings.LastIndex(program, "/"); i >= 0 {
program = program[i+1:]
}
action := t.Name
if len(t.Cmd) > 1 && !strings.HasPrefix(t.Cmd[1], "-") {
action = t.Cmd[1]
}
return Capability{Scope: scope, Domain: program, Action: action}
}
// MatchCapability reports whether an id matches a pattern. A pattern is a
// dotted id whose segments may be "*", and a pattern with fewer segments than
// the id matches every id under it: "house" and "house.*" both cover
// house.lock.unlock.
//
// Prefix widening is deliberate and one-directional. "house.lock" covers every
// action on the locks; nothing lets a narrower id claim a wider pattern.
func MatchCapability(pattern string, c Capability) bool {
want := strings.Split(strings.ToLower(strings.TrimSpace(pattern)), ".")
got := strings.Split(c.String(), ".")
if len(want) > len(got) {
return false
}
for i, w := range want {
if w == "*" || w == "" {
continue
}
if w != got[i] {
return false
}
}
return true
}
// GroupByDomain buckets rows by "scope.domain" and returns the buckets in a
// stable order, which is what makes the allowlist readable past the point
// where a flat list stops being.
func GroupByDomain(tools []ipc.Tool) []CapabilityGroup {
byKey := map[string][]ipc.Tool{}
for _, t := range tools {
c := CapabilityOf(t)
byKey[capSegment(c.Scope)+"."+capSegment(c.Domain)] = append(byKey[capSegment(c.Scope)+"."+capSegment(c.Domain)], t)
}
out := make([]CapabilityGroup, 0, len(byKey))
for k, v := range byKey {
sort.Slice(v, func(i, j int) bool { return v[i].Name < v[j].Name })
out = append(out, CapabilityGroup{Prefix: k, Tools: v})
}
sort.Slice(out, func(i, j int) bool { return out[i].Prefix < out[j].Prefix })
return out
}
// CapabilityGroup — one scope.domain and the rows under it.
type CapabilityGroup struct {
Prefix string
Tools []ipc.Tool
}
+92
View File
@@ -0,0 +1,92 @@
package tool
import (
"testing"
"github.com/kami/maven/internal/ipc"
)
func TestCapabilityOfDescribesTheRow(t *testing.T) {
cases := []struct {
name string
tool ipc.Tool
want string
}{
{
"a process with a subcommand",
ipc.Tool{Name: "restart", Scope: "homelab", Cmd: []string{"docker", "restart"}},
"homelab.docker.restart",
},
{
"a program with a path and a flag",
ipc.Tool{Name: "backup", Scope: "homelab", Cmd: []string{"/usr/local/bin/borg", "-v"}},
"homelab.borg.backup",
},
{
"the house",
ipc.Tool{Name: "unlock_front", Cmd: []string{"smarthome", "lock.front_door", "unlock"}},
"house.lock.unlock",
},
{
"an mcp tool",
ipc.Tool{Name: "vikunja_delete_task", Cmd: []string{"mcp", "vikunja", "delete_task"}},
"mcp_vikunja.vikunja.delete_task",
},
{
"a proposal with no command yet",
ipc.Tool{Name: "перезапусти", Scope: "homelab"},
"homelab.unknown.перезапусти",
},
}
for _, c := range cases {
if got := CapabilityOf(c.tool).String(); got != c.want {
t.Errorf("%s: %q; want %q", c.name, got, c.want)
}
}
}
// A dotted id always has three segments, so a prefix pattern cannot widen by
// accident onto a row whose scope happens to be empty.
func TestCapabilityStringAlwaysHasThreeSegments(t *testing.T) {
if got := (Capability{}).String(); got != "unknown.unknown.unknown" {
t.Errorf("empty capability = %q", got)
}
if got := (Capability{Scope: "home lab", Domain: "a.b", Action: "X"}).String(); got != "home_lab.a_b.x" {
t.Errorf("segments not folded: %q", got)
}
}
func TestMatchCapabilityWidensOneWay(t *testing.T) {
c := CapabilityOf(ipc.Tool{Name: "unlock_front", Cmd: []string{"smarthome", "lock.front_door", "unlock"}})
for _, p := range []string{"house", "house.lock", "house.lock.unlock", "house.*.unlock", "*.lock"} {
if !MatchCapability(p, c) {
t.Errorf("%q did not match %s", p, c)
}
}
for _, p := range []string{"homelab", "house.light", "house.lock.lock", "house.lock.unlock.now"} {
if MatchCapability(p, c) {
t.Errorf("%q matched %s", p, c)
}
}
}
func TestGroupByDomainIsStable(t *testing.T) {
tools := []ipc.Tool{
{Name: "restart", Scope: "homelab", Cmd: []string{"docker", "restart"}},
{Name: "unlock_front", Cmd: []string{"smarthome", "lock.front_door", "unlock"}},
{Name: "logs", Scope: "homelab", Cmd: []string{"docker", "logs"}},
}
groups := GroupByDomain(tools)
if len(groups) != 2 {
t.Fatalf("%d groups; want 2", len(groups))
}
if groups[0].Prefix != "homelab.docker" || len(groups[0].Tools) != 2 {
t.Errorf("first group %+v; want homelab.docker with 2 rows", groups[0])
}
if groups[0].Tools[0].Name != "logs" {
t.Errorf("rows not sorted: %+v", groups[0].Tools)
}
if groups[1].Prefix != "house.lock" {
t.Errorf("second group %q; want house.lock", groups[1].Prefix)
}
}
+194
View File
@@ -0,0 +1,194 @@
package tool
import (
"strings"
"github.com/kami/maven/internal/ipc"
"github.com/kami/maven/internal/mcp"
"github.com/kami/maven/internal/smarthome"
)
// Risk tiers (Vikunja #449).
//
// What existed before this file was a mechanism and no policy: one
// `Destructive` boolean per row, set by whoever ticked the checkbox on /tools.
// Nothing said which acts are destructive, whether a confirmed act stays
// confirmed, or what a new tool domain inherits — so every domain answered
// those questions for itself, and two of them answered differently.
//
// The tiers below are the policy. They are derived from the row, not stored:
// a derivation can be argued with and corrected in one place, while a column
// is whatever the last person to enable the tool believed.
//
// The three questions, answered once:
//
// - WHICH ACTS ARE DESTRUCTIVE. A house row always is, because there is no
// read-only way to turn the heating off. A row whose argv names one of the
// irreversible verbs always is, whatever the checkbox says. Everything else
// is what the row was enabled as.
// - DOES A CONFIRMED ACT STAY CONFIRMED. No. Never, at any tier. A
// confirmation binds one capability, one target and one argument list, and
// it expires with the parked turn (confirmTTL, 90s). "Same act again" is a
// new act and costs a new turn. A sticky confirm is a standing grant, and
// nothing on the voice path may hold one.
// - WHAT A NEW DOMAIN INHERITS. The default is TierDestructive, not
// TierSafe. A dispatch shape this file does not recognise gets the confirm
// turn — a new domain must argue its way DOWN to running freely, never up
// to needing a confirm.
type Risk string
const (
// TierSafe — a read, or a mutation the owner can undo by saying the
// opposite. Runs on first hearing.
TierSafe Risk = "safe"
// TierDestructive — it changes something real and undoing it takes work.
// One confirm turn, every time, never remembered.
TierDestructive Risk = "destructive"
// TierIrreversible — the thing it acts on does not come back: a wipe, a
// format, a delete with no bin behind it. A confirm turn is not enough,
// because the whole chain that proposed it — an STT guess, a router guess,
// a fuzzy allowlist match — has a spoken "да" as its only check. She names
// the gap and he runs it himself.
TierIrreversible Risk = "irreversible"
)
// Policy — what a tier requires of the act path.
//
// There is deliberately no "sticky for" field. Non-stickiness is the policy,
// and a knob that could turn it off would be the thing to argue with instead
// of the rule.
type Policy struct {
// Confirm — the act does not run on first hearing.
Confirm bool
// VoiceMayRun — a spoken confirmation is enough authority to run it.
VoiceMayRun bool
}
// PolicyFor returns the requirements of a tier. An unknown tier is treated as
// destructive, for the same reason the default derivation is.
func PolicyFor(r Risk) Policy {
switch r {
case TierSafe:
return Policy{Confirm: false, VoiceMayRun: true}
case TierIrreversible:
return Policy{Confirm: true, VoiceMayRun: false}
default:
return Policy{Confirm: true, VoiceMayRun: true}
}
}
// irreversibleVerbs — argv heads and subcommands that destroy the thing they
// name. Matched as whole argv elements, never as substrings: "rm" must not
// fire on "/usr/bin/rmdir-report" and "drop" must not fire on "dropbox".
//
// The list is short on purpose. It is not a sandbox and it does not try to be
// one — an enabled row can already run anything the daemon's user can run.
// What it is, is the set of words that mean "and then it is gone", so that the
// one act nobody can walk back is the one act a spoken "да" cannot authorise.
var irreversibleVerbs = map[string]bool{
"rm": true, "rmdir": true, "shred": true, "srm": true,
"mkfs": true, "fdisk": true, "parted": true, "wipefs": true,
"dd": true, "format": true,
"drop": true, "drop-database": true, "destroy": true, "purge": true,
"prune": true, "truncate": true,
}
// RiskOfCapability — the tier of a Hexis capability, which Hexis decides.
//
// Everything below this comment in RiskOf is a derivation, and a derivation is
// only honest where nobody else holds the answer. Hexis does hold it: the
// capability carries risk, read_only and requires_confirmation, and its own
// contract says requires_confirmation is server-derived from the tier and never
// settable by a caller. Deriving a second opinion here is the same defect as
// inventing a local fact key for something Nexus resolves — two answers, one of
// them stale, and the wrong one authorising an act (Vikunja #523).
//
// So this reads rather than decides. The three arguments are Capability.Risk,
// Capability.ReadOnly and Capability.RequiresConfirmation, passed as plain
// values so internal/tool keeps no dependency on the Hexis client.
//
// The one judgement left is what to do with an answer we cannot read. It goes
// up, never down: an unrecognised tier gets the confirm turn, the same default
// a dispatch shape RiskOf does not know gets. And requires_confirmation may
// only raise — a capability that calls itself safe and then asks for a confirm
// is telling us two things, and the cautious one wins.
func RiskOfCapability(risk string, readOnly, requiresConfirmation bool) Risk {
switch Risk(strings.ToLower(strings.TrimSpace(risk))) {
case TierIrreversible:
return TierIrreversible
case TierDestructive:
return TierDestructive
case TierSafe:
// Safe and mutating is a contradiction, and so is safe with a confirm
// required. Either way the act changes something.
if requiresConfirmation || !readOnly {
return TierDestructive
}
return TierSafe
case "":
// No tier declared. Fall back to the shape Hexis did give us: a
// read-only capability that wants no confirm is a read, and anything
// else takes the confirm turn.
if readOnly && !requiresConfirmation {
return TierSafe
}
return TierDestructive
default:
// A word this file has never seen. It is not safe by default.
return TierDestructive
}
}
// RiskOf derives the tier of a locally enabled tool row — a shell command, an
// MCP call or a house service. Nothing here is a Hexis capability, and nothing
// upstream has an opinion about a row the owner ticked on /tools, which is why
// this one derives and RiskOfCapability reads.
func RiskOf(t ipc.Tool) Risk {
if isIrreversible(t.Cmd) {
return TierIrreversible
}
// A house row is a physical change to the flat, and the confirm turn on it
// is structural rather than a column: /tools writes the checkbox straight
// through on enable, so unticking it once turned an unlock into a row that
// ran on first hearing. Nothing any surface writes removes the second turn
// from a physical device.
if _, _, ok := smarthome.ParseCmd(t.Cmd); ok {
return TierDestructive
}
// An MCP row is a call to somebody else's server. It is enabled with a
// fingerprint of what it declared at approval time (Vikunja #251), and the
// tier tracks the same flag every other row uses — the point of this branch
// is that it is NOT special-cased into running freely.
if _, _, ok := mcp.ParseCmd(t.Cmd); ok {
if t.Destructive {
return TierDestructive
}
return TierSafe
}
if t.Destructive {
return TierDestructive
}
if len(t.Cmd) == 0 {
// Not a shape this file knows how to read. The default is the confirm
// turn: a new domain argues its way down, not up.
return TierDestructive
}
return TierSafe
}
// isIrreversible reports whether any argv element is one of the verbs that
// destroys what it names. Every element, not just the head: "sudo rm" and
// "docker volume prune" both hide the verb behind a wrapper.
func isIrreversible(cmd []string) bool {
for _, arg := range cmd {
word := strings.ToLower(strings.TrimSpace(arg))
// Take the last path element, so /bin/rm reads as rm.
if i := strings.LastIndex(word, "/"); i >= 0 {
word = word[i+1:]
}
if irreversibleVerbs[word] {
return true
}
}
return false
}
+111
View File
@@ -0,0 +1,111 @@
package tool
import (
"context"
"errors"
"testing"
"github.com/kami/maven/internal/ipc"
)
func TestRiskOfReadsTheRow(t *testing.T) {
cases := []struct {
name string
tool ipc.Tool
want Risk
}{
{"a plain read", ipc.Tool{Cmd: []string{"systemctl", "status"}}, TierSafe},
{"the checkbox", ipc.Tool{Cmd: []string{"systemctl", "restart"}, Destructive: true}, TierDestructive},
{"a wipe", ipc.Tool{Cmd: []string{"rm", "-rf"}}, TierIrreversible},
{"a wipe behind a wrapper", ipc.Tool{Cmd: []string{"sudo", "/bin/rm"}}, TierIrreversible},
{"a prune behind a subcommand", ipc.Tool{Cmd: []string{"docker", "volume", "prune"}}, TierIrreversible},
{"the house", ipc.Tool{Cmd: []string{"smarthome", "light.kitchen", "turn_off"}}, TierDestructive},
{"the house with the box unticked", ipc.Tool{Cmd: []string{"smarthome", "lock.front", "unlock"}}, TierDestructive},
{"an mcp read", ipc.Tool{Cmd: []string{"mcp", "vikunja", "list_tasks"}}, TierSafe},
{"an mcp write", ipc.Tool{Cmd: []string{"mcp", "vikunja", "delete_task"}, Destructive: true}, TierDestructive},
{"a shape nobody wrote yet", ipc.Tool{}, TierDestructive},
}
for _, c := range cases {
if got := RiskOf(c.tool); got != c.want {
t.Errorf("%s: RiskOf = %q; want %q", c.name, got, c.want)
}
}
}
// The default is the confirm turn. A tier this file does not know is not a
// tier that runs freely.
func TestPolicyForDefaultsToConfirming(t *testing.T) {
for _, r := range []Risk{TierDestructive, Risk("whatever-lands-here-next")} {
p := PolicyFor(r)
if !p.Confirm || !p.VoiceMayRun {
t.Errorf("PolicyFor(%q) = %+v; want a confirm turn she may run", r, p)
}
}
if p := PolicyFor(TierSafe); p.Confirm || !p.VoiceMayRun {
t.Errorf("PolicyFor(safe) = %+v; want it to run", p)
}
if p := PolicyFor(TierIrreversible); !p.Confirm || p.VoiceMayRun {
t.Errorf("PolicyFor(irreversible) = %+v; want voice refused", p)
}
}
// An irreversible act is refused whether or not he said "да", because there is
// no second answer that changes what it would do.
func TestExecRefusesIrreversibleEvenConfirmed(t *testing.T) {
api := fakeAPI{tools: map[string]ipc.Tool{
"wipe": {Name: "wipe", Status: "enabled", Cmd: []string{"rm", "-rf"}, Destructive: true},
}}
e := NewExecutor(api, 0)
ran := false
e.run = func(context.Context, []string) (string, error) { ran = true; return "", nil }
for _, confirmed := range []bool{false, true} {
if _, err := e.Exec(context.Background(), "wipe", []string{"/data"}, confirmed); !errors.Is(err, ErrNeedsAuthedSurface) {
t.Errorf("confirmed=%v: %v; want ErrNeedsAuthedSurface", confirmed, err)
}
}
if ran {
t.Fatal("an irreversible act ran from the voice path")
}
}
// A row with no cmd at all is not a shape this file reads, and it must not
// slide through as safe.
func TestExecConfirmsAnUnreadableRow(t *testing.T) {
api := fakeAPI{tools: map[string]ipc.Tool{
"mystery": {Name: "mystery", Status: "enabled"},
}}
e := NewExecutor(api, 0)
if _, err := e.Exec(context.Background(), "mystery", nil, false); !errors.Is(err, ErrNeedsConfirm) {
t.Errorf("%v; want ErrNeedsConfirm", err)
}
}
// Hexis owns the tier of a Hexis capability, so this reads rather than derives
// (Vikunja #523). The cases that matter are the ones where the three fields
// disagree, or where the tier is a word this package has never seen: every one
// of those goes up to a confirm, never down to running freely.
func TestRiskOfCapabilityReadsHexis(t *testing.T) {
for _, c := range []struct {
name string
risk string
ro bool
confirm bool
want Risk
}{
{"hexis says irreversible", "irreversible", false, true, TierIrreversible},
{"case and space do not change the tier", " Irreversible ", false, true, TierIrreversible},
{"hexis says destructive", "destructive", false, true, TierDestructive},
{"a read hexis calls safe", "safe", true, false, TierSafe},
{"safe but mutating is a contradiction", "safe", false, false, TierDestructive},
{"safe but wants a confirm is a contradiction", "safe", true, true, TierDestructive},
{"no tier, read-only, no confirm", "", true, false, TierSafe},
{"no tier and mutating", "", false, false, TierDestructive},
{"no tier but hexis wants a confirm", "", true, true, TierDestructive},
{"a word we have never seen", "spicy", true, false, TierDestructive},
} {
if got := RiskOfCapability(c.risk, c.ro, c.confirm); got != c.want {
t.Errorf("%s: RiskOfCapability(%q, ro=%v, confirm=%v) = %q; want %q",
c.name, c.risk, c.ro, c.confirm, got, c.want)
}
}
}
+21 -2
View File
@@ -67,6 +67,12 @@ var (
// proposal, and drafting a new proposal for a tool that already exists and
// is enabled is a lie about what is wrong.
ErrNotConnected = errors.New("tool is enabled but its backend is not connected")
// ErrNeedsAuthedSurface — the row is enabled and the act is understood,
// and its tier is one a spoken "да" may not authorise (risk.go,
// TierIrreversible). Held apart from ErrNeedsConfirm because there is no
// confirm turn that would help: asking again would imply the second answer
// changes the outcome.
ErrNeedsAuthedSurface = errors.New("tool is irreversible and voice may not authorise it")
)
// MCPCaller is the seam for an act that is an MCP tool call rather than a
@@ -121,7 +127,12 @@ func (e *Executor) WithHome(h HomeCaller) *Executor {
// Exec looks up name in the store and runs Cmd+args as argv (no shell).
// confirmed=true is the second turn of a destructive act (the user said "да");
// it bypasses the ErrNeedsConfirm gate. Non-enabled ⇒ ErrNotEnabled; a
// destructive tool with confirmed=false ⇒ ErrNeedsConfirm.
// destructive tool with confirmed=false ⇒ ErrNeedsConfirm; an irreversible one
// ⇒ ErrNeedsAuthedSurface, confirmed or not.
//
// Exec IS the voice path. Nothing else calls it, which is why the tier check
// needs no surface argument: the authority it can offer a tool is a spoken
// "да", and TierIrreversible says that is not enough.
func (e *Executor) Exec(ctx context.Context, name string, args []string, confirmed bool) (string, error) {
t, err := e.api.LookupTool(ctx, name)
if errors.Is(err, ipc.ErrToolNotFound) {
@@ -133,7 +144,15 @@ func (e *Executor) Exec(ctx context.Context, name string, args []string, confirm
if t.Status != "enabled" {
return "", ErrNotEnabled
}
if t.Destructive && !confirmed {
// The tier decides, not the column (Vikunja #449). RiskOf reads the row and
// answers the three questions the boolean never did: which acts are
// destructive, whether a confirm sticks (it never does), and what an
// unrecognised shape inherits (the confirm turn).
policy := PolicyFor(RiskOf(t))
if !policy.VoiceMayRun {
return "", ErrNeedsAuthedSurface
}
if policy.Confirm && !confirmed {
return "", ErrNeedsConfirm
}
// An MCP row is a call to a configured server, not a process. Everything
+128
View File
@@ -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)
}
+85
View File
@@ -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)
}
}
+6 -3
View File
@@ -7,10 +7,13 @@ import (
"regexp"
"strconv"
"strings"
"github.com/kami/maven/internal/lexicon"
)
var months = [...]string{"", "января", "февраля", "марта", "апреля", "мая",
"июня", "июля", "августа", "сентября", "октября", "ноября", "декабря"}
// The month names are a closed class and live in internal/lexicon, 1-indexed,
// which is also where the voice reply path reads them. There used to be a second
// copy of the twelve names in cmd/mavend/ruwords.go (Vikunja #525).
var (
reDateY = regexp.MustCompile(`\b(\d{1,2})\.(\d{1,2})\.(\d{4})\b`)
@@ -47,7 +50,7 @@ func spokenDate(dd, mm, yyyy string) string {
return dd + " " + mm + gap(yyyy)
}
day := strconv.Itoa(mustInt(dd))
out := day + " " + months[mi]
out := day + " " + lexicon.MonthGenitive(mi)
if yyyy != "" {
out += " " + yyyy
}
+11 -11
View File
@@ -25,7 +25,10 @@
// the daemon seam (config wiring, no CoreAPI or voice-package change).
package voice
import "github.com/kami/maven/internal/router"
import (
"github.com/kami/maven/internal/phraser"
"github.com/kami/maven/internal/router"
)
// Replier — the reactive reply phrasing seam. The daemon's reactive handler
// calls Reply with the router's Decision; the impl produces a terse reply
@@ -60,27 +63,24 @@ func (s *StubReplier) Reply(d router.Decision) string {
if !d.Slots.HasFn {
return "не могу это сделать — не разобрала действие."
}
return "ок, записала действие: " + d.Slots.Fn
return phraser.Ack(phraser.AckAct, map[string]string{"fn": d.Slots.Fn})
case router.IntentReminder:
if d.Slots.HasTime {
return "напомню."
}
return "напомню."
return phraser.Ack(phraser.AckReminder, nil)
case router.IntentFact:
if d.Slots.HasKey {
if d.Slots.Value != "" {
return "отметила: " + d.Slots.Key + " = " + d.Slots.Value
return phraser.Ack(phraser.AckFactValue, map[string]string{"key": d.Slots.Key, "value": d.Slots.Value})
}
return "отметила: " + d.Slots.Key
return phraser.Ack(phraser.AckFactKey, map[string]string{"key": d.Slots.Key})
}
return "записала факт."
return phraser.Ack(phraser.AckFact, nil)
case router.IntentNote:
return "сохранила заметку."
return phraser.Ack(phraser.AckNote, nil)
case router.IntentQuery:
return "поискала в заметках — ничего не нашла."
case router.IntentChat:
return "поговорили." // stub — LLMReplier replaces this
default:
return "приняла."
return phraser.Ack(phraser.AckGeneric, nil)
}
}