5d2d27c499
Prep prose (services/journal_prep.py): - Emit explicit "WEATHER: none available — do NOT mention weather" absent-marker so a small model can't invent partly-cloudy/temperature prose when both configured locations have empty addresses. - Replace negative-only system rule with positive-anchored guidance forbidding weather/temp/precip mentions unless a numeric WEATHER section is present; also bans echoing parenthetical labels verbatim. - Reword overdue header to "(past their due date, still open — backlog, not today's work)" and render lines as "was due <date>, N day(s) overdue" with correct singular/plural. Supersedes the wording noted in Fable task #159. - Deterministic fabricated-weather reconciler: low-false-positive regex detects fabricated weather phrasing; on trip with an empty section, regenerate once with a corrective. Persistent fabrication logs ERROR rather than mangling prose. Journal route (routes/journal.py): - Override message_count with len(messages) in _day_payload. The chat path already does this; the journal path was hitting the Conversation.to_dict() fallback to 0 because messages aren't eager-loaded on that instance. Tests: - tests/test_journal_message_count.py — pins the model-level trap and the override contract (3 cases). - tests/test_journal_prep_hardening.py — 11 cases covering the fabricated-weather reconciler and absent-marker rendering. - tests/test_journal_prep_filtering.py — updated one stale assertion. Tracks Fable task #171. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
421 lines
15 KiB
Python
421 lines
15 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("/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],
|
|
})
|