fix(journal): restore prep prose; soften persona toward chat-like

Per user clarification: previous over-rotation dropped the LLM-generated
prep prose entirely (just a phase greeting) and made the chat persona
extremely sparse ("you are a place where words go down"). User actually
wanted only the chat replies pulled back, NOT the prep dropped, and the
chat to behave largely like normal /chat — asking follow-ups and
verifying earlier details.

services/journal_prep.py — restored:
- _render_sections_for_prompt
- _PREP_SYSTEM_PROMPT (the direct, briefing-style prompt from 590a07b)
- _generate_prep_prose
- _fallback_prep_text
- ensure_daily_prep_message now calls _generate_prep_prose again
- removed _phase_for_now / _phase_prompt helpers (no longer needed)

services/journal_pipeline.py — persona rewritten:
- Old: "You are the user's journal. Be quiet. Listen. You are not helpful."
- New: "You are the user's assistant. Behave like the rest of the app's
  chat: respond conversationally, ask follow-up questions, verify details
  from earlier turns, use tools naturally."
- Calibration block reorganized: PEOPLE/PLACES (ask first), MOMENTS
  (silent + use *_names), STATE-CHANGING TOOLS (confirmation flow),
  OTHER, RESPONSE STYLE.
- RESPONSE STYLE keeps the no-apologizing / no-option-menus /
  no-verbatim-repetition / match-user-length rules but drops the "be
  quiet, one short sentence" framing.

Net behavior:
- Open journal → LLM-generated prep prose with today's tasks/events/weather
- Reply → assistant responds conversationally like /chat, asks follow-ups,
  verifies details, uses tools
- Background: silently records moments via *_names, asks before creating
  new people/places

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-26 18:04:34 -04:00
parent 4668c0950b
commit 0ed9cbf666
2 changed files with 202 additions and 93 deletions
@@ -17,68 +17,58 @@ from fabledassistant.services.journal_search import search_journal
from fabledassistant.services.user_profile import build_profile_context from fabledassistant.services.user_profile import build_profile_context
JOURNAL_PERSONA = ( JOURNAL_PERSONA = (
"You are the user's journal. They are setting things down with you. Be quiet. " "You are the user's assistant. They've opened their journal — a day-anchored "
"Listen. You are NOT a customer-service bot. You are NOT a therapist. You are " "conversation surface where they record their day. Behave like the rest of "
"NOT a task manager. You are NOT helpful in the chatbot sense — you don't " "the app's chat: respond conversationally, ask follow-up questions about what "
"offer to do things, you don't apologize for the user's feelings, you don't " "they just said, verify details from earlier in the conversation when "
"try to reframe what they said. You are a place where words go down." "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_CALIBRATION = """\
CALIBRATION (read carefully — these rules are not optional): JOURNAL-SPECIFIC TOOL GUIDANCE:
RESPONSE STYLE — this is the single most important rule: PEOPLE / PLACES — ask before creating new entries.
- Most replies are ONE short sentence. A brief acknowledgement, or a single - If the user mentions a name you don't already know about, ASK them in plain
specific follow-up question. Sometimes silence-equivalent: just record_moment language ("Who's Sarah to you?") and WAIT for the reply before calling
and reply with nothing more than "Got it." or a single open question. save_person or save_place.
- NEVER apologize ("I'm sorry you're feeling…"). Don't reframe the user's - For ambiguous references (multiple matches in their existing people/places),
feelings. Don't validate. Don't reassure. ask which one. Never guess.
- NEVER offer to do things ("Would you like me to…", "I can help by…", - For unambiguous references to people they've already established, no need
"Let me…"). The user knows what tools exist. Don't pitch. to ask — proceed normally.
- 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. MOMENTS — record silently.
- BEFORE you call save_person or save_place for someone the user just mentioned, - Use record_moment freely for meaningful beats (events, encounters, decisions,
ASK them in plain language. Example: user says "I had coffee with Sarah." You observations, feelings the user shares). No confirmation needed. Moments are
reply "Who's Sarah to you?" — WAIT for the user's reply, THEN call save_person. cheap and user-correctable later.
- If the user later confirms with relationship info, only then call the - When linking entities to a moment, use the *_names parameters
save_person/save_place tool. (person_names, place_names, task_titles, note_titles) — server resolves
- If a name is ambiguous (multiple matches in their existing people), ask which them to IDs by lookup. Do NOT pass *_ids unless you have the exact ID
one. Never guess. returned from another tool call in this same turn. Never invent IDs.
- If the user clearly references a person/place they've already established (no
ambiguity), proceed silently — no need to ask again.
TASK / NOTE STATE CHANGES — confirmation pattern. STATE-CHANGING TOOLS — use the confirmation flow.
- update_task / update_note that change state (status, completion, deletion) - update_task / update_note that change state (status, completion, deletion)
use the confirmation flow: pass `confirmed=false` first. The frontend renders follow the standard confirmation pattern: pass `confirmed=false` first; the
an inline confirm UI. After the user clicks confirm, call again with frontend shows a confirm UI; call again with `confirmed=true` after the
`confirmed=true`. NEVER pass `confirmed=true` on the first call for these user confirms.
destructive/structural updates.
- Pure-read tools (list_tasks, search_notes, search_journal, get_weather, etc.) - Pure-read tools (list_tasks, search_notes, search_journal, get_weather, etc.)
are free — no confirmation needed. don't need confirmation.
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.
OTHER: OTHER:
- Do NOT call set_rag_scope. The journal scope is implicit. - 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. 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 = { PHASE_GREETINGS = {
+162 -43
View File
@@ -1,21 +1,22 @@
"""Daily prep generator for the Journal. """Daily prep generator for the Journal.
Runs once per day per user (scheduled, or lazy on first journal-open of a 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 1. Gather structured data (tasks/events/weather/projects/recent moments/
widgets (weather, upcoming events) already surface today's data; the prep open threads) — deterministic, no LLM call.
doesn't repeat it. Just opens the day with a plain prompt the user can 2. Hand the structured data to the LLM and ask it for a direct, informative
respond to. Phase-aware (morning / midday / evening) so it matches when conversational opener — flowing prose, briefing-style. Result is persisted
the user actually opens the journal. 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`` The structured data is preserved on ``Message.msg_metadata.sections`` for
for provenance and possible future tooling (search, analysis), but the provenance and future tooling.
prep MESSAGE the user sees is just the phase greeting.
Message shape: Message shape:
role: 'assistant' role: 'assistant'
content: <single phase greeting line> content: <prose opener>
msg_metadata: { kind: 'daily_prep', sections: { ...raw data... } } msg_metadata: { kind: 'daily_prep', sections: { ...raw data... } }
""" """
from __future__ import annotations from __future__ import annotations
@@ -25,11 +26,13 @@ import logging
from sqlalchemy import select from sqlalchemy import select
from fabledassistant.config import Config
from fabledassistant.models import Conversation, Message, async_session from fabledassistant.models import Conversation, Message, async_session
from fabledassistant.services.events import list_events from fabledassistant.services.events import list_events
from fabledassistant.services.journal_search import search_journal from fabledassistant.services.journal_search import search_journal
from fabledassistant.services.notes import list_notes from fabledassistant.services.notes import list_notes
from fabledassistant.services.projects import list_projects from fabledassistant.services.projects import list_projects
from fabledassistant.services.settings import get_setting
from fabledassistant.services.weather import get_cached_weather_rows from fabledassistant.services.weather import get_cached_weather_rows
logger = logging.getLogger(__name__) 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: def _render_sections_for_prompt(sections: dict) -> str:
"""Return the time-of-day phase label for the user's local moment. """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: try:
from zoneinfo import ZoneInfo prose = await generate_completion(
tz = ZoneInfo(user_timezone) messages=messages,
model=model,
max_tokens=400,
)
except Exception: except Exception:
from zoneinfo import ZoneInfo logger.exception("Daily prep prose generation failed for day %s", day_date)
tz = ZoneInfo("UTC") return _fallback_prep_text(day_date)
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()
_PHASE_PROMPTS = { if not prose:
"morning": "How are you starting the day?", logger.warning("LLM returned empty prep prose for day %s — using fallback", day_date)
"midday": "How's it going so far?", return _fallback_prep_text(day_date)
"evening": "How did the day shake out?", return prose
}
def _phase_prompt(phase: str) -> str:
return _PHASE_PROMPTS.get(phase, _PHASE_PROMPTS["morning"])
async def ensure_daily_prep_message( async def ensure_daily_prep_message(
@@ -236,11 +357,9 @@ async def ensure_daily_prep_message(
sections = await gather_daily_sections( sections = await gather_daily_sections(
user_id=user_id, day_date=day_date, user_timezone=user_timezone user_id=user_id, day_date=day_date, user_timezone=user_timezone
) )
# Prep prose is intentionally minimal — a single phase-aware check-in prose = await _generate_prep_prose(
# question. The right-side widgets surface tasks/events/weather; the sections=sections, day_date=day_date, user_id=user_id
# 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} new_metadata = {"kind": "daily_prep", "sections": sections}
if existing_prep: if existing_prep: