Files
FabledScribe/src/fabledassistant/services/journal_prep.py
T
bvandeusen 590a07bc13 fix(journal): tighten prep prompt — direct briefing, not flowery letter
Previous system prompt asked for "warm, conversational, like a friend
writing a letter" which produced flowery preludes that buried the actual
data. Rewritten to:

- Lead with practical data (tasks, events, weather) — concrete and specific
- 4-7 sentences total, tight prose, no padding
- Recent moments / open threads mentioned briefly at the END as context,
  not as the lead
- Voice: "competent assistant briefing the user" not "friend writing a letter"
- Close with a short journal invitation under 8 words

Also dropped max_tokens 600 -> 400 to bias toward concision.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-26 16:10:57 -04:00

386 lines
14 KiB
Python

"""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:
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 structured data is preserved on ``Message.msg_metadata.sections`` for
provenance and future tooling, but is NOT visually surfaced as a card.
Message shape:
role: 'assistant'
content: <prose opener>
msg_metadata: { kind: 'daily_prep', sections: { ...raw data... } }
"""
from __future__ import annotations
import datetime
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__)
async def gather_daily_sections(
*,
user_id: int,
day_date: datetime.date,
user_timezone: str,
) -> dict:
"""Gather all daily-prep sections and return them as a dict.
Pure data fetching — no LLM call. Each section degrades to an empty
list/dict on failure so the caller always gets a complete shape.
"""
sections: dict = {}
try:
tasks_today, _ = await list_notes(
user_id=user_id,
is_task=True,
status=["todo", "in_progress"],
due_before=day_date,
limit=20,
sort="due_date",
order="asc",
)
sections["tasks"] = [
{
"id": t.id,
"title": t.title,
"status": t.status,
"priority": t.priority,
"due_date": t.due_date.isoformat() if t.due_date else None,
}
for t in tasks_today
]
except Exception:
logger.exception("daily_prep tasks section failed for user %d", user_id)
sections["tasks"] = []
try:
day_start = datetime.datetime.combine(day_date, datetime.time.min)
day_end = datetime.datetime.combine(day_date, datetime.time.max)
sections["events"] = await list_events(
user_id=user_id,
date_from=day_start,
date_to=day_end,
)
except Exception:
logger.exception("daily_prep events section failed for user %d", user_id)
sections["events"] = []
try:
weather_rows = await get_cached_weather_rows(user_id)
sections["weather"] = [w.to_dict() for w in weather_rows]
except Exception:
logger.exception("daily_prep weather section failed for user %d", user_id)
sections["weather"] = []
try:
projects = await list_projects(user_id=user_id, status="active")
sections["projects"] = [
{
"id": p.id,
"title": p.title,
"auto_summary": p.auto_summary,
}
for p in projects[:5]
]
except Exception:
logger.exception("daily_prep projects section failed for user %d", user_id)
sections["projects"] = []
try:
sections["recent_moments"] = await search_journal(
user_id=user_id,
date_from=day_date - datetime.timedelta(days=3),
date_to=day_date - datetime.timedelta(days=1),
limit=10,
)
except Exception:
logger.exception("daily_prep recent_moments section failed for user %d", user_id)
sections["recent_moments"] = []
try:
sections["open_threads"] = await _open_threads(user_id=user_id, day_date=day_date)
except Exception:
logger.exception("daily_prep open_threads section failed for user %d", user_id)
sections["open_threads"] = []
return sections
async def _open_threads(*, user_id: int, day_date: datetime.date) -> list[dict]:
"""Heuristic: moments from the last 7 days that look unresolved.
Treated as 'unresolved' when they have no linked tasks/notes and aren't
pinned. Starting heuristic — refine empirically.
"""
candidates = await search_journal(
user_id=user_id,
date_from=day_date - datetime.timedelta(days=7),
date_to=day_date - datetime.timedelta(days=1),
limit=50,
)
return [
m for m in candidates
if not m.get("task_ids")
and not m.get("note_ids")
and not m.get("pinned")
]
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},
]
try:
prose = await generate_completion(
messages=messages,
model=model,
max_tokens=400,
)
except Exception:
logger.exception("Daily prep prose generation failed for day %s", day_date)
return _fallback_prep_text(day_date)
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(
*,
user_id: int,
day_date: datetime.date,
user_timezone: str,
force: bool = False,
) -> Message:
"""Get or create today's journal Conversation, then ensure the prep message exists.
The prep message is an *assistant* role message containing the prose opener,
with the structured sections preserved on ``msg_metadata``. If a legacy
system-role prep exists from an earlier version, it gets upgraded in place
on the next call.
With ``force=True`` the prose is regenerated even when a prep already exists.
Used by the manual /api/journal/trigger-prep endpoint.
"""
async with async_session() as session:
result = await session.execute(
select(Conversation).where(
Conversation.user_id == user_id,
Conversation.conversation_type == "journal",
Conversation.day_date == day_date,
)
)
conv = result.scalar_one_or_none()
if conv is None:
conv = Conversation(
user_id=user_id,
conversation_type="journal",
day_date=day_date,
title=day_date.isoformat(),
)
session.add(conv)
await session.flush()
# Find any existing prep (system or assistant role from any version).
prep_stmt = select(Message).where(Message.conversation_id == conv.id)
existing_prep = None
for msg in (await session.execute(prep_stmt)).scalars():
if msg.msg_metadata and msg.msg_metadata.get("kind") == "daily_prep":
existing_prep = msg
break
# If we already have an assistant-role prep with prose content and the
# caller didn't ask to force regeneration, we're done.
if (
existing_prep
and not force
and existing_prep.role == "assistant"
and (existing_prep.content or "").strip()
):
return existing_prep
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
)
new_metadata = {"kind": "daily_prep", "sections": sections}
if existing_prep:
# Upgrade in place: bump role, replace content + metadata.
existing_prep.role = "assistant"
existing_prep.content = prose
existing_prep.msg_metadata = new_metadata
await session.commit()
return existing_prep
prep_msg = Message(
conversation_id=conv.id,
role="assistant",
content=prose,
msg_metadata=new_metadata,
)
session.add(prep_msg)
await session.commit()
return prep_msg
# Backwards-compat alias — older imports may use the old name.
generate_daily_prep = gather_daily_sections