feat: rewrite briefing pipeline to conversational prose

Replace two-pass structured LLM synthesis (## Your Day / ## The World
sections with bullets and formatted news cards) with a single
conversational pass. The new prompt instructs the model to write 3-5
flowing sentences covering weather, today's tasks/events, and 1-2 news
highlights — no markdown, no headers, no lists. Full news detail stays
in the right panel; weather detail stays in the weather card.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-30 13:37:47 -04:00
parent 2a8c0cfa56
commit 9f3b9e45c6
+66 -103
View File
@@ -7,7 +7,7 @@ Slot names: 'compilation' (4am), 'morning' (8am), 'midday' (12pm), 'afternoon' (
import asyncio import asyncio
import hashlib import hashlib
import logging import logging
from datetime import date, datetime, timezone from datetime import datetime, timezone
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
import httpx import httpx
@@ -22,14 +22,6 @@ logger = logging.getLogger(__name__)
SLOT_NAMES = ("compilation", "morning", "midday", "afternoon") SLOT_NAMES = ("compilation", "morning", "midday", "afternoon")
def slot_greeting(slot: str) -> str:
return {
"compilation": "Good morning",
"morning": "Good morning — you're at the office",
"midday": "Midday check-in",
"afternoon": "End of day wrap-up",
}.get(slot, "Update")
def format_task(task: dict) -> str: def format_task(task: dict) -> str:
parts = [task.get("title", "Untitled")] parts = [task.get("title", "Untitled")]
@@ -263,57 +255,69 @@ async def _llm_synthesise(system_prompt: str, user_prompt: str, model: str) -> s
return "" return ""
def _internal_system_prompt(profile_body: str) -> str: def _unified_system_prompt(profile_body: str) -> str:
return ( return (
"You are a personal briefing assistant. Your job is to give the user a clear, " "You are a personal assistant delivering a daily briefing. "
"concise summary of their internal workload: tasks, calendar, and projects. " "Speak naturally and conversationally — as if talking to the user, not writing a report. "
"Be direct and prioritised — lead with what's urgent. Use plain text with light " "Use no markdown: no headers, no bullet points, no bold, no lists. Write in flowing prose. "
"markdown. Do not include weather or news.\n\n" "Weave together what matters today: mention the weather in a sentence, note any calendar "
"events or tasks due today, and briefly reference one or two noteworthy news stories. "
"Only mention projects if a task from one is specifically due today. "
"Be warm, concise, and human — aim for 3 to 5 sentences. "
"Future context like emails and messages will be added over time — keep the tone open and helpful.\n\n"
+ (f"User profile:\n{profile_body}\n" if profile_body else "") + (f"User profile:\n{profile_body}\n" if profile_body else "")
) )
def _external_system_prompt() -> str: def _unified_user_prompt(internal_data: dict, external_data: dict, slot: str, temp_unit: str = "C") -> str:
return ( lines = [f"Date: {internal_data['date']}", f"Slot: {slot}", ""]
"You are a briefing assistant for external information. Your job is to present "
"selected news items and summarise any remaining RSS content. "
"IMPORTANT: Weather is handled separately — do NOT include any weather section.\n\n"
"Format each news item EXACTLY as:\n"
"**[Headline text](source_url)**\n"
"*Outlet Name · Day Month*\n"
"One or two sentence summary.\n\n"
"Present news items in the EXACT ORDER they are provided. Do not reorder them. "
"After the news cards, add a brief paragraph for any remaining context."
)
# Weather (brief — card handles detail)
weather = external_data.get("weather") or []
if weather:
loc = weather[0]
days = loc.get("days") or []
if days:
d = days[0]
t_min = _format_temp(d["temp_min"], temp_unit)
t_max = _format_temp(d["temp_max"], temp_unit)
unit_sym = f"°{temp_unit}"
lines.append(
f"WEATHER: {loc['location_label']}{d['description']}, "
f"{t_min}{t_max}{unit_sym}"
)
lines.append("")
def _internal_user_prompt(data: dict, slot: str) -> str: # Today's calendar events
lines = [f"Briefing slot: {slot}", f"Date: {data['date']}", ""] if internal_data.get("calendar_events"):
if data.get("unchanged_task_count", 0) > 0: lines.append("TODAY'S EVENTS:")
lines.append( lines.extend(f" - {e}" for e in internal_data["calendar_events"])
f"({data['unchanged_task_count']} tasks are unchanged since the last briefing "
"— acknowledge briefly, do not list them.)"
)
lines.append("") lines.append("")
changed = data.get("changed_tasks") or data.get("overdue_tasks", [])
if changed: # Tasks due today
lines.append(f"CHANGED/NEW TASKS ({len(changed)}):") if internal_data.get("due_today"):
lines.extend(f" - {t}" for t in changed) lines.append("DUE TODAY:")
lines.extend(f" - {t}" for t in internal_data["due_today"])
lines.append("") lines.append("")
if data.get("due_today"):
lines.append(f"DUE TODAY ({len(data['due_today'])}):") # Overdue tasks (brief mention only)
lines.extend(f" - {t}" for t in data["due_today"]) if internal_data.get("overdue_tasks"):
overdue = internal_data["overdue_tasks"]
lines.append(f"OVERDUE ({len(overdue)} task{'s' if len(overdue) != 1 else ''}):")
lines.extend(f" - {t}" for t in overdue[:3])
if len(overdue) > 3:
lines.append(f" (and {len(overdue) - 3} more)")
lines.append("") lines.append("")
if data.get("high_priority"):
lines.append("HIGH PRIORITY (in progress):") # News highlights (top 3 — right panel shows full list)
lines.extend(f" - {t}" for t in data["high_priority"]) rss = external_data.get("rss_items") or []
if rss:
lines.append("NEWS HIGHLIGHTS (mention 1-2 briefly, the full list is shown separately):")
for item in rss[:3]:
source = item.get("feed_title") or item.get("source") or "News"
lines.append(f" [{source}] {item.get('title', '')}")
lines.append("") lines.append("")
if data["calendar_events"]:
lines.append("CALENDAR TODAY:")
lines.extend(f" - {e}" for e in data["calendar_events"])
lines.append("")
if data["active_projects"]:
lines.append(f"ACTIVE PROJECTS: {', '.join(data['active_projects'])}")
return "\n".join(lines) return "\n".join(lines)
@@ -324,31 +328,6 @@ def _format_temp(value: float, unit: str) -> str:
return f"{value:.0f}" return f"{value:.0f}"
def _external_user_prompt(data: dict, slot: str, temp_unit: str = "C") -> str:
unit_sym = f"°{temp_unit}"
lines = [f"Briefing slot: {slot}", ""]
if data["weather"]:
lines.append("WEATHER:")
for loc in data["weather"]:
lines.append(f" {loc['location_label']}:")
for day in loc["days"][:3]:
t_min = _format_temp(day["temp_min"], temp_unit)
t_max = _format_temp(day["temp_max"], temp_unit)
lines.append(
f" {day['date']}: {day['description']}, "
f"{t_min}{t_max}{unit_sym}, {day['precip_mm']}mm rain"
)
if loc["changes_since_last_fetch"]:
lines.append(" FORECAST CHANGES:")
lines.extend(f" - {c}" for c in loc["changes_since_last_fetch"])
lines.append("")
if data["rss_items"]:
lines.append(f"RSS DIGEST ({len(data['rss_items'])} items):")
for item in data["rss_items"][:15]:
lines.append(f" [{item.get('feed_title', 'Feed')}] {item['title']}")
if item.get("content"):
lines.append(f" {item['content'][:200]}")
return "\n".join(lines)
# ── Main entry point ─────────────────────────────────────────────────────────── # ── Main entry point ───────────────────────────────────────────────────────────
@@ -434,7 +413,6 @@ async def run_compilation(
# ── LLM Synthesis ────────────────────────────────────────────────────────── # ── LLM Synthesis ──────────────────────────────────────────────────────────
# Build filtered internal data with only changed tasks # Build filtered internal data with only changed tasks
today = internal_data["date"]
internal_data_filtered = dict(internal_data) internal_data_filtered = dict(internal_data)
internal_data_filtered["unchanged_task_count"] = unchanged_count internal_data_filtered["unchanged_task_count"] = unchanged_count
internal_data_filtered["changed_tasks"] = [format_task(t) for t in changed_tasks] internal_data_filtered["changed_tasks"] = [format_task(t) for t in changed_tasks]
@@ -445,17 +423,10 @@ async def run_compilation(
"weather": [], "weather": [],
} }
internal_text, external_text = await asyncio.gather( briefing_text = await _llm_synthesise(
_llm_synthesise( _unified_system_prompt(profile_body),
_internal_system_prompt(profile_body), _unified_user_prompt(internal_data_filtered, external_data_filtered, slot, temp_unit),
_internal_user_prompt(internal_data_filtered, slot), model,
model,
),
_llm_synthesise(
_external_system_prompt(),
_external_user_prompt(external_data_filtered, slot, temp_unit),
model,
),
) )
# ── Post-processing ───────────────────────────────────────────────────────── # ── Post-processing ─────────────────────────────────────────────────────────
@@ -463,18 +434,11 @@ async def run_compilation(
metadata: dict = {"rss_item_ids": rss_item_ids, "rss_items": rss_items_meta, "weather": weather_card} metadata: dict = {"rss_item_ids": rss_item_ids, "rss_items": rss_items_meta, "weather": weather_card}
if not internal_text and not external_text: if not briefing_text:
logger.warning("Briefing compilation produced no content for user %d slot %s", user_id, slot) logger.warning("Briefing compilation produced no content for user %d slot %s", user_id, slot)
return "", metadata return "", metadata
greeting = slot_greeting(slot) return briefing_text, metadata
parts = [f"**{greeting}{today}**", ""]
if internal_text:
parts += ["## Your Day", "", internal_text, ""]
if external_text:
parts += ["## The World", "", external_text]
return "\n".join(parts).strip(), metadata
async def run_slot_injection(user_id: int, slot: str, model: str | None = None) -> str: async def run_slot_injection(user_id: int, slot: str, model: str | None = None) -> str:
@@ -492,13 +456,12 @@ async def run_slot_injection(user_id: int, slot: str, model: str | None = None)
) )
system = ( system = (
f"You are a briefing assistant providing a {slot} update. Be brief — " f"You are a personal assistant giving a brief {slot} check-in. "
"the user has already seen the morning briefing. Focus on what's changed or new." "The user already had their morning briefing — focus only on what's changed or newly relevant. "
"Speak naturally in 2-3 sentences, no markdown formatting, no headers or bullet points."
) )
user_prompt = ( return await _llm_synthesise(
f"Slot: {slot}\n\n" system,
+ _internal_user_prompt(internal_data, slot) _unified_user_prompt(internal_data, external_data, slot, temp_unit),
+ "\n\n" model,
+ _external_user_prompt(external_data, slot, temp_unit)
) )
return await _llm_synthesise(system, user_prompt, model)