fix(briefing): stale weather auto-refresh + task filter + overdue prompt

Three briefing quality fixes surfaced by reading today's 2026-04-11
compilation output:

- **Stale weather**: get_weather was returning 48h-old cache data
  after a missed scheduler run. Tool now auto-refreshes any cached
  location older than 6h (fetching fresh data from Open-Meteo), and
  stamps each location with cache_age_hours + is_stale so the model
  can hedge instead of faithfully relaying old numbers.

- **Cancelled tasks leaking into prose**: briefing loop now defaults
  list_tasks calls to status=["todo","in_progress"] when the model
  doesn't specify, so cancelled/done items stop showing up in the
  summary. Localized to the briefing path — chat still sees full
  history.

- **Overdue in-progress tasks missed by midday check-in**: tightened
  the check-in prompt to explicitly require two list_tasks calls —
  one for in_progress (catches items dragging past their due date)
  and one filtered by due date — so long-running tasks stop getting
  silently dropped.
This commit is contained in:
2026-04-11 13:32:52 -04:00
parent e63b4634ea
commit 9f6608fc8f
2 changed files with 69 additions and 8 deletions
@@ -106,6 +106,10 @@ def _agentic_system_prompt(
f"You are the user's personal assistant giving a brief {slot} check-in. "
"Use tools to see what's changed since this morning. Focus on progress and "
"what's still unaddressed.\n\n"
"When checking tasks, call list_tasks at least twice:\n"
"- once with status=\"in_progress\" to see anything already being worked on "
"(regardless of due date — these can quietly drag past their due dates)\n"
"- once filtered by due date for what's coming up or overdue today\n\n"
"Rules:\n"
"- Call tools to see current state. Never assert facts without tool results.\n"
"- If nothing meaningful has changed, say so briefly — don't invent progress.\n"
@@ -239,6 +243,12 @@ async def run_agentic_briefing(
except Exception:
arguments = {}
# Default list_tasks to active statuses only so cancelled/done
# items don't slip into briefing prose. The model can still
# pass an explicit status filter when it wants something else.
if tool_name == "list_tasks" and not arguments.get("status"):
arguments["status"] = ["todo", "in_progress"]
try:
if tool_name == "get_rss_items" and rss_override is not None:
# Use topic-scored/filtered items already computed by
+59 -8
View File
@@ -2132,10 +2132,12 @@ async def execute_tool(
elif tool_name == "get_weather":
from fabledassistant.services.weather import (
get_cached_weather, geocode, _fetch_open_meteo, parse_forecast
get_cached_weather, geocode, _fetch_open_meteo, parse_forecast,
refresh_location_cache,
)
from fabledassistant.services.briefing_pipeline import _get_temp_unit
from datetime import timezone as _tz
from datetime import timezone as _tz, timedelta as _td
import json as _wx_json
location = (arguments.get("location") or "").strip()
num_days = max(1, min(8, int(arguments.get("days") or 1)))
temp_unit = await _get_temp_unit(user_id)
@@ -2167,15 +2169,64 @@ async def execute_tool(
}]}}
except Exception as e:
return {"success": False, "error": f"Could not get weather for '{location}': {e}"}
# Fall back to cached configured locations
# Fall back to cached configured locations. Auto-refresh any
# row older than 6 hours so stale forecasts don't slip into
# briefings after an outage or missed scheduler run.
locations = await get_cached_weather(user_id)
if location.lower() not in ("all", ""):
locations = [loc for loc in locations if loc["location_key"] == location.lower()]
locations = [
{**loc, "days": _apply_unit(loc.get("days", []))[:num_days], "temp_unit": temp_unit}
for loc in locations
]
return {"success": True, "data": {"locations": locations}}
now_utc = datetime.now(_tz.utc)
stale_cutoff = now_utc - _td(hours=6)
stale_keys = []
for loc in locations:
try:
fetched = datetime.fromisoformat(loc.get("fetched_at", ""))
except Exception:
fetched = None
if fetched is None or fetched < stale_cutoff:
stale_keys.append(loc["location_key"])
if stale_keys:
try:
from fabledassistant.services.settings import get_setting as _wx_get_setting
raw_cfg = await _wx_get_setting(user_id, "briefing_config", "{}")
cfg = _wx_json.loads(raw_cfg) if isinstance(raw_cfg, str) else (raw_cfg or {})
cfg_locs = cfg.get("locations", {}) if isinstance(cfg, dict) else {}
for key in stale_keys:
meta = cfg_locs.get(key) or {}
if meta.get("lat") and meta.get("lon"):
try:
await refresh_location_cache(
user_id=user_id,
location_key=key,
location_label=meta.get("label", key),
lat=meta["lat"],
lon=meta["lon"],
)
except Exception:
logger.debug("Weather refresh failed for %s", key, exc_info=True)
locations = await get_cached_weather(user_id)
if location.lower() not in ("all", ""):
locations = [loc for loc in locations if loc["location_key"] == location.lower()]
except Exception:
logger.debug("Weather staleness refresh failed", exc_info=True)
# Mark each location with a stale flag so the model can hedge
# or skip instead of faithfully relaying old numbers.
stamped_locations = []
for loc in locations:
days = _apply_unit(loc.get("days", []))[:num_days]
try:
fetched = datetime.fromisoformat(loc.get("fetched_at", ""))
age_h = (now_utc - fetched).total_seconds() / 3600.0
except Exception:
age_h = None
stamped_locations.append({
**loc,
"days": days,
"temp_unit": temp_unit,
"cache_age_hours": round(age_h, 1) if age_h is not None else None,
"is_stale": age_h is not None and age_h > 12.0,
})
return {"success": True, "data": {"locations": stamped_locations}}
elif tool_name == "get_rss_items":
from fabledassistant.services.rss import get_recent_items