diff --git a/src/fabledassistant/routes/briefing.py b/src/fabledassistant/routes/briefing.py index 266f080..7aa38a6 100644 --- a/src/fabledassistant/routes/briefing.py +++ b/src/fabledassistant/routes/briefing.py @@ -232,6 +232,6 @@ async def manual_trigger(): model = await get_setting(g.user.id, "default_model", "") conv = await get_or_create_today_conversation(g.user.id, model) - text = await run_compilation(g.user.id, slot, model) - msg = await post_message(conv.id, "assistant", text) + text, metadata = await run_compilation(g.user.id, slot, model) + msg = await post_message(conv.id, "assistant", text, metadata=metadata) return jsonify({"conversation_id": conv.id, "message_id": msg.id, "slot": slot}) diff --git a/src/fabledassistant/services/briefing_pipeline.py b/src/fabledassistant/services/briefing_pipeline.py index 3306327..ffcdb7a 100644 --- a/src/fabledassistant/services/briefing_pipeline.py +++ b/src/fabledassistant/services/briefing_pipeline.py @@ -248,24 +248,36 @@ def _internal_system_prompt(profile_body: str) -> str: def _external_system_prompt() -> str: return ( - "You are a briefing assistant for external information. Your job is to summarise " - "the user's RSS feed digest and weather forecast into a concise, engaging update. " - "Group related news items. Note any significant weather changes. " - "Be informative but brief. Do not discuss tasks, calendar, or work items." + "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 _internal_user_prompt(data: dict, slot: str) -> str: lines = [f"Briefing slot: {slot}", f"Date: {data['date']}", ""] - if data["overdue_tasks"]: - lines.append(f"OVERDUE ({len(data['overdue_tasks'])}):") - lines.extend(f" - {t}" for t in data["overdue_tasks"]) + 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.)" + ) lines.append("") - if data["due_today"]: + 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) + 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"]) lines.append("") - if data["high_priority"]: + if data.get("high_priority"): lines.append("HIGH PRIORITY (in progress):") lines.extend(f" - {t}" for t in data["high_priority"]) lines.append("") @@ -326,53 +338,104 @@ async def _get_temp_unit(user_id: int) -> str: return "C" -async def run_compilation(user_id: int, slot: str, model: str | None = None) -> str: +async def run_compilation( + user_id: int, + slot: str, + model: str | None = None, +) -> tuple[str, dict]: """ Run the full two-lane briefing pipeline for a user and slot. - Returns the combined briefing text to be posted as the opening assistant message. + Returns (briefing_text, metadata_dict) where metadata contains + weather card data and rss_item_ids for frontend rendering. """ if model is None: model = await get_setting(user_id, "default_model", Config.OLLAMA_MODEL) from fabledassistant.services.briefing_profile import get_profile_body + from fabledassistant.services.briefing_preferences import ( + load_topic_preferences, + load_topic_reaction_scores, + score_and_filter_items, + ) + from fabledassistant.services.weather import parse_weather_card_data, get_cached_weather_rows + profile_body, temp_unit = await asyncio.gather( get_profile_body(user_id), _get_temp_unit(user_id), ) - # Parallel gather - internal_data, external_data = await asyncio.gather( + # ── Pre-processing ────────────────────────────────────────────────────────── + include_topics, exclude_topics = await load_topic_preferences(user_id) + topic_scores = await load_topic_reaction_scores(user_id) + + # Parallel raw gather — weather rows fetched in same gather to avoid extra DB round-trip + internal_data, external_data, weather_rows = await asyncio.gather( _gather_internal(user_id), _gather_external(user_id), + get_cached_weather_rows(user_id), ) - # Two-lane LLM synthesis (both calls run concurrently) + # Task change detection + all_tasks = internal_data.get("all_tasks_raw", []) + changed_tasks, unchanged_count = await split_changed_tasks(user_id, all_tasks) + + # RSS filtering + raw_rss = external_data.get("rss_items") or [] + filtered_rss = score_and_filter_items( + raw_rss, + include_topics=include_topics, + exclude_topics=exclude_topics, + topic_scores=topic_scores, + max_items=10, + ) + rss_item_ids = [item["id"] for item in filtered_rss if item.get("id")] + + # Weather staleness gate — returns None if data is >24h old + weather_card = parse_weather_card_data(weather_rows[0], temp_unit) if weather_rows else None + + # ── 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] + + # Build filtered external data (suppress weather prose — card handles it) + external_data_filtered = { + "rss_items": filtered_rss, + "weather": [], + } + internal_text, external_text = await asyncio.gather( _llm_synthesise( _internal_system_prompt(profile_body), - _internal_user_prompt(internal_data, slot), + _internal_user_prompt(internal_data_filtered, slot), model, ), _llm_synthesise( _external_system_prompt(), - _external_user_prompt(external_data, slot, temp_unit), + _external_user_prompt(external_data_filtered, slot, temp_unit), model, ), ) + # ── Post-processing ───────────────────────────────────────────────────────── + await upsert_task_snapshots(user_id, all_tasks) + + metadata: dict = {"rss_item_ids": rss_item_ids, "weather": weather_card} + if not internal_text and not external_text: logger.warning("Briefing compilation produced no content for user %d slot %s", user_id, slot) - return "" + return "", metadata greeting = slot_greeting(slot) - today = internal_data["date"] 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() + return "\n".join(parts).strip(), metadata async def run_slot_injection(user_id: int, slot: str, model: str | None = None) -> str: diff --git a/src/fabledassistant/services/briefing_scheduler.py b/src/fabledassistant/services/briefing_scheduler.py index 47e2b58..fd5d5c6 100644 --- a/src/fabledassistant/services/briefing_scheduler.py +++ b/src/fabledassistant/services/briefing_scheduler.py @@ -153,9 +153,9 @@ async def _run_slot_for_user(user_id: int, slot: str) -> None: await _run_profile_closeout(user_id, model) conv = await get_or_create_today_conversation(user_id, model) - text = await run_compilation(user_id, slot, model) + text, metadata = await run_compilation(user_id, slot, model) if text: - await post_message(conv.id, "assistant", text) + await post_message(conv.id, "assistant", text, metadata=metadata) else: conv = await get_or_create_today_conversation(user_id, model)