From d9ef9ecef2865e3878fef7e5430195d3e6ec36fa Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 21:18:36 +0400 Subject: [PATCH 1/4] stage0: one rest-of-day grammar, not two (V-530) The textual merge in fe489df left a second rest-of-day-query grammar inside NarrativeQueryGrammars. buildRouter wires the agenda grammars first, so the copy never claimed a turn, and narrative_test.go only ever indexed the narrative rule beside it. Co-Authored-By: Claude Opus 5 --- internal/router/narrative_test.go | 2 +- internal/router/stage0.go | 31 ++++++++++++++----------------- 2 files changed, 15 insertions(+), 18 deletions(-) diff --git a/internal/router/narrative_test.go b/internal/router/narrative_test.go index dacca0c..b357a9f 100644 --- a/internal/router/narrative_test.go +++ b/internal/router/narrative_test.go @@ -15,7 +15,7 @@ func narrativeRouter(t *testing.T) *Router { 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]) + r.grammars = append(r.grammars, NarrativeQueryGrammars()...) return r } diff --git a/internal/router/stage0.go b/internal/router/stage0.go index 64973ff..82f58ab 100644 --- a/internal/router/stage0.go +++ b/internal/router/stage0.go @@ -230,27 +230,24 @@ func AgendaQueryGrammars() []Grammar { // 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). +// NarrativeQueryGrammars — the stage-0 grammar for "расскажи про X", a question +// shape that carries no question mark and no interrogative, and so reached the +// resident model with nothing deterministic in front of it (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. +// The model routed it IntentFact. The fact gate catches the write and re-runs +// the turn as a query, so nothing broke; what it cost is a full model round trip +// to reach a decision one pattern makes 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. +// It held a second grammar named rest-of-day-query until V-530. fe489df merged +// task/467 into the sweep line and both sides had landed V-498, so the merge +// kept both blocks textually. buildRouter wires the agenda grammars first and +// the agenda copy claims every case this one did, so it could never fire. +// +// Wired after the agenda grammars, which is where the overlap resolves: +// "расскажи, что у меня сегодня" is claimed there 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 From 997f92f5c49f6a9d0405be0ee31cdd33e42f3693 Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 21:18:52 +0400 Subject: [PATCH 2/4] numbers and reminder markers come from the lexicon (V-530) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ruNumerals was a second copy of the number words. It stopped at fifty, had no oblique forms, and disagreed with lexicon_ru_v1.json about its own members, so "к семи" was not the hour "в семь" was. The lexicon now carries the oblique cardinals and numwords.go asks lexicon.Cardinal. "час" and "часу" stay local: they are the hour noun as often as the number one, and nobody counts "час яблок". reminderbody.go built its markers from three inline word lists. Two of them are new lexicon sets, reminder_verbs and parts_of_day, and the day offsets were already there. The alternation helper sorts by length so a longer form wins the regex. Co-Authored-By: Claude Opus 5 --- cmd/mavend/reminderbody.go | 38 +++++++++++++-- internal/lexicon/lexicon.go | 10 +++- internal/lexicon/lexicon_ru_v1.json | 72 +++++++++++++++++------------ internal/router/numwords.go | 60 +++++++++++------------- 4 files changed, 115 insertions(+), 65 deletions(-) diff --git a/cmd/mavend/reminderbody.go b/cmd/mavend/reminderbody.go index 19ea305..f399668 100644 --- a/cmd/mavend/reminderbody.go +++ b/cmd/mavend/reminderbody.go @@ -2,12 +2,20 @@ package main import ( "regexp" + "sort" "strings" + + "github.com/kami/maven/internal/lexicon" ) // reminderMarker — the words that open a reminder. Stripped because they are // the instruction, not the thing to say at the hour. -var reminderMarker = regexp.MustCompile(`(?i)^\s*(?:напомни(?:те)?|напомнить|remind)\s*(?:мне|me)?[\s,:—-]*`) +// +// The verbs come from the lexicon (Vikunja #530). They are a closed set of the +// commands she answers to, exactly like capture_verbs, and the literal that +// stood here knew four of them. +var reminderMarker = regexp.MustCompile(`(?i)^\s*(?:` + alternation(lexicon.ReminderVerbs()) + + `)\s*(?:мне|me)?[\s,:—-]*`) // reminderTimeWords — the time expressions a reminder carries, removed from // the body because the fire time is already a column. Ordered longest-first @@ -17,12 +25,36 @@ var reminderMarker = regexp.MustCompile(`(?i)^\s*(?:напомни(?:те)?|на // Go's \b is ASCII-only and never fires next to a Cyrillic letter, so the word // boundaries here are written out as whitespace or an end of string — the same // trap the agenda grammars hit. +// +// The Russian word lists are gone (Vikunja #530). The day words are +// lexicon.DayOffsetWords, which is why "вчера" and "позавчера" are stripped now +// and were not before, and the times of day are lexicon.PartsOfDay. What is +// still written out here is the shape of a clock reading — a preposition, digits, +// a colon — which is structured input rather than a claim about Russian. var reminderTimeWords = []*regexp.Regexp{ regexp.MustCompile(`(?i)(^|\s)через\s+\S+(\s+(часа?|часов|минут[уы]?|секунд[уы]?|дня|дней|недел[юи]))?(\s|$)`), regexp.MustCompile(`(?i)(^|\s)(в|во)\s+\d{1,2}(:\d{2})?(\s*(часа?|часов))?(\s*(утра|вечера|дня|ночи))?(\s|$)`), - regexp.MustCompile(`(?i)(^|\s)(завтра|послезавтра|сегодня|вечером|утром|днём|днем|ночью)(\s|$)`), + regexp.MustCompile(`(?i)(^|\s)(` + alternation(lexicon.DayOffsetWords()) + `)(\s|$)`), + regexp.MustCompile(`(?i)(^|\s)(` + alternation(lexicon.PartsOfDay()) + `)(\s|$)`), regexp.MustCompile(`(?i)(^|\s)(at|in)\s+\d{1,2}(:\d{2})?\s*(am|pm)?(\s|$)`), - regexp.MustCompile(`(?i)(^|\s)(tomorrow|today|tonight)(\s|$)`), +} + +// alternation folds a lexicon set into one regexp branch, longest member first +// so "послезавтра" is not matched as "завтра" with a tail left behind. Sorted +// rather than taken as given, because two members of equal length must still +// produce the same pattern on every build. +func alternation(set []string) string { + out := make([]string, 0, len(set)) + for _, w := range set { + out = append(out, regexp.QuoteMeta(w)) + } + sort.Slice(out, func(i, j int) bool { + if len(out[i]) != len(out[j]) { + return len(out[i]) > len(out[j]) + } + return out[i] < out[j] + }) + return strings.Join(out, "|") } // reminderBody is what she says at the hour. diff --git a/internal/lexicon/lexicon.go b/internal/lexicon/lexicon.go index d044327..0e65770 100644 --- a/internal/lexicon/lexicon.go +++ b/internal/lexicon/lexicon.go @@ -63,7 +63,7 @@ func mustLoad() lexiconFile { for _, name := range []string{ "interrogatives", "capture_verbs", "narrative_requests", "cardinals", "day_offsets", "weekdays", "months_genitive", "hours_spoken", - "not_place_after_v", + "not_place_after_v", "parts_of_day", "reminder_verbs", } { s, ok := f.Sets[name] if !ok || (len(s.Words) == 0 && len(s.Values) == 0) { @@ -104,6 +104,14 @@ 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") } +// PartsOfDay returns the one-word names for a time of day: "вечером", "утром". +// They say which part of a day and never which day, so a caller that needs the +// day wants DayOffsetWords instead. +func PartsOfDay() []string { return words("parts_of_day") } + +// ReminderVerbs returns the imperatives that open a reminder. +func ReminderVerbs() []string { return words("reminder_verbs") } + // 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. diff --git a/internal/lexicon/lexicon_ru_v1.json b/internal/lexicon/lexicon_ru_v1.json index 15fdf20..0087e13 100644 --- a/internal/lexicon/lexicon_ru_v1.json +++ b/internal/lexicon/lexicon_ru_v1.json @@ -38,37 +38,37 @@ ] }, "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.", + "note": "Number words as spoken, with the gender variants Russian requires (один/одна/одно and два/две agree with the noun that follows) and the oblique forms, because a spoken time declines: \"в семь\", \"к семи\", \"около семи\" are three forms of one hour (Vikunja #530). 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 + "один": 1, "одна": 1, "одно": 1, "одного": 1, "одной": 1, "одну": 1, "one": 1, + "два": 2, "две": 2, "двух": 2, "two": 2, + "три": 3, "трёх": 3, "трех": 3, "three": 3, + "четыре": 4, "четырёх": 4, "четырех": 4, "four": 4, + "пять": 5, "пяти": 5, "five": 5, + "шесть": 6, "шести": 6, "six": 6, + "семь": 7, "семи": 7, "seven": 7, + "восемь": 8, "восьми": 8, "eight": 8, + "девять": 9, "девяти": 9, "nine": 9, + "десять": 10, "десяти": 10, "ten": 10, + "одиннадцать": 11, "одиннадцати": 11, "eleven": 11, + "двенадцать": 12, "двенадцати": 12, "twelve": 12, + "тринадцать": 13, "тринадцати": 13, "thirteen": 13, + "четырнадцать": 14, "четырнадцати": 14, "fourteen": 14, + "пятнадцать": 15, "пятнадцати": 15, "fifteen": 15, + "шестнадцать": 16, "шестнадцати": 16, "sixteen": 16, + "семнадцать": 17, "семнадцати": 17, "seventeen": 17, + "восемнадцать": 18, "восемнадцати": 18, "eighteen": 18, + "девятнадцать": 19, "девятнадцати": 19, "nineteen": 19, + "двадцать": 20, "двадцати": 20, "twenty": 20, + "тридцать": 30, "тридцати": 30, "thirty": 30, + "сорок": 40, "сорока": 40, "forty": 40, + "пятьдесят": 50, "пятидесяти": 50, "fifty": 50, + "шестьдесят": 60, "шестидесяти": 60, "sixty": 60, + "семьдесят": 70, "семидесяти": 70, "seventy": 70, + "восемьдесят": 80, "восьмидесяти": 80, "eighty": 80, + "девяносто": 90, "девяноста": 90, "ninety": 90, + "сто": 100, "ста": 100, "hundred": 100 } }, "day_offsets": { @@ -134,6 +134,20 @@ "сутках", "часах", "минутах", "секундах", "неделе", "месяце", "году", "начале", "конце", "середине", "течение", "течении" ] + }, + "parts_of_day": { + "note": "The times of day named as one word, in the instrumental case Russian uses for when something happens. A day has as many parts as it has, so this set is finished. They are not day offsets: \"вечером\" says which part of a day, never which day (Vikunja #530).", + "words": [ + "утром", "днём", "днем", "вечером", "ночью", + "morning", "afternoon", "evening", "night" + ] + }, + "reminder_verbs": { + "note": "The imperatives that mean \"remind me\", in the forms he speaks. The same kind of set as capture_verbs and decided the same way: it is her vocabulary, not a discovery about Russian (Vikunja #530).", + "words": [ + "напомни", "напомните", "напомнить", "напоминай", + "remind" + ] } } } diff --git a/internal/router/numwords.go b/internal/router/numwords.go index dbce260..8581e41 100644 --- a/internal/router/numwords.go +++ b/internal/router/numwords.go @@ -1,38 +1,34 @@ package router -import "strings" +import ( + "strconv" + "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 "семья". + "github.com/kami/maven/internal/lexicon" +) + +// hourNouns — the two words that are the hour noun as often as they are the +// number one. "в час дня" means one o'clock, so rewriting it to "в 1 дня" is +// right either way. // -// 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", +// They are not cardinals and do not belong in the lexicon's number set: nobody +// counts "час яблок". Everything else this file reads comes from +// lexicon.Cardinal, which is where the number words live complete, oblique forms +// included (Vikunja #530). The table here used to be a second copy that stopped +// at fifty and disagreed with the lexicon about its own members. +var hourNouns = map[string]string{"час": "1", "часу": "1"} + +// numeralDigit reports the digits a spoken number is written as, for a clock +// hour or the minutes after it. +func numeralDigit(word string) (string, bool) { + if d, ok := hourNouns[word]; ok { + return d, true + } + n, ok := lexicon.Cardinal(word) + if !ok { + return "", false + } + return strconv.Itoa(n), true } // numeralContext — the words that make a numeral a time. A numeral is only @@ -67,7 +63,7 @@ func SpellOutDigits(text string) string { copy(out, toks) for i, tok := range toks { key := strings.ToLower(strings.Trim(tok, ".,!?;:«»\"'")) - digit, ok := ruNumerals[key] + digit, ok := numeralDigit(key) if !ok { continue } From 58b546a27e9bcaf6f9c6289fec3ddeb77be1027a Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 21:18:52 +0400 Subject: [PATCH 3/4] history questions match verbs by lemma (V-530) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit historyMarkers were truncated Russian prefixes, so "что я читал рассказ" read as a history question because "рассказ" is a prefix of "рассказывал". That is the defect V-528 fixed in complaint.go, where "лаг" matched "лагерь". A history question is now an interrogative, plus a first- or second-person subject, plus a verb of saying or recording matched through morph.SameWord. "что записать?" is a verb with nobody saying it and no longer claims the turn. Co-Authored-By: Claude Opus 5 --- cmd/mavend/historyq.go | 89 +++++++++++++++++++++++++++++++------ cmd/mavend/historyq_test.go | 11 +++++ 2 files changed, 87 insertions(+), 13 deletions(-) diff --git a/cmd/mavend/historyq.go b/cmd/mavend/historyq.go index ea85394..1375dbd 100644 --- a/cmd/mavend/historyq.go +++ b/cmd/mavend/historyq.go @@ -6,6 +6,8 @@ import ( "log" "strings" "time" + + "github.com/kami/maven/internal/morph" ) // Command history — "что я тебе говорил?", "что ты записала сегодня?" @@ -15,18 +17,34 @@ import ( // storage: everything he tapped in is already a row with a source and a // timestamp, and this only reads them back. -// historyMarkers — the ways he asks what he told her. Each entry is a pair of -// substrings that must BOTH appear, because either half alone is a different -// question: "что я говорил про сервер" is a recall question the notes pass -// answers better, and "что ты записала" with no "что" is not a question at all. -var historyMarkers = [][2]string{ - {"что я", "говорил"}, - {"что я", "сказал"}, - {"что я", "рассказ"}, - {"что ты", "записал"}, - {"что ты", "запомнил"}, - {"что я", "отмечал"}, - {"что я", "отметил"}, +// A history question needs three things in one utterance: the interrogative, +// whose turn is being asked about, and a verb of saying or recording. Any two of +// them are a different question. "что я говорил про сервер" names a topic and +// the notes pass answers it better; "записал молоко" is a capture. +// +// The verbs are matched by lemma through internal/morph, not by a truncated +// prefix (Vikunja #530). The pairs here used to hold "рассказ" and "записал", +// which is the defect V-528 fixed in complaint.go: "рассказ" is also the noun, +// so "что я рассказал ей" and "что я читал рассказ" were the same string test. +// Aspect pairs are separate lemmas in the dictionary, so both members are listed. +var ( + // historySpokenVerbs — what HE did. "что я тебе говорил". + historySpokenVerbs = []string{"говорить", "сказать", "рассказать", "рассказывать", "отметить", "отмечать"} + + // historyRecordedVerbs — what SHE did with it. "что ты записала сегодня". + historyRecordedVerbs = []string{"записать", "запомнить", "отметить", "отмечать"} + + // firstPersonSubjects and secondPersonSubjects — whose turn the question is + // about. Only the subject forms: "что я тебе говорил" is his turn, and the + // dative "тебе" in it is not the subject. + firstPersonSubjects = []string{"я"} + secondPersonSubjects = []string{"ты"} +) + +// historyMarkersEn — the English pairs, kept as substrings because the +// dictionary is Russian. Each half alone is a different question, the same way +// the Russian test needs all three parts. +var historyMarkersEn = [][2]string{ {"what did i", "tell"}, {"what did you", "record"}, } @@ -47,11 +65,56 @@ func isHistoryQuery(u string) bool { return false } } - for _, pair := range historyMarkers { + for _, pair := range historyMarkersEn { if strings.Contains(s, pair[0]) && strings.Contains(s, pair[1]) { return true } } + toks := historyTokens(s) + if !hasAny(toks, "что", "чего") { + return false + } + if hasAny(toks, firstPersonSubjects...) && hasVerbForm(toks, historySpokenVerbs) { + return true + } + return hasAny(toks, secondPersonSubjects...) && hasVerbForm(toks, historyRecordedVerbs) +} + +// historyTokens splits an utterance into bare words. The punctuation goes +// because "говорил?" is the same word as "говорил". +func historyTokens(s string) []string { + toks := strings.Fields(s) + out := make([]string, 0, len(toks)) + for _, t := range toks { + if t = strings.Trim(t, ".,!?;:—–-()\"'«»"); t != "" { + out = append(out, t) + } + } + return out +} + +func hasAny(toks []string, want ...string) bool { + for _, t := range toks { + for _, w := range want { + if t == w { + return true + } + } + } + return false +} + +// hasVerbForm reports whether any token is a form of any of the lemmas. Both +// sides go through the dictionary, so a caller may name the infinitive and he +// may say the past tense. +func hasVerbForm(toks []string, lemmas []string) bool { + for _, t := range toks { + for _, l := range lemmas { + if morph.SameWord(t, l) { + return true + } + } + } return false } diff --git a/cmd/mavend/historyq_test.go b/cmd/mavend/historyq_test.go index b19342a..94ebf06 100644 --- a/cmd/mavend/historyq_test.go +++ b/cmd/mavend/historyq_test.go @@ -41,6 +41,17 @@ func TestIsHistoryQuery(t *testing.T) { {"что я тебе говорил?", true}, {"что ты записала сегодня?", true}, {"что я отмечал?", true}, + // Forms the truncated prefixes did not reach. The dictionary answers + // these because it lemmatises both sides (V-530). + {"что я тебе рассказывал?", true}, + {"что я сказала вчера", true}, + {"что ты запомнила?", true}, + // The noun, not the verb. "рассказ" was a prefix of the old pair, so + // this read as a history question — the same defect V-528 fixed in + // complaint.go, where "лаг" matched "лагерь". + {"что я читал рассказ", false}, + // A verb of saying with nobody saying it. + {"что записать?", false}, // A named topic is a recall question, and the notes pass answers it // better than a list of the last five facts does. {"что я говорил про сервер?", false}, From c62c7034fa9344adf22b59f7407a19340f2f3eaf Mon Sep 17 00:00:00 2001 From: claude Date: Tue, 4 Aug 2026 21:18:52 +0400 Subject: [PATCH 4/4] weather asks the dictionary before guessing case (V-530) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit locationCandidates reversed endings by hand to turn "в Казани" into the nominative the geocoder wants. internal/morph knows the answer for the places it has, so it goes first and the reversals stay behind it for the ones it does not: "Твери" and "Перми" come back unchanged. The four-rune floor was there to stop a two-letter stem, so it now tests the stem instead. "Уфе" was under the floor and "Уфа" was never tried. Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 6 +++-- internal/weather/openmeteo.go | 35 +++++++++++++++++++++++++----- internal/weather/openmeteo_test.go | 10 +++++++-- 3 files changed, 42 insertions(+), 9 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 45cec54..752fa1f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -231,8 +231,10 @@ fact or a route is the defect; a regex over structured input — HTML, MIME, JSO argv list — is not. Before writing a Russian word list, pick one of these: - **`internal/lexicon`** — closed classes, in `lexicon_ru_v1.json`. Interrogatives, - capture verbs, cardinals, day offsets, weekdays, months, spoken hours. Editing a word is - a data change, and there is exactly one copy: months used to live in three files. + capture verbs, reminder verbs, cardinals, day offsets, parts of day, weekdays, months, + spoken hours. Editing a word is a data change, and there is exactly one copy: months used + to live in three files. Cardinals carry the oblique forms, because a spoken time declines + and `в семь` / `к семи` are one hour. - **`internal/morph`** — grammar, from the vendored golem Russian dictionary. `IsVerbForm` and `SameWord`. Note that lemma matching is BROADER than stem-plus-one-ending, so a verb slot that means the imperative must be matched exactly — `говори` and `говорил` are one diff --git a/internal/weather/openmeteo.go b/internal/weather/openmeteo.go index 4eedf2d..29818b9 100644 --- a/internal/weather/openmeteo.go +++ b/internal/weather/openmeteo.go @@ -8,6 +8,9 @@ import ( "net/http" "net/url" "time" + "unicode" + + "github.com/kami/maven/internal/morph" ) type OpenMeteoProvider struct { @@ -101,10 +104,20 @@ func (p *OpenMeteoProvider) CurrentWeather(ctx context.Context, location string) // sentence, in order. He says "какая погода в Казани", so the word arrives in // the prepositional case and the geocoder wants the nominative (Vikunja #421). // -// Two cheap reversals cover most of what he says: a final "е" is usually a -// nominative "а" (Москве → Москва) or nothing at all (Лондоне → Лондон), and a -// final "и" is usually a soft sign (Казани → Казань). Indeclinable names — -// Тбилиси, Сочи, Осло — are already nominative and the first candidate answers. +// The dictionary answers first (Vikunja #530). internal/morph lemmatises +// "Уфе" to "Уфа" and "Москве" to "Москва", which is the same question this +// used to guess at by reversing endings, asked of something that knows. +// +// The reversals stay behind it, because the dictionary does not know every +// place: "Твери" and "Перми" come back unchanged, and a final "и" is usually a +// soft sign. A final "е" is usually a nominative "а" (Москве → Москва) or +// nothing at all (Лондоне → Лондон). Indeclinable names — Тбилиси, Сочи, Осло — +// are already nominative and the first candidate answers, which is why the word +// as spoken is always tried before anything derived from it. +// +// There used to be a four-rune floor here, so "Уфе" was asked as spoken and +// "Уфа" was never tried. The floor was there to stop a two-letter stem, and the +// stem length is what it now tests. // // Nothing here is a guess about the weather: a wrong candidate finds no city // and the caller says so. It only decides which strings are worth asking about. @@ -121,8 +134,9 @@ func locationCandidates(location string) []string { } out = append(out, s) } + add(titleFirst(morph.Lemma(location))) r := []rune(location) - if len(r) < 4 { + if len(r) < 3 { return out } stem := string(r[:len(r)-1]) @@ -139,6 +153,17 @@ func locationCandidates(location string) []string { return out } +// titleFirst restores the leading capital a place name carries. morph.Lemma +// answers lowercased, because a lemma is a dictionary entry and the dictionary +// has no opinion about proper nouns. +func titleFirst(s string) string { + r := []rune(s) + if len(r) == 0 { + return s + } + return string(unicode.ToUpper(r[0])) + string(r[1:]) +} + func (p *OpenMeteoProvider) geocode(ctx context.Context, location string) (lat, lon float64, name string, err error) { for _, cand := range locationCandidates(location) { lat, lon, name, err = p.geocodeOne(ctx, cand) diff --git a/internal/weather/openmeteo_test.go b/internal/weather/openmeteo_test.go index b51aae3..8d84fb8 100644 --- a/internal/weather/openmeteo_test.go +++ b/internal/weather/openmeteo_test.go @@ -86,14 +86,20 @@ func TestStubProvider(t *testing.T) { // TestLocationCandidates — he speaks the prepositional case and the geocoder // wants the nominative (Vikunja #421). +// +// The dictionary answers before the reversals now, so the nominative it knows +// comes second and anything derived by hand follows (V-530). "Уфе" used to fall +// under a four-rune floor and was asked as spoken, so "Уфа" was never tried. func TestLocationCandidates(t *testing.T) { cases := map[string][]string{ "Москве": {"Москве", "Москва", "Москв"}, "Казани": {"Казани", "Казань", "Казан"}, - "Лондоне": {"Лондоне", "Лондона", "Лондон"}, + "Лондоне": {"Лондоне", "Лондон", "Лондона"}, "Тбилиси": {"Тбилиси", "Тбились", "Тбилис"}, "Berlin": {"Berlin"}, - "Уфе": {"Уфе"}, // too short to strip — asked as spoken + "Уфе": {"Уфе", "Уфа", "Уф"}, + // The dictionary does not know it, so the soft-sign reversal answers. + "Твери": {"Твери", "Тверь", "Твер"}, } for in, want := range cases { got := locationCandidates(in)