diff --git a/alembic/versions/0038_add_rss_item_full_content.py b/alembic/versions/0038_add_rss_item_full_content.py new file mode 100644 index 0000000..c98adb2 --- /dev/null +++ b/alembic/versions/0038_add_rss_item_full_content.py @@ -0,0 +1,34 @@ +"""Add content_full and context_prepared caches to rss_items. + +Revision ID: 0038 +Revises: 0037 +Create Date: 2026-04-13 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = "0038" +down_revision = "0037" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column("rss_items", sa.Column("content_full", sa.Text(), nullable=True)) + op.add_column( + "rss_items", + sa.Column("context_prepared", sa.Text(), nullable=True), + ) + op.add_column( + "rss_items", + sa.Column("content_fetched_at", sa.DateTime(timezone=True), nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("rss_items", "content_fetched_at") + op.drop_column("rss_items", "context_prepared") + op.drop_column("rss_items", "content_full") diff --git a/src/fabledassistant/models/rss_feed.py b/src/fabledassistant/models/rss_feed.py index e5b3c87..63e013f 100644 --- a/src/fabledassistant/models/rss_feed.py +++ b/src/fabledassistant/models/rss_feed.py @@ -46,6 +46,17 @@ class RssItem(Base): published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) # Truncated to 2000 chars to keep DB size reasonable content: Mapped[str] = mapped_column(Text, default="") + # Full trafilatura-extracted article body, populated lazily on first + # discuss-click / enrichment pass. Nullable — most items never get this + # cached. Expires naturally with the item (90-day retention). + content_full: Mapped[str | None] = mapped_column(Text, nullable=True) + # Map-reduced conversation-ready context derived from content_full. See + # services/article_context.py — populated on first discuss click so + # repeat clicks skip both the fetch and the LLM map step. + context_prepared: Mapped[str | None] = mapped_column(Text, nullable=True) + content_fetched_at: Mapped[datetime | None] = mapped_column( + DateTime(timezone=True), nullable=True + ) fetched_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), default=lambda: datetime.now(timezone.utc) ) diff --git a/src/fabledassistant/routes/briefing.py b/src/fabledassistant/routes/briefing.py index d288d85..fc2ebe0 100644 --- a/src/fabledassistant/routes/briefing.py +++ b/src/fabledassistant/routes/briefing.py @@ -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 diff --git a/src/fabledassistant/services/article_context.py b/src/fabledassistant/services/article_context.py new file mode 100644 index 0000000..965a3b6 --- /dev/null +++ b/src/fabledassistant/services/article_context.py @@ -0,0 +1,161 @@ +"""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) diff --git a/src/fabledassistant/services/rss.py b/src/fabledassistant/services/rss.py index 6b652e7..1785709 100644 --- a/src/fabledassistant/services/rss.py +++ b/src/fabledassistant/services/rss.py @@ -34,6 +34,34 @@ def _html_to_text(html: str) -> str: return html +async def get_or_fetch_full_article(item: RssItem) -> str | None: + """Return the full article body, fetching+caching on miss. + + Checks ``item.content_full`` first — populated either by the enrichment + pass at feed-ingest time or by a previous discuss-click. On miss, fetches + via ``_fetch_full_article`` and writes through. Returns ``None`` only if + the fetch itself fails; ``item.content_full == ""`` is still a cache hit. + + Callers must pass an RssItem attached to an open session if they want + the write-through to persist — otherwise the fetched text is returned + but the cache stays empty and the next click will re-fetch. + """ + if item.content_full is not None: + return item.content_full + if not item.url: + return None + text = await _fetch_full_article(item.url) + if text is None: + return None + async with async_session() as session: + fresh = await session.get(RssItem, item.id) + if fresh is not None: + fresh.content_full = text + fresh.content_fetched_at = datetime.now(timezone.utc) + await session.commit() + return text + + async def _fetch_full_article(url: str) -> str | None: """Fetch a URL and extract its main article text via trafilatura. @@ -209,6 +237,11 @@ async def fetch_and_cache_feed(feed_id: int, url: str) -> int: item = await session.get(RssItem, item_id) if item: item.content = full_text + # Populate the discuss-click cache too so the + # first click skips straight to the map-reduce + # step without re-fetching. + item.content_full = full_text + item.content_fetched_at = datetime.now(timezone.utc) await session.commit() await upsert_rss_item_embedding( item_id, feed_user_id, item.title or "", item.content diff --git a/tests/test_article_reading.py b/tests/test_article_reading.py index c0cb6e3..715013b 100644 --- a/tests/test_article_reading.py +++ b/tests/test_article_reading.py @@ -134,3 +134,83 @@ def test_history_builder_no_tool_calls_unchanged(): assert len(history) == 2 assert history[0] == {"role": "user", "content": "Hello"} assert history[1] == {"role": "assistant", "content": "Hi there!"} + + +# --------------------------------------------------------------------------- +# prepare_article_context tests +# --------------------------------------------------------------------------- + + +@pytest.mark.asyncio +async def test_prepare_article_context_small_passthrough(): + """Articles under CHAR_BUDGET pass through unchanged with zero LLM calls.""" + from fabledassistant.services import article_context + + body = "A short article.\n\nWith two paragraphs." + with patch( + "fabledassistant.services.article_context.generate_completion", + new_callable=AsyncMock, + ) as mock_gen: + out = await article_context.prepare_article_context( + "Title", "https://example.com", body, "test-model", + ) + + assert out == body + mock_gen.assert_not_called() + + +@pytest.mark.asyncio +async def test_prepare_article_context_large_runs_map_reduce(): + """Articles over CHAR_BUDGET are chunked and map-reduced via the background model.""" + from fabledassistant.services import article_context + + # CHAR_BUDGET is 48_000 — build a body well over that with paragraph breaks + # so the chunker has natural splits to work with. + paragraph = ("Lorem ipsum dolor sit amet, consectetur adipiscing elit. " * 40).strip() + body = "\n\n".join([paragraph] * 30) # ~70k+ chars across 30 paragraphs + assert len(body) > article_context.CHAR_BUDGET + + with patch( + "fabledassistant.services.article_context.generate_completion", + new_callable=AsyncMock, + return_value="Summary of this section with specific claims preserved.", + ) as mock_gen: + out = await article_context.prepare_article_context( + "Long Article", "https://example.com/long", body, "test-model", + ) + + # At least one LLM call fired (the map step ran) + assert mock_gen.await_count >= 1 + # Output carries the oversized-article header and section markers + assert "longer than the chat window" in out + assert "## Section 1" in out + # Map output is much smaller than the raw body + assert len(out) < len(body) + + +def test_chunk_by_paragraph_respects_boundaries(): + """Chunker splits on paragraph breaks, not mid-sentence.""" + from fabledassistant.services.article_context import _chunk_by_paragraph, CHUNK_CHARS + + paragraphs = [f"Paragraph {i}. " + ("x" * 1000) for i in range(20)] + body = "\n\n".join(paragraphs) + + chunks = _chunk_by_paragraph(body) + + # Each chunk stays under the budget + for chunk in chunks: + assert len(chunk) <= CHUNK_CHARS + # Total content is preserved (modulo overlap duplication, so ≥ original) + assert sum(len(c) for c in chunks) >= len(body) * 0.9 + + +def test_chunk_by_paragraph_handles_oversized_paragraph(): + """A single paragraph larger than CHUNK_CHARS gets split mid-paragraph.""" + from fabledassistant.services.article_context import _chunk_by_paragraph, CHUNK_CHARS + + body = "x" * (CHUNK_CHARS * 3) # one huge paragraph, no breaks + chunks = _chunk_by_paragraph(body) + + assert len(chunks) >= 3 + for chunk in chunks: + assert len(chunk) <= CHUNK_CHARS