diff --git a/src/fabledassistant/services/briefing_pipeline.py b/src/fabledassistant/services/briefing_pipeline.py index ebc3526..b0cb905 100644 --- a/src/fabledassistant/services/briefing_pipeline.py +++ b/src/fabledassistant/services/briefing_pipeline.py @@ -7,7 +7,7 @@ Slot names: 'compilation' (4am), 'morning' (8am), 'midday' (12pm), 'afternoon' ( import asyncio import hashlib import logging -from datetime import date, datetime, timezone +from datetime import datetime, timezone from zoneinfo import ZoneInfo, ZoneInfoNotFoundError import httpx @@ -22,14 +22,6 @@ logger = logging.getLogger(__name__) 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: parts = [task.get("title", "Untitled")] @@ -263,57 +255,69 @@ async def _llm_synthesise(system_prompt: str, user_prompt: str, model: str) -> s return "" -def _internal_system_prompt(profile_body: str) -> str: +def _unified_system_prompt(profile_body: str) -> str: return ( - "You are a personal briefing assistant. Your job is to give the user a clear, " - "concise summary of their internal workload: tasks, calendar, and projects. " - "Be direct and prioritised — lead with what's urgent. Use plain text with light " - "markdown. Do not include weather or news.\n\n" + "You are a personal assistant delivering a daily briefing. " + "Speak naturally and conversationally — as if talking to the user, not writing a report. " + "Use no markdown: no headers, no bullet points, no bold, no lists. Write in flowing prose. " + "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 "") ) -def _external_system_prompt() -> str: - return ( - "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." - ) +def _unified_user_prompt(internal_data: dict, external_data: dict, slot: str, temp_unit: str = "C") -> str: + lines = [f"Date: {internal_data['date']}", f"Slot: {slot}", ""] + # 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: - lines = [f"Briefing slot: {slot}", f"Date: {data['date']}", ""] - if data.get("unchanged_task_count", 0) > 0: - lines.append( - f"({data['unchanged_task_count']} tasks are unchanged since the last briefing " - "— acknowledge briefly, do not list them.)" - ) + # Today's calendar events + if internal_data.get("calendar_events"): + lines.append("TODAY'S EVENTS:") + lines.extend(f" - {e}" for e in internal_data["calendar_events"]) lines.append("") - changed = data.get("changed_tasks") or data.get("overdue_tasks", []) - if changed: - lines.append(f"CHANGED/NEW TASKS ({len(changed)}):") - lines.extend(f" - {t}" for t in changed) + + # Tasks due today + if internal_data.get("due_today"): + lines.append("DUE TODAY:") + lines.extend(f" - {t}" for t in internal_data["due_today"]) lines.append("") - if data.get("due_today"): - lines.append(f"DUE TODAY ({len(data['due_today'])}):") - lines.extend(f" - {t}" for t in data["due_today"]) + + # Overdue tasks (brief mention only) + 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("") - if data.get("high_priority"): - lines.append("HIGH PRIORITY (in progress):") - lines.extend(f" - {t}" for t in data["high_priority"]) + + # News highlights (top 3 — right panel shows full list) + 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("") - 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) @@ -324,31 +328,6 @@ def _format_temp(value: float, unit: str) -> str: 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 ─────────────────────────────────────────────────────────── @@ -434,7 +413,6 @@ async def run_compilation( # ── LLM Synthesis ────────────────────────────────────────────────────────── # Build filtered internal data with only changed tasks - today = internal_data["date"] internal_data_filtered = dict(internal_data) internal_data_filtered["unchanged_task_count"] = unchanged_count internal_data_filtered["changed_tasks"] = [format_task(t) for t in changed_tasks] @@ -445,17 +423,10 @@ async def run_compilation( "weather": [], } - internal_text, external_text = await asyncio.gather( - _llm_synthesise( - _internal_system_prompt(profile_body), - _internal_user_prompt(internal_data_filtered, slot), - model, - ), - _llm_synthesise( - _external_system_prompt(), - _external_user_prompt(external_data_filtered, slot, temp_unit), - model, - ), + briefing_text = await _llm_synthesise( + _unified_system_prompt(profile_body), + _unified_user_prompt(internal_data_filtered, external_data_filtered, slot, temp_unit), + model, ) # ── 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} - 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) return "", metadata - greeting = slot_greeting(slot) - 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 + return briefing_text, metadata 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 = ( - f"You are a briefing assistant providing a {slot} update. Be brief — " - "the user has already seen the morning briefing. Focus on what's changed or new." + f"You are a personal assistant giving a brief {slot} check-in. " + "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 = ( - f"Slot: {slot}\n\n" - + _internal_user_prompt(internal_data, slot) - + "\n\n" - + _external_user_prompt(external_data, slot, temp_unit) + return await _llm_synthesise( + system, + _unified_user_prompt(internal_data, external_data, slot, temp_unit), + model, ) - return await _llm_synthesise(system, user_prompt, model)