quickshell: answer "where"/"when"/"who" questions, pick the right article

"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 <noreply@anthropic.com>
This commit is contained in:
rope 2026-08-01 16:26:10 +01:00
parent 95637d91c8
commit 5c3d84d0c9

View file

@ -232,6 +232,9 @@ in
# title-only endpoints return nothing. --data-urlencode keeps the query # title-only endpoints return nothing. --data-urlencode keeps the query
# out of the URL literal. # 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" — # Prints nothing on any failure, which the QML treats as "no answer" —
# including the empty 4xx/5xx that Wikipedia returns when a burst of # including the empty 4xx/5xx that Wikipedia returns when a burst of
# requests gets throttled. # requests gets throttled.
@ -241,7 +244,7 @@ in
${pkgs.curl}/bin/curl -sf --max-time 6 -G \ ${pkgs.curl}/bin/curl -sf --max-time 6 -G \
--data-urlencode "gsrsearch=$q" \ --data-urlencode "gsrsearch=$q" \
-H 'User-Agent: quickshell-launcher/1.0 (personal desktop use)' \ -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 # cava in raw mode: one newline-terminated frame per tick, bars as
@ -1698,6 +1701,22 @@ in
"speed", "fast", "temperature", "cost", "price", "year", "speed", "fast", "temperature", "cost", "price", "year",
"when", "many", "much", "capital", "big" "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 // "firefox" and "vs code" are app searches; "mount everest
// height" is a question. Two words plus some length sorts // height" is a question. Two words plus some length sorts
@ -1796,10 +1815,18 @@ in
const ql = query.toLowerCase(); const ql = query.toLowerCase();
const tw = title.toLowerCase().split(/[^a-z0-9]+/); const tw = title.toLowerCase().split(/[^a-z0-9]+/);
const terms = ql.split(/[^a-z0-9]+/).filter(w => const words = ql.split(/[^a-z0-9]+/);
w.length > 2 && answerStop.indexOf(w) === -1 // 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)); && (answerAttrs.indexOf(w) !== -1 || tw.indexOf(w) === -1));
const wantsNumber = answerAttrs.some(a => ql.indexOf(a) !== -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 best = 0;
let bestScore = -1; let bestScore = -1;
@ -1809,6 +1836,7 @@ in
for (let t = 0; t < terms.length; t++) for (let t = 0; t < terms.length; t++)
if (s.indexOf(terms[t]) !== -1) sc += 3; if (s.indexOf(terms[t]) !== -1) sc += 3;
if (wantsNumber && /\d/.test(sents[i])) sc += 4; 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; } if (sc > bestScore) { bestScore = sc; best = i; }
} }
return { 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) { function takeAnswer(raw) {
if (answerDropNext) { answerDropNext = false; return; } if (answerDropNext) { answerDropNext = false; return; }
let title = ""; let title = "";
let extract = ""; let extract = "";
try { try {
const pages = JSON.parse(raw).query.pages; const obj = JSON.parse(raw).query.pages;
for (const k in pages) { // single page under an unpredictable key let pages = [];
title = pages[k].title || ""; for (const k in obj) pages.push(obj[k]); // keyed by pageid
extract = pages[k].extract || ""; pages.sort((a, b) => a.index - b.index); // restore search order
break; const p = pickPage(pages, answerQuery);
} title = p.title || "";
extract = p.extract || "";
} catch (e) { } catch (e) {
title = ""; // no match, or a throttled empty reply title = ""; // no match, or a throttled empty reply
extract = ""; extract = "";