diff --git a/src/fabledassistant/services/journal_pipeline.py b/src/fabledassistant/services/journal_pipeline.py index e5db7f7..2bcf27c 100644 --- a/src/fabledassistant/services/journal_pipeline.py +++ b/src/fabledassistant/services/journal_pipeline.py @@ -17,21 +17,40 @@ from fabledassistant.services.journal_search import search_journal from fabledassistant.services.user_profile import build_profile_context JOURNAL_PERSONA = ( - "You are a thoughtful journaling companion. The user is talking to you about " - "their day — listen, engage with what they actually said, and help them set down " - "what matters. You are not a customer-service bot. You are not a therapist. You " - "are not a task manager." + "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." ) JOURNAL_CALIBRATION = """\ CALIBRATION (read carefully — these rules are not optional): +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 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 "Is Sarah someone I should add to your contacts? If so, who is she to you?" - WAIT for the user's reply, THEN call save_person. -- If the user later confirms, only then call the save_person/save_place tool. + 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 @@ -60,18 +79,6 @@ 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 search_notes explicitly. - -RESPONSE STYLE: -- Vary your replies. NEVER repeat a previous reply verbatim. If the user gives - similar input twice (e.g., mentions feeling overwhelmed twice), respond - differently — pick one specific thread from the new input to dig into. -- Avoid canned multi-option menus like "1. Show your calendar 2. List your - tasks 3. ...". They sound like a help-desk bot. Instead, pick a single - specific follow-up question or observation tied to what the user just said. -- Acknowledge what is NEW in the user's latest message — don't restart from - scratch each turn. -- Match the user's energy. Short replies for short messages; deeper engagement - for longer ones. Don't pad short replies into paragraphs. """ PHASE_GREETINGS = { diff --git a/src/fabledassistant/services/journal_prep.py b/src/fabledassistant/services/journal_prep.py index 27a0335..388a5a3 100644 --- a/src/fabledassistant/services/journal_prep.py +++ b/src/fabledassistant/services/journal_prep.py @@ -1,22 +1,21 @@ """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: +new day). -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. +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. -The structured data is preserved on ``Message.msg_metadata.sections`` for -provenance and future tooling, but is NOT visually surfaced as a card. +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. Message shape: role: 'assistant' - content: + content: msg_metadata: { kind: 'daily_prep', sections: { ...raw data... } } """ from __future__ import annotations @@ -26,13 +25,11 @@ 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__) @@ -148,156 +145,38 @@ 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 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"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}, - ] +def _phase_for_now(user_timezone: str) -> str: + """Return the time-of-day phase label for the user's local moment. + 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: - prose = await generate_completion( - messages=messages, - model=model, - max_tokens=400, - ) + from zoneinfo import ZoneInfo + tz = ZoneInfo(user_timezone) except Exception: - logger.exception("Daily prep prose generation failed for day %s", day_date) - return _fallback_prep_text(day_date) + 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" - 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 + +_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"]) async def ensure_daily_prep_message( @@ -357,9 +236,11 @@ async def ensure_daily_prep_message( 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 - ) + # 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)) new_metadata = {"kind": "daily_prep", "sections": sections} if existing_prep: