Files
FabledScribe/src/fabledassistant/routes/briefing.py
T
bvandeusen 7bd1548f71 fix(discuss): hard-fail empty articles and skip RAG on seed turn
Discuss flow was hallucinating unrelated content when article
extraction returned empty or RAG pulled in orphan notes that looked
more relevant than the generic seed prompt.

- seed_article_discussion raises EmptyArticleError on empty body;
  briefing and /news routes return 422 instead of staging an empty
  synthetic tool result.
- build_context skips RAG auto-injection when user_message matches
  ARTICLE_DISCUSS_SEED so the article IS the context on turn one;
  follow-up turns keep RAG on.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 18:13:17 -04:00

683 lines
26 KiB
Python

"""Briefing API: RSS feeds, weather, and briefing configuration."""
import asyncio
import json
import logging
from quart import Blueprint, g, jsonify, request
from sqlalchemy import select
from fabledassistant.auth import login_required
from fabledassistant.models import async_session
from fabledassistant.models.conversation import Conversation, Message
from fabledassistant.models.rss_feed import RssFeed, RssItem
from fabledassistant.services import rss as rss_svc
from fabledassistant.services import weather as weather_svc
from fabledassistant.services.briefing_conversations import (
get_or_create_today_conversation,
list_briefing_conversations,
post_message,
)
from fabledassistant.services.chat import add_message, get_conversation
from fabledassistant.services.generation_buffer import create_buffer, get_buffer
from fabledassistant.services.generation_task import run_generation
from fabledassistant.services.settings import get_setting, set_settings_batch
logger = logging.getLogger(__name__)
briefing_bp = Blueprint("briefing", __name__, url_prefix="/api/briefing")
_REQUIRE = login_required
# ── Config ────────────────────────────────────────────────────────────────────
@briefing_bp.route("/config", methods=["GET"])
@_REQUIRE
async def get_config():
raw = await get_setting(g.user.id, "briefing_config", "{}")
try:
config = json.loads(raw) if isinstance(raw, str) else (raw or {})
except Exception:
config = {}
return jsonify(config)
@briefing_bp.route("/config", methods=["PUT"])
@_REQUIRE
async def put_config():
data = await request.get_json()
await set_settings_batch(g.user.id, {"briefing_config": json.dumps(data)})
# Live-patch the scheduler using the stored user_timezone.
from fabledassistant.services.briefing_scheduler import update_user_schedule
tz_override = await get_setting(g.user.id, "user_timezone") or None
update_user_schedule(g.user.id, data, tz_override=tz_override)
return jsonify({"ok": True})
# ── RSS Feeds ─────────────────────────────────────────────────────────────────
@briefing_bp.route("/feeds", methods=["GET"])
@_REQUIRE
async def list_feeds():
async with async_session() as session:
result = await session.execute(
select(RssFeed).where(RssFeed.user_id == g.user.id).order_by(RssFeed.id)
)
feeds = list(result.scalars().all())
return jsonify([f.to_dict() for f in feeds])
@briefing_bp.route("/feeds", methods=["POST"])
@_REQUIRE
async def add_feed():
data = await request.get_json()
url = (data.get("url") or "").strip()
if not url:
return jsonify({"error": "url required"}), 400
scheme = url.split("://")[0].lower() if "://" in url else ""
if scheme not in ("http", "https"):
return jsonify({"error": "Feed URL must use http or https"}), 400
from fabledassistant.services.llm import _is_private_url
if _is_private_url(url):
return jsonify({"error": "Feed URL must not point to an internal address"}), 400
category = data.get("category") or None
async with async_session() as session:
# Prevent duplicates
existing = await session.execute(
select(RssFeed).where(RssFeed.user_id == g.user.id, RssFeed.url == url)
)
if existing.scalars().first():
return jsonify({"error": "Feed already added"}), 409
feed = RssFeed(user_id=g.user.id, url=url, title="", category=category)
session.add(feed)
await session.commit()
await session.refresh(feed)
feed_id = feed.id
# Fetch in background to auto-populate title and get initial items
asyncio.create_task(rss_svc.fetch_and_cache_feed(feed_id, url))
return jsonify({"id": feed_id, "url": url, "title": "", "category": category}), 201
@briefing_bp.route("/feeds/<int:feed_id>", methods=["DELETE"])
@_REQUIRE
async def delete_feed(feed_id: int):
async with async_session() as session:
result = await session.execute(
select(RssFeed).where(RssFeed.id == feed_id, RssFeed.user_id == g.user.id)
)
feed = result.scalars().first()
if not feed:
return jsonify({"error": "Not found"}), 404
await session.delete(feed)
await session.commit()
return jsonify({"ok": True})
@briefing_bp.route("/feeds/refresh", methods=["POST"])
@_REQUIRE
async def refresh_feeds():
results = await rss_svc.refresh_all_feeds(g.user.id)
total_new = sum(results.values())
return jsonify({"feeds_refreshed": len(results), "new_items": total_new})
@briefing_bp.route("/feeds/recent", methods=["GET"])
@_REQUIRE
async def recent_items():
limit = min(int(request.args.get("limit", 20)), 100)
items = await rss_svc.get_recent_items(g.user.id, limit=limit)
return jsonify({"items": items})
# ── Weather ───────────────────────────────────────────────────────────────────
@briefing_bp.route("/weather", methods=["GET"])
@_REQUIRE
async def get_weather():
rows = await weather_svc.get_cached_weather_rows(g.user.id)
import json as _json
raw = await get_setting(g.user.id, "briefing_config", "{}")
try:
_cfg = _json.loads(raw) if isinstance(raw, str) else (raw or {})
temp_unit = _cfg.get("temp_unit", "C")
if temp_unit not in ("C", "F"):
temp_unit = "C"
except Exception:
temp_unit = "C"
cards = [
card for row in rows
if (card := weather_svc.parse_weather_card_data(row, temp_unit)) is not None
]
return jsonify({"locations": cards, "temp_unit": temp_unit})
@briefing_bp.route("/weather/current", methods=["GET"])
@_REQUIRE
async def get_current_weather():
"""Return current temperature, conditions, and precipitation for the user's primary location.
Lightweight — fetches live from Open-Meteo, no caching. Intended for periodic frontend polling.
"""
raw = await get_setting(g.user.id, "briefing_config", "{}")
try:
config = json.loads(raw) if isinstance(raw, str) else (raw or {})
temp_unit = config.get("temp_unit", "C")
if temp_unit not in ("C", "F"):
temp_unit = "C"
locations = config.get("locations", {})
except Exception:
return jsonify({"error": "No briefing config"}), 404
# Use home location, fall back to work
loc = locations.get("home") or locations.get("work")
if not loc or not loc.get("lat") or not loc.get("lon"):
return jsonify({"error": "No location configured"}), 404
from fabledassistant.services.weather import fetch_current_conditions
current = await fetch_current_conditions(loc["lat"], loc["lon"])
if current is None:
return jsonify({"error": "Failed to fetch current conditions"}), 502
# Convert temperature if needed
temp = current["temperature"]
if temp is not None and temp_unit == "F":
temp = temp * 9 / 5 + 32
current["temperature"] = round(temp) if temp is not None else None
current["temp_unit"] = temp_unit
current["location"] = loc.get("label") or "Home"
return jsonify(current)
@briefing_bp.route("/weather/geocode", methods=["POST"])
@_REQUIRE
async def geocode_location():
data = await request.get_json()
query = (data.get("query") or "").strip()
if not query:
return jsonify({"error": "query required"}), 400
try:
lat, lon, label = await weather_svc.geocode(query)
return jsonify({"lat": lat, "lon": lon, "label": label})
except ValueError as e:
return jsonify({"error": str(e)}), 404
@briefing_bp.route("/weather/refresh", methods=["POST"])
@_REQUIRE
async def refresh_weather():
raw = await get_setting(g.user.id, "briefing_config", "{}")
try:
config = json.loads(raw) if isinstance(raw, str) else {}
except Exception:
config = {}
locations = config.get("locations", {})
refreshed = []
for key, loc in locations.items():
if not loc.get("lat") or not loc.get("lon"):
continue
try:
await weather_svc.refresh_location_cache(
user_id=g.user.id,
location_key=key,
location_label=loc.get("label", key),
lat=loc["lat"],
lon=loc["lon"],
)
refreshed.append(key)
except Exception:
logger.warning("Failed to refresh weather for %s", key, exc_info=True)
return jsonify({"refreshed": refreshed})
# ── Briefing Conversations ─────────────────────────────────────────────────────
@briefing_bp.route("/conversations", methods=["GET"])
@_REQUIRE
async def get_conversations():
convs = await list_briefing_conversations(g.user.id)
return jsonify({"conversations": convs})
@briefing_bp.route("/conversations/today", methods=["GET"])
@_REQUIRE
async def get_today_conversation():
model = await get_setting(g.user.id, "default_model", "")
conv = await get_or_create_today_conversation(g.user.id, model)
# Load messages
async with async_session() as session:
result = await session.execute(
select(Message)
.where(Message.conversation_id == conv.id)
.order_by(Message.created_at)
)
messages = [m.to_dict() for m in result.scalars().all()]
data = conv.to_dict()
data["messages"] = messages
return jsonify(data)
@briefing_bp.route("/conversations/<int:conv_id>/messages", methods=["GET"])
@_REQUIRE
async def get_conversation_messages(conv_id: int):
# Verify ownership
async with async_session() as session:
conv = await session.get(Conversation, conv_id)
if not conv or conv.user_id != g.user.id or conv.conversation_type != "briefing":
return jsonify({"error": "Not found"}), 404
result = await session.execute(
select(Message)
.where(Message.conversation_id == conv_id)
.order_by(Message.created_at)
)
messages = [m.to_dict() for m in result.scalars().all()]
return jsonify({"messages": messages})
@briefing_bp.route("/trigger", methods=["POST"])
@_REQUIRE
async def manual_trigger():
"""Manually trigger a briefing compilation, including a fresh data refresh."""
data = await request.get_json() or {}
slot = data.get("slot", "compilation")
if slot not in ("compilation", "morning", "midday", "afternoon"):
return jsonify({"error": "invalid slot"}), 400
# Refresh external data first (mirrors what the scheduler does)
try:
from fabledassistant.services.rss import refresh_all_feeds
config_raw = await get_setting(g.user.id, "briefing_config", "{}")
config = json.loads(config_raw) if isinstance(config_raw, str) else {}
await refresh_all_feeds(g.user.id)
for key, loc in config.get("locations", {}).items():
if loc.get("lat") and loc.get("lon"):
await weather_svc.refresh_location_cache(
user_id=g.user.id,
location_key=key,
location_label=loc.get("label", key),
lat=loc["lat"],
lon=loc["lon"],
)
except Exception:
logger.warning("Pre-trigger refresh failed for user %d", g.user.id, exc_info=True)
from fabledassistant.services.briefing_pipeline import run_compilation
from fabledassistant.services.briefing_scheduler import _persist_agentic_messages
model = await get_setting(g.user.id, "default_model", "")
conv = await get_or_create_today_conversation(g.user.id, model)
text, metadata = await run_compilation(g.user.id, slot, model)
await _persist_agentic_messages(conv.id, slot, metadata)
final_meta = {k: v for k, v in metadata.items() if k != "agentic_messages"}
final_meta["briefing_slot"] = slot
msg = await post_message(conv.id, "assistant", text, metadata=final_meta)
return jsonify({"conversation_id": conv.id, "message_id": msg.id, "slot": slot})
@briefing_bp.route("/reset-today", methods=["POST"])
@_REQUIRE
async def reset_today_briefing():
"""Delete all messages in today's briefing conversation.
The conversation row itself is kept so its id stays stable for any
open UI sessions. Intended for "wipe and start fresh" workflows
driven from the MCP when iterating on prompts. Pair with
``POST /api/briefing/trigger`` to immediately regenerate.
"""
from sqlalchemy import delete as _delete
from fabledassistant.services.tz import user_briefing_date
# User-local briefing day (flips at 4am local), not ``date.today()`` —
# see services/tz.py for rationale.
today = await user_briefing_date(g.user.id)
async with async_session() as session:
result = await session.execute(
select(Conversation).where(
Conversation.user_id == g.user.id,
Conversation.conversation_type == "briefing",
Conversation.briefing_date == today,
)
)
conv = result.scalars().first()
if conv is None:
return jsonify({"deleted": 0, "conversation_id": None})
deleted_result = await session.execute(
_delete(Message).where(Message.conversation_id == conv.id)
)
await session.commit()
deleted = deleted_result.rowcount or 0
return jsonify({"deleted": deleted, "conversation_id": conv.id})
# ── RSS Reactions ──────────────────────────────────────────────────────────────
@briefing_bp.route("/rss-reactions", methods=["POST"])
@_REQUIRE
async def upsert_rss_reaction():
"""Upsert a 👍/👎 reaction on an RSS item. Same reaction toggles off; opposite flips."""
data = await request.get_json()
rss_item_id = data.get("rss_item_id")
reaction = data.get("reaction")
if not rss_item_id or reaction not in ("up", "down"):
return jsonify({"error": "rss_item_id and reaction ('up'|'down') required"}), 400
from sqlalchemy import text as _text
async with async_session() as session:
# Ownership check: verify item belongs to a feed owned by this user
result = await session.execute(
_text("""
SELECT i.id FROM rss_items i
JOIN rss_feeds f ON f.id = i.feed_id
WHERE i.id = :item_id AND f.user_id = :uid
""").bindparams(item_id=rss_item_id, uid=g.user.id)
)
if result.first() is None:
return jsonify({"error": "Not found"}), 404
# Check existing reaction
existing = await session.execute(
_text("""
SELECT id, reaction FROM rss_item_reactions
WHERE user_id = :uid AND rss_item_id = :item_id
""").bindparams(uid=g.user.id, item_id=rss_item_id)
)
row = existing.first()
if row is None:
await session.execute(
_text("""
INSERT INTO rss_item_reactions (user_id, rss_item_id, reaction)
VALUES (:uid, :item_id, :reaction)
""").bindparams(uid=g.user.id, item_id=rss_item_id, reaction=reaction)
)
action = "created"
elif row.reaction == reaction:
# Toggle off (same reaction clicked again)
await session.execute(
_text("""
DELETE FROM rss_item_reactions
WHERE user_id = :uid AND rss_item_id = :item_id
""").bindparams(uid=g.user.id, item_id=rss_item_id)
)
action = "removed"
else:
# Flip to opposite reaction
await session.execute(
_text("""
UPDATE rss_item_reactions SET reaction = :reaction
WHERE user_id = :uid AND rss_item_id = :item_id
""").bindparams(reaction=reaction, uid=g.user.id, item_id=rss_item_id)
)
action = "updated"
await session.commit()
return jsonify({"ok": True, "action": action})
@briefing_bp.route("/rss-reactions/<int:item_id>", methods=["DELETE"])
@_REQUIRE
async def delete_rss_reaction(item_id: int):
"""Explicitly remove a reaction (useful for MCP/external API callers)."""
from sqlalchemy import text as _text
async with async_session() as session:
await session.execute(
_text("""
DELETE FROM rss_item_reactions
WHERE user_id = :uid AND rss_item_id = :item_id
""").bindparams(uid=g.user.id, item_id=item_id)
)
await session.commit()
return jsonify({"ok": True})
@briefing_bp.route("/news", methods=["GET"])
@_REQUIRE
async def list_news():
"""Return recent RSS articles with optional feed filter and pagination.
Query params:
days — lookback window (default 2, max 90)
limit — items per page (default 40, max 100)
offset — pagination offset (default 0)
feed_id — optional integer filter by feed
"""
from sqlalchemy import text as _text
days = min(int(request.args.get("days", 2)), 90)
limit = min(int(request.args.get("limit", 40)), 100)
offset = max(int(request.args.get("offset", 0)), 0)
feed_id = request.args.get("feed_id", type=int)
async with async_session() as session:
result = await session.execute(
_text("""
SELECT
i.id, i.title, i.url, i.content, i.published_at,
i.topics, f.title AS feed_title,
r.reaction
FROM rss_items i
JOIN rss_feeds f ON f.id = i.feed_id
LEFT JOIN rss_item_reactions r
ON r.rss_item_id = i.id AND r.user_id = :uid
WHERE f.user_id = :uid
AND (CAST(:feed_id AS integer) IS NULL OR f.id = CAST(:feed_id AS integer))
AND COALESCE(i.published_at, i.fetched_at) >= NOW() - make_interval(days => :days)
ORDER BY COALESCE(i.published_at, i.fetched_at) DESC
LIMIT :limit OFFSET :offset
""").bindparams(uid=g.user.id, days=days, limit=limit,
offset=offset, feed_id=feed_id)
)
rows = result.mappings().all()
items = [
{
"id": r["id"],
"title": r["title"],
"url": r["url"],
"snippet": (r["content"] or "")[:300],
"content": r["content"] or "",
"published_at": r["published_at"].isoformat() if r["published_at"] else None,
"topics": r["topics"] or [],
"source": r["feed_title"],
"reaction": r["reaction"],
}
for r in rows
]
return jsonify({"items": items, "offset": offset, "limit": limit})
# ── Article Discuss ────────────────────────────────────────────────────────────
@briefing_bp.route("/articles/<int:item_id>/discuss", methods=["POST"])
@_REQUIRE
async def discuss_article(item_id: int):
"""Inject article content as a synthetic tool exchange and trigger generation."""
data = await request.get_json() or {}
conv_id = data.get("conv_id")
if not conv_id:
return jsonify({"error": "conv_id required"}), 400
uid = g.user.id
# Verify item belongs to user via feed ownership
async with async_session() as session:
result = await session.execute(
select(RssItem)
.join(RssFeed, RssFeed.id == RssItem.feed_id)
.where(RssItem.id == item_id, RssFeed.user_id == uid)
)
item = result.scalars().first()
if item is None:
return jsonify({"error": "Not found"}), 404
# Verify conversation belongs to user
conv = await get_conversation(uid, conv_id)
if conv is None:
return jsonify({"error": "Conversation not found"}), 404
# Reject if generation already running
if get_buffer(conv_id) is not None:
return jsonify({"error": "Generation already in progress"}), 409
# Shared helper handles the three-layer cache (context_prepared →
# content_full → fresh fetch), writes the synthetic read_article tool
# exchange and the conversational seed user prompt into the conversation.
# The /news from-article route calls the same helper so behavior stays
# byte-identical across entry points.
from fabledassistant.services.article_context import (
EmptyArticleError,
seed_article_discussion,
)
model = await get_setting(uid, "default_model", "") or ""
try:
discuss_prompt = await seed_article_discussion(conv_id, item, model)
except EmptyArticleError as e:
return jsonify({"error": str(e)}), 422
# Reload conversation with fresh messages to build history
conv = await get_conversation(uid, conv_id)
assert conv is not None
history = []
for msg in conv.messages:
if msg.role == "system":
continue
msg_dict = {"role": msg.role, "content": msg.content or ""}
if msg.tool_calls:
msg_dict["tool_calls"] = [
{"function": {"name": tc["function"], "arguments": tc["arguments"]}}
for tc in msg.tool_calls
]
history.append(msg_dict)
for tc in msg.tool_calls:
history.append({"role": "tool", "content": json.dumps(tc.get("result", {}))})
else:
history.append(msg_dict)
assistant_msg = await add_message(conv_id, "assistant", "", status="generating")
buf = create_buffer(conv_id, assistant_msg.id)
asyncio.create_task(run_generation(
buf, history, model,
uid, conv_id, conv.title or "",
discuss_prompt,
))
return jsonify({"assistant_message_id": assistant_msg.id, "status": "generating"}), 202
@briefing_bp.route("/topics/<topic>/discuss", methods=["POST"])
@_REQUIRE
async def discuss_topic(topic: str):
"""Discuss all recent articles in a topic cluster — multi-article deep analysis."""
data = await request.get_json() or {}
conv_id = data.get("conv_id")
if not conv_id:
return jsonify({"error": "conv_id required"}), 400
uid = g.user.id
# Verify conversation belongs to user
conv = await get_conversation(uid, conv_id)
if conv is None:
return jsonify({"error": "Conversation not found"}), 404
if get_buffer(conv_id) is not None:
return jsonify({"error": "Generation already in progress"}), 409
# Find recent articles with this topic (last 2 days)
async with async_session() as session:
result = await session.execute(
select(RssItem)
.join(RssFeed, RssFeed.id == RssItem.feed_id)
.where(RssFeed.user_id == uid)
.where(RssItem.topics.contains([topic]))
.order_by(RssItem.published_at.desc().nullslast())
.limit(5)
)
items = list(result.scalars().all())
if not items:
return jsonify({"error": f"No articles found for topic '{topic}'"}), 404
# Fetch full content for each article
from fabledassistant.services.rss import _fetch_full_article
synthetic_tool_calls = []
for item in items:
content = await _fetch_full_article(item.url) if item.url else None
content = content or item.content or ""
synthetic_tool_calls.append({
"function": "read_article",
"arguments": {"url": item.url or ""},
"result": {
"success": True,
"type": "article_content",
"url": item.url or "",
"title": item.title or "",
"source": "",
"content": content[:8000], # cap per article to stay within context
"truncated": len(content) > 8000,
},
})
await add_message(conv_id, "assistant", "", status="complete", tool_calls=synthetic_tool_calls)
user_prompt = (
f"I'd like to discuss the {len(items)} articles about '{topic}'. "
"Don't just summarize each one — draw connections between the sources, "
"highlight where they agree or disagree, and share your analysis of what "
"this means. Let's have a real discussion about this topic."
)
await add_message(conv_id, "user", user_prompt)
conv = await get_conversation(uid, conv_id)
assert conv is not None
history = []
for msg in conv.messages:
if msg.role == "system":
continue
msg_dict = {"role": msg.role, "content": msg.content or ""}
if msg.tool_calls:
msg_dict["tool_calls"] = [
{"function": {"name": tc["function"], "arguments": tc["arguments"]}}
for tc in msg.tool_calls
]
history.append(msg_dict)
for tc in msg.tool_calls:
history.append({"role": "tool", "content": json.dumps(tc.get("result", {}))})
else:
history.append(msg_dict)
model = await get_setting(uid, "default_model", "") or ""
assistant_msg = await add_message(conv_id, "assistant", "", status="generating")
buf = create_buffer(conv_id, assistant_msg.id)
asyncio.create_task(run_generation(
buf, history, model,
uid, conv_id, conv.title or "",
user_prompt,
))
return jsonify({
"assistant_message_id": assistant_msg.id,
"status": "generating",
"article_count": len(items),
}), 202