"""Daily prep generator for the Journal. Runs once per day per user (scheduled, or lazy on first journal-open of a new day). Two phases: 1. Gather structured data (tasks/events/weather/projects/recent moments/ open threads) — deterministic, no LLM call. 2. Hand the structured data to the LLM and ask it for a direct, informative conversational opener — flowing prose, briefing-style. Result is persisted as the first *assistant* message in today's journal Conversation, so it renders with the standard Illuminated Transcript bubble styling alongside the rest of the conversation. The structured data is preserved on ``Message.msg_metadata.sections`` for provenance and future tooling. Message shape: role: 'assistant' content: msg_metadata: { kind: 'daily_prep', sections: { ...raw data... } } """ from __future__ import annotations import datetime import logging from zoneinfo import ZoneInfo from sqlalchemy import select from fabledassistant.config import Config 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.settings import get_setting from fabledassistant.services.weather import get_cached_weather_rows logger = logging.getLogger(__name__) # How many days out from today an event needs to be before the prep treats # it as too far-future to surface. Catches recurring-event canonical rows # whose RRULE expansion missed (an `rrulestr` failure falls back to the # canonical event in `list_events`, which leaks far-future occurrences # into today's prep). _EVENT_PROXIMITY_DAYS = 7 def _task_to_prep_dict(task, today: datetime.date) -> dict: """Render a Note row as a prep-payload task entry, tagging overdue staleness when relevant so the prompt can frame it correctly.""" d = { "id": task.id, "title": task.title, "status": task.status, "priority": task.priority, "due_date": task.due_date.isoformat() if task.due_date else None, } if task.due_date and task.due_date < today: d["days_overdue"] = (today - task.due_date).days return d def _filter_proximate_events( events: list[dict], *, day_date: datetime.date, user_tz: ZoneInfo ) -> list[dict]: """Drop events whose start_dt is more than ``_EVENT_PROXIMITY_DAYS`` away from ``day_date`` in the user's local timezone. Belt-and-suspenders against `list_events` returning a canonical far-future event (e.g. when RRULE expansion fails and the loop falls back to the original event row, regardless of date). The user observed "Birthday — 2026-09-29 (FREQ=YEARLY)" surfacing in every daily prep 5 months out; this filter keeps the prep proximate. """ proximate: list[dict] = [] for e in events: raw = e.get("start_dt") or "" try: start_dt = datetime.datetime.fromisoformat( raw.replace("Z", "+00:00") if isinstance(raw, str) else "" ) local_date = start_dt.astimezone(user_tz).date() delta = abs((local_date - day_date).days) except (ValueError, TypeError, AttributeError): # Unparseable date — keep the event rather than suppress real data. proximate.append(e) continue if delta <= _EVENT_PROXIMITY_DAYS: proximate.append(e) else: logger.info( "daily_prep: dropping non-proximate event %r start_local=%s " "(%d days from day %s)", e.get("title"), local_date.isoformat(), delta, day_date.isoformat(), ) return proximate async def gather_daily_sections( *, user_id: int, day_date: datetime.date, user_timezone: str, ) -> dict: """Gather all daily-prep sections and return them as a dict. Pure data fetching — no LLM call. Each section degrades to an empty list/dict on failure so the caller always gets a complete shape. Tasks are returned in three explicit buckets so the prompt can frame overdue items correctly (instead of calling them "due today" — the pre-2026-04-29 behavior, before this rewrite). """ sections: dict = {} next_day = day_date + datetime.timedelta(days=1) upcoming_end = day_date + datetime.timedelta(days=8) try: due_today_rows, _ = await list_notes( user_id=user_id, is_task=True, status=["todo", "in_progress"], due_after=day_date, due_before=next_day, limit=20, sort="due_date", order="asc", ) upcoming_rows, _ = await list_notes( user_id=user_id, is_task=True, status=["todo", "in_progress"], due_after=next_day, due_before=upcoming_end, limit=20, sort="due_date", order="asc", ) overdue_rows, _ = 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_due_today"] = [_task_to_prep_dict(t, day_date) for t in due_today_rows] sections["tasks_upcoming"] = [_task_to_prep_dict(t, day_date) for t in upcoming_rows] sections["tasks_overdue"] = [_task_to_prep_dict(t, day_date) for t in overdue_rows] # Backwards-compat alias for any consumers still reading sections["tasks"]. # The combined view is more useful than the prior overdue-only behavior. sections["tasks"] = ( sections["tasks_due_today"] + sections["tasks_upcoming"] + sections["tasks_overdue"] ) except Exception: logger.exception("daily_prep tasks section failed for user %d", user_id) sections["tasks_due_today"] = [] sections["tasks_upcoming"] = [] sections["tasks_overdue"] = [] sections["tasks"] = [] try: try: user_tz = ZoneInfo(user_timezone) except Exception: logger.warning("daily_prep: invalid user_timezone %r — defaulting to UTC", user_timezone) user_tz = ZoneInfo("UTC") # Build the local-day window in the user's TZ, then convert to UTC # for the DB / RRULE expansion. A naive datetime here previously # caused rrule.between() to throw, falling back to the canonical # event row regardless of date — the source of stale recurring # events polluting every daily prep. day_start_local = datetime.datetime.combine(day_date, datetime.time.min, tzinfo=user_tz) day_end_local = datetime.datetime.combine(day_date, datetime.time.max, tzinfo=user_tz) day_start = day_start_local.astimezone(datetime.timezone.utc) day_end = day_end_local.astimezone(datetime.timezone.utc) all_events = await list_events( user_id=user_id, date_from=day_start, date_to=day_end, ) sections["events"] = _filter_proximate_events( all_events, day_date=day_date, user_tz=user_tz, ) 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") ] def _render_task_line(t: dict, *, include_due: bool, include_overdue: bool) -> str: line = f" - {t.get('title', '?')}" if include_overdue and t.get("days_overdue"): line += f" (due {t['due_date']}, {t['days_overdue']} days ago)" elif include_due and t.get("due_date"): line += f" (due {t['due_date']})" if t.get("priority") and t["priority"] not in (None, "none"): line += f" [{t['priority']} priority]" if t.get("status") == "in_progress": line += " [in progress]" return line def _render_sections_for_prompt(sections: dict) -> str: """Render the gathered sections as a structured plain-text block for the LLM.""" lines: list[str] = [] due_today = sections.get("tasks_due_today") or [] upcoming = sections.get("tasks_upcoming") or [] overdue = sections.get("tasks_overdue") or [] if due_today: lines.append("TASKS DUE TODAY:") for t in due_today[:8]: lines.append(_render_task_line(t, include_due=False, include_overdue=False)) lines.append("") if upcoming: lines.append("UPCOMING TASKS (next 7 days):") for t in upcoming[:8]: lines.append(_render_task_line(t, include_due=True, include_overdue=False)) lines.append("") if overdue: lines.append("OVERDUE TASKS (still on the list, not currently due):") for t in overdue[:8]: lines.append(_render_task_line(t, include_due=False, include_overdue=True)) lines.append("") events = sections.get("events") or [] if events: lines.append("CALENDAR EVENTS TODAY:") for e in events[:8]: title = e.get("title", "Untitled") when = e.get("start_dt", "?") location = e.get("location") or "" line = f" - {title} at {when}" if location: line += f" ({location})" lines.append(line) lines.append("") weather = sections.get("weather") or [] if weather: lines.append("WEATHER:") for w in weather: label = w.get("location_label") or w.get("location_key") or "Location" forecast_json = w.get("forecast_json") or {} daily = forecast_json.get("daily") or {} today_max = (daily.get("temperature_2m_max") or [None])[0] today_min = (daily.get("temperature_2m_min") or [None])[0] precip = (daily.get("precipitation_probability_max") or [None])[0] bits = [label] if today_max is not None and today_min is not None: bits.append(f"high {today_max}° / low {today_min}°") if precip is not None: bits.append(f"{precip}% chance of precipitation") lines.append(" - " + ", ".join(bits)) lines.append("") projects = sections.get("projects") or [] if projects: lines.append("ACTIVE PROJECTS:") for p in projects[:5]: line = f" - {p.get('title', '?')}" if p.get("auto_summary"): summary = p["auto_summary"][:160] line += f" — {summary}" lines.append(line) lines.append("") recent_moments = sections.get("recent_moments") or [] if recent_moments: lines.append("RECENT JOURNAL MOMENTS (last few days):") for m in recent_moments[:8]: day = m.get("day_date", "?") content = (m.get("content") or "").strip() lines.append(f" - [{day}] {content}") lines.append("") open_threads = sections.get("open_threads") or [] if open_threads: lines.append("OPEN THREADS (mentioned recently but not resolved):") for m in open_threads[:5]: day = m.get("day_date", "?") content = (m.get("content") or "").strip() lines.append(f" - [{day}] {content}") lines.append("") if not lines: return "(No data for today — quiet morning.)" return "\n".join(lines).rstrip() _PREP_SYSTEM_PROMPT = ( "You are briefing the user on their day. Direct and informative — tell them what's " "actually on their plate so they can step into the day with a clear picture.\n\n" "Rules:\n" "- LEAD with the practical data: tasks due today, calendar events, weather.\n" "- Be specific and concrete. Use real task titles, event times, temperatures, " "precipitation chances. Don't paraphrase data into vague summaries.\n" "- Write in flowing sentences — no markdown, no bullet points, no headers — but " "keep the prose factual and useful, not sentimental.\n" "- 4 to 7 sentences total. Tight. No padding, no flowery openings, no \"Good morning\" " "greetings unless the actual content warrants two clauses' worth.\n" "- TASK BUCKETS — three sections may appear: TASKS DUE TODAY, UPCOMING TASKS, " "OVERDUE TASKS. Lead with TASKS DUE TODAY when present. Do NOT call overdue items " "\"due today\" — they aren't. When OVERDUE TASKS appears, surface it with the " "staleness duration (\"still on the list 68 days\") and frame it as something to " "revisit, not as today's work. If the only data is overdue, lead with it but " "frame it as a backlog reminder.\n" "- If RECENT JOURNAL MOMENTS or OPEN THREADS are present, mention one or two BRIEFLY " "at the end as context — not as the lead. Skip them if nothing notable.\n" "- Close with one short invitation to journal: \"What's on your mind?\", " "\"Anything to set down?\", \"How's the morning shaping up?\" — pick one, keep it under 8 words.\n" "- Don't fabricate. Skip categories with no data; don't acknowledge their absence.\n" "- Voice is competent assistant briefing the user. Not a friend writing a letter." ) def _fallback_prep_text(day_date: datetime.date) -> str: """If the LLM call fails, return a minimal greeting so the user still sees something.""" weekday = day_date.strftime("%A") return f"{weekday}, {day_date.isoformat()}. What's on your mind?" async def _generate_prep_prose( *, sections: dict, day_date: datetime.date, user_id: int, ) -> str: """Ask the LLM for a direct conversational journal opener built from the sections.""" from fabledassistant.services.llm import generate_completion model = (await get_setting(user_id, "default_model", "")) or Config.OLLAMA_MODEL if not model: logger.warning("No LLM model configured for daily prep — using fallback text") return _fallback_prep_text(day_date) rendered = _render_sections_for_prompt(sections) user_trigger = ( f"Today is {day_date.strftime('%A, %B %-d, %Y')} ({day_date.isoformat()}).\n\n" f"Here is what I gathered for you:\n\n{rendered}\n\n" f"Write the opener for today's journal." ) messages = [ {"role": "system", "content": _PREP_SYSTEM_PROMPT}, {"role": "user", "content": user_trigger}, ] try: prose = await generate_completion( messages=messages, model=model, max_tokens=400, ) except Exception: logger.exception("Daily prep prose generation failed for day %s", day_date) return _fallback_prep_text(day_date) prose = (prose or "").strip() if not prose: logger.warning("LLM returned empty prep prose for day %s — using fallback", day_date) return _fallback_prep_text(day_date) return prose 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. The prep message is an *assistant* role message containing the prose opener, with the structured sections preserved on ``msg_metadata``. If a legacy system-role prep exists from an earlier version, it gets upgraded in place on the next call. With ``force=True`` the prose is regenerated even when a prep already exists. 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() # Find any existing prep (system or assistant role from any version). prep_stmt = select(Message).where(Message.conversation_id == conv.id) 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 we already have an assistant-role prep with prose content and the # caller didn't ask to force regeneration, we're done. if ( existing_prep and not force and existing_prep.role == "assistant" and (existing_prep.content or "").strip() ): return existing_prep sections = await gather_daily_sections( user_id=user_id, day_date=day_date, user_timezone=user_timezone ) prose = await _generate_prep_prose( sections=sections, day_date=day_date, user_id=user_id ) new_metadata = {"kind": "daily_prep", "sections": sections} if existing_prep: # Upgrade in place: bump role, replace content + metadata. existing_prep.role = "assistant" existing_prep.content = prose existing_prep.msg_metadata = new_metadata await session.commit() return existing_prep prep_msg = Message( conversation_id=conv.id, role="assistant", content=prose, msg_metadata=new_metadata, ) session.add(prep_msg) await session.commit() return prep_msg # Backwards-compat alias — older imports may use the old name. generate_daily_prep = gather_daily_sections