"""Daily prep generator for the Journal. Runs once per day per user (scheduled, or lazy on first journal-open of a new day). Gathers raw data into a structured prep block that becomes the first message of the day's journal Conversation. The output shape lives on Message.msg_metadata as: { kind: 'daily_prep', sections: { tasks, events, weather, projects, recent_moments, open_threads } } Deliberately deterministic — no LLM call here. The journal LLM weaves narrative into the conversation later, when the user actually starts talking. Generating prose at scheduled-prep time would burn model time on something the user may never read. """ from __future__ import annotations import datetime import logging from sqlalchemy import select from fabledassistant.models import Conversation, Message, async_session from fabledassistant.services.events import list_events from fabledassistant.services.journal_search import search_journal from fabledassistant.services.notes import list_notes from fabledassistant.services.projects import list_projects from fabledassistant.services.weather import get_cached_weather_rows logger = logging.getLogger(__name__) async def generate_daily_prep( *, user_id: int, day_date: datetime.date, user_timezone: str, ) -> dict: """Gather all daily-prep sections and return them as a dict.""" sections: dict = {} try: tasks_today, _ = await list_notes( user_id=user_id, is_task=True, status=["todo", "in_progress"], due_before=day_date, limit=20, sort="due_date", order="asc", ) sections["tasks"] = [ { "id": t.id, "title": t.title, "status": t.status, "priority": t.priority, "due_date": t.due_date.isoformat() if t.due_date else None, } for t in tasks_today ] except Exception: logger.exception("daily_prep tasks section failed for user %d", user_id) sections["tasks"] = [] try: day_start = datetime.datetime.combine(day_date, datetime.time.min) day_end = datetime.datetime.combine(day_date, datetime.time.max) # list_events already returns dicts. sections["events"] = await list_events( user_id=user_id, date_from=day_start, date_to=day_end, ) except Exception: logger.exception("daily_prep events section failed for user %d", user_id) sections["events"] = [] try: weather_rows = await get_cached_weather_rows(user_id) sections["weather"] = [w.to_dict() for w in weather_rows] except Exception: logger.exception("daily_prep weather section failed for user %d", user_id) sections["weather"] = [] try: projects = await list_projects(user_id=user_id, status="active") sections["projects"] = [ { "id": p.id, "title": p.title, "auto_summary": p.auto_summary, } for p in projects[:5] ] except Exception: logger.exception("daily_prep projects section failed for user %d", user_id) sections["projects"] = [] try: sections["recent_moments"] = await search_journal( user_id=user_id, date_from=day_date - datetime.timedelta(days=3), date_to=day_date - datetime.timedelta(days=1), limit=10, ) except Exception: logger.exception("daily_prep recent_moments section failed for user %d", user_id) sections["recent_moments"] = [] try: sections["open_threads"] = await _open_threads(user_id=user_id, day_date=day_date) except Exception: logger.exception("daily_prep open_threads section failed for user %d", user_id) sections["open_threads"] = [] return sections async def _open_threads(*, user_id: int, day_date: datetime.date) -> list[dict]: """Heuristic: moments from the last 7 days that look unresolved. Treated as 'unresolved' when they have no linked tasks/notes and aren't pinned. Starting heuristic — refine empirically. """ candidates = await search_journal( user_id=user_id, date_from=day_date - datetime.timedelta(days=7), date_to=day_date - datetime.timedelta(days=1), limit=50, ) return [ m for m in candidates if not m.get("task_ids") and not m.get("note_ids") and not m.get("pinned") ] async def ensure_daily_prep_message( *, user_id: int, day_date: datetime.date, user_timezone: str, force: bool = False, ) -> Message: """Get or create today's journal Conversation, then ensure the prep message exists. With force=True, regenerate even if a prep message already exists today (used by the manual /api/journal/trigger-prep endpoint). """ async with async_session() as session: result = await session.execute( select(Conversation).where( Conversation.user_id == user_id, Conversation.conversation_type == "journal", Conversation.day_date == day_date, ) ) conv = result.scalar_one_or_none() if conv is None: conv = Conversation( user_id=user_id, conversation_type="journal", day_date=day_date, title=day_date.isoformat(), ) session.add(conv) await session.flush() prep_stmt = select(Message).where( Message.conversation_id == conv.id, Message.role == "system", ) existing_prep = None for msg in (await session.execute(prep_stmt)).scalars(): if msg.msg_metadata and msg.msg_metadata.get("kind") == "daily_prep": existing_prep = msg break if existing_prep and not force: return existing_prep sections = await generate_daily_prep( user_id=user_id, day_date=day_date, user_timezone=user_timezone ) if existing_prep: existing_prep.msg_metadata = {"kind": "daily_prep", "sections": sections} await session.commit() return existing_prep prep_msg = Message( conversation_id=conv.id, role="system", content="", msg_metadata={"kind": "daily_prep", "sections": sections}, ) session.add(prep_msg) await session.commit() return prep_msg