feat(article-discuss): unify /news + briefing entry points, persist summaries to RAG

Both the /news discuss button and the briefing discuss button now call a
shared seed_article_discussion() helper that stages the synthetic
read_article tool exchange and the conversational seed prompt — behavior
stays byte-identical across entry points. /news also auto-starts
generation so the chat screen lands on an in-flight stream.

First assistant reply in a seeded article conversation is persisted as a
Note (tags: article-summary + article topics) and backlinked via
rss_items.discussion_note_id, so the knowledge base stops being amnesiac
about articles the user has engaged with.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-14 07:54:24 -04:00
parent 9157740069
commit ba90ad8132
8 changed files with 284 additions and 78 deletions
+44 -25
View File
@@ -510,47 +510,66 @@ async def delete_model_route():
@chat_bp.route("/from-article/<int:item_id>", methods=["POST"])
@login_required
async def create_conversation_from_article(item_id: int):
"""Create a chat conversation seeded with an RSS article's content."""
"""Create a chat conversation seeded for article discussion and auto-run.
Mirrors the briefing ``discuss_article`` route: creates a fresh
conversation, stages the shared synthetic read_article exchange + seed
prompt, then kicks off generation so the client lands on an in-flight
stream. The Flutter and web chat screens reconnect to the running buffer
on mount.
"""
from sqlalchemy import select as _select
from fabledassistant.models import async_session as _async_session
from fabledassistant.models.conversation import Message
from fabledassistant.models.rss_feed import RssItem, RssFeed
from fabledassistant.services.article_context import seed_article_discussion
uid = get_current_user_id()
async with _async_session() as session:
result = await session.execute(
_select(RssItem, RssFeed.title.label("feed_title"))
_select(RssItem)
.join(RssFeed, RssItem.feed_id == RssFeed.id)
.where(RssItem.id == item_id, RssFeed.user_id == uid)
)
row = result.first()
item = result.scalars().first()
if row is None:
if item is None:
return jsonify({"error": "Article not found"}), 404
item, feed_title = row
conv_title = (item.title or "Article discussion")[:80]
conv = await create_conversation(uid, title=conv_title, conversation_type="chat")
from fabledassistant.services.rss import _fetch_full_article
source = feed_title or "News"
content_body = (await _fetch_full_article(item.url) if item.url else None) or (item.content or "").strip()
seeded_text = f"**{source}**\n\n**{item.title}**"
if content_body:
seeded_text += f"\n\n{content_body}"
if item.url:
seeded_text += f"\n\nSource: {item.url}"
model = await get_setting(uid, "default_model", "") or Config.OLLAMA_MODEL
discuss_prompt = await seed_article_discussion(conv.id, item, model)
async with _async_session() as session:
msg = Message(
conversation_id=conv.id,
role="assistant",
content=seeded_text,
msg_metadata={"rss_item_ids": [item_id]},
)
session.add(msg)
await session.commit()
# Reload conversation so we see the two messages the helper just added.
conv = await get_conversation(uid, conv.id)
assert conv is not None
return jsonify({"conversation_id": conv.id}), 201
history: list[dict] = []
for msg in conv.messages:
if msg.role == "system":
continue
msg_dict: 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({
"conversation_id": conv.id,
"assistant_message_id": assistant_msg.id,
"status": "generating",
}), 202