"""System prompt + ambient context injection for journal conversations. Distinct from the chat pipeline (services/llm.py) in three ways: 1. New persona: warm, curious listener; not a task manager. 2. Calibration rules: ask before structural changes; record_moment freely. 3. Auto-injects last ~48h of moments for ambient continuity (no notes-RAG). Notes-RAG auto-injection MUST be disabled for journal sessions — the journal's ambient context replaces it. Failing to disable would let notes leak into journal sessions, violating the design's isolation invariant. """ from __future__ import annotations import datetime from fabledassistant.services.journal_search import search_journal from fabledassistant.services.user_profile import build_profile_context JOURNAL_PERSONA = ( "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 = """\ JOURNAL-SPECIFIC TOOL GUIDANCE: 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. MOMENTS — recording them is your primary job, not a "nice to have." After every substantive user message, BEFORE you compose your reply, check: did the user describe ANY of these? - An event that happened ("I went grocery shopping") - An encounter with a person ("had coffee with Sarah") - A decision ("I'm going to switch jobs") - An observation about themselves or the world ("the new place is loud") - A plan or commitment ("watching a show with Victoria tonight") - A feeling or state ("I'm tired", "feeling decompressed") - A small accomplishment or change they made ("installed the new AP") If the answer is YES to ANY of those — CALL record_moment FIRST, before composing your reply. This is not optional. The journal exists to capture these beats; if you skip the call, the beat is lost. Multiple distinct beats in one message → multiple record_moment calls, one per beat. WHEN LINKING ENTITIES: 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 an exact ID returned from another tool call in this same turn. Never invent IDs. The ONLY messages where you skip record_moment are purely meta-conversational ones — about the journal itself or about a prior tool result ("thanks", "no priority needed", "can you also add X to that one", "I meant tasks not notes"). Those aren't journal beats; they're chat about the chat. STATE-CHANGING TOOLS — use the confirmation flow. - update_task / update_note that change state (status, completion, deletion) 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.) don't need confirmation. OTHER: - Do NOT call set_rag_scope. The journal scope is implicit. - 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 = { "morning": "Morning. What's the day looking like for you?", "midday": "How's it going so far?", "evening": "Wrapping up — how'd the day shake out?", } def determine_phase( *, now_local: datetime.datetime, day_rollover_hour: int = 4, morning_end_hour: int = 12, midday_end_hour: int = 18, ) -> str: """Return 'morning' | 'midday' | 'evening' for a local datetime.""" h = now_local.hour if h < day_rollover_hour: return "evening" if h < morning_end_hour: return "morning" if h < midday_end_hour: return "midday" return "evening" def phase_greeting(phase: str) -> str: return PHASE_GREETINGS.get(phase, PHASE_GREETINGS["morning"]) async def build_journal_system_prompt( *, user_id: int, day_date: datetime.date, user_timezone: str, ) -> str: """Static-then-dynamic system prompt. Static prefix (persona + calibration) is identical on every request, preserving Ollama KV cache. Dynamic suffix changes per-day. """ static_block = f"{JOURNAL_PERSONA}\n\n{JOURNAL_CALIBRATION}" today_iso = day_date.isoformat() # Include the day-of-week explicitly. LLMs are unreliable at deriving # weekday names from ISO dates, which causes "this Friday" / "next # Monday" to land on the wrong calendar day. weekday = day_date.strftime("%A") tz_block = f"Today is {weekday}, {today_iso} ({user_timezone})." profile_context = await build_profile_context(user_id) profile_section = f"\n\n{profile_context}" if profile_context else "" ambient = await _ambient_moments_block(user_id=user_id, day_date=day_date) ambient_section = ( f"\n\nRECENT JOURNAL CONTEXT (last 48h):\n{ambient}" if ambient else "" ) return f"{static_block}\n\n{tz_block}{profile_section}{ambient_section}" async def _ambient_moments_block( *, user_id: int, day_date: datetime.date ) -> str: """Render last 48h of moments as a compact text block. Capped at 20 moments / 1500 chars total. Distinct from RAG retrieval. """ moments = await search_journal( user_id=user_id, date_from=day_date - datetime.timedelta(days=2), date_to=day_date, limit=20, ) if not moments: return "" lines: list[str] = [] total_chars = 0 for m in moments: line = f"- [{m['occurred_at']}] {m['content']}" if total_chars + len(line) > 1500: break lines.append(line) total_chars += len(line) return "\n".join(lines)