"""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. 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. """ from __future__ import annotations import asyncio import logging import re 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)