Files
FabledScribe/src/fabledassistant/routes/journal.py
T
bvandeusen de4b1d7c7e fix(weather): match prep behavior — serve cached weather regardless of age
The /api/journal/weather route was filtering out cache rows older than 24
hours via parse_weather_card_data, while journal_prep.py read the same
rows raw without freshness checking. Result: the daily prep referenced
"home" and "work" temperatures while the right-rail UI showed nothing —
two surfaces, same backing data, inconsistent visibility.

Two changes:

1. parse_weather_card_data no longer returns None for stale data.
   WeatherCard already exposes fetched_at and gracefully hides
   today_high / forecast fields when they're absent, so old data renders
   with whatever fields the cached forecast still covers.

2. The /weather route opportunistically schedules a background refresh
   for any cache row older than 4 hours. If the user's journal_config
   has lat/lon for that location_key, the refresh runs and the next
   page load gets fresh data; if no usable config, the refresh is a
   silent no-op and the stale cache is still served.

This makes prep and UI consistent. It also self-heals over time — once
locations are configured, stale caches get refreshed on the next page
load instead of waiting indefinitely.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 21:19:57 -04:00

369 lines
13 KiB
Python

"""HTTP endpoints for the Journal feature.
Includes the conversational journal endpoints (config / today / day / days /
trigger-prep / moments) plus the ambient-context surface lifted from the
old Briefing routes (RSS feeds, weather, news, RSS reactions, article-discuss).
The ambient endpoints read locations + temp_unit + topic preferences from the
``journal_config`` user setting.
"""
from __future__ import annotations
import asyncio
import datetime
import json
import logging
from zoneinfo import ZoneInfo
from quart import Blueprint, jsonify, request
from sqlalchemy import select
from fabledassistant.auth import get_current_user_id, login_required
from fabledassistant.models import Conversation, Message, async_session
from fabledassistant.services import weather as weather_svc
from fabledassistant.services.journal_prep import ensure_daily_prep_message
from fabledassistant.services.journal_scheduler import (
DEFAULT_CONFIG as DEFAULT_JOURNAL_CONFIG,
update_user_schedule,
)
from fabledassistant.services.journal_search import search_journal
from fabledassistant.services.moments import delete_moment, update_moment
from fabledassistant.services.settings import get_setting, set_setting
logger = logging.getLogger(__name__)
journal_bp = Blueprint("journal", __name__, url_prefix="/api/journal")
def _resolve_tz(tz_str: str) -> ZoneInfo:
try:
return ZoneInfo(tz_str)
except Exception:
return ZoneInfo("UTC")
def _today_in_tz(tz_str: str, *, day_rollover_hour: int) -> datetime.date:
tz = _resolve_tz(tz_str)
now = datetime.datetime.now(tz)
if now.hour < day_rollover_hour:
return (now - datetime.timedelta(days=1)).date()
return now.date()
async def _user_timezone(user_id: int) -> str:
return await get_setting(user_id, "user_timezone", "UTC") or "UTC"
async def _resolve_config(user_id: int) -> dict:
raw = await get_setting(user_id, "journal_config", "")
config: dict = {}
if raw:
try:
parsed = json.loads(raw) if isinstance(raw, str) else raw
if isinstance(parsed, dict):
config = parsed
except Exception:
logger.warning("Invalid journal_config for user %d", user_id)
return {**DEFAULT_JOURNAL_CONFIG, **config}
@journal_bp.get("/config")
@login_required
async def get_config():
user_id = get_current_user_id()
return jsonify(await _resolve_config(user_id))
@journal_bp.put("/config")
@login_required
async def put_config():
user_id = get_current_user_id()
body = await request.get_json()
if not isinstance(body, dict):
return jsonify({"error": "config must be an object"}), 400
await set_setting(user_id, "journal_config", json.dumps(body))
await update_user_schedule(user_id)
return jsonify({"ok": True})
@journal_bp.get("/today")
@login_required
async def get_today():
user_id = get_current_user_id()
config = await _resolve_config(user_id)
tz_str = await _user_timezone(user_id)
today = _today_in_tz(tz_str, day_rollover_hour=int(config.get("day_rollover_hour", 4)))
await ensure_daily_prep_message(
user_id=user_id, day_date=today, user_timezone=tz_str
)
return await _day_payload(user_id=user_id, day_date=today)
@journal_bp.get("/day/<iso_date>")
@login_required
async def get_day(iso_date: str):
user_id = get_current_user_id()
try:
day = datetime.date.fromisoformat(iso_date)
except ValueError:
return jsonify({"error": "invalid date"}), 400
return await _day_payload(user_id=user_id, day_date=day)
@journal_bp.get("/days")
@login_required
async def list_days():
user_id = get_current_user_id()
async with async_session() as session:
stmt = (
select(Conversation.day_date)
.where(
Conversation.user_id == user_id,
Conversation.conversation_type == "journal",
Conversation.day_date.is_not(None),
)
.order_by(Conversation.day_date.desc())
)
rows = (await session.execute(stmt)).scalars().all()
return jsonify({"days": [d.isoformat() for d in rows]})
@journal_bp.post("/trigger-prep")
@login_required
async def trigger_prep():
user_id = get_current_user_id()
body = await request.get_json(silent=True) or {}
iso_date = body.get("date")
config = await _resolve_config(user_id)
tz_str = await _user_timezone(user_id)
day = (
datetime.date.fromisoformat(iso_date)
if iso_date
else _today_in_tz(tz_str, day_rollover_hour=int(config.get("day_rollover_hour", 4)))
)
msg = await ensure_daily_prep_message(
user_id=user_id, day_date=day, user_timezone=tz_str, force=True
)
return jsonify({"ok": True, "message_id": msg.id})
@journal_bp.get("/moments")
@login_required
async def list_moments():
user_id = get_current_user_id()
args = request.args
df = args.get("date_from")
dt = args.get("date_to")
person_id = args.get("person_id", type=int)
place_id = args.get("place_id", type=int)
tag = args.get("tag")
query = args.get("query")
pinned_only = args.get("pinned_only", "false").lower() == "true"
limit = args.get("limit", default=50, type=int)
results = await search_journal(
user_id=user_id,
query=query,
person_id=person_id,
place_id=place_id,
tag=tag,
date_from=datetime.date.fromisoformat(df) if df else None,
date_to=datetime.date.fromisoformat(dt) if dt else None,
limit=limit,
)
if pinned_only:
results = [r for r in results if r.get("pinned")]
return jsonify({"moments": results})
@journal_bp.patch("/moments/<int:moment_id>")
@login_required
async def patch_moment(moment_id: int):
user_id = get_current_user_id()
body = await request.get_json()
if not isinstance(body, dict):
return jsonify({"error": "body must be an object"}), 400
moment = await update_moment(
user_id=user_id,
moment_id=moment_id,
content=body.get("content"),
tags=body.get("tags"),
pinned=body.get("pinned"),
person_ids=body.get("person_ids"),
place_ids=body.get("place_ids"),
task_ids=body.get("task_ids"),
note_ids=body.get("note_ids"),
)
if moment is None:
return jsonify({"error": "not found"}), 404
return jsonify(moment.to_dict())
@journal_bp.delete("/moments/<int:moment_id>")
@login_required
async def remove_moment(moment_id: int):
user_id = get_current_user_id()
deleted = await delete_moment(user_id=user_id, moment_id=moment_id)
if not deleted:
return jsonify({"error": "not found"}), 404
return jsonify({"ok": True})
# ───────────────────────────────────────────────────────────────────────────────
# Ambient endpoints (lifted from the old briefing surface).
# ───────────────────────────────────────────────────────────────────────────────
async def _journal_temp_unit(user_id: int) -> str:
cfg = await _resolve_config(user_id)
unit = cfg.get("temp_unit", "C")
return unit if unit in ("C", "F") else "C"
# ── Weather ───────────────────────────────────────────────────────────────────
_STALE_THRESHOLD_SECONDS = 4 * 3600 # 4 hours — start refreshing well before the 7-day forecast window slides past today
def _is_stale(cache_row) -> bool:
if cache_row is None or cache_row.fetched_at is None:
return True
age = (datetime.datetime.now(datetime.timezone.utc) - cache_row.fetched_at).total_seconds()
return age > _STALE_THRESHOLD_SECONDS
async def _refresh_stale_in_background(user_id: int, stale_keys: set[str]) -> None:
"""Best-effort refresh of stale cache rows. Silently no-ops if the user's
config has no usable lat/lon for a given location_key."""
cfg = await _resolve_config(user_id)
locations = cfg.get("locations") or {}
for key in stale_keys:
loc = locations.get(key)
if not loc or not loc.get("lat") or not loc.get("lon"):
continue
try:
await weather_svc.refresh_location_cache(
user_id=user_id,
location_key=key,
location_label=loc.get("label", key),
lat=loc["lat"],
lon=loc["lon"],
)
except Exception:
logger.warning("Background weather refresh failed for user %d / %s", user_id, key, exc_info=True)
@journal_bp.get("/weather")
@login_required
async def get_weather():
user_id = get_current_user_id()
rows = await weather_svc.get_cached_weather_rows(user_id)
temp_unit = await _journal_temp_unit(user_id)
# Kick off a best-effort background refresh for stale rows so the next page
# load gets fresh data; we still serve whatever's currently cached now.
stale_keys = {row.location_key for row in rows if _is_stale(row)}
if stale_keys:
asyncio.create_task(_refresh_stale_in_background(user_id, stale_keys))
cards = [
card for row in rows
if (card := weather_svc.parse_weather_card_data(row, temp_unit)) is not None
]
return jsonify({"locations": cards, "temp_unit": temp_unit})
@journal_bp.get("/weather/current")
@login_required
async def get_current_weather():
"""Live current temperature + conditions for the user's primary location."""
user_id = get_current_user_id()
cfg = await _resolve_config(user_id)
temp_unit = await _journal_temp_unit(user_id)
locations = cfg.get("locations") or {}
loc = locations.get("home") or locations.get("work")
if not loc or not loc.get("lat") or not loc.get("lon"):
return jsonify({"error": "No location configured"}), 404
current = await weather_svc.fetch_current_conditions(loc["lat"], loc["lon"])
if current is None:
return jsonify({"error": "Failed to fetch current conditions"}), 502
temp = current["temperature"]
if temp is not None and temp_unit == "F":
temp = temp * 9 / 5 + 32
current["temperature"] = round(temp) if temp is not None else None
current["temp_unit"] = temp_unit
current["location"] = loc.get("label") or "Home"
return jsonify(current)
@journal_bp.post("/weather/refresh")
@login_required
async def refresh_weather():
user_id = get_current_user_id()
cfg = await _resolve_config(user_id)
temp_unit = await _journal_temp_unit(user_id)
for key, loc in (cfg.get("locations") or {}).items():
if not loc.get("lat") or not loc.get("lon"):
continue
try:
await weather_svc.refresh_location_cache(
user_id=user_id,
location_key=key,
location_label=loc.get("label", key),
lat=loc["lat"],
lon=loc["lon"],
)
except Exception:
logger.warning("Failed to refresh weather for %s", key, exc_info=True)
rows = await weather_svc.get_cached_weather_rows(user_id)
cards = [
card for row in rows
if (card := weather_svc.parse_weather_card_data(row, temp_unit)) is not None
]
return jsonify({"locations": cards, "temp_unit": temp_unit})
@journal_bp.post("/weather/geocode")
@login_required
async def geocode_location():
data = await request.get_json()
query = (data.get("query") or "").strip()
if not query:
return jsonify({"error": "query required"}), 400
try:
lat, lon, label = await weather_svc.geocode(query)
return jsonify({"lat": lat, "lon": lon, "label": label})
except ValueError as e:
return jsonify({"error": str(e)}), 404
async def _day_payload(*, user_id: int, day_date: datetime.date):
async with async_session() as session:
conv_stmt = select(Conversation).where(
Conversation.user_id == user_id,
Conversation.conversation_type == "journal",
Conversation.day_date == day_date,
)
conv = (await session.execute(conv_stmt)).scalar_one_or_none()
if conv is None:
return jsonify({
"day_date": day_date.isoformat(),
"conversation": None,
"messages": [],
})
msgs_stmt = (
select(Message)
.where(Message.conversation_id == conv.id)
.order_by(Message.created_at)
)
messages = (await session.execute(msgs_stmt)).scalars().all()
return jsonify({
"day_date": day_date.isoformat(),
"conversation": conv.to_dict(),
"messages": [m.to_dict() for m in messages],
})