The Discuss button on news cards was producing one-shot replies because the model got the whole trafilatura blob dropped into history with a canned "summarize and discuss this article" prompt — no length guard, no prep, no invitation to converse. Large articles got silently truncated by Ollama; small articles got a tepid reply. This reworks discuss_article around a three-layer cache: context_prepared → content_full → fresh trafilatura fetch First click on a small article fetches once, writes through to both caches, and passes the body straight into the synthetic read_article tool-result. First click on a large article additionally runs a parallel map step (services/article_context.py) that chunks the body on paragraph boundaries, summarizes each ~8k chunk to ~300 words of dense factual prose via the background model, and concatenates the summaries under section headers — all pinned to num_ctx=16384 so the map step doesn't itself fall victim to silent truncation. Repeat clicks on either path skip straight to the chat turn. The canned summary prompt is replaced with a conversational seed that invites the user into an actual discussion rather than a one-shot synopsis, matching the goal of "have a conversation about an article, not just read it." discuss_topic is intentionally left untouched — it's the multi-article aggregation path and needs a separate rework. Follow-up task will decide whether to retire it or rework it on the cached-context approach. Closes task #106. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
162 lines
5.8 KiB
Python
162 lines
5.8 KiB
Python
"""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)
|