7bd1548f71
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>
271 lines
10 KiB
Python
271 lines
10 KiB
Python
"""Prepare article bodies as conversation-ready context.
|
||
|
||
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
|
||
|
||
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__)
|
||
|
||
# ~12k tokens at 4 chars/token. Comfortably under OLLAMA_NUM_CTX=16384
|
||
# with room left for system prompt, chat history, and the assistant reply.
|
||
CHAR_BUDGET = 48_000
|
||
|
||
# Chunk size for the map step on oversized articles. Overlap preserves
|
||
# context across paragraph boundaries that happen to land mid-sentence.
|
||
CHUNK_CHARS = 8_000
|
||
CHUNK_OVERLAP = 400
|
||
|
||
_PARA_SPLIT = re.compile(r"\n\s*\n")
|
||
|
||
|
||
def _chunk_by_paragraph(body: str) -> list[str]:
|
||
"""Split ``body`` into chunks of up to CHUNK_CHARS, respecting paragraphs.
|
||
|
||
Paragraphs longer than CHUNK_CHARS are split mid-paragraph as a last
|
||
resort. Adjacent chunks share CHUNK_OVERLAP chars of trailing text so
|
||
a sentence straddling the boundary stays readable on both sides.
|
||
"""
|
||
paragraphs = [p.strip() for p in _PARA_SPLIT.split(body) if p.strip()]
|
||
chunks: list[str] = []
|
||
current: list[str] = []
|
||
current_len = 0
|
||
for para in paragraphs:
|
||
para_len = len(para)
|
||
if para_len > CHUNK_CHARS:
|
||
if current:
|
||
chunks.append("\n\n".join(current))
|
||
current, current_len = [], 0
|
||
for i in range(0, para_len, CHUNK_CHARS - CHUNK_OVERLAP):
|
||
chunks.append(para[i : i + CHUNK_CHARS])
|
||
continue
|
||
if current_len + para_len + 2 > CHUNK_CHARS and current:
|
||
chunks.append("\n\n".join(current))
|
||
tail = current[-1][-CHUNK_OVERLAP:] if current else ""
|
||
current = [tail, para] if tail else [para]
|
||
current_len = len(tail) + para_len + (2 if tail else 0)
|
||
else:
|
||
current.append(para)
|
||
current_len += para_len + 2
|
||
if current:
|
||
chunks.append("\n\n".join(current))
|
||
return chunks
|
||
|
||
|
||
async def _summarize_chunk(title: str, chunk: str, index: int, total: int, model: str) -> str:
|
||
"""Map-step summary of one article chunk.
|
||
|
||
Aims for ~300 words of dense, factual prose — not bullet points — so the
|
||
downstream chat model can quote from it naturally.
|
||
"""
|
||
messages = [
|
||
{
|
||
"role": "system",
|
||
"content": (
|
||
"You are summarizing one section of a larger article so a downstream "
|
||
"conversation model can discuss the full article without having to read "
|
||
"every word.\n\n"
|
||
"Requirements:\n"
|
||
"- 250–350 words of dense factual prose\n"
|
||
"- Preserve specific claims, numbers, names, and quotes\n"
|
||
"- Do NOT editorialize or add analysis\n"
|
||
"- Do NOT use bullet points or headings\n"
|
||
"- Do NOT say 'this section' or 'this article' — write content, not meta"
|
||
),
|
||
},
|
||
{
|
||
"role": "user",
|
||
"content": (
|
||
f"Article: {title}\n"
|
||
f"Section {index + 1} of {total}:\n\n{chunk}"
|
||
),
|
||
},
|
||
]
|
||
try:
|
||
# Pin num_ctx — same rationale as services/research.py:66. A large
|
||
# chunk plus system prompt can push well past the default window;
|
||
# silent truncation here would drop the tail of the chunk without
|
||
# any error, producing a misleading summary.
|
||
raw = await generate_completion(
|
||
messages, model, max_tokens=600, num_ctx=16384
|
||
)
|
||
return raw.strip()
|
||
except Exception:
|
||
logger.warning(
|
||
"Article chunk summary failed for section %d/%d of '%s'",
|
||
index + 1, total, title, exc_info=True,
|
||
)
|
||
# Fall back to the raw chunk truncated to ~1500 chars so the overall
|
||
# pipeline still delivers something rather than dropping the section.
|
||
return chunk[:1500]
|
||
|
||
|
||
async def prepare_article_context(
|
||
title: str,
|
||
url: str,
|
||
body: str,
|
||
model: str,
|
||
) -> str:
|
||
"""Return a conversation-ready context block for ``body``.
|
||
|
||
- Small article (≤ CHAR_BUDGET): returns ``body`` unchanged.
|
||
- Oversized article: runs a parallel map step over paragraph-aware
|
||
chunks and concatenates the summaries under section headers.
|
||
|
||
The returned string is what should go into the ``read_article`` synthetic
|
||
tool-result in chat history. Callers are responsible for caching it to
|
||
``rss_items.context_prepared``.
|
||
"""
|
||
body = body or ""
|
||
if len(body) <= CHAR_BUDGET:
|
||
return body
|
||
|
||
chunks = _chunk_by_paragraph(body)
|
||
logger.info(
|
||
"Article '%s' is %d chars, map-reducing into %d chunks",
|
||
title, len(body), len(chunks),
|
||
)
|
||
|
||
summaries = await asyncio.gather(
|
||
*[
|
||
_summarize_chunk(title, chunk, i, len(chunks), model)
|
||
for i, chunk in enumerate(chunks)
|
||
]
|
||
)
|
||
|
||
header = (
|
||
f"(This article was longer than the chat window could hold verbatim, "
|
||
f"so the full text was split into {len(chunks)} sections and each was "
|
||
"summarized below. Each section preserves specific claims, numbers, "
|
||
"and quotes from the original.)\n\n"
|
||
)
|
||
parts = [
|
||
f"## Section {i + 1}\n\n{summary}"
|
||
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."
|
||
)
|
||
|
||
|
||
class EmptyArticleError(Exception):
|
||
"""Raised when an article has no extractable body text.
|
||
|
||
Callers (the briefing and /news discuss routes) map this to a 422 so the
|
||
user sees a clear error instead of a hallucinated summary built from an
|
||
empty synthetic tool result.
|
||
"""
|
||
|
||
|
||
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 ""
|
||
if not raw_body.strip():
|
||
# Hard-fail rather than stage an empty synthetic tool result.
|
||
# An empty `content` field silently tells the model "the article
|
||
# has nothing in it" and it confabulates from RAG/history. Better
|
||
# to surface a clean error to the user.
|
||
logger.warning(
|
||
"Article discussion aborted: empty body for rss_item %s (%s)",
|
||
item.id, item.url,
|
||
)
|
||
raise EmptyArticleError(
|
||
"Couldn't extract any readable text from this article."
|
||
)
|
||
article_content = await prepare_article_context(
|
||
item.title or "", item.url, raw_body, model,
|
||
)
|
||
if not article_content.strip():
|
||
raise EmptyArticleError(
|
||
"Couldn't extract any readable text from this article."
|
||
)
|
||
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
|