package kiwix import ( "strings" "unicode" "github.com/kami/maven/internal/lexicon" "github.com/kami/maven/internal/morph" ) // Topic reduces a question to the thing it is about, because Kiwix ranks by // keyword overlap and a whole sentence buries the keyword that matters. // // This package's own doc says it: "why is the sky blue" finds a TV episode. // Measured against the Russian ZIM on 2026-08-09, the sentence and the topic // return different articles for the same question. "кто написал Войну и мир" // returns "Радуйся, мир (Доктор Кто)"; "Войну и мир" returns the novel first. // "что такое TCP" returns "Перехват TCP-соединения"; "TCP" returns TCP. The // English path had a rewriter doing this with a model call. The Russian path // reads the book verbatim (V-508) and had nothing. // // It drops three things off the front and stops: the narrative request, the // interrogative, and a verb sitting between them and the noun. Everything else // is kept, because a word this cannot classify is more likely the topic than // noise. An empty return means the utterance was question words alone, and the // caller searches the sentence as before. func Topic(utterance string) string { words := strings.Fields(strings.TrimSpace(utterance)) cut := 0 for cut < len(words) { w := strings.Trim(strings.ToLower(words[cut]), ".,!?…:;\"'«»") if w == "" { cut++ continue } switch { case inList(lexicon.NarrativeRequests(), w), inList(lexicon.Interrogatives(), w), inList(lexicon.FirstPerson(), w), // "что ТАКОЕ x", "кто ТАКОЙ x" — the copula that only ever follows // an interrogative, and never a topic on its own. cut > 0 && isCopula(w), // "расскажи ПРО x", "о x". One-letter and two-letter prepositions // are not a closed class worth a lexicon set of their own. cut > 0 && isLeadingPreposition(w), // "кто НАПИСАЛ Войну и мир". A verb here is the question's own // verb, not part of the title. Only after something was already // dropped, so "написал отчёт" as a topic survives intact. cut > 0 && morph.IsVerbForm(w): cut++ default: // The question mark is the sentence's, not the title's, and Kiwix // carries it into the keyword match. topic := strings.TrimRight(strings.Join(words[cut:], " "), " .,!?…:;\"'«»") if !hasLetter(topic) { return "" } return topic } } 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": return true } return false } func isLeadingPreposition(w string) bool { switch w { case "про", "о", "об", "обо", "по", "about", "of", "on": return true } return false } func inList(list []string, w string) bool { for _, x := range list { if x == w { return true } } return false } // hasLetter is the guard against a topic that reduced to punctuation or digits // alone, which no ZIM title matches. func hasLetter(s string) bool { for _, r := range s { if unicode.IsLetter(r) { return true } } return false }