4168167f24
PR 1.5 + PR 2 of the agentic briefing rollout. Extends the agentic
path to cover the morning/midday/afternoon check-in slots in addition
to the 4am compilation, and persists the full tool-call sequence
into the briefing conversation so chat follow-ups see the receipts.
run_slot_injection now honors the briefing_mode setting the same way
run_compilation does: agentic mode routes through run_agentic_briefing
with a check-in system prompt variant, legacy falls back automatically
if the new path returns empty. Signature changed from str to
(str, dict) to surface the agentic message list to the caller.
_agentic_system_prompt now takes the user's local-day window and
timezone, pre-computed by run_agentic_briefing, and embeds them in
the prompt. This eliminates a whole class of "wrong day" bugs that
would otherwise happen when the model tried to translate "today" into
ISO 8601 ranges without knowing the user's timezone.
briefing_scheduler._run_slot_for_user now calls _persist_agentic_messages
after each slot, which walks the agentic message list and stores
every intermediate assistant turn (with its tool calls and results
folded into the flat storage format the existing chat loader expects)
as a real message row. The synthetic "[Midday briefing update]"
user-role messages are no longer created — final prose is written
with metadata.briefing_slot so the UI can still identify it as a
scheduled entry. The agentic user-trigger ("Generate my morning
briefing…") is deliberately skipped so it doesn't recreate the same
fake-user-message problem we're trying to remove.
briefing_conversations.post_message now accepts tool_calls, matching
the schema the Message model already supports. This lets scheduled
briefings write structured tool-use history without reaching into
the model layer.
Net effect with briefing_mode="agentic" on:
- All four slots are grounded in tool results, no more hallucinated
events or tasks
- Chat follow-ups in the briefing conversation see morning's
list_events → [] receipt (and everything else), so "what meeting?"
gets an honest "nothing on the calendar" reply grounded in data
- No more fake [Briefing update] user messages in the chat scroll
Still to come (PR 3): UI polish — tool-call status row, inline cards
for list_events/list_tasks results, visual treatment for slot messages.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1138 lines
47 KiB
Python
1138 lines
47 KiB
Python
"""
|
||
Briefing pipeline: parallel data gather + two-lane LLM synthesis.
|
||
|
||
Slot names: 'compilation' (4am), 'morning' (8am), 'midday' (12pm), 'afternoon' (4pm)
|
||
"""
|
||
|
||
import asyncio
|
||
import hashlib
|
||
import logging
|
||
from datetime import datetime, timezone
|
||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||
|
||
import httpx
|
||
|
||
from fabledassistant.models import async_session
|
||
|
||
from fabledassistant.config import Config
|
||
from fabledassistant.services.settings import get_setting
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
SLOT_NAMES = ("compilation", "morning", "midday", "afternoon")
|
||
|
||
|
||
|
||
def format_task(task: dict, today: str | None = None) -> str:
|
||
parts = [task.get("title", "Untitled")]
|
||
if task.get("status"):
|
||
parts.append(f"[{task['status'].replace('_', ' ').title()}]")
|
||
if task.get("due_date"):
|
||
parts.append(f"due {task['due_date']}")
|
||
if today and task["due_date"] < today and task.get("status") not in ("done", "cancelled"):
|
||
from datetime import date
|
||
try:
|
||
due = date.fromisoformat(task["due_date"])
|
||
td = date.fromisoformat(today)
|
||
days = (td - due).days
|
||
parts.append(f"({days} day{'s' if days != 1 else ''} overdue)")
|
||
except ValueError:
|
||
pass
|
||
if task.get("priority") and task["priority"] not in ("none", ""):
|
||
parts.append(f"priority: {task['priority']}")
|
||
if task.get("project_title"):
|
||
parts.append(f"project: {task['project_title']}")
|
||
return " — ".join(parts)
|
||
|
||
|
||
def compute_task_hash(task: dict) -> str:
|
||
"""Stable SHA-256 of the task's key change-detectable fields."""
|
||
key = "|".join([
|
||
str(task.get("status") or ""),
|
||
str(task.get("priority") or ""),
|
||
str(task.get("due_date") or ""),
|
||
str(task.get("title") or ""),
|
||
])
|
||
return hashlib.sha256(key.encode()).hexdigest()
|
||
|
||
|
||
async def split_changed_tasks(
|
||
user_id: int,
|
||
tasks: list[dict],
|
||
) -> tuple[list[dict], int]:
|
||
"""
|
||
Compare tasks against the briefing_task_snapshot table.
|
||
Returns (changed_tasks, unchanged_count).
|
||
changed_tasks includes new tasks (no snapshot row) and tasks whose hash differs.
|
||
"""
|
||
from sqlalchemy import text
|
||
|
||
if not tasks:
|
||
return [], 0
|
||
|
||
task_ids = [t["task_id"] for t in tasks if t.get("task_id")]
|
||
|
||
async with async_session() as session:
|
||
result = await session.execute(
|
||
text("""
|
||
SELECT task_id, snapshot_hash
|
||
FROM briefing_task_snapshot
|
||
WHERE user_id = :uid AND task_id = ANY(:ids)
|
||
""").bindparams(uid=user_id, ids=task_ids)
|
||
)
|
||
snapshots = {row.task_id: row.snapshot_hash for row in result}
|
||
|
||
changed = []
|
||
unchanged_count = 0
|
||
for task in tasks:
|
||
current_hash = compute_task_hash(task)
|
||
stored_hash = snapshots.get(task.get("task_id"))
|
||
if stored_hash is None or stored_hash != current_hash:
|
||
changed.append(task)
|
||
else:
|
||
unchanged_count += 1
|
||
|
||
return changed, unchanged_count
|
||
|
||
|
||
async def upsert_task_snapshots(user_id: int, tasks: list[dict]) -> None:
|
||
"""Upsert snapshot hashes for all tasks included in this briefing."""
|
||
from sqlalchemy import text
|
||
|
||
if not tasks:
|
||
return
|
||
|
||
now = datetime.now(timezone.utc)
|
||
async with async_session() as session:
|
||
for task in tasks:
|
||
task_id = task.get("task_id")
|
||
if not task_id:
|
||
continue
|
||
await session.execute(
|
||
text("""
|
||
INSERT INTO briefing_task_snapshot (user_id, task_id, snapshot_hash, last_briefed)
|
||
VALUES (:uid, :tid, :hash, :now)
|
||
ON CONFLICT (user_id, task_id)
|
||
DO UPDATE SET snapshot_hash = EXCLUDED.snapshot_hash,
|
||
last_briefed = EXCLUDED.last_briefed
|
||
""").bindparams(
|
||
uid=user_id,
|
||
tid=task_id,
|
||
hash=compute_task_hash(task),
|
||
now=now,
|
||
)
|
||
)
|
||
await session.commit()
|
||
|
||
|
||
# ── Internal data gather ──────────────────────────────────────────────────────
|
||
|
||
async def _gather_internal(user_id: int) -> dict:
|
||
"""Collect tasks, calendar events, and project data."""
|
||
from fabledassistant.services.notes import list_notes
|
||
from fabledassistant.services.projects import list_projects
|
||
from fabledassistant.services.caldav import is_caldav_configured, list_events
|
||
|
||
tz_name = await get_setting(user_id, "user_timezone") or "UTC"
|
||
try:
|
||
user_tz = ZoneInfo(tz_name)
|
||
except ZoneInfoNotFoundError:
|
||
user_tz = ZoneInfo("UTC")
|
||
|
||
today = datetime.now(user_tz).date().isoformat()
|
||
|
||
# Tasks: overdue, due today, high priority in-progress
|
||
all_tasks: list[dict] = []
|
||
try:
|
||
all_task_objs, _total = await list_notes(user_id, is_task=True, limit=100)
|
||
# Build project title lookup for enrichment
|
||
project_titles: dict[int, str] = {}
|
||
try:
|
||
for p in await list_projects(user_id):
|
||
project_titles[p.id] = p.title or "Untitled"
|
||
except Exception:
|
||
pass
|
||
all_tasks = [
|
||
{
|
||
"task_id": t.id,
|
||
"title": t.title,
|
||
"status": t.status,
|
||
"due_date": t.due_date.isoformat() if t.due_date else None,
|
||
"priority": t.priority,
|
||
"project_title": project_titles.get(t.project_id) if t.project_id else None,
|
||
}
|
||
for t in all_task_objs
|
||
]
|
||
overdue = [
|
||
format_task(t, today) for t in all_tasks
|
||
if t.get("due_date") and t["due_date"] < today and t.get("status") not in ("done", "cancelled")
|
||
]
|
||
due_today = [
|
||
format_task(t, today) for t in all_tasks
|
||
if t.get("due_date") == today and t.get("status") not in ("done", "cancelled")
|
||
]
|
||
high_priority = [
|
||
format_task(t, today) for t in all_tasks
|
||
if t.get("priority") == "high" and t.get("status") not in ("done", "cancelled") and
|
||
format_task(t, today) not in overdue and format_task(t, today) not in due_today
|
||
][:5]
|
||
except Exception:
|
||
logger.warning("Failed to gather tasks for briefing", exc_info=True)
|
||
overdue, due_today, high_priority = [], [], []
|
||
|
||
# Calendar events today — internal store
|
||
calendar_events: list[str] = []
|
||
internal_events: list[dict] = []
|
||
try:
|
||
from fabledassistant.services.events import list_events as list_internal_events
|
||
today_date = datetime.now(user_tz).date()
|
||
day_start = datetime(today_date.year, today_date.month, today_date.day, 0, 0, 0, tzinfo=user_tz)
|
||
day_end = datetime(today_date.year, today_date.month, today_date.day, 23, 59, 59, tzinfo=user_tz)
|
||
internal_events = await list_internal_events(
|
||
user_id=user_id, date_from=day_start, date_to=day_end
|
||
)
|
||
for e in internal_events:
|
||
if e.get("all_day"):
|
||
time_str = "all day"
|
||
elif e.get("start_dt"):
|
||
from datetime import datetime as _dt
|
||
start = e["start_dt"]
|
||
if isinstance(start, str):
|
||
start = _dt.fromisoformat(start)
|
||
local_dt = start.astimezone(user_tz) if start.tzinfo else start.replace(tzinfo=timezone.utc).astimezone(user_tz)
|
||
time_str = local_dt.strftime("%-I:%M %p")
|
||
else:
|
||
time_str = "unknown time"
|
||
calendar_events.append(f"{e.get('title', 'Event')} at {time_str}")
|
||
except Exception:
|
||
logger.warning("Failed to gather internal calendar events for briefing", exc_info=True)
|
||
# Also pull CalDAV events (deduped)
|
||
try:
|
||
if await is_caldav_configured(user_id):
|
||
caldav_evs = await list_events(user_id, start=today, end=today)
|
||
for e in (caldav_evs or []):
|
||
summary = f"{e.get('summary', 'Event')} at {e.get('dtstart', 'unknown time')}"
|
||
if summary not in calendar_events:
|
||
calendar_events.append(summary)
|
||
except Exception:
|
||
logger.warning("Failed to gather CalDAV calendar events for briefing", exc_info=True)
|
||
|
||
# Weather × calendar cross-reference
|
||
weather_warnings: list[str] = []
|
||
outdoor_keywords = {"park", "field", "patio", "garden", "outdoor", "trail", "beach",
|
||
"pool", "stadium", "yard", "lawn", "hike", "walk", "run", "bike",
|
||
"soccer", "baseball", "football", "tennis", "golf", "playground"}
|
||
try:
|
||
import json as _json
|
||
raw_config = await get_setting(user_id, "briefing_config", "{}")
|
||
config = _json.loads(raw_config) if isinstance(raw_config, str) else (raw_config or {})
|
||
locations = config.get("locations", {})
|
||
loc = locations.get("home") or locations.get("work")
|
||
if loc and loc.get("lat") and loc.get("lon") and internal_events:
|
||
from fabledassistant.services.weather import fetch_hourly_precip
|
||
hourly_precip = await fetch_hourly_precip(loc["lat"], loc["lon"])
|
||
if hourly_precip:
|
||
for e in internal_events:
|
||
if e.get("all_day"):
|
||
continue
|
||
# Check if event title or location contains outdoor keywords
|
||
event_text = f"{e.get('title', '')} {e.get('location', '')}".lower()
|
||
if not any(kw in event_text for kw in outdoor_keywords):
|
||
continue
|
||
# Get the event's start hour
|
||
start = e.get("start_dt")
|
||
if not start:
|
||
continue
|
||
from datetime import datetime as _dt
|
||
if isinstance(start, str):
|
||
start = _dt.fromisoformat(start)
|
||
local_start = start.astimezone(user_tz) if start.tzinfo else start.replace(tzinfo=timezone.utc).astimezone(user_tz)
|
||
# Check precip probability around the event time (hour before through event)
|
||
hour_key = local_start.strftime("%Y-%m-%dT%H:00")
|
||
precip = hourly_precip.get(hour_key, 0)
|
||
if precip >= 30:
|
||
time_str = local_start.strftime("%-I:%M %p")
|
||
weather_warnings.append(
|
||
f"Rain likely ({precip}%) around {time_str} when '{e.get('title', 'Event')}' "
|
||
f"is scheduled — consider an umbrella or backup plan"
|
||
)
|
||
except Exception:
|
||
logger.debug("Weather cross-reference failed", exc_info=True)
|
||
|
||
# Recent activity — what was the user working on yesterday?
|
||
recent_activity: list[str] = []
|
||
stale_projects: list[str] = []
|
||
try:
|
||
from sqlalchemy import select as _sel, func as _func
|
||
from fabledassistant.models.note import Note
|
||
from fabledassistant.models.project import Project
|
||
|
||
yesterday = datetime.now(user_tz) - __import__('datetime').timedelta(days=1)
|
||
async with async_session() as session:
|
||
# Notes/tasks updated in last 24h, grouped by project
|
||
result = await session.execute(
|
||
_sel(Project.title, _func.count(Note.id))
|
||
.join(Note, Note.project_id == Project.id)
|
||
.where(Note.user_id == user_id)
|
||
.where(Note.updated_at >= yesterday)
|
||
.group_by(Project.title)
|
||
.order_by(_func.count(Note.id).desc())
|
||
.limit(3)
|
||
)
|
||
for proj_title, count in result.all():
|
||
recent_activity.append(f"{proj_title}: {count} item{'s' if count != 1 else ''} updated")
|
||
|
||
# Stale projects — active projects with no note/task activity in 7+ days
|
||
week_ago = datetime.now(user_tz) - __import__('datetime').timedelta(days=7)
|
||
active_projects_q = await session.execute(
|
||
_sel(Project).where(Project.user_id == user_id, Project.status == "active")
|
||
)
|
||
for p in active_projects_q.scalars().all():
|
||
latest = await session.execute(
|
||
_sel(_func.max(Note.updated_at))
|
||
.where(Note.project_id == p.id, Note.user_id == user_id)
|
||
)
|
||
last_activity = latest.scalar()
|
||
if last_activity is None or last_activity < week_ago:
|
||
stale_projects.append(p.title or "Untitled")
|
||
except Exception:
|
||
logger.debug("Failed to gather recent activity for briefing", exc_info=True)
|
||
|
||
# Projects: active projects
|
||
projects_summary = []
|
||
try:
|
||
projects = await list_projects(user_id)
|
||
for p in projects[:5]:
|
||
projects_summary.append(p.title or "Untitled project")
|
||
except Exception:
|
||
logger.warning("Failed to gather projects for briefing", exc_info=True)
|
||
|
||
# Build prioritized "Your Day" focus list
|
||
# Priority: overdue > due today > high priority in-progress > other in-progress
|
||
focus_tasks: list[str] = []
|
||
seen_titles: set[str] = set()
|
||
for t in sorted(all_tasks, key=lambda x: (
|
||
0 if (x.get("due_date") and x["due_date"] < today and x.get("status") not in ("done", "cancelled")) else
|
||
1 if (x.get("due_date") == today and x.get("status") not in ("done", "cancelled")) else
|
||
2 if (x.get("priority") == "high" and x.get("status") not in ("done", "cancelled")) else
|
||
3 if (x.get("status") == "in_progress") else 4
|
||
)):
|
||
if t.get("status") in ("done", "cancelled"):
|
||
continue
|
||
formatted = format_task(t, today)
|
||
if formatted not in seen_titles:
|
||
seen_titles.add(formatted)
|
||
focus_tasks.append(formatted)
|
||
if len(focus_tasks) >= 5:
|
||
break
|
||
|
||
# Calendar gap analysis — extract event times for LLM
|
||
event_time_blocks: list[str] = []
|
||
for e in internal_events:
|
||
try:
|
||
if e.get("all_day"):
|
||
continue
|
||
start = e.get("start_dt")
|
||
end = e.get("end_dt")
|
||
if not start:
|
||
continue
|
||
from datetime import datetime as _dt
|
||
if isinstance(start, str):
|
||
start = _dt.fromisoformat(start)
|
||
if isinstance(end, str):
|
||
end = _dt.fromisoformat(end)
|
||
s_local = start.astimezone(user_tz) if start.tzinfo else start.replace(tzinfo=timezone.utc).astimezone(user_tz)
|
||
s_str = s_local.strftime("%-I:%M %p")
|
||
if end:
|
||
e_local = end.astimezone(user_tz) if end.tzinfo else end.replace(tzinfo=timezone.utc).astimezone(user_tz)
|
||
e_str = e_local.strftime("%-I:%M %p")
|
||
event_time_blocks.append(f"{e.get('title', 'Event')}: {s_str}–{e_str}")
|
||
else:
|
||
event_time_blocks.append(f"{e.get('title', 'Event')}: {s_str}–?")
|
||
except Exception:
|
||
pass
|
||
|
||
return {
|
||
"date": today,
|
||
"overdue_tasks": overdue,
|
||
"due_today": due_today,
|
||
"high_priority": high_priority,
|
||
"calendar_events": calendar_events,
|
||
"active_projects": projects_summary,
|
||
"all_tasks_raw": all_tasks,
|
||
"focus_tasks": focus_tasks,
|
||
"event_time_blocks": event_time_blocks,
|
||
"weather_warnings": weather_warnings,
|
||
"recent_activity": recent_activity,
|
||
"stale_projects": stale_projects,
|
||
}
|
||
|
||
|
||
async def _gather_weekly_review(user_id: int) -> dict:
|
||
"""Collect 7-day activity summary for the weekly review."""
|
||
from fabledassistant.services.notes import list_notes
|
||
from fabledassistant.services.projects import list_projects
|
||
from fabledassistant.services.events import list_events as list_internal_events
|
||
|
||
tz_name = await get_setting(user_id, "user_timezone") or "UTC"
|
||
try:
|
||
user_tz = ZoneInfo(tz_name)
|
||
except ZoneInfoNotFoundError:
|
||
user_tz = ZoneInfo("UTC")
|
||
|
||
now = datetime.now(user_tz)
|
||
week_ago = now - __import__('datetime').timedelta(days=7)
|
||
next_week = now + __import__('datetime').timedelta(days=7)
|
||
|
||
# Tasks completed this week
|
||
completed_tasks = []
|
||
overdue_tasks = []
|
||
try:
|
||
all_task_objs, _ = await list_notes(user_id, is_task=True, limit=200)
|
||
for t in all_task_objs:
|
||
if t.status == "done" and t.completed_at and t.completed_at >= week_ago:
|
||
completed_tasks.append(t.title or "Untitled")
|
||
elif t.due_date and t.due_date.isoformat() < now.date().isoformat() and t.status not in ("done", "cancelled"):
|
||
overdue_tasks.append(t.title or "Untitled")
|
||
except Exception:
|
||
logger.debug("Weekly review: failed to gather tasks", exc_info=True)
|
||
|
||
# Notes created this week
|
||
notes_created = 0
|
||
try:
|
||
all_notes, _ = await list_notes(user_id, is_task=False, limit=200)
|
||
notes_created = sum(1 for n in all_notes if n.created_at and n.created_at >= week_ago)
|
||
except Exception:
|
||
logger.debug("Weekly review: failed to count notes", exc_info=True)
|
||
|
||
# Project activity
|
||
active_projects = []
|
||
try:
|
||
projects = await list_projects(user_id)
|
||
active_projects = [p.title for p in projects if p.status == "active"][:5]
|
||
except Exception:
|
||
pass
|
||
|
||
# Events next week
|
||
upcoming_events = []
|
||
try:
|
||
next_events = await list_internal_events(
|
||
user_id=user_id,
|
||
date_from=now,
|
||
date_to=next_week,
|
||
)
|
||
for e in next_events[:8]:
|
||
upcoming_events.append(f"{e.get('title', 'Event')} — {e.get('start_dt', '')[:10]}")
|
||
except Exception:
|
||
pass
|
||
|
||
return {
|
||
"completed_count": len(completed_tasks),
|
||
"completed_highlights": completed_tasks[:5],
|
||
"overdue_count": len(overdue_tasks),
|
||
"overdue_items": overdue_tasks[:5],
|
||
"notes_created": notes_created,
|
||
"active_projects": active_projects,
|
||
"upcoming_events": upcoming_events,
|
||
}
|
||
|
||
|
||
# ── External data gather ──────────────────────────────────────────────────────
|
||
|
||
async def _gather_external(user_id: int) -> dict:
|
||
"""Collect RSS items and weather."""
|
||
from fabledassistant.services.rss import get_recent_items
|
||
from fabledassistant.services.weather import get_cached_weather
|
||
|
||
rss_items, weather = await asyncio.gather(
|
||
get_recent_items(user_id, limit=20),
|
||
get_cached_weather(user_id),
|
||
return_exceptions=True,
|
||
)
|
||
return {
|
||
"rss_items": rss_items if not isinstance(rss_items, Exception) else [],
|
||
"weather": weather if not isinstance(weather, Exception) else [],
|
||
}
|
||
|
||
|
||
# ── Agentic briefing (tool-use loop) ──────────────────────────────────────────
|
||
|
||
_BRIEFING_AGENT_MAX_ROUNDS = 8
|
||
_BRIEFING_AGENT_NUM_CTX = 8192
|
||
|
||
|
||
def _agentic_system_prompt(
|
||
profile_body: str,
|
||
slot: str,
|
||
today_iso: str,
|
||
tz_name: str,
|
||
day_from_iso: str,
|
||
day_to_iso: str,
|
||
) -> str:
|
||
"""System prompt for the agentic briefing path.
|
||
|
||
Pushes the model to ground every factual claim in a tool result and
|
||
to be honest when tools return nothing, rather than fabricating
|
||
content to fill the narrative.
|
||
|
||
Pre-computes today's window in the user's local timezone so the
|
||
model can call date-sensitive tools (list_events, list_tasks filters)
|
||
without having to do any timezone math itself — eliminating a whole
|
||
class of "wrong day" bugs.
|
||
"""
|
||
tz_block = (
|
||
f"Today is {today_iso} ({tz_name}). "
|
||
f"When calling list_events for today, use:\n"
|
||
f" date_from = {day_from_iso}\n"
|
||
f" date_to = {day_to_iso}\n"
|
||
f"These are already the correct local-day boundaries — do not convert "
|
||
f"them to UTC or any other timezone. For other date ranges, compute in "
|
||
f"the same timezone.\n\n"
|
||
)
|
||
|
||
if slot == "compilation":
|
||
base = (
|
||
"You are the user's personal assistant giving their full morning briefing. "
|
||
"Use the tools available to see what's actually relevant today — tasks due, "
|
||
"overdue items, events on the calendar, weather, news themes, project state — "
|
||
"and weave it into a warm, natural-sounding summary.\n\n"
|
||
"Rules:\n"
|
||
"- Call tools to see the data. Never assert facts you didn't learn from a tool.\n"
|
||
"- If a tool returns nothing (no events today, no overdue tasks), say so honestly. "
|
||
"Don't fabricate items to fill space.\n"
|
||
"- Write flowing prose. No markdown, no headers, no bullet points.\n"
|
||
"- Aim for 6 to 10 sentences. Skip topics that have nothing interesting.\n"
|
||
"- Close on one or two concrete, actionable suggestions.\n\n"
|
||
)
|
||
elif slot == "weekly_review":
|
||
base = (
|
||
"You are the user's personal assistant delivering a weekly review. "
|
||
"Use the tools available to see what was accomplished this week, what's still "
|
||
"overdue, how many notes were captured, and what's coming up in the next seven days. "
|
||
"Write a reflective recap that celebrates real progress and gently flags what's stuck.\n\n"
|
||
"Rules:\n"
|
||
"- Call tools to see the data. Never assert facts you didn't learn from a tool.\n"
|
||
"- If a category is empty, say so honestly rather than inventing items.\n"
|
||
"- Write flowing prose. No markdown, no bullet points.\n"
|
||
"- Aim for 5 to 8 sentences. Reflective and encouraging tone.\n\n"
|
||
)
|
||
else: # morning, midday, afternoon check-ins
|
||
base = (
|
||
f"You are the user's personal assistant giving a brief {slot} check-in. "
|
||
"Use tools to see what's changed since this morning. Focus on progress and "
|
||
"what's still unaddressed.\n\n"
|
||
"Rules:\n"
|
||
"- Call tools to see current state. Never assert facts without tool results.\n"
|
||
"- If nothing meaningful has changed, say so briefly — don't invent progress.\n"
|
||
"- 3 to 5 sentences, natural prose, no markdown.\n\n"
|
||
)
|
||
|
||
base = tz_block + base
|
||
if profile_body:
|
||
base += f"User profile (tone and preferences):\n{profile_body}\n"
|
||
return base
|
||
|
||
|
||
def _agentic_user_trigger(slot: str, date_str: str) -> str:
|
||
"""Seed user-role message that kicks off the agentic run."""
|
||
labels = {
|
||
"compilation": "morning briefing",
|
||
"morning": "morning check-in",
|
||
"midday": "midday check-in",
|
||
"afternoon": "afternoon wrap-up",
|
||
"weekly_review": "weekly review",
|
||
}
|
||
label = labels.get(slot, f"{slot} briefing")
|
||
return f"Generate my {label} for {date_str}."
|
||
|
||
|
||
async def run_agentic_briefing(
|
||
user_id: int,
|
||
slot: str,
|
||
model: str,
|
||
conv_id: int | None = None,
|
||
) -> tuple[str, list[dict]]:
|
||
"""
|
||
Run the agentic briefing loop for a user and slot.
|
||
|
||
Uses the chat pipeline's tool-use loop with a curated read-only tool
|
||
subset and a slot-specific system prompt. Every fact the model states
|
||
is either derived from a tool result visible in the returned message
|
||
list or it's the model hallucinating — so follow-up chat in the same
|
||
conversation can hold the model to what the tool results actually show.
|
||
|
||
Returns ``(final_prose, message_list)`` where ``message_list`` is the
|
||
full sequence including system, user trigger, tool calls, and tool
|
||
results. Callers are expected to persist those intermediate turns
|
||
alongside the final prose so the receipts remain in conversation
|
||
history on follow-up.
|
||
|
||
If the loop fails or the model returns empty prose, returns
|
||
``("", [])`` and the caller should fall back to the legacy path.
|
||
"""
|
||
from fabledassistant.services.llm import stream_chat_with_tools, ChatChunk # noqa: F401
|
||
from fabledassistant.services.tools import execute_tool
|
||
from fabledassistant.services.briefing_tools import get_briefing_tools
|
||
from fabledassistant.services.user_profile import build_profile_context
|
||
|
||
profile_context = await build_profile_context(user_id)
|
||
tools = await get_briefing_tools(user_id)
|
||
|
||
if not tools:
|
||
logger.warning(
|
||
"Agentic briefing for user %d slot %s: no tools available — aborting",
|
||
user_id, slot,
|
||
)
|
||
return "", []
|
||
|
||
# Compute today's window in the user's local timezone so the model
|
||
# receives ready-to-use ISO 8601 boundaries and never has to do its
|
||
# own tz math when calling date-sensitive tools like list_events.
|
||
tz_name = await get_setting(user_id, "user_timezone") or "UTC"
|
||
try:
|
||
user_tz = ZoneInfo(tz_name)
|
||
except ZoneInfoNotFoundError:
|
||
user_tz = ZoneInfo("UTC")
|
||
tz_name = "UTC"
|
||
now_local = datetime.now(user_tz)
|
||
today_iso = now_local.date().isoformat()
|
||
day_start = datetime(now_local.year, now_local.month, now_local.day, 0, 0, 0, tzinfo=user_tz)
|
||
day_end = datetime(now_local.year, now_local.month, now_local.day, 23, 59, 59, tzinfo=user_tz)
|
||
day_from_iso = day_start.isoformat()
|
||
day_to_iso = day_end.isoformat()
|
||
|
||
system_prompt = _agentic_system_prompt(
|
||
profile_context, slot, today_iso, tz_name, day_from_iso, day_to_iso,
|
||
)
|
||
messages: list[dict] = [
|
||
{"role": "system", "content": system_prompt},
|
||
{"role": "user", "content": _agentic_user_trigger(slot, today_iso)},
|
||
]
|
||
|
||
final_text = ""
|
||
for round_idx in range(_BRIEFING_AGENT_MAX_ROUNDS):
|
||
accumulated_content = ""
|
||
accumulated_tool_calls: list[dict] = []
|
||
|
||
try:
|
||
async for chunk in stream_chat_with_tools(
|
||
messages, model, tools=tools, think=False,
|
||
num_ctx=_BRIEFING_AGENT_NUM_CTX,
|
||
):
|
||
if chunk.type == "content" and chunk.content:
|
||
accumulated_content += chunk.content
|
||
elif chunk.type == "tool_calls" and chunk.tool_calls:
|
||
accumulated_tool_calls.extend(chunk.tool_calls)
|
||
except Exception:
|
||
logger.warning(
|
||
"Agentic briefing stream failed (user %d, slot %s, round %d)",
|
||
user_id, slot, round_idx, exc_info=True,
|
||
)
|
||
return "", []
|
||
|
||
# Append the assistant turn (content + any tool calls) to history
|
||
assistant_msg: dict = {"role": "assistant", "content": accumulated_content}
|
||
if accumulated_tool_calls:
|
||
assistant_msg["tool_calls"] = accumulated_tool_calls
|
||
messages.append(assistant_msg)
|
||
|
||
# No tool calls → the model is done
|
||
if not accumulated_tool_calls:
|
||
final_text = accumulated_content.strip()
|
||
break
|
||
|
||
# Execute each tool call and append results as tool-role messages
|
||
for tc in accumulated_tool_calls:
|
||
fn = tc.get("function") or {}
|
||
tool_name = fn.get("name", "")
|
||
arguments = fn.get("arguments") or {}
|
||
if isinstance(arguments, str):
|
||
try:
|
||
import json as _json
|
||
arguments = _json.loads(arguments)
|
||
except Exception:
|
||
arguments = {}
|
||
|
||
try:
|
||
result = await execute_tool(user_id, tool_name, arguments, conv_id=conv_id)
|
||
except Exception as exc:
|
||
logger.warning(
|
||
"Tool %s failed during agentic briefing: %s", tool_name, exc,
|
||
)
|
||
result = {"success": False, "error": str(exc)}
|
||
|
||
# Serialize the result compactly for the model's context
|
||
import json as _json
|
||
try:
|
||
result_str = _json.dumps(result, default=str)[:4000]
|
||
except Exception:
|
||
result_str = str(result)[:4000]
|
||
|
||
messages.append({
|
||
"role": "tool",
|
||
"content": result_str,
|
||
"tool_name": tool_name,
|
||
})
|
||
else:
|
||
logger.warning(
|
||
"Agentic briefing hit max rounds (%d) for user %d slot %s — using last content",
|
||
_BRIEFING_AGENT_MAX_ROUNDS, user_id, slot,
|
||
)
|
||
# Walk back to find the last assistant message with non-empty content
|
||
for m in reversed(messages):
|
||
if m.get("role") == "assistant" and m.get("content"):
|
||
final_text = m["content"].strip()
|
||
break
|
||
|
||
return final_text, messages
|
||
|
||
|
||
# ── Legacy one-shot synthesis ─────────────────────────────────────────────────
|
||
|
||
async def _llm_synthesise(system_prompt: str, user_prompt: str, model: str, num_ctx: int = 4096) -> str:
|
||
"""Single non-streaming LLM call. Returns the assistant's response text."""
|
||
payload = {
|
||
"model": model,
|
||
"messages": [
|
||
{"role": "system", "content": system_prompt},
|
||
{"role": "user", "content": user_prompt},
|
||
],
|
||
"stream": False,
|
||
"options": {"num_ctx": num_ctx, "temperature": 0.4},
|
||
}
|
||
try:
|
||
async with httpx.AsyncClient(timeout=120.0) as client:
|
||
resp = await client.post(f"{Config.OLLAMA_URL}/api/chat", json=payload)
|
||
resp.raise_for_status()
|
||
data = resp.json()
|
||
return data.get("message", {}).get("content", "").strip()
|
||
except Exception:
|
||
logger.warning("LLM synthesis failed", exc_info=True)
|
||
return ""
|
||
|
||
|
||
def _unified_system_prompt(profile_body: str, slot: str = "compilation") -> str:
|
||
base = (
|
||
"You are a personal assistant. "
|
||
"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. "
|
||
"Be warm, concise, and human. "
|
||
"The user can reply to act on your suggestions via tool calls.\n\n"
|
||
)
|
||
|
||
if slot == "compilation":
|
||
base += (
|
||
"This is the MORNING BRIEFING — the full daily overview. "
|
||
"Weave together what matters today: mention the weather, note calendar events and tasks, "
|
||
"briefly reference one or two noteworthy news themes, and suggest a daily focus. "
|
||
"Be actionable: for overdue or due-today tasks, suggest a concrete next step. "
|
||
"Aim for 4 to 8 sentences.\n\n"
|
||
)
|
||
elif slot in ("morning", "midday"):
|
||
base += (
|
||
"This is a MIDDAY CHECK-IN — brief progress nudge, not a full briefing. "
|
||
"Focus on what's been done and what hasn't been touched yet today. "
|
||
"If weather conditions have changed, mention it briefly. "
|
||
"Keep it to 2-3 sentences. Don't repeat news or the full task list.\n\n"
|
||
)
|
||
elif slot == "afternoon":
|
||
base += (
|
||
"This is the AFTERNOON WRAP-UP — help the user close out the day. "
|
||
"Summarize what was accomplished today. Flag anything still overdue. "
|
||
"Ask if there's anything to capture before tomorrow — notes, thoughts, follow-ups. "
|
||
"Keep it to 2-4 sentences. Reflective and encouraging tone.\n\n"
|
||
)
|
||
elif slot == "weekly_review":
|
||
base += (
|
||
"This is the WEEKLY REVIEW — a reflective recap of the past 7 days. "
|
||
"Summarize accomplishments, flag lingering overdue items, note how many notes were created, "
|
||
"and preview the upcoming week's key events. "
|
||
"Be reflective and encouraging — celebrate progress, gently flag what's stuck. "
|
||
"Aim for 5 to 8 sentences. This wraps up a chapter.\n\n"
|
||
)
|
||
|
||
if profile_body:
|
||
base += f"User profile:\n{profile_body}\n"
|
||
return base
|
||
|
||
|
||
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}", ""]
|
||
|
||
# Weekly review — separate data path
|
||
if slot == "weekly_review":
|
||
weekly = internal_data.get("weekly_review") or {}
|
||
lines.append(f"COMPLETED THIS WEEK: {weekly.get('completed_count', 0)} tasks")
|
||
if weekly.get("completed_highlights"):
|
||
lines.extend(f" - {t}" for t in weekly["completed_highlights"])
|
||
lines.append("")
|
||
if weekly.get("overdue_count", 0) > 0:
|
||
lines.append(f"STILL OVERDUE: {weekly['overdue_count']} task(s)")
|
||
lines.extend(f" - {t}" for t in weekly.get("overdue_items", []))
|
||
lines.append("")
|
||
lines.append(f"NOTES CREATED: {weekly.get('notes_created', 0)}")
|
||
lines.append("")
|
||
if weekly.get("active_projects"):
|
||
lines.append(f"ACTIVE PROJECTS: {', '.join(weekly['active_projects'])}")
|
||
lines.append("")
|
||
if weekly.get("upcoming_events"):
|
||
lines.append("UPCOMING WEEK:")
|
||
lines.extend(f" - {e}" for e in weekly["upcoming_events"])
|
||
lines.append("")
|
||
return "\n".join(lines)
|
||
|
||
# For check-in slots, build a slimmer prompt
|
||
if slot in ("morning", "midday"):
|
||
# Just tasks status + any weather changes
|
||
if internal_data.get("due_today"):
|
||
lines.append("TASKS DUE TODAY (note which are still untouched):")
|
||
lines.extend(f" - {t}" for t in internal_data["due_today"])
|
||
lines.append("")
|
||
if internal_data.get("overdue_tasks"):
|
||
lines.append(f"STILL OVERDUE: {len(internal_data['overdue_tasks'])} task(s)")
|
||
lines.append("")
|
||
if internal_data.get("calendar_events"):
|
||
lines.append("REMAINING EVENTS TODAY:")
|
||
lines.extend(f" - {e}" for e in internal_data["calendar_events"])
|
||
lines.append("")
|
||
return "\n".join(lines)
|
||
|
||
if slot == "afternoon":
|
||
if internal_data.get("due_today"):
|
||
lines.append("TASKS THAT WERE DUE TODAY:")
|
||
lines.extend(f" - {t}" for t in internal_data["due_today"])
|
||
lines.append("")
|
||
if internal_data.get("overdue_tasks"):
|
||
overdue = internal_data["overdue_tasks"]
|
||
lines.append(f"OVERDUE ({len(overdue)} remaining):")
|
||
lines.extend(f" - {t}" for t in overdue[:3])
|
||
lines.append("")
|
||
lines.append("Ask the user if there's anything to capture before tomorrow — a note, a thought, a follow-up task.")
|
||
return "\n".join(lines)
|
||
|
||
# ── Full compilation prompt below ──────────────────────────────────────────
|
||
|
||
# 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("")
|
||
|
||
# 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("")
|
||
|
||
# Weather warnings for outdoor events
|
||
if internal_data.get("weather_warnings"):
|
||
lines.append("WEATHER ALERTS (mention these naturally when discussing affected events):")
|
||
lines.extend(f" ⚠ {w}" for w in internal_data["weather_warnings"])
|
||
lines.append("")
|
||
|
||
# Tasks due today
|
||
if internal_data.get("due_today"):
|
||
lines.append("DUE TODAY (suggest next steps — offer to mark in progress or help prioritize):")
|
||
lines.extend(f" - {t}" for t in internal_data["due_today"])
|
||
lines.append("")
|
||
|
||
# Overdue tasks
|
||
if internal_data.get("overdue_tasks"):
|
||
overdue = internal_data["overdue_tasks"]
|
||
lines.append(f"OVERDUE ({len(overdue)} task{'s' if len(overdue) != 1 else ''}) — suggest rescheduling or breaking down:")
|
||
lines.extend(f" - {t}" for t in overdue[:5])
|
||
if len(overdue) > 5:
|
||
lines.append(f" (and {len(overdue) - 5} more)")
|
||
lines.append("")
|
||
|
||
# High priority in-progress
|
||
if internal_data.get("high_priority"):
|
||
lines.append("HIGH PRIORITY:")
|
||
lines.extend(f" - {t}" for t in internal_data["high_priority"])
|
||
lines.append("")
|
||
|
||
# Your Day — prioritized focus + calendar gaps (compilation slot only)
|
||
if internal_data.get("focus_tasks") and slot == "compilation":
|
||
lines.append("YOUR DAY — top tasks to focus on (mention these as a suggested plan):")
|
||
for i, t in enumerate(internal_data["focus_tasks"], 1):
|
||
lines.append(f" {i}. {t}")
|
||
if internal_data.get("event_time_blocks"):
|
||
lines.append("")
|
||
lines.append(" Scheduled time blocks:")
|
||
lines.extend(f" {b}" for b in internal_data["event_time_blocks"])
|
||
lines.append(" (Identify free blocks between events for focused work.)")
|
||
lines.append("")
|
||
|
||
# Project continuity — what was the user working on?
|
||
if internal_data.get("recent_activity") and slot == "compilation":
|
||
lines.append("YESTERDAY'S ACTIVITY (mention what the user was working on to help them pick up):")
|
||
lines.extend(f" - {a}" for a in internal_data["recent_activity"])
|
||
lines.append("")
|
||
|
||
if internal_data.get("stale_projects") and slot == "compilation":
|
||
lines.append("STALE PROJECTS (no activity in 7+ days — gently flag):")
|
||
lines.extend(f" - {p}" for p in internal_data["stale_projects"])
|
||
lines.append("")
|
||
|
||
# News — clustered by topic, ranked by preference score
|
||
rss = external_data.get("rss_items") or []
|
||
topic_scores = external_data.get("topic_scores") or {}
|
||
if rss:
|
||
# Group articles by their primary topic
|
||
clusters: dict[str, list[dict]] = {}
|
||
uncategorized: list[dict] = []
|
||
for item in rss:
|
||
topics = item.get("topics") or []
|
||
if topics:
|
||
primary = topics[0]
|
||
clusters.setdefault(primary, []).append(item)
|
||
else:
|
||
uncategorized.append(item)
|
||
|
||
# Score each cluster by aggregate preference (positive = user likes this topic)
|
||
def cluster_score(topic: str, items: list[dict]) -> float:
|
||
pref = topic_scores.get(topic, 0.0)
|
||
return pref * 2.0 + len(items) # preference-weighted, size as tiebreak
|
||
|
||
# Filter out clusters where the user has a strongly negative preference
|
||
filtered_clusters = [
|
||
(topic, items) for topic, items in clusters.items()
|
||
if topic_scores.get(topic, 0.0) >= -2.0
|
||
]
|
||
|
||
# Sort by preference-weighted score
|
||
sorted_clusters = sorted(filtered_clusters, key=lambda x: cluster_score(x[0], x[1]), reverse=True)
|
||
|
||
lines.append("NEWS THEMES (synthesize 1-2 themes into your briefing; mention article count per theme):")
|
||
for i, (topic, items) in enumerate(sorted_clusters[:4]):
|
||
count = len(items)
|
||
titles = [it.get("title", "") for it in items[:3]]
|
||
sources = list({it.get("feed_title") or it.get("source") or "News" for it in items})
|
||
pref_indicator = ""
|
||
pref = topic_scores.get(topic, 0.0)
|
||
if pref >= 2.0:
|
||
pref_indicator = " ★" # user's preferred topic
|
||
lines.append(f" [{topic.title()}]{pref_indicator} ({count} article{'s' if count != 1 else ''} from {', '.join(sources[:2])})")
|
||
for t in titles:
|
||
lines.append(f" • {t}")
|
||
# Include excerpt for preferred clusters or the top cluster
|
||
if pref >= 1.0 or i == 0:
|
||
excerpt = (items[0].get("content") or items[0].get("snippet") or "")[:400].strip()
|
||
if excerpt:
|
||
lines.append(f" > {excerpt}")
|
||
if uncategorized:
|
||
lines.append(f" [Other] ({len(uncategorized)} uncategorized article{'s' if len(uncategorized) != 1 else ''})")
|
||
lines.append("")
|
||
|
||
return "\n".join(lines)
|
||
|
||
|
||
def _format_temp(value: float, unit: str) -> str:
|
||
"""Convert Celsius to the requested unit and format as an integer string."""
|
||
if unit == "F":
|
||
return f"{value * 9 / 5 + 32:.0f}"
|
||
return f"{value:.0f}"
|
||
|
||
|
||
|
||
|
||
# ── Main entry point ───────────────────────────────────────────────────────────
|
||
|
||
async def _get_temp_unit(user_id: int) -> str:
|
||
"""Read the user's preferred temperature unit from briefing_config ('C' or 'F')."""
|
||
import json
|
||
raw = await get_setting(user_id, "briefing_config", "{}")
|
||
try:
|
||
config = json.loads(raw) if isinstance(raw, str) else (raw or {})
|
||
unit = config.get("temp_unit", "C")
|
||
return unit if unit in ("C", "F") else "C"
|
||
except Exception:
|
||
return "C"
|
||
|
||
|
||
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 (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.user_profile import build_profile_context
|
||
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_context, temp_unit = await asyncio.gather(
|
||
build_profile_context(user_id),
|
||
_get_temp_unit(user_id),
|
||
)
|
||
|
||
# ── 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),
|
||
)
|
||
|
||
# Weekly review: gather 7-day summary data
|
||
if slot == "weekly_review":
|
||
internal_data["weekly_review"] = await _gather_weekly_review(user_id)
|
||
|
||
# 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")]
|
||
rss_items_meta = [
|
||
{
|
||
"id": item["id"],
|
||
"title": item.get("title", ""),
|
||
"url": item.get("url", ""),
|
||
"source": item.get("feed_title", ""),
|
||
"snippet": (item.get("content") or "")[:300],
|
||
"published_at": item.get("published_at"),
|
||
}
|
||
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
|
||
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": [],
|
||
"topic_scores": topic_scores,
|
||
}
|
||
|
||
briefing_mode = await get_setting(user_id, "briefing_mode", "legacy")
|
||
agentic_messages: list[dict] = []
|
||
briefing_text = ""
|
||
|
||
if briefing_mode == "agentic":
|
||
briefing_text, agentic_messages = await run_agentic_briefing(
|
||
user_id, slot, model, conv_id=None,
|
||
)
|
||
if not briefing_text:
|
||
logger.warning(
|
||
"Agentic briefing returned empty for user %d slot %s — falling back to legacy path",
|
||
user_id, slot,
|
||
)
|
||
|
||
if not briefing_text:
|
||
briefing_text = await _llm_synthesise(
|
||
_unified_system_prompt(profile_context, slot),
|
||
_unified_user_prompt(internal_data_filtered, external_data_filtered, slot, temp_unit),
|
||
model,
|
||
num_ctx=8192,
|
||
)
|
||
|
||
# ── Post-processing ─────────────────────────────────────────────────────────
|
||
await upsert_task_snapshots(user_id, all_tasks)
|
||
|
||
metadata: dict = {"rss_item_ids": rss_item_ids, "rss_items": rss_items_meta, "weather": weather_card}
|
||
if agentic_messages:
|
||
metadata["agentic_messages"] = agentic_messages
|
||
|
||
if not briefing_text:
|
||
logger.warning("Briefing compilation produced no content for user %d slot %s", user_id, slot)
|
||
return "", metadata
|
||
|
||
return briefing_text, metadata
|
||
|
||
|
||
async def run_slot_injection(
|
||
user_id: int,
|
||
slot: str,
|
||
model: str | None = None,
|
||
) -> tuple[str, dict]:
|
||
"""
|
||
Lighter update for 8am/12pm/4pm — generates a slot-specific check-in.
|
||
|
||
Honors the ``briefing_mode`` setting just like ``run_compilation``:
|
||
agentic mode routes through the tool-use loop, legacy mode runs the
|
||
one-shot synthesis path. Falls back to legacy automatically if
|
||
agentic returns empty.
|
||
|
||
Returns ``(text, metadata)`` where metadata may contain
|
||
``agentic_messages`` (the full tool-call sequence) if the agentic
|
||
path was used.
|
||
"""
|
||
if model is None:
|
||
model = await get_setting(user_id, "default_model", Config.OLLAMA_MODEL)
|
||
|
||
briefing_mode = await get_setting(user_id, "briefing_mode", "legacy")
|
||
agentic_messages: list[dict] = []
|
||
text = ""
|
||
|
||
if briefing_mode == "agentic":
|
||
text, agentic_messages = await run_agentic_briefing(
|
||
user_id, slot, model, conv_id=None,
|
||
)
|
||
if not text:
|
||
logger.warning(
|
||
"Agentic slot injection returned empty for user %d slot %s — falling back to legacy",
|
||
user_id, slot,
|
||
)
|
||
|
||
if not text:
|
||
internal_data, external_data, temp_unit = await asyncio.gather(
|
||
_gather_internal(user_id),
|
||
_gather_external(user_id),
|
||
_get_temp_unit(user_id),
|
||
)
|
||
system = (
|
||
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."
|
||
)
|
||
text = await _llm_synthesise(
|
||
system,
|
||
_unified_user_prompt(internal_data, external_data, slot, temp_unit),
|
||
model,
|
||
)
|
||
|
||
metadata: dict = {}
|
||
if agentic_messages:
|
||
metadata["agentic_messages"] = agentic_messages
|
||
return text, metadata
|