From 5c3d84d0c9cb31082251e64986398d5160acb3a9 Mon Sep 17 00:00:00 2001 From: rope Date: Sat, 1 Aug 2026 16:26:10 +0100 Subject: [PATCH] quickshell: answer "where"/"when"/"who" questions, pick the right article MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "where is K2" had no scoring signal at all — "where" is a stopword and "k2" fell under the 3-char term floor, so every sentence tied at zero and the card fell back to the opening line. Fixes: - answer-type hints: a "where" question scores sentences carrying the vocabulary of place, "when" of dates, "who" of attribution. Stems, not whole words — the Telephone intro says "granted a United States patent", never "invented". "is a"/"was a" deliberately excluded, since they match the definitional opening of every article. - term floor down to 2 chars so "K2" and "UK" count. - fetch 3 candidates and choose: an exact title match wins, and a disambiguated title loses unless the query asked for that sense. Search rank alone put "Chernobyl (miniseries)" above the city. Verified 11/11 on live responses by running the generated QML functions. Co-Authored-By: Claude Opus 5 --- settings/quickshell.nix | 67 +++++++++++++++++++++++++++++++++++------ 1 file changed, 58 insertions(+), 9 deletions(-) diff --git a/settings/quickshell.nix b/settings/quickshell.nix index 69611d5..487eaaf 100644 --- a/settings/quickshell.nix +++ b/settings/quickshell.nix @@ -232,6 +232,9 @@ in # title-only endpoints return nothing. --data-urlencode keeps the query # out of the URL literal. # + # Three candidates, not one: search rank puts "Chernobyl (miniseries)" + # above the city, so the QML picks between them (see pickPage). + # # Prints nothing on any failure, which the QML treats as "no answer" — # including the empty 4xx/5xx that Wikipedia returns when a burst of # requests gets throttled. @@ -241,7 +244,7 @@ in ${pkgs.curl}/bin/curl -sf --max-time 6 -G \ --data-urlencode "gsrsearch=$q" \ -H 'User-Agent: quickshell-launcher/1.0 (personal desktop use)' \ - 'https://en.wikipedia.org/w/api.php?action=query&format=json&generator=search&gsrlimit=1&prop=extracts&exintro&explaintext&redirects=1' + 'https://en.wikipedia.org/w/api.php?action=query&format=json&generator=search&gsrlimit=3&prop=extracts&exintro&explaintext&redirects=1' ''; # cava in raw mode: one newline-terminated frame per tick, bars as @@ -1698,6 +1701,22 @@ in "speed", "fast", "temperature", "cost", "price", "year", "when", "many", "much", "capital", "big" ] + // What a sentence answering THIS kind of question looks like. + // A digit is the tell for "how tall"; for "where" it's the + // vocabulary of place. Stems, not whole words — the Telephone + // intro says "granted a United States patent", never + // "invented", so "patent" has to match "patent" in either. + readonly property var answerHints: ({ + where: ["located", "lies", "situated", "region", "country", + "border", "city", "province", "state", "range", + "north", "south", "east", "west", "coast", "island", "near"], + when: ["century", "founded", "established", "opened", + "began", "completed", "built", "inaugurated"], + // Deliberately NOT "is a"/"was a": those match the + // definitional opening of every article. + who: ["invent", "discover", "found", "creat", "develop", + "credit", "patent", "known for", "named after"] + }) // "firefox" and "vs code" are app searches; "mount everest // height" is a question. Two words plus some length sorts @@ -1796,10 +1815,18 @@ in const ql = query.toLowerCase(); const tw = title.toLowerCase().split(/[^a-z0-9]+/); - const terms = ql.split(/[^a-z0-9]+/).filter(w => - w.length > 2 && answerStop.indexOf(w) === -1 + const words = ql.split(/[^a-z0-9]+/); + // 2 chars, not 3: "K2" and "UK" are real search terms. + const terms = words.filter(w => + w.length >= 2 && answerStop.indexOf(w) === -1 && (answerAttrs.indexOf(w) !== -1 || tw.indexOf(w) === -1)); const wantsNumber = answerAttrs.some(a => ql.indexOf(a) !== -1); + // "where is K2" carries no scoring terms at all once the + // stopword and the title word are gone — the answer-type + // hints are the only signal it has. + let hints = []; + for (const k in answerHints) + if (words.indexOf(k) !== -1) hints = hints.concat(answerHints[k]); let best = 0; let bestScore = -1; @@ -1809,6 +1836,7 @@ in for (let t = 0; t < terms.length; t++) if (s.indexOf(terms[t]) !== -1) sc += 3; if (wantsNumber && /\d/.test(sents[i])) sc += 4; + if (hints.some(h => s.indexOf(h) !== -1)) sc += 4; if (sc > bestScore) { bestScore = sc; best = i; } } return { @@ -1819,17 +1847,38 @@ in }; } + // Which of the three candidates the query actually meant. + // Search rank alone is not enough: "chernobyl" ranks the HBO + // miniseries above the city. + function pickPage(pages, query) { + const ql = query.toLowerCase(); + const content = ql.split(/[^a-z0-9]+/) + .filter(w => w.length >= 2 && answerStop.indexOf(w) === -1) + .join(" "); + // A title that IS the query beats whatever ranked first. + const exact = pages.find(p => (p.title || "").toLowerCase() === content); + if (exact !== undefined) return exact; + // Otherwise skip a disambiguated title unless the query + // asked for that sense ("chernobyl miniseries"). + const plain = pages.find(p => { + const m = (p.title || "").match(/\(([^)]+)\)/); + return m === null || ql.indexOf(m[1].toLowerCase()) !== -1; + }); + return plain !== undefined ? plain : pages[0]; + } + function takeAnswer(raw) { if (answerDropNext) { answerDropNext = false; return; } let title = ""; let extract = ""; try { - const pages = JSON.parse(raw).query.pages; - for (const k in pages) { // single page under an unpredictable key - title = pages[k].title || ""; - extract = pages[k].extract || ""; - break; - } + const obj = JSON.parse(raw).query.pages; + let pages = []; + for (const k in obj) pages.push(obj[k]); // keyed by pageid + pages.sort((a, b) => a.index - b.index); // restore search order + const p = pickPage(pages, answerQuery); + title = p.title || ""; + extract = p.extract || ""; } catch (e) { title = ""; // no match, or a throttled empty reply extract = "";