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
|
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
|
# 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
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue