diff --git a/CLAUDE.md b/CLAUDE.md index 502639b..4b2c72f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -681,8 +681,11 @@ world questions, so she needs to read external sources. What replaces it: returned "Перехват TCP-соединения". "кто написал Войну и мир" returned an episode of Doctor Who. `kiwix.Topic` drops the narrative request, the interrogative and a verb behind one. `kiwix.TitlePath` tries the exact article first, since a ZIM is addressable - by title and a wrong title is a 404. Four of eight questions reach the right article - where they did not, two were already right, and nothing regressed. Both apply on the + by title and a wrong title is a 404. Five of eight questions reach the right article + where they did not, one was already right, and nothing regressed. The title needs its + leading capital, so `TitleCandidates` tries the spoken form and then the capitalized + one. **"столица Франции" is answered by a title redirect to Париж**, which is the case + the 2026-08-05 measurement named as unreachable by any lexical signal. Both apply on the verbatim path alone. The rewriter already reduces a question, and reducing twice takes the topic off its input. `Response.Empty()` is the whole gate and there is no quality threshold in front of it: diff --git a/cmd/mavend/actions_query.go b/cmd/mavend/actions_query.go index 4a79406..a0aaaab 100644 --- a/cmd/mavend/actions_query.go +++ b/cmd/mavend/actions_query.go @@ -938,10 +938,12 @@ func (h *reactiveHandler) queryKiwix(ctx context.Context, t *queryTurn) (string, // The article named exactly, before any ranking runs. A ZIM is // addressable by title and a wrong title is a 404, so this either // answers or costs one request that says nothing. - page, err := h.kiwix.client.Article(ctxK, kiwix.TitlePath(book, topic), h.kiwix.runes) - if err == nil && page.Text != "" { - log.Printf("voice: kiwix: %q in %q → title hit %q", topic, book, page.Title) - return h.kiwixReply(ctx, t, page.Title, page.Text) + for _, cand := range kiwix.TitleCandidates(topic) { + page, err := h.kiwix.client.Article(ctxK, kiwix.TitlePath(book, cand), h.kiwix.runes) + if err == nil && page.Text != "" { + log.Printf("voice: kiwix: %q in %q → title hit %q", topic, book, page.Title) + return h.kiwixReply(ctx, t, page.Title, page.Text) + } } pattern = topic } diff --git a/docs/evals/2026-08-09-kiwix-topic-retrieval.md b/docs/evals/2026-08-09-kiwix-topic-retrieval.md index 115605d..6c59d54 100644 --- a/docs/evals/2026-08-09-kiwix-topic-retrieval.md +++ b/docs/evals/2026-08-09-kiwix-topic-retrieval.md @@ -60,6 +60,10 @@ A ZIM is also addressable by title, which nothing here used. `/A/Франция` exact title is safe to try first: it either answers or costs one request that says nothing. +The title has to carry its capital. `/A/фотосинтез` is a 404 and +`/A/Фотосинтез` is a 200. The spoken form is tried first anyway, so a title +that begins lowercase on purpose keeps its chance. + ## What shipped, measured `kiwix.Topic` drops the narrative request, the interrogative and a verb sitting @@ -71,25 +75,31 @@ would take the topic off the rewriter's input. | question | before | after | |---|---|---| | что такое TCP? | Перехват TCP-соединения | **TCP** (by title) | -| что такое фотосинтез | C4-фотосинтез | **Фотосинтез** | +| что такое фотосинтез | C4-фотосинтез | **Фотосинтез** (by title) | | кто такой Линус Торвальдс? | Tux | **Торвальдс, Линус** (by title) | | кто написал Войну и мир | Радуйся, мир (Доктор Кто) | **Война и мир** | -| что такое чёрная дыра | Чёрная дыра | Чёрная дыра | +| столица Франции | Список столиц Олимпийских игр | **Париж** (by title) | +| что такое чёрная дыра | Чёрная дыра | Чёрная дыра (by title) | | почему небо голубое | Город золотой | Под небом голубым… (фильм) | | почему трава зелёная | Сено | Зелень | -| столица Франции | Список столиц Олимпийских игр | Список столиц Олимпийских игр | -Four questions reach the right article where they did not. Two were already -right and stay right. Nothing regressed. +Five questions reach the right article where they did not. One was already +right and stays right. Nothing regressed. + +"столица Франции" is the surprise. The 2026-08-05 measurement named it as the +case a quality gate must not break, because the answer is Париж and that word +is not in the question. The ZIM holds a title redirect, so asking for the +article titled "Столица Франции" returns Париж. Retrieval by title reaches an +answer that retrieval by keyword cannot. ## What is still wrong Two of the eight are still not answered, and both are the same shape. The -question names no article. "почему небо голубое" is answered by Rayleigh -scattering and "столица Франции" by the lead of Франция. Neither title is in -the question. Keyword retrieval cannot bridge that and neither can a -threshold. The candidates are a semantic index over titles, or asking the -resident model for the article title rather than for keywords. +question names no article and no redirect covers it. "почему небо голубое" is +answered by Rayleigh scattering, and nothing in the question says so. Keyword +retrieval cannot bridge that and neither can a threshold. The candidates are a +semantic index over titles, or asking the resident model for the article title +rather than for keywords. `Response.Empty()` is still the whole gate. A wrong article that the search does return is still spoken. What this change buys is that the article is diff --git a/internal/kiwix/live_test.go b/internal/kiwix/live_test.go index 95343de..45545f5 100644 --- a/internal/kiwix/live_test.go +++ b/internal/kiwix/live_test.go @@ -39,9 +39,13 @@ func TestLiveTopicBeatsTheSentence(t *testing.T) { before := firstTitle(ctx, c, q, book) topic := Topic(q) after := "" - if page, err := c.Article(ctx, TitlePath(book, topic), 400); err == nil && page.Text != "" { - after = page.Title + " (by title)" - } else { + for _, cand := range TitleCandidates(topic) { + if page, err := c.Article(ctx, TitlePath(book, cand), 400); err == nil && page.Text != "" { + after = page.Title + " (by title)" + break + } + } + if after == "" { after = firstTitle(ctx, c, topic, book) } cancel() diff --git a/internal/kiwix/topic.go b/internal/kiwix/topic.go index 2237070..5358a39 100644 --- a/internal/kiwix/topic.go +++ b/internal/kiwix/topic.go @@ -61,6 +61,26 @@ func Topic(utterance string) string { return "" } +// TitleCandidates is the topic as it might be titled, best first. +// +// A ZIM title is capitalized and the utterance is not: measured on 2026-08-09, +// `/A/фотосинтез` is a 404 and `/A/Фотосинтез` is a 200. The spoken form is +// tried first anyway, because a title that begins lowercase on purpose +// ("iPhone") would not survive capitalizing it. Both are one request each +// against a server on the same box, and a miss is a 404 rather than a wrong +// article. +func TitleCandidates(topic string) []string { + if topic == "" { + return nil + } + r := []rune(topic) + up := unicode.ToUpper(r[0]) + if up == r[0] { + return []string{topic} + } + return []string{topic, string(up) + string(r[1:])} +} + func isCopula(w string) bool { switch w { case "такое", "такой", "такая", "такие", "is", "are", "was", "were": diff --git a/internal/kiwix/topic_test.go b/internal/kiwix/topic_test.go index bc01683..4a22ea7 100644 --- a/internal/kiwix/topic_test.go +++ b/internal/kiwix/topic_test.go @@ -49,3 +49,19 @@ func TestTitlePathEscapesAndUnderscores(t *testing.T) { t.Errorf("TitlePath = %q, want %q", got, want) } } + +// A ZIM title carries a leading capital and the utterance does not. The spoken +// form is still tried first, so a title that begins lowercase on purpose keeps +// its chance. +func TestTitleCandidatesTryTheSpokenFormFirst(t *testing.T) { + got := TitleCandidates("фотосинтез") + if len(got) != 2 || got[0] != "фотосинтез" || got[1] != "Фотосинтез" { + t.Errorf("TitleCandidates = %q", got) + } + if got := TitleCandidates("TCP"); len(got) != 1 || got[0] != "TCP" { + t.Errorf("an already-capital topic was tried twice: %q", got) + } + if got := TitleCandidates(""); got != nil { + t.Errorf("TitleCandidates(\"\") = %q, want nil", got) + } +} diff --git a/v668-transcript.md b/v668-transcript.md new file mode 100644 index 0000000..1ad5b28 --- /dev/null +++ b/v668-transcript.md @@ -0,0 +1,41 @@ +# Raw transcript, two weeks of usage + +YOU: что такое TCP? +MAVEN: есть такое: Tcp/Ip — Википедия — TCP позволяет регулировать нагрузку на сеть, а также уменьшать время ожидания данных при передаче на большие расстояния. Более того, TCP гарантирует, что полученные ... + [2.1s src=search t=666] + +YOU: кто такой Линус Торвальдс? +MAVEN: вот что я нашла: Торвальдс, Линус — Википедия — Ли́нус Бенедикт То́рвальдс (встречается написание Ту́рвальдс, швед. Linus Benedict Torvalds МФА: [ˈliːn.ɵs ˈtuːr.valds] о файле; род. 28 декабря 1969, Хельсинки) — финско-американский программист, создатель ядра Linux. + [0.8s src=search t=667] + +YOU: что такое фотосинтез +MAVEN: нашла вот это: Фотосинтез — Фотосинтез +Схематическое изображение процесса фотосинтеза, происходящего в растениях +Упрощённое уравнение фотосинтеза, происходящего в растениях +Упрощённая схема фотосинтеза +Фотоси́нтез (от др.-греч. φῶς  — « свет » и σύνθεσις  — «соединённый», «складной», «связывание», « синтез ») — сл… + [0.6s src=kiwix t=668] + +YOU: кто написал Войну и мир +MAVEN: вот что я нашла: Война и мир — Война и мир +Война и мир +Война и миръ +Обложка издания 1873 года +Жанр +роман-эпопея +Автор +Лев Николаевич Толстой +Язык оригинала +русский, фрагменты на французском и немецком языках +Дата написания +1863—1869, 1873 +Дата первой публикации +1865—1869 +Предыдущее +Казаки +Следующее +Анна Каренина +Тек… + [12.4s src=kiwix t=669] + +``` diff --git a/v668.jsonl b/v668.jsonl new file mode 100644 index 0000000..86c0458 --- /dev/null +++ b/v668.jsonl @@ -0,0 +1,4 @@ +{"reply": "есть такое: Tcp/Ip — Википедия — TCP позволяет регулировать нагрузку на сеть, а также уменьшать время ожидания данных при передаче на большие расстояния. Более того, TCP гарантирует, что полученные ...", "source": "search", "trace": "666", "secs": 2.1235475060530007, "day": 0, "n": 1, "utterance": "что такое TCP?"} +{"reply": "вот что я нашла: Торвальдс, Линус — Википедия — Ли́нус Бенедикт То́рвальдс (встречается написание Ту́рвальдс, швед. Linus Benedict Torvalds МФА: [ˈliːn.ɵs ˈtuːr.valds] о файле; род. 28 декабря 1969, Хельсинки) — финско-американский программист, создатель ядра Linux.", "source": "search", "trace": "667", "secs": 0.7689587379572913, "day": 0, "n": 2, "utterance": "кто такой Линус Торвальдс?"} +{"reply": "нашла вот это: Фотосинтез — Фотосинтез\nСхематическое изображение процесса фотосинтеза, происходящего в растениях\nУпрощённое уравнение фотосинтеза, происходящего в растениях\nУпрощённая схема фотосинтеза\nФотоси́нтез (от др.-греч. φῶς  — « свет » и σύνθεσις  — «соединённый», «складной», «связывание», « синтез ») — сл…", "source": "kiwix", "trace": "668", "secs": 0.575586927938275, "day": 0, "n": 3, "utterance": "что такое фотосинтез"} +{"reply": "вот что я нашла: Война и мир — Война и мир\nВойна и мир\nВойна и миръ\nОбложка издания 1873 года\nЖанр\nроман-эпопея\nАвтор\nЛев Николаевич Толстой\nЯзык оригинала\nрусский, фрагменты на французском и немецком языках\nДата написания\n1863—1869, 1873\nДата первой публикации\n1865—1869\nПредыдущее\nКазаки\nСледующее\nАнна Каренина\nТек…", "source": "kiwix", "trace": "669", "secs": 12.44599153404124, "day": 0, "n": 4, "utterance": "кто написал Войну и мир"}