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:
parent
748a345ea9
commit
17d8ba0b1a
1 changed files with 162 additions and 7 deletions
|
|
@ -269,6 +269,82 @@ in
|
|||
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¤t=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
|
||||
# semicolon-separated 0-100 ints — trivially parsed by SplitParser.
|
||||
# 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 answerFetch: "${answerFetchScript}"
|
||||
readonly property string askFetch: "${askFetchScript}"
|
||||
readonly property string wxFetch: "${wxFetchScript}"
|
||||
readonly property string xdgOpen: "${pkgs.xdg-utils}/bin/xdg-open"
|
||||
}
|
||||
'';
|
||||
|
|
@ -1725,6 +1802,12 @@ in
|
|||
property string answerSource: ""
|
||||
property bool answerDropNext: 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
|
||||
// Wikipedia result must not overwrite it.
|
||||
property bool askWon: false
|
||||
|
|
@ -1769,9 +1852,34 @@ in
|
|||
const q = raw.trim();
|
||||
if (q === "" || calcResult !== "") return false;
|
||||
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;
|
||||
}
|
||||
|
||||
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.
|
||||
function answerKey(s) {
|
||||
return s.replace(/\?/g, " ").replace(/\s+/g, " ").trim();
|
||||
|
|
@ -1801,6 +1909,13 @@ in
|
|||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: wxProc
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: launcherPanel.takeWx(text)
|
||||
}
|
||||
}
|
||||
|
||||
function killAnswer() {
|
||||
if (!answerProc.running) return;
|
||||
answerDropNext = true;
|
||||
|
|
@ -1813,10 +1928,17 @@ in
|
|||
askProc.running = false;
|
||||
}
|
||||
|
||||
function killWx() {
|
||||
if (!wxProc.running) return;
|
||||
wxDropNext = true;
|
||||
wxProc.running = false;
|
||||
}
|
||||
|
||||
function clearAnswer() {
|
||||
answerTimer.stop();
|
||||
killAnswer();
|
||||
killAsk();
|
||||
killWx();
|
||||
askWon = false;
|
||||
answerFact = "";
|
||||
answerLead = "";
|
||||
|
|
@ -1832,6 +1954,19 @@ in
|
|||
return;
|
||||
}
|
||||
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];
|
||||
if (hit !== undefined) {
|
||||
showAnswer(hit);
|
||||
|
|
@ -1874,6 +2009,23 @@ in
|
|||
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) {
|
||||
if (Object.keys(answerCache).length > 40) answerCache = ({});
|
||||
answerCache[answerQuery] = res;
|
||||
|
|
@ -2415,10 +2567,10 @@ in
|
|||
height: answerCol.height + 28
|
||||
radius: Theme.radiusCard
|
||||
color: Theme.cardBg
|
||||
// Also up while Claude's call is out with nothing
|
||||
// on screen yet, so the spinner has somewhere to
|
||||
// Also up while a call is out with nothing on
|
||||
// screen yet, so the spinner has somewhere to
|
||||
// live during the first second.
|
||||
visible: launcherPanel.answerFact !== "" || askProc.running
|
||||
visible: launcherPanel.answerFact !== "" || askProc.running || wxProc.running
|
||||
|
||||
Column {
|
||||
id: answerCol
|
||||
|
|
@ -2481,7 +2633,7 @@ in
|
|||
spacing: 5
|
||||
SIcon {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: askProc.running
|
||||
visible: launcherPanel.answerPending
|
||||
text: "loader-circle"
|
||||
font.pixelSize: 11
|
||||
color: Theme.base03
|
||||
|
|
@ -2489,13 +2641,16 @@ in
|
|||
from: 0; to: 360
|
||||
duration: 900
|
||||
loops: Animation.Infinite
|
||||
running: askProc.running
|
||||
running: launcherPanel.answerPending
|
||||
}
|
||||
}
|
||||
SText {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: askProc.running
|
||||
? (launcherPanel.answerSource === "" ? "Claude" : launcherPanel.answerSource + " · Claude")
|
||||
// Name what is still coming, next to
|
||||
// whatever is already on screen.
|
||||
text: launcherPanel.answerPending
|
||||
? (launcherPanel.answerSource === "" ? launcherPanel.pendingSource
|
||||
: launcherPanel.answerSource + " · " + launcherPanel.pendingSource)
|
||||
: launcherPanel.answerSource
|
||||
font.pixelSize: 10
|
||||
color: Theme.base03
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue