fix(journal): minimal prep + quieter persona
The prep was generating a multi-sentence recap of tasks/events/weather/
projects/recent moments via an LLM call. Per user direction, that's
redundant — the right-side widgets already show today's data — and the
verbosity made the journal feel chatty when the user wanted quiet.
Replaces the prep prose generator with a single phase-aware check-in
question (drawn from a static map: morning="How are you starting the
day?", midday="How's it going so far?", evening="How did the day shake
out?"). No LLM call. The structured `sections` are still gathered and
persisted on msg_metadata for provenance and possible future tooling
(e.g., search), they just don't render in the prep message.
Also pulls the journal persona way back. The prior framing pushed the
model toward stock therapy-template patterns ("I'm sorry you're feeling…"
+ numbered option lists). The new persona is "you are the user's journal —
listen, be quiet, stay out of the way." RESPONSE STYLE rules now lead the
calibration block and explicitly forbid:
- apologizing for the user's feelings
- offering to help / pitching tools
- multi-option menus
- verbatim repetition of prior replies
- padding short replies into paragraphs
Most replies should be one short sentence. Sometimes the right reply is
"Got it" + a record_moment tool call.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -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 = {
|
||||
|
||||
@@ -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: <prose opener>
|
||||
content: <single phase greeting line>
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user