From c9f2134ad415e9c1d25b0b0455e1fa46ddb9e666 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sun, 26 Apr 2026 15:42:30 -0400 Subject: [PATCH] feat(journal): conversational LLM-generated daily prep (replaces card) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The structured prep card was data-rich but voiceless. Replaced with an LLM-generated conversational opener — same shape the briefing's compilation slot had — that renders as a normal assistant chat bubble at the top of the day's conversation. Backend (services/journal_prep.py): - Renamed generate_daily_prep -> gather_daily_sections (still pure data fetching, no LLM); kept the old name as a backwards-compat alias. - New _generate_prep_prose: hands the gathered sections to generate_completion with a warm-conversational system prompt; returns prose. Falls back to a plain greeting if the LLM call fails or no model is configured. - ensure_daily_prep_message now persists the prep as role='assistant' with the prose as content. Structured sections stay on msg_metadata for provenance. Auto-upgrades legacy system-role preps in place on next call. Frontend: - Drop the
structured block from JournalView. The prep is now just the first chat bubble — picks up the existing Illuminated Transcript styling automatically. - Drop dayMessages / prepMessage / prepSections / asArray helpers — no longer needed. - ChatMessage hideMessage filter: comment refined to clarify it only catches LEGACY system-role prep rows. Current preps are assistant-role and render normally. Net effect: open /journal -> first thing you see is a warm assistant bubble that talks about your day -> input bar below to reply. Co-Authored-By: Claude Opus 4.7 --- frontend/src/components/ChatMessage.vue | 7 +- frontend/src/views/JournalView.vue | 90 +------- src/fabledassistant/services/journal_prep.py | 231 +++++++++++++++++-- 3 files changed, 212 insertions(+), 116 deletions(-) diff --git a/frontend/src/components/ChatMessage.vue b/frontend/src/components/ChatMessage.vue index 2652f94..59df77b 100644 --- a/frontend/src/components/ChatMessage.vue +++ b/frontend/src/components/ChatMessage.vue @@ -43,9 +43,10 @@ function formatMs(ms: number | null | undefined): string { const metadata = computed(() => (props.message.metadata ?? {}) as Record); -// Hide daily-prep system messages — they're rendered separately as a structured -// card in JournalView. Without this filter they render as a flat unstyled block -// inside the chat (no .role-system bubble styling exists). +// Hide LEGACY system-role daily_prep messages from earlier versions. The +// current journal prep is a normal assistant message (renders with proper +// bubble styling). Old system-role rows lurking in existing conversations +// would render as a flat unstyled block — filter them. const hideMessage = computed(() => props.message.role === "system" && metadata.value.kind === "daily_prep" ); diff --git a/frontend/src/views/JournalView.vue b/frontend/src/views/JournalView.vue index 205c2b4..551ecfe 100644 --- a/frontend/src/views/JournalView.vue +++ b/frontend/src/views/JournalView.vue @@ -13,7 +13,6 @@ import { triggerJournalPrep, listEvents, type EventEntry, - type JournalMessage, } from '@/api/client' interface WeatherDay { @@ -53,21 +52,8 @@ const days = ref([]) const todayDate = ref(null) const selectedDay = ref(null) const dayConvId = ref(null) -const dayMessages = ref([]) const isToday = computed(() => selectedDay.value !== null && selectedDay.value === todayDate.value) -// ── Daily prep card ────────────────────────────────────────────────────────── -const prepMessage = computed(() => { - return dayMessages.value.find( - (m) => m.role === 'system' && (m.metadata as { kind?: string } | null)?.kind === 'daily_prep', - ) ?? null -}) -const prepSections = computed>(() => { - const meta = prepMessage.value?.metadata as { sections?: Record } | null - return meta?.sections ?? {} -}) -function asArray(v: unknown): unknown[] { return Array.isArray(v) ? v : [] } - // ── Weather panel ──────────────────────────────────────────────────────────── const weatherData = ref([]) const selectedWeatherIdx = ref(0) @@ -153,7 +139,6 @@ async function loadEvents() { // ── Day load + switch ──────────────────────────────────────────────────────── async function loadDay(iso: string) { const payload = iso === todayDate.value ? await getJournalToday() : await getJournalDay(iso) - dayMessages.value = payload.messages if (payload.conversation) { dayConvId.value = payload.conversation.id await chatStore.fetchConversation(payload.conversation.id) @@ -167,7 +152,6 @@ async function loadAll() { const today = await getJournalToday() todayDate.value = today.day_date selectedDay.value = today.day_date - dayMessages.value = today.messages if (today.conversation) { dayConvId.value = today.conversation.id await chatStore.fetchConversation(today.conversation.id) @@ -259,55 +243,8 @@ onMounted(async () => { - +
-
-

Today

- -
-

Tasks

-
    -
  • - {{ t.title }} · due {{ t.due_date }} -
  • -
-
- -
-

Calendar

-
    -
  • - {{ e.title }} — {{ e.start_dt }} -
  • -
-
- -
-

Active projects

-
    -
  • {{ p.title }}
  • -
-
- -
-

Recent moments

-
    -
  • - {{ m.day_date }} — {{ m.content }} -
  • -
-
- -
-

Open threads

-
    -
  • - {{ m.day_date }} — {{ m.content }} -
  • -
-
-
- { .journal-chat-panel { flex: 1; min-height: 0; } -.daily-prep { - border: 1px solid var(--color-border); - border-radius: var(--radius-md); - padding: 1rem 1.25rem; - margin: 1rem 1rem 0.5rem; - background: var(--color-bg-card); - flex-shrink: 0; -} -.daily-prep > header h2 { - font-family: 'Fraunces', Georgia, serif; - font-size: 1.1rem; - margin: 0 0 0.5rem 0; -} -.prep-section { margin-top: 0.75rem; } -.prep-section h3 { - font-size: 0.72rem; - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--color-text-muted); - margin: 0 0 0.25rem 0; - font-weight: 600; -} -.prep-section ul { margin: 0; padding-left: 1.1rem; font-size: 0.88rem; color: var(--color-text); } -.prep-section li { margin-bottom: 0.15rem; } - /* ── Right column ─────────────────────────────────────────────────────────── */ .journal-right { grid-column: 2; diff --git a/src/fabledassistant/services/journal_prep.py b/src/fabledassistant/services/journal_prep.py index b919483..28b8c54 100644 --- a/src/fabledassistant/services/journal_prep.py +++ b/src/fabledassistant/services/journal_prep.py @@ -1,17 +1,23 @@ """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. +new day). Two phases: -The output shape lives on Message.msg_metadata as: - { kind: 'daily_prep', sections: { tasks, events, weather, - projects, recent_moments, open_threads } } +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 warm conversational + opener — flowing prose, not a card. The 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. -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. +The structured data is preserved on ``Message.msg_metadata.sections`` for +provenance and future tooling, but is NOT visually surfaced as a card. + +Message shape: + role: 'assistant' + content: + msg_metadata: { kind: 'daily_prep', sections: { ...raw data... } } """ from __future__ import annotations @@ -20,23 +26,29 @@ import logging 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__) -async def generate_daily_prep( +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.""" + """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. + """ sections: dict = {} try: @@ -66,7 +78,6 @@ async def generate_daily_prep( 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, @@ -137,6 +148,156 @@ async def _open_threads(*, user_id: int, day_date: datetime.date) -> list[dict]: ] +def _render_sections_for_prompt(sections: dict) -> str: + """Render the gathered sections as a structured plain-text block for the LLM.""" + lines: list[str] = [] + + tasks = sections.get("tasks") or [] + if tasks: + lines.append("TASKS (todo or in-progress):") + for t in tasks[:12]: + line = f" - {t.get('title', '?')}" + if 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]" + lines.append(line) + 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 opening the user's daily journal with a warm, conversational greeting. " + "Weave the data they're handing you into a flowing prose welcome — like a " + "thoughtful friend recapping what's on the user's plate today and gently inviting " + "them to journal about it.\n\n" + "Rules:\n" + "- Write flowing sentences. NO markdown, NO bullet points, NO headers.\n" + "- Reference items that genuinely matter; skip categories with no real data.\n" + "- If RECENT JOURNAL MOMENTS are present, briefly acknowledge what they were doing, thinking, or feeling.\n" + "- If OPEN THREADS are present, gently surface one or two as questions worth revisiting.\n" + "- Aim for 5 to 9 sentences. Warm but brief.\n" + "- Close with one short open invitation to journal — examples: \"What's on your mind?\", " + "\"How are you starting the day?\", \"Anything you want to set down before this kicks off?\"\n" + "- Don't fabricate details. If a category is empty, just skip it; don't lie about what's there.\n" + "- Don't list things mechanically (\"You have 3 tasks today...\"). Talk like a person." +) + + +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"Good morning. {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 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=600, + ) + 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, @@ -146,8 +307,13 @@ async def ensure_daily_prep_message( ) -> 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). + 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( @@ -168,33 +334,50 @@ async def ensure_daily_prep_message( session.add(conv) await session.flush() - prep_stmt = select(Message).where( - Message.conversation_id == conv.id, - Message.role == "system", - ) + # 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 existing_prep and not force: + # 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 generate_daily_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: - existing_prep.msg_metadata = {"kind": "daily_prep", "sections": sections} + # 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="system", - content="", - msg_metadata={"kind": "daily_prep", "sections": sections}, + 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