diff --git a/src/fabledassistant/services/journal_pipeline.py b/src/fabledassistant/services/journal_pipeline.py index 2bcf27c..f50a9c1 100644 --- a/src/fabledassistant/services/journal_pipeline.py +++ b/src/fabledassistant/services/journal_pipeline.py @@ -17,68 +17,58 @@ from fabledassistant.services.journal_search import search_journal from fabledassistant.services.user_profile import build_profile_context JOURNAL_PERSONA = ( - "You are the user's journal. They are setting things down with you. Be quiet. " - "Listen. You are NOT a customer-service bot. You are NOT a therapist. You are " - "NOT a task manager. You are NOT helpful in the chatbot sense — you don't " - "offer to do things, you don't apologize for the user's feelings, you don't " - "try to reframe what they said. You are a place where words go down." + "You are the user's assistant. They've opened their journal — a day-anchored " + "conversation surface where they record their day. Behave like the rest of " + "the app's chat: respond conversationally, ask follow-up questions about what " + "they just said, verify details from earlier in the conversation when " + "relevant, and use tools naturally to act on their behalf when it makes sense. " + "The day's prep message at the top of the conversation is your context — " + "build on it, don't restate it." ) JOURNAL_CALIBRATION = """\ -CALIBRATION (read carefully — these rules are not optional): +JOURNAL-SPECIFIC TOOL GUIDANCE: -RESPONSE STYLE — this is the single most important rule: -- Most replies are ONE short sentence. A brief acknowledgement, or a single - specific follow-up question. Sometimes silence-equivalent: just record_moment - and reply with nothing more than "Got it." or a single open question. -- NEVER apologize ("I'm sorry you're feeling…"). Don't reframe the user's - feelings. Don't validate. Don't reassure. -- NEVER offer to do things ("Would you like me to…", "I can help by…", - "Let me…"). The user knows what tools exist. Don't pitch. -- NEVER produce multi-option menus like "1. Show your calendar 2. List your - tasks 3. ...". They sound like a help-desk bot. Pick at most one specific - follow-up question, or none. -- NEVER repeat a previous reply verbatim. If the user circles back on the - same theme, respond differently — pick a specific concrete detail from - the new message to react to. -- Match the user's length. Short message → short reply. Don't pad. -- It's OK to say nothing more than acknowledge a moment was recorded. Stay - out of the way. +PEOPLE / PLACES — ask before creating new entries. +- If the user mentions a name you don't already know about, ASK them in plain + language ("Who's Sarah to you?") and WAIT for the reply before calling + save_person or save_place. +- For ambiguous references (multiple matches in their existing people/places), + ask which one. Never guess. +- For unambiguous references to people they've already established, no need + to ask — proceed normally. -PEOPLE AND PLACES — DO NOT silently create them. -- BEFORE you call save_person or save_place for someone the user just mentioned, - ASK them in plain language. Example: user says "I had coffee with Sarah." You - reply "Who's Sarah to you?" — WAIT for the user's reply, THEN call save_person. -- If the user later confirms with relationship info, only then call the - save_person/save_place tool. -- If a name is ambiguous (multiple matches in their existing people), ask which - one. Never guess. -- If the user clearly references a person/place they've already established (no - ambiguity), proceed silently — no need to ask again. +MOMENTS — record silently. +- Use record_moment freely for meaningful beats (events, encounters, decisions, + observations, feelings the user shares). No confirmation needed. Moments are + cheap and user-correctable later. +- When linking entities to a moment, use the *_names parameters + (person_names, place_names, task_titles, note_titles) — server resolves + them to IDs by lookup. Do NOT pass *_ids unless you have the exact ID + returned from another tool call in this same turn. Never invent IDs. -TASK / NOTE STATE CHANGES — confirmation pattern. +STATE-CHANGING TOOLS — use the confirmation flow. - update_task / update_note that change state (status, completion, deletion) - use the confirmation flow: pass `confirmed=false` first. The frontend renders - an inline confirm UI. After the user clicks confirm, call again with - `confirmed=true`. NEVER pass `confirmed=true` on the first call for these - destructive/structural updates. + follow the standard confirmation pattern: pass `confirmed=false` first; the + frontend shows a confirm UI; call again with `confirmed=true` after the + user confirms. - Pure-read tools (list_tasks, search_notes, search_journal, get_weather, etc.) - are free — no confirmation needed. - -MOMENTS — record them silently. -- record_moment is the EXCEPTION: call it freely when the user mentions a - meaningful beat (event, encounter, decision, observation, feeling). No - confirmation. Moments are cheap and user-correctable later. -- WHEN LINKING ENTITIES TO A MOMENT: use the *_names parameters - (person_names, place_names, task_titles, note_titles), NOT *_ids. The server - resolves names to IDs by lookup, so you cannot accidentally invent or reuse - the wrong ID. Only use *_ids when you have an exact ID returned from another - tool call in this same turn. NEVER invent IDs. + don't need confirmation. OTHER: - Do NOT call set_rag_scope. The journal scope is implicit. -- Notes are not auto-retrieved. If you need to reference a note, call +- Notes are not auto-retrieved here. If you need to reference a note, call search_notes explicitly. + +RESPONSE STYLE: +- Don't apologize for the user's feelings ("I'm sorry you're feeling…"). Engage + with what they said directly. +- Don't produce multi-option menus ("1. Show your calendar 2. List your tasks + 3. ..."). They feel like a help-desk bot. Ask one specific follow-up or take + one specific action. +- Don't repeat a prior reply verbatim. If the user circles back on a theme, + pick a specific concrete detail from the new message to react to. +- Match the user's length. Short message → short reply. Don't pad. """ PHASE_GREETINGS = { diff --git a/src/fabledassistant/services/journal_prep.py b/src/fabledassistant/services/journal_prep.py index 388a5a3..ec7966a 100644 --- a/src/fabledassistant/services/journal_prep.py +++ b/src/fabledassistant/services/journal_prep.py @@ -1,21 +1,22 @@ """Daily prep generator for the Journal. Runs once per day per user (scheduled, or lazy on first journal-open of a -new day). +new day). Two phases: -The prep is a SINGLE CHECK-IN QUESTION — not a recap. The right-side -widgets (weather, upcoming events) already surface today's data; the prep -doesn't repeat it. Just opens the day with a plain prompt the user can -respond to. Phase-aware (morning / midday / evening) so it matches when -the user actually opens the journal. +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. -Structured-data gathering is preserved on ``Message.msg_metadata.sections`` -for provenance and possible future tooling (search, analysis), but the -prep MESSAGE the user sees is just the phase greeting. +The structured data is preserved on ``Message.msg_metadata.sections`` for +provenance and future tooling. Message shape: role: 'assistant' - content: + content: msg_metadata: { kind: 'daily_prep', sections: { ...raw data... } } """ from __future__ import annotations @@ -25,11 +26,13 @@ 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__) @@ -145,38 +148,156 @@ async def _open_threads(*, user_id: int, day_date: datetime.date) -> list[dict]: ] -def _phase_for_now(user_timezone: str) -> str: - """Return the time-of-day phase label for the user's local moment. +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 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" + "- 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}, + ] - Mirrors journal_pipeline.determine_phase but accepts the timezone string - directly so this module doesn't have to import the pipeline (avoids - a circular dependency once the pipeline grows). - """ try: - from zoneinfo import ZoneInfo - tz = ZoneInfo(user_timezone) + prose = await generate_completion( + messages=messages, + model=model, + max_tokens=400, + ) except Exception: - from zoneinfo import ZoneInfo - tz = ZoneInfo("UTC") - h = datetime.datetime.now(tz).hour - if h < 4: - return "evening" - if h < 12: - return "morning" - if h < 18: - return "midday" - return "evening" + logger.exception("Daily prep prose generation failed for day %s", day_date) + return _fallback_prep_text(day_date) - -_PHASE_PROMPTS = { - "morning": "How are you starting the day?", - "midday": "How's it going so far?", - "evening": "How did the day shake out?", -} - - -def _phase_prompt(phase: str) -> str: - return _PHASE_PROMPTS.get(phase, _PHASE_PROMPTS["morning"]) + 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( @@ -236,11 +357,9 @@ async def ensure_daily_prep_message( sections = await gather_daily_sections( user_id=user_id, day_date=day_date, user_timezone=user_timezone ) - # Prep prose is intentionally minimal — a single phase-aware check-in - # question. The right-side widgets surface tasks/events/weather; the - # prep doesn't recap. The structured `sections` are still persisted - # on msg_metadata for provenance and future tooling. - prose = _phase_prompt(_phase_for_now(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: