Files
FabledGrimoire/src/fabledassistant/routes/journal.py
T
bvandeusenandClaude Opus 4.7 83f1676d72 feat(journal): auto-scheduler for curator (Phase 2)
The curator now runs automatically every 15 minutes against any
journal conversation that has user messages newer than its last
curator run. Manual triggers from Phase 1b still work and now also
stamp the timestamp so the scheduler doesn't double-process.

Migration 0048:
- conversations.last_curator_run_at (timestamptz, nullable).
- Partial index ix_conversations_journal_last_curator on the column
  filtered to conversation_type='journal'. The scheduler's candidate
  query is "journal AND (NULL OR stale)" so an index narrowed to
  journal rows is the right shape — index size stays small even on
  instances with many non-journal conversations.

models/conversation.py:
- New `last_curator_run_at` column on Conversation. DateTime imported.

services/curator_scheduler.py (new):
- IntervalTrigger every 15 min via BackgroundScheduler (same pattern
  as journal_scheduler.py).
- _candidate_conversations(): SELECT journal conversations where the
  newest user message is newer than last_curator_run_at (or NULL).
  Capped at 20 per sweep so a backlog after downtime doesn't stall
  the scheduler.
- _sweep() processes candidates sequentially under an asyncio.Lock
  so overlapping ticks can't double-fire on the same conversation.
  Failed runs leave the timestamp alone — natural retry on next sweep.
- start_/stop_curator_scheduler() wired into app.py boot/shutdown.

routes/journal.py:
- Manual /api/journal/curator/run/<conv_id> stamps last_curator_run_at
  on success. Errors don't stamp so the scheduler retries.

What's still pending:
- Phase 3: feedback loop (curator summary into chat context). Currently
  the curator's summary lives in the run result but doesn't reach the
  chat model.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 10:07:12 -04:00

470 lines
17 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}
def _valid_location_keys(cfg: dict) -> set[str]:
"""Keys in ``cfg.locations`` that have a usable lat/lon. Anything else
(orphaned cache rows, locations the user typed but didn't geocode) is
excluded so it can't render as a fake site in the UI."""
locations = cfg.get("locations") or {}
return {
key for key, loc in locations.items()
if isinstance(loc, dict) and loc.get("lat") is not None and loc.get("lon") is not None
}
@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)
# Trigger a background weather refresh for any newly-saved location with
# valid lat/lon. Without this, the cache row for the location doesn't
# exist (or stays stale) until the user clicks the manual refresh button,
# so the journal weather panel renders empty for newly-entered sites.
valid_locs = [
(key, loc)
for key, loc in (body.get("locations") or {}).items()
if isinstance(loc, dict) and loc.get("lat") is not None and loc.get("lon") is not None
]
if valid_locs:
asyncio.create_task(_refresh_locations_in_background(user_id, valid_locs))
return jsonify({"ok": True})
async def _refresh_locations_in_background(
user_id: int, locations: list[tuple[str, dict]]
) -> None:
for key, loc in locations:
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(
"Post-save weather refresh failed for user %d / %s",
user_id, key, exc_info=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("/curator/run/<int:conv_id>")
@login_required
async def trigger_curator_run(conv_id: int):
"""Manually run the journal curator over a conversation.
The curator reads recent messages and fires tool calls (record_moment,
update_task, etc.) the chat model can't (chat models have tools=[]).
Returns a summary of what was captured.
See services/curator.py for the architectural background.
"""
user_id = get_current_user_id()
# Confirm the conversation belongs to this user (curator runs against
# arbitrary conv_ids would otherwise leak data across tenants).
from sqlalchemy import select as _select
from fabledassistant.models import async_session as _async_session
from fabledassistant.models.conversation import Conversation as _Conversation
async with _async_session() as _sess:
_res = await _sess.execute(
_select(_Conversation).where(
_Conversation.id == conv_id,
_Conversation.user_id == user_id,
)
)
if _res.scalar_one_or_none() is None:
return jsonify({"error": "Conversation not found"}), 404
from fabledassistant.services.curator import run_curator_for_conversation
result = await run_curator_for_conversation(conv_id)
# Stamp last_curator_run_at on success so the scheduler doesn't
# immediately re-process the same conversation on its next sweep.
# Errored runs intentionally leave the timestamp alone so the
# scheduler retries them.
if not result.error:
import datetime as _dt
from sqlalchemy import update as _update
async with _async_session() as _sess:
await _sess.execute(
_update(_Conversation)
.where(_Conversation.id == conv_id)
.values(last_curator_run_at=_dt.datetime.now(_dt.timezone.utc))
)
await _sess.commit()
return jsonify(result.to_dict())
@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()
cfg = await _resolve_config(user_id)
valid_keys = _valid_location_keys(cfg)
rows = await weather_svc.get_cached_weather_rows(user_id, valid_keys)
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)
valid_keys = _valid_location_keys(cfg)
rows = await weather_svc.get_cached_weather_rows(user_id, valid_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.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()
# conv.to_dict() recomputes message_count from the `messages`
# relationship, which isn't eager-loaded here, so it would report 0.
# We already have the real list — override with the known count, same
# convention the chat-list path uses (services/chat.py).
conv_dict = conv.to_dict()
conv_dict["message_count"] = len(messages)
return jsonify({
"day_date": day_date.isoformat(),
"conversation": conv_dict,
"messages": [m.to_dict() for m in messages],
})