Files
FabledGrimoire/src/fabledassistant/services/article_context.py
T
bvandeusenandClaude Opus 4.6 8205590f8d feat(briefing): cache + map-reduce article context for rich discuss chats
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>
2026-04-13 20:52:00 -04:00

162 lines
5.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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)