launcher: answer weather queries from open-meteo instead of the LLM

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-08-02 11:39:47 +01:00
parent 748a345ea9
commit 17d8ba0b1a

View file

@ -269,6 +269,82 @@ in
fred@10.0.0.1 qs-ask 2>/dev/null fred@10.0.0.1 qs-ask 2>/dev/null
''; '';
# Launcher weather. Neither of the other two answer paths can serve
# this: qs-ask runs with no tools at all, so Claude has no live data and
# correctly refuses, and Wikipedia answers "weather in kristiansand"
# with the city's founding date. open-meteo needs no key — geocode the
# place name, then read current conditions at those coordinates.
#
# An empty place (a bare "weather now") falls back to the same IP
# geolocation the calendar widget uses, so the common case needs no
# place name at all.
#
# Prints one finished sentence, or nothing when the place doesn't
# resolve or either call fails — which the QML reads as "no answer",
# exactly like the other two scripts.
wxFetchScript = pkgs.writeShellScript "wx-fetch" ''
place="$1"
jq="${pkgs.jq}/bin/jq"
if [ -n "$place" ]; then
geo=$(${pkgs.curl}/bin/curl -sf --max-time 5 -G \
--data-urlencode "name=$place" \
'https://geocoding-api.open-meteo.com/v1/search?count=1&language=en&format=json' 2>/dev/null)
[ -z "$geo" ] && exit 0
lat=$(printf '%s' "$geo" | $jq -r '.results[0].latitude // empty' 2>/dev/null)
lon=$(printf '%s' "$geo" | $jq -r '.results[0].longitude // empty' 2>/dev/null)
# Name it back the way open-meteo spells it ("Kristiansand, Norway"),
# which doubles as confirmation that it resolved what was meant.
where=$(printf '%s' "$geo" \
| $jq -r '[.results[0].name, .results[0].country] | map(select(. != null)) | join(", ")' 2>/dev/null)
else
loc=$(${pkgs.curl}/bin/curl -sf --max-time 5 https://ipinfo.io/loc 2>/dev/null || true)
case "$loc" in
*,*) lat=''${loc%,*}; lon=''${loc#*,}; where="Here" ;;
esac
fi
[ -z "$lat" ] && exit 0
wx=$(${pkgs.curl}/bin/curl -sf --max-time 6 \
"https://api.open-meteo.com/v1/forecast?latitude=$lat&longitude=$lon&current=temperature_2m,apparent_temperature,weather_code,wind_speed_10m&timezone=auto" 2>/dev/null)
[ -z "$wx" ] && exit 0
# One jq pass, four fields, split on whitespace — every value is a
# number, so there is nothing here for word splitting to break.
cur=$(printf '%s' "$wx" \
| $jq -r '.current | "\(.weather_code) \(.temperature_2m) \(.apparent_temperature) \(.wind_speed_10m)"' 2>/dev/null)
case "$cur" in ""|*null*) exit 0 ;; esac
set -- $cur
code="$1"; temp="$2"; feels="$3"; wind="$4"
# WMO weather codes, grouped to the distinctions worth a word.
case "$code" in
0) desc="Clear" ;;
1) desc="Mainly clear" ;;
2) desc="Partly cloudy" ;;
3) desc="Overcast" ;;
45|48) desc="Fog" ;;
51|53|55) desc="Drizzle" ;;
56|57) desc="Freezing drizzle" ;;
61|63|65) desc="Rain" ;;
66|67) desc="Freezing rain" ;;
71|73|75) desc="Snow" ;;
77) desc="Snow grains" ;;
80|81|82) desc="Rain showers" ;;
85|86) desc="Snow showers" ;;
95) desc="Thunderstorm" ;;
96|99) desc="Thunderstorm with hail" ;;
*) desc="" ;;
esac
# "right now" is not filler: the launcher strips day words off the
# query so the place still geocodes, so a query asking about tomorrow
# gets today's numbers and has to be able to see that it did.
[ -n "$desc" ] && desc="$desc, "
printf '%s right now: %s%s°C (feels %s°C), wind %s km/h' \
"$where" "$desc" "$temp" "$feels" "$wind"
'';
# cava in raw mode: one newline-terminated frame per tick, bars as # cava in raw mode: one newline-terminated frame per tick, bars as
# semicolon-separated 0-100 ints — trivially parsed by SplitParser. # semicolon-separated 0-100 ints — trivially parsed by SplitParser.
# Drives the media card's spectrum ring. Only spawned while the # Drives the media card's spectrum ring. Only spawned while the
@ -778,6 +854,7 @@ in
readonly property string fd: "${pkgs.fd}/bin/fd" readonly property string fd: "${pkgs.fd}/bin/fd"
readonly property string answerFetch: "${answerFetchScript}" readonly property string answerFetch: "${answerFetchScript}"
readonly property string askFetch: "${askFetchScript}" readonly property string askFetch: "${askFetchScript}"
readonly property string wxFetch: "${wxFetchScript}"
readonly property string xdgOpen: "${pkgs.xdg-utils}/bin/xdg-open" readonly property string xdgOpen: "${pkgs.xdg-utils}/bin/xdg-open"
} }
''; '';
@ -1725,6 +1802,12 @@ in
property string answerSource: "" property string answerSource: ""
property bool answerDropNext: false property bool answerDropNext: false
property bool askDropNext: false property bool askDropNext: false
property bool wxDropNext: false
// Whose answer the card is still waiting on, if any. Only
// the slow paths count: Wikipedia lands inside the debounce
// and would just flicker the spinner.
readonly property bool answerPending: askProc.running || wxProc.running
readonly property string pendingSource: wxProc.running ? "open-meteo" : "Claude"
// Claude answered the current query, so the slower-arriving // Claude answered the current query, so the slower-arriving
// Wikipedia result must not overwrite it. // Wikipedia result must not overwrite it.
property bool askWon: false property bool askWon: false
@ -1769,9 +1852,34 @@ in
const q = raw.trim(); const q = raw.trim();
if (q === "" || calcResult !== "") return false; if (q === "" || calcResult !== "") return false;
if (q.indexOf("?") !== -1) return true; if (q.indexOf("?") !== -1) return true;
// "weather" on its own is one short word, which the
// length rule below would throw out.
if (wantsWeather(q)) return true;
return q.split(/\s+/).length >= 2 && q.length >= 8; return q.split(/\s+/).length >= 2 && q.length >= 8;
} }
function wantsWeather(q) {
return /\b(weather|forecast)\b/i.test(q);
}
// "weather", "weather in kristiansand", "what's the weather
// like right now" the same query with a varying amount of
// noise around an optional place name. Strip the noise and
// whatever survives is the place, or nothing at all, which
// wx-fetch reads as "wherever this machine is".
//
// The day words go too, even though only current conditions
// come back: leaving "tomorrow" in sends "oslo tomorrow" to
// the geocoder, which resolves nothing and answers with a
// blank card. Current conditions are the wrong answer to
// "tomorrow", so wx-fetch labels them "right now" rather
// than letting them pass as a forecast.
function weatherPlace(q) {
return q.toLowerCase()
.replace(/\b(what'?s|what|is|it|the|weather|forecast|temperature|temp|like|in|for|at|right|now|today|tonight|tomorrow|this|week|weekend|currently|outside)\b/g, " ")
.replace(/\s+/g, " ").trim();
}
// The "?" is a trigger, not a search term. // The "?" is a trigger, not a search term.
function answerKey(s) { function answerKey(s) {
return s.replace(/\?/g, " ").replace(/\s+/g, " ").trim(); return s.replace(/\?/g, " ").replace(/\s+/g, " ").trim();
@ -1801,6 +1909,13 @@ in
} }
} }
Process {
id: wxProc
stdout: StdioCollector {
onStreamFinished: launcherPanel.takeWx(text)
}
}
function killAnswer() { function killAnswer() {
if (!answerProc.running) return; if (!answerProc.running) return;
answerDropNext = true; answerDropNext = true;
@ -1813,10 +1928,17 @@ in
askProc.running = false; askProc.running = false;
} }
function killWx() {
if (!wxProc.running) return;
wxDropNext = true;
wxProc.running = false;
}
function clearAnswer() { function clearAnswer() {
answerTimer.stop(); answerTimer.stop();
killAnswer(); killAnswer();
killAsk(); killAsk();
killWx();
askWon = false; askWon = false;
answerFact = ""; answerFact = "";
answerLead = ""; answerLead = "";
@ -1832,6 +1954,19 @@ in
return; return;
} }
answerQuery = q; answerQuery = q;
// Weather takes the open-meteo path alone: Claude has no
// network to look it up with, Wikipedia answers with the
// city's founding date, and the cache has no notion of
// age so it must not serve a temperature twice.
if (wantsWeather(q)) {
killAsk();
killAnswer();
killWx();
askWon = false;
wxProc.command = [Commands.wxFetch, weatherPlace(q)];
wxProc.running = true;
return;
}
const hit = answerCache[q]; const hit = answerCache[q];
if (hit !== undefined) { if (hit !== undefined) {
showAnswer(hit); showAnswer(hit);
@ -1874,6 +2009,23 @@ in
showAnswer(res); showAnswer(res);
} }
function takeWx(raw) {
if (wxDropNext) { wxDropNext = false; return; }
const text = raw.trim();
// Place didn't resolve, or open-meteo was unreachable.
if (text === "") return;
if (answerQuery !== answerKey(searchInput.text)) return;
// Deliberately not cached a temperature is stale in
// minutes and the cache never expires.
showAnswer({
title: "",
fact: text,
lead: "",
url: "",
source: "open-meteo"
});
}
function cacheAnswer(res) { function cacheAnswer(res) {
if (Object.keys(answerCache).length > 40) answerCache = ({}); if (Object.keys(answerCache).length > 40) answerCache = ({});
answerCache[answerQuery] = res; answerCache[answerQuery] = res;
@ -2415,10 +2567,10 @@ in
height: answerCol.height + 28 height: answerCol.height + 28
radius: Theme.radiusCard radius: Theme.radiusCard
color: Theme.cardBg color: Theme.cardBg
// Also up while Claude's call is out with nothing // Also up while a call is out with nothing on
// on screen yet, so the spinner has somewhere to // screen yet, so the spinner has somewhere to
// live during the first second. // live during the first second.
visible: launcherPanel.answerFact !== "" || askProc.running visible: launcherPanel.answerFact !== "" || askProc.running || wxProc.running
Column { Column {
id: answerCol id: answerCol
@ -2481,7 +2633,7 @@ in
spacing: 5 spacing: 5
SIcon { SIcon {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
visible: askProc.running visible: launcherPanel.answerPending
text: "loader-circle" text: "loader-circle"
font.pixelSize: 11 font.pixelSize: 11
color: Theme.base03 color: Theme.base03
@ -2489,13 +2641,16 @@ in
from: 0; to: 360 from: 0; to: 360
duration: 900 duration: 900
loops: Animation.Infinite loops: Animation.Infinite
running: askProc.running running: launcherPanel.answerPending
} }
} }
SText { SText {
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
text: askProc.running // Name what is still coming, next to
? (launcherPanel.answerSource === "" ? "Claude" : launcherPanel.answerSource + " · Claude") // whatever is already on screen.
text: launcherPanel.answerPending
? (launcherPanel.answerSource === "" ? launcherPanel.pendingSource
: launcherPanel.answerSource + " · " + launcherPanel.pendingSource)
: launcherPanel.answerSource : launcherPanel.answerSource
font.pixelSize: 10 font.pixelSize: 10
color: Theme.base03 color: Theme.base03