feat(briefing): wire pre-processing pipeline; run_compilation returns (text, metadata)
- Task change detection via snapshot diff - RSS scoring/filtering via briefing_preferences - Weather card via parse_weather_card_data (staleness-gated) - News card markdown format with ordering constraint - Metadata stored on Message record via post_message() Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -232,6 +232,6 @@ async def manual_trigger():
|
|||||||
|
|
||||||
model = await get_setting(g.user.id, "default_model", "")
|
model = await get_setting(g.user.id, "default_model", "")
|
||||||
conv = await get_or_create_today_conversation(g.user.id, model)
|
conv = await get_or_create_today_conversation(g.user.id, model)
|
||||||
text = await run_compilation(g.user.id, slot, model)
|
text, metadata = await run_compilation(g.user.id, slot, model)
|
||||||
msg = await post_message(conv.id, "assistant", text)
|
msg = await post_message(conv.id, "assistant", text, metadata=metadata)
|
||||||
return jsonify({"conversation_id": conv.id, "message_id": msg.id, "slot": slot})
|
return jsonify({"conversation_id": conv.id, "message_id": msg.id, "slot": slot})
|
||||||
|
|||||||
@@ -248,24 +248,36 @@ def _internal_system_prompt(profile_body: str) -> str:
|
|||||||
|
|
||||||
def _external_system_prompt() -> str:
|
def _external_system_prompt() -> str:
|
||||||
return (
|
return (
|
||||||
"You are a briefing assistant for external information. Your job is to summarise "
|
"You are a briefing assistant for external information. Your job is to present "
|
||||||
"the user's RSS feed digest and weather forecast into a concise, engaging update. "
|
"selected news items and summarise any remaining RSS content. "
|
||||||
"Group related news items. Note any significant weather changes. "
|
"IMPORTANT: Weather is handled separately — do NOT include any weather section.\n\n"
|
||||||
"Be informative but brief. Do not discuss tasks, calendar, or work items."
|
"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:
|
def _internal_user_prompt(data: dict, slot: str) -> str:
|
||||||
lines = [f"Briefing slot: {slot}", f"Date: {data['date']}", ""]
|
lines = [f"Briefing slot: {slot}", f"Date: {data['date']}", ""]
|
||||||
if data["overdue_tasks"]:
|
if data.get("unchanged_task_count", 0) > 0:
|
||||||
lines.append(f"OVERDUE ({len(data['overdue_tasks'])}):")
|
lines.append(
|
||||||
lines.extend(f" - {t}" for t in data["overdue_tasks"])
|
f"({data['unchanged_task_count']} tasks are unchanged since the last briefing "
|
||||||
|
"— acknowledge briefly, do not list them.)"
|
||||||
|
)
|
||||||
lines.append("")
|
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.append(f"DUE TODAY ({len(data['due_today'])}):")
|
||||||
lines.extend(f" - {t}" for t in data["due_today"])
|
lines.extend(f" - {t}" for t in data["due_today"])
|
||||||
lines.append("")
|
lines.append("")
|
||||||
if data["high_priority"]:
|
if data.get("high_priority"):
|
||||||
lines.append("HIGH PRIORITY (in progress):")
|
lines.append("HIGH PRIORITY (in progress):")
|
||||||
lines.extend(f" - {t}" for t in data["high_priority"])
|
lines.extend(f" - {t}" for t in data["high_priority"])
|
||||||
lines.append("")
|
lines.append("")
|
||||||
@@ -326,53 +338,104 @@ async def _get_temp_unit(user_id: int) -> str:
|
|||||||
return "C"
|
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.
|
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:
|
if model is None:
|
||||||
model = await get_setting(user_id, "default_model", Config.OLLAMA_MODEL)
|
model = await get_setting(user_id, "default_model", Config.OLLAMA_MODEL)
|
||||||
|
|
||||||
from fabledassistant.services.briefing_profile import get_profile_body
|
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(
|
profile_body, temp_unit = await asyncio.gather(
|
||||||
get_profile_body(user_id),
|
get_profile_body(user_id),
|
||||||
_get_temp_unit(user_id),
|
_get_temp_unit(user_id),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Parallel gather
|
# ── Pre-processing ──────────────────────────────────────────────────────────
|
||||||
internal_data, external_data = await asyncio.gather(
|
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_internal(user_id),
|
||||||
_gather_external(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(
|
internal_text, external_text = await asyncio.gather(
|
||||||
_llm_synthesise(
|
_llm_synthesise(
|
||||||
_internal_system_prompt(profile_body),
|
_internal_system_prompt(profile_body),
|
||||||
_internal_user_prompt(internal_data, slot),
|
_internal_user_prompt(internal_data_filtered, slot),
|
||||||
model,
|
model,
|
||||||
),
|
),
|
||||||
_llm_synthesise(
|
_llm_synthesise(
|
||||||
_external_system_prompt(),
|
_external_system_prompt(),
|
||||||
_external_user_prompt(external_data, slot, temp_unit),
|
_external_user_prompt(external_data_filtered, slot, temp_unit),
|
||||||
model,
|
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:
|
if not internal_text and not external_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 ""
|
return "", metadata
|
||||||
|
|
||||||
greeting = slot_greeting(slot)
|
greeting = slot_greeting(slot)
|
||||||
today = internal_data["date"]
|
|
||||||
parts = [f"**{greeting} — {today}**", ""]
|
parts = [f"**{greeting} — {today}**", ""]
|
||||||
if internal_text:
|
if internal_text:
|
||||||
parts += ["## Your Day", "", internal_text, ""]
|
parts += ["## Your Day", "", internal_text, ""]
|
||||||
if external_text:
|
if external_text:
|
||||||
parts += ["## The World", "", 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:
|
async def run_slot_injection(user_id: int, slot: str, model: str | None = None) -> str:
|
||||||
|
|||||||
@@ -153,9 +153,9 @@ async def _run_slot_for_user(user_id: int, slot: str) -> None:
|
|||||||
|
|
||||||
await _run_profile_closeout(user_id, model)
|
await _run_profile_closeout(user_id, model)
|
||||||
conv = await get_or_create_today_conversation(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:
|
if text:
|
||||||
await post_message(conv.id, "assistant", text)
|
await post_message(conv.id, "assistant", text, metadata=metadata)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
conv = await get_or_create_today_conversation(user_id, model)
|
conv = await get_or_create_today_conversation(user_id, model)
|
||||||
|
|||||||
Reference in New Issue
Block a user