"""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