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>
This commit is contained in:
2026-04-13 20:52:00 -04:00
parent 939b910372
commit 8205590f8d
6 changed files with 353 additions and 7 deletions
+34 -7
View File
@@ -532,8 +532,28 @@ async def discuss_article(item_id: int):
if get_buffer(conv_id) is not None:
return jsonify({"error": "Generation already in progress"}), 409
from fabledassistant.services.rss import _fetch_full_article
article_content = await _fetch_full_article(item.url) or item.content or ""
# Three-layer cache: context_prepared (post-map-reduce) → content_full
# (raw trafilatura) → fresh fetch. Only the first miss pays the fetch
# cost; only a large uncached article pays the map-reduce cost. Repeat
# clicks on the same article skip straight to the chat turn.
from fabledassistant.services.article_context import prepare_article_context
from fabledassistant.services.rss import get_or_fetch_full_article
model = await get_setting(uid, "default_model", "") or ""
if item.context_prepared:
article_content = item.context_prepared
else:
raw_body = await get_or_fetch_full_article(item) or item.content or ""
article_content = await prepare_article_context(
item.title or "", item.url, raw_body, model,
)
if article_content:
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()
# Store synthetic assistant message with read_article tool result
synthetic_tool_calls = [{
@@ -549,8 +569,17 @@ async def discuss_article(item_id: int):
}]
await add_message(conv_id, "assistant", "", status="complete", tool_calls=synthetic_tool_calls)
# Store user message
await add_message(conv_id, "user", "Please summarize and discuss this article.")
# Conversational seed — invites a real discussion rather than asking for
# a one-shot summary. The model sees the article context in the tool
# result above and responds to this user turn as the start of an ongoing
# conversation the user will steer with follow-ups.
discuss_prompt = (
"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."
)
await add_message(conv_id, "user", discuss_prompt)
# Reload conversation with fresh messages to build history
conv = await get_conversation(uid, conv_id)
@@ -572,15 +601,13 @@ async def discuss_article(item_id: int):
else:
history.append(msg_dict)
model = await get_setting(uid, "default_model", "") or ""
assistant_msg = await add_message(conv_id, "assistant", "", status="generating")
buf = create_buffer(conv_id, assistant_msg.id)
asyncio.create_task(run_generation(
buf, history, model,
uid, conv_id, conv.title or "",
"Please summarize and discuss this article.",
discuss_prompt,
))
return jsonify({"assistant_message_id": assistant_msg.id, "status": "generating"}), 202