The architecture loop closes. Curator extracts beats and writes a ≤240-char summary; the next chat turn loads that summary into the journal system prompt so the chat model — which has no tools and cannot retrieve anything itself — gains awareness of recent topics captured by the curator. Migration 0049: - conversations.curator_summary (text, nullable). Last-write-wins; no history of prior summaries. models/conversation.py: - New curator_summary column on Conversation. services/curator_scheduler.py: - _stamp_last_run() takes an optional summary kwarg; persists it when non-empty (clobbering the previous summary). Empty summary keeps the existing one rather than overwriting useful context with "". - _sweep() passes result.summary through. routes/journal.py: - Manual /api/journal/curator/run/<conv_id> writes curator_summary alongside last_curator_run_at on success. services/journal_pipeline.py: - build_journal_system_prompt() gains an optional `conv_id` param. When provided, appends a "CURATOR NOTES" block at the end of the system prompt with the conversation's stored summary. Positioned after ambient context so the chat model treats it as current awareness rather than background. services/llm.py: - Threads conv_id through to build_journal_system_prompt. This is the last commit of the conversation+curator architecture arc (Fable #172): - Phase 1a (a7002a8): chat=tools[], curator service backend - Phase 1b (a73dd17): right-rail captures panel + manual trigger - Phase 2 (83f1676): auto-scheduler every 15 min - Phase 3 (this): curator summary → chat context feedback loop Operator can now device-test the architecture end-to-end: have a journal conversation (model can't lie about tool calls because it has none), wait for the scheduler or hit "Process captures", see moments appear in the right rail, then continue the conversation and notice the chat model staying topic-aware via the summary block. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
473 lines
17 KiB
Python
473 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. Persist the curator's summary too when
|
|
# non-empty (Phase 3 feedback loop) — empty summary keeps the
|
|
# existing one rather than clobbering useful context.
|
|
if not result.error:
|
|
import datetime as _dt
|
|
from sqlalchemy import update as _update
|
|
_values: dict = {"last_curator_run_at": _dt.datetime.now(_dt.timezone.utc)}
|
|
if result.summary:
|
|
_values["curator_summary"] = result.summary.strip()[:240]
|
|
async with _async_session() as _sess:
|
|
await _sess.execute(
|
|
_update(_Conversation).where(_Conversation.id == conv_id).values(**_values)
|
|
)
|
|
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],
|
|
})
|