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:
@@ -0,0 +1,38 @@
|
||||
"""Link rss_items to their discussion-summary note.
|
||||
|
||||
Revision ID: 0039
|
||||
Revises: 0038
|
||||
Create Date: 2026-04-14
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
revision = "0039"
|
||||
down_revision = "0038"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"rss_items",
|
||||
sa.Column("discussion_note_id", sa.BigInteger(), nullable=True),
|
||||
)
|
||||
op.create_foreign_key(
|
||||
"fk_rss_items_discussion_note_id",
|
||||
"rss_items",
|
||||
"notes",
|
||||
["discussion_note_id"],
|
||||
["id"],
|
||||
ondelete="SET NULL",
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_constraint(
|
||||
"fk_rss_items_discussion_note_id", "rss_items", type_="foreignkey"
|
||||
)
|
||||
op.drop_column("rss_items", "discussion_note_id")
|
||||
@@ -426,7 +426,9 @@ export async function deleteRssReaction(rssItemId: number): Promise<void> {
|
||||
return apiDelete(`/api/briefing/rss-reactions/${rssItemId}`);
|
||||
}
|
||||
|
||||
export async function openArticleInChat(itemId: number): Promise<{ conversation_id: number }> {
|
||||
export async function openArticleInChat(
|
||||
itemId: number,
|
||||
): Promise<{ conversation_id: number; assistant_message_id?: number; status?: string }> {
|
||||
return apiPost(`/api/chat/from-article/${itemId}`, {});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import ARRAY, DateTime, ForeignKey, Index, Integer, Text, UniqueConstraint
|
||||
from sqlalchemy import ARRAY, BigInteger, DateTime, ForeignKey, Index, Integer, Text, UniqueConstraint
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from fabledassistant.models import Base
|
||||
@@ -66,6 +66,13 @@ class RssItem(Base):
|
||||
classified_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True), nullable=True
|
||||
)
|
||||
# Note persisting the first-click discussion summary. Set by the article
|
||||
# discussion pipeline once the seeded chat completes its first assistant
|
||||
# reply; links back into RAG so re-discussing the same article lands the
|
||||
# prior summary in context.
|
||||
discussion_note_id: Mapped[int | None] = mapped_column(
|
||||
BigInteger, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
feed: Mapped["RssFeed"] = relationship(back_populates="items")
|
||||
|
||||
|
||||
@@ -532,54 +532,15 @@ async def discuss_article(item_id: int):
|
||||
if get_buffer(conv_id) is not None:
|
||||
return jsonify({"error": "Generation already in progress"}), 409
|
||||
|
||||
# Three-layer cache: context_prepared (post-map-reduce) → content_full
|
||||
# (raw trafilatura) → fresh fetch. Only the first miss pays the fetch
|
||||
# cost; only a large uncached article pays the map-reduce cost. Repeat
|
||||
# clicks on the same article skip straight to the chat turn.
|
||||
from fabledassistant.services.article_context import prepare_article_context
|
||||
from fabledassistant.services.rss import get_or_fetch_full_article
|
||||
# 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 seed_article_discussion
|
||||
|
||||
model = await get_setting(uid, "default_model", "") or ""
|
||||
|
||||
if item.context_prepared:
|
||||
article_content = item.context_prepared
|
||||
else:
|
||||
raw_body = await get_or_fetch_full_article(item) or item.content or ""
|
||||
article_content = await prepare_article_context(
|
||||
item.title or "", item.url, raw_body, model,
|
||||
)
|
||||
if article_content:
|
||||
async with async_session() as session:
|
||||
fresh = await session.get(RssItem, item.id)
|
||||
if fresh is not None:
|
||||
fresh.context_prepared = article_content
|
||||
await session.commit()
|
||||
|
||||
# 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)
|
||||
|
||||
# Conversational seed — invites a real discussion rather than asking for
|
||||
# a one-shot summary. The model sees the article context in the tool
|
||||
# result above and responds to this user turn as the start of an ongoing
|
||||
# conversation the user will steer with follow-ups.
|
||||
discuss_prompt = (
|
||||
"I want to talk about this article. Start with a substantive summary "
|
||||
"of what it's arguing and the key evidence it uses, then tell me what "
|
||||
"stood out to you or seems worth pushing back on. I'll ask follow-ups "
|
||||
"from there."
|
||||
)
|
||||
await add_message(conv_id, "user", discuss_prompt)
|
||||
discuss_prompt = await seed_article_discussion(conv_id, item, model)
|
||||
|
||||
# Reload conversation with fresh messages to build history
|
||||
conv = await get_conversation(uid, conv_id)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
"""Prepare article bodies as conversation-ready context.
|
||||
|
||||
Used by the briefing ``discuss-article`` flow. A raw trafilatura extraction
|
||||
is often too large to drop whole into a chat history without eating the
|
||||
context window, so this module runs a map-reduce step over oversized
|
||||
articles and returns a compact, structured context that still preserves the
|
||||
article's meaning across sections.
|
||||
Used by the briefing ``discuss-article`` flow and the ``/news`` discuss button.
|
||||
A raw trafilatura extraction is often too large to drop whole into a chat
|
||||
history without eating the context window, so this module runs a map-reduce
|
||||
step over oversized articles and returns a compact, structured context that
|
||||
still preserves the article's meaning across sections.
|
||||
|
||||
Small articles pass through unchanged — map-reduce only fires when the raw
|
||||
body exceeds CHAR_BUDGET. The output is cached on ``rss_items.context_prepared``
|
||||
by the caller, so repeat discuss-clicks on the same article skip this work
|
||||
entirely.
|
||||
|
||||
The module also owns ``seed_article_discussion``, the shared routine that
|
||||
stages a synthetic ``read_article`` tool exchange plus a conversational seed
|
||||
prompt into a conversation. Both the briefing and ``/news`` entry points call
|
||||
it so the two flows stay byte-identical — the only thing that differs between
|
||||
them is whether the conversation already existed or was freshly created.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -18,6 +24,9 @@ import asyncio
|
||||
import logging
|
||||
import re
|
||||
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.rss_feed import RssItem
|
||||
from fabledassistant.services.chat import add_message
|
||||
from fabledassistant.services.llm import generate_completion
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -159,3 +168,79 @@ async def prepare_article_context(
|
||||
for i, summary in enumerate(summaries)
|
||||
]
|
||||
return header + "\n\n".join(parts)
|
||||
|
||||
|
||||
# Conversational seed prompt for article discussions. Kept here so both the
|
||||
# briefing and /news entry points use the exact same wording. See
|
||||
# feedback_discuss_prompt_style memory: numbered checklists produce
|
||||
# assignment-completion responses; this conversational seed opens a dialogue.
|
||||
ARTICLE_DISCUSS_SEED = (
|
||||
"I want to talk about this article. Start with a substantive summary "
|
||||
"of what it's arguing and the key evidence it uses, then tell me what "
|
||||
"stood out to you or seems worth pushing back on. I'll ask follow-ups "
|
||||
"from there."
|
||||
)
|
||||
|
||||
|
||||
async def seed_article_discussion(
|
||||
conv_id: int,
|
||||
item: RssItem,
|
||||
model: str,
|
||||
) -> str:
|
||||
"""Stage the synthetic read_article tool exchange + conversational seed.
|
||||
|
||||
Used by both the briefing ``discuss_article`` route and the ``/news``
|
||||
``from-article`` conversation creator. Handles the three-layer cache
|
||||
(``context_prepared`` → ``content_full`` → fresh fetch) and inserts two
|
||||
messages into ``conv_id``:
|
||||
|
||||
1. An assistant message with a synthetic ``read_article`` tool_call whose
|
||||
``result.content`` carries the prepared article context. The message
|
||||
also carries ``msg_metadata={"rss_item_id": ...}`` so the post-generation
|
||||
hook in ``generation_task.py`` can locate it and persist the first
|
||||
reply as a discussion-summary Note.
|
||||
2. A user message with the shared conversational seed prompt.
|
||||
|
||||
Returns the seed prompt string so callers can pass it to ``run_generation``
|
||||
as ``user_content``.
|
||||
"""
|
||||
# Avoid circulars: rss helper imports article_context indirectly nowhere,
|
||||
# but keep this local for symmetry with the route-level imports it
|
||||
# replaces.
|
||||
from fabledassistant.services.rss import get_or_fetch_full_article
|
||||
|
||||
if item.context_prepared:
|
||||
article_content = item.context_prepared
|
||||
else:
|
||||
raw_body = await get_or_fetch_full_article(item) or item.content or ""
|
||||
article_content = await prepare_article_context(
|
||||
item.title or "", item.url, raw_body, model,
|
||||
)
|
||||
if article_content:
|
||||
async with async_session() as session:
|
||||
fresh = await session.get(RssItem, item.id)
|
||||
if fresh is not None:
|
||||
fresh.context_prepared = article_content
|
||||
await session.commit()
|
||||
|
||||
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,
|
||||
msg_metadata={"rss_item_id": item.id, "article_seed": True},
|
||||
)
|
||||
await add_message(conv_id, "user", ARTICLE_DISCUSS_SEED)
|
||||
return ARTICLE_DISCUSS_SEED
|
||||
|
||||
@@ -187,6 +187,7 @@ async def add_message(
|
||||
context_note_id: int | None = None,
|
||||
status: str | None = None,
|
||||
tool_calls: list | None = None,
|
||||
msg_metadata: dict | None = None,
|
||||
) -> Message:
|
||||
async with async_session() as session:
|
||||
kwargs: dict = dict(
|
||||
@@ -199,6 +200,8 @@ async def add_message(
|
||||
kwargs["status"] = status
|
||||
if tool_calls is not None:
|
||||
kwargs["tool_calls"] = tool_calls
|
||||
if msg_metadata is not None:
|
||||
kwargs["msg_metadata"] = msg_metadata
|
||||
msg = Message(**kwargs)
|
||||
session.add(msg)
|
||||
# Touch conversation updated_at
|
||||
|
||||
@@ -36,6 +36,85 @@ _TOOL_CALL_MARKER = re.compile(r"^\s*\[TOOL_CALLS\]\s*", re.IGNORECASE)
|
||||
|
||||
DB_FLUSH_INTERVAL = 5.0 # seconds between partial DB flushes
|
||||
|
||||
|
||||
async def _maybe_save_article_discussion_note(
|
||||
user_id: int, conv_id: int, reply_content: str,
|
||||
) -> None:
|
||||
"""Persist a seeded article-discussion's first reply as a Note.
|
||||
|
||||
Fires after ``run_generation`` completes. Looks for a synthetic
|
||||
read_article seed message on the conversation; if found AND the linked
|
||||
``rss_items`` row has no ``discussion_note_id`` yet, saves ``reply_content``
|
||||
as a Note, tags it, and writes the backlink. Subsequent discuss clicks on
|
||||
the same article are a no-op (already linked).
|
||||
|
||||
Failures are logged and swallowed — the chat UI should never break because
|
||||
Note persistence hit a snag.
|
||||
"""
|
||||
try:
|
||||
if not reply_content or not reply_content.strip():
|
||||
return
|
||||
from sqlalchemy import select as _select
|
||||
from fabledassistant.models.conversation import Message as _Message
|
||||
from fabledassistant.models.rss_feed import RssItem as _RssItem
|
||||
from fabledassistant.services.notes import create_note
|
||||
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
_select(_Message)
|
||||
.where(_Message.conversation_id == conv_id)
|
||||
.order_by(_Message.id.asc())
|
||||
)
|
||||
messages = result.scalars().all()
|
||||
seed_meta = None
|
||||
for m in messages:
|
||||
meta = m.msg_metadata or {}
|
||||
if meta.get("article_seed") and meta.get("rss_item_id"):
|
||||
seed_meta = meta
|
||||
break
|
||||
if seed_meta is None:
|
||||
return
|
||||
item_id = int(seed_meta["rss_item_id"])
|
||||
item = await session.get(_RssItem, item_id)
|
||||
if item is None or item.discussion_note_id is not None:
|
||||
return
|
||||
article_title = (item.title or "Untitled article").strip()
|
||||
article_url = item.url
|
||||
article_topics = list(item.topics or [])
|
||||
|
||||
note_title = f"Article: {article_title}"[:200]
|
||||
body_parts = [f"**Source:** {article_url}"] if article_url else []
|
||||
body_parts.append(reply_content.strip())
|
||||
note_body = "\n\n".join(body_parts)
|
||||
tags = ["article-summary"] + [t for t in article_topics if t]
|
||||
note = await create_note(
|
||||
user_id=user_id,
|
||||
title=note_title,
|
||||
body=note_body,
|
||||
tags=tags,
|
||||
entity_meta={
|
||||
"source": "article_discussion",
|
||||
"rss_item_id": item_id,
|
||||
"url": article_url,
|
||||
"conversation_id": conv_id,
|
||||
},
|
||||
)
|
||||
|
||||
async with async_session() as session:
|
||||
fresh = await session.get(_RssItem, item_id)
|
||||
if fresh is not None and fresh.discussion_note_id is None:
|
||||
fresh.discussion_note_id = note.id
|
||||
await session.commit()
|
||||
logger.info(
|
||||
"Saved article-discussion summary as note %d for rss_item %d (conv %d)",
|
||||
note.id, item_id, conv_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to persist article-discussion note for conv %d",
|
||||
conv_id, exc_info=True,
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Thinking decision
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -526,6 +605,18 @@ async def run_generation(
|
||||
msg_count = len(non_system)
|
||||
should_gen_title = not conv_title or (msg_count > 0 and msg_count % 10 == 0)
|
||||
|
||||
# Persist article-discussion seed conversations as a Note on their
|
||||
# first assistant reply. This makes "Discuss" summaries part of RAG
|
||||
# so the knowledge base stops being amnesiac about articles the user
|
||||
# has already engaged with. The hook detects a seeded conversation by
|
||||
# finding a synthetic read_article assistant message whose
|
||||
# msg_metadata carries ``article_seed: True`` and whose rss_items row
|
||||
# has no discussion_note_id yet. Fire-and-forget so the done event
|
||||
# lands immediately.
|
||||
asyncio.create_task(_maybe_save_article_discussion_note(
|
||||
user_id, conv_id, buf.content_so_far,
|
||||
))
|
||||
|
||||
if should_gen_title:
|
||||
# Feed the title model the *raw* conversation turns only — never
|
||||
# the post-build_context ``messages`` list. ``build_context``
|
||||
|
||||
Reference in New Issue
Block a user