From ce76a003f73a39e24d31b31dbac6bec8f1fbdf31 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 25 Apr 2026 22:40:56 -0400 Subject: [PATCH] feat(journal): /api/journal/* routes blueprint + cosine helper unit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Endpoints: - GET/PUT /api/journal/config — per-user journal config - GET /api/journal/today — today's journal conversation, generates daily prep on demand - GET /api/journal/day/ — past day's journal - GET /api/journal/days — list of dates with journal content - POST /api/journal/trigger-prep — manual regeneration of prep - GET /api/journal/moments — list/search moments with filters - PATCH /api/journal/moments/ — edit content/tags/pinned + junctions - DELETE /api/journal/moments/ Blueprint registered in app.py. tests/test_journal_search.py — cosine helper coverage. Co-Authored-By: Claude Opus 4.7 --- src/fabledassistant/app.py | 2 + src/fabledassistant/routes/journal.py | 228 ++++++++++++++++++++++++++ tests/test_journal_search.py | 23 +++ 3 files changed, 253 insertions(+) create mode 100644 src/fabledassistant/routes/journal.py create mode 100644 tests/test_journal_search.py diff --git a/src/fabledassistant/app.py b/src/fabledassistant/app.py index 4b879c9..70fbb86 100644 --- a/src/fabledassistant/app.py +++ b/src/fabledassistant/app.py @@ -12,6 +12,7 @@ from fabledassistant.routes.admin import admin_bp from fabledassistant.routes.api import api from fabledassistant.routes.auth import auth_bp from fabledassistant.routes.chat import chat_bp +from fabledassistant.routes.journal import journal_bp from fabledassistant.routes.export import export_bp from fabledassistant.routes.notes import notes_bp from fabledassistant.routes.images import images_bp @@ -75,6 +76,7 @@ def create_app() -> Quart: app.register_blueprint(api) app.register_blueprint(auth_bp) app.register_blueprint(chat_bp) + app.register_blueprint(journal_bp) app.register_blueprint(export_bp) app.register_blueprint(images_bp) app.register_blueprint(milestones_bp) diff --git a/src/fabledassistant/routes/journal.py b/src/fabledassistant/routes/journal.py new file mode 100644 index 0000000..bb6c687 --- /dev/null +++ b/src/fabledassistant/routes/journal.py @@ -0,0 +1,228 @@ +"""HTTP endpoints for the Journal feature.""" +from __future__ import annotations + +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.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/") +@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/") +@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/") +@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}) + + +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], + }) diff --git a/tests/test_journal_search.py b/tests/test_journal_search.py new file mode 100644 index 0000000..44c2e4f --- /dev/null +++ b/tests/test_journal_search.py @@ -0,0 +1,23 @@ +"""Unit tests for the cosine helper inside journal_search.""" +import math + +from fabledassistant.services.journal_search import _cosine + + +def test_cosine_identical_vectors(): + v = [1.0, 2.0, 3.0] + assert math.isclose(_cosine(v, v), 1.0, rel_tol=1e-9) + + +def test_cosine_orthogonal_vectors(): + a = [1.0, 0.0] + b = [0.0, 1.0] + assert math.isclose(_cosine(a, b), 0.0, abs_tol=1e-9) + + +def test_cosine_zero_vector_returns_zero(): + assert _cosine([0.0, 0.0, 0.0], [1.0, 1.0, 1.0]) == 0.0 + + +def test_cosine_empty_returns_zero(): + assert _cosine([], [1.0]) == 0.0