650 lines
24 KiB
Python
650 lines
24 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
|
|
|
|
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)
|
|
msg = await post_message(conv.id, "assistant", text, metadata=metadata)
|
|
return jsonify({"conversation_id": conv.id, "message_id": msg.id, "slot": slot})
|
|
|
|
|
|
# ── 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
|
|
|
|
from fabledassistant.services.rss import _fetch_full_article
|
|
article_content = await _fetch_full_article(item.url) or item.content or ""
|
|
|
|
# Store synthetic assistant message with read_article tool result
|
|
synthetic_tool_calls = [{
|
|
"function": "read_article",
|
|
"arguments": {"url": item.url},
|
|
"result": {
|
|
"success": True,
|
|
"type": "article_content",
|
|
"url": item.url,
|
|
"content": article_content,
|
|
"truncated": False,
|
|
},
|
|
}]
|
|
await add_message(conv_id, "assistant", "", status="complete", tool_calls=synthetic_tool_calls)
|
|
|
|
# Store user message
|
|
await add_message(conv_id, "user", "Please summarize and discuss this article.")
|
|
|
|
# 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)
|
|
|
|
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 "",
|
|
"Please summarize and discuss this article.",
|
|
think=True,
|
|
))
|
|
|
|
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,
|
|
think=True,
|
|
))
|
|
|
|
return jsonify({
|
|
"assistant_message_id": assistant_msg.id,
|
|
"status": "generating",
|
|
"article_count": len(items),
|
|
}), 202
|