From 7bd1548f715489e15264942ac566a221648d85ec Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 14 Apr 2026 18:13:17 -0400 Subject: [PATCH] fix(discuss): hard-fail empty articles and skip RAG on seed turn Discuss flow was hallucinating unrelated content when article extraction returned empty or RAG pulled in orphan notes that looked more relevant than the generic seed prompt. - seed_article_discussion raises EmptyArticleError on empty body; briefing and /news routes return 422 instead of staging an empty synthetic tool result. - build_context skips RAG auto-injection when user_message matches ARTICLE_DISCUSS_SEED so the article IS the context on turn one; follow-up turns keep RAG on. Co-Authored-By: Claude Opus 4.6 --- src/fabledassistant/routes/briefing.py | 10 ++++-- src/fabledassistant/routes/chat.py | 16 +++++++-- .../services/article_context.py | 36 +++++++++++++++---- src/fabledassistant/services/llm.py | 31 ++++++++++------ 4 files changed, 72 insertions(+), 21 deletions(-) diff --git a/src/fabledassistant/routes/briefing.py b/src/fabledassistant/routes/briefing.py index 2b050a8..7b3e6da 100644 --- a/src/fabledassistant/routes/briefing.py +++ b/src/fabledassistant/routes/briefing.py @@ -537,10 +537,16 @@ async def discuss_article(item_id: int): # exchange and the conversational seed user prompt into the conversation. # The /news from-article route calls the same helper so behavior stays # byte-identical across entry points. - from fabledassistant.services.article_context import seed_article_discussion + from fabledassistant.services.article_context import ( + EmptyArticleError, + seed_article_discussion, + ) model = await get_setting(uid, "default_model", "") or "" - discuss_prompt = await seed_article_discussion(conv_id, item, model) + try: + discuss_prompt = await seed_article_discussion(conv_id, item, model) + except EmptyArticleError as e: + return jsonify({"error": str(e)}), 422 # Reload conversation with fresh messages to build history conv = await get_conversation(uid, conv_id) diff --git a/src/fabledassistant/routes/chat.py b/src/fabledassistant/routes/chat.py index 6b81bdb..6212d57 100644 --- a/src/fabledassistant/routes/chat.py +++ b/src/fabledassistant/routes/chat.py @@ -521,7 +521,10 @@ async def create_conversation_from_article(item_id: int): from sqlalchemy import select as _select from fabledassistant.models import async_session as _async_session from fabledassistant.models.rss_feed import RssItem, RssFeed - from fabledassistant.services.article_context import seed_article_discussion + from fabledassistant.services.article_context import ( + EmptyArticleError, + seed_article_discussion, + ) uid = get_current_user_id() @@ -540,7 +543,16 @@ async def create_conversation_from_article(item_id: int): conv = await create_conversation(uid, title=conv_title, conversation_type="chat") model = await get_setting(uid, "default_model", "") or Config.OLLAMA_MODEL - discuss_prompt = await seed_article_discussion(conv.id, item, model) + try: + discuss_prompt = await seed_article_discussion(conv.id, item, model) + except EmptyArticleError as e: + # Roll back the empty conversation so the user doesn't end up with a + # phantom entry in their chat list. + try: + await delete_conversation(uid, conv.id) + except Exception: + logger.warning("Failed to clean up empty article conversation %s", conv.id) + return jsonify({"error": str(e)}), 422 # Reload conversation so we see the two messages the helper just added. conv = await get_conversation(uid, conv.id) diff --git a/src/fabledassistant/services/article_context.py b/src/fabledassistant/services/article_context.py index 1a41a94..79bd685 100644 --- a/src/fabledassistant/services/article_context.py +++ b/src/fabledassistant/services/article_context.py @@ -182,6 +182,15 @@ ARTICLE_DISCUSS_SEED = ( ) +class EmptyArticleError(Exception): + """Raised when an article has no extractable body text. + + Callers (the briefing and /news discuss routes) map this to a 422 so the + user sees a clear error instead of a hallucinated summary built from an + empty synthetic tool result. + """ + + async def seed_article_discussion( conv_id: int, item: RssItem, @@ -213,15 +222,30 @@ async def seed_article_discussion( article_content = item.context_prepared else: raw_body = await get_or_fetch_full_article(item) or item.content or "" + if not raw_body.strip(): + # Hard-fail rather than stage an empty synthetic tool result. + # An empty `content` field silently tells the model "the article + # has nothing in it" and it confabulates from RAG/history. Better + # to surface a clean error to the user. + logger.warning( + "Article discussion aborted: empty body for rss_item %s (%s)", + item.id, item.url, + ) + raise EmptyArticleError( + "Couldn't extract any readable text from this article." + ) 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() + if not article_content.strip(): + raise EmptyArticleError( + "Couldn't extract any readable text from this article." + ) + 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() synthetic_tool_calls = [{ "function": "read_article", diff --git a/src/fabledassistant/services/llm.py b/src/fabledassistant/services/llm.py index f88e833..ba7ab13 100644 --- a/src/fabledassistant/services/llm.py +++ b/src/fabledassistant/services/llm.py @@ -724,18 +724,27 @@ async def build_context( orphan_only = rag_project_id is None effective_project_id = rag_project_id if (rag_project_id is not None and rag_project_id != -1) else None - try: - from fabledassistant.services.embeddings import semantic_search_notes - for score, note in await semantic_search_notes( - user_id, user_message, exclude_ids=search_exclude or None, limit=8, - project_id=effective_project_id, - orphan_only=orphan_only, - ): - found_scored.append((score, note)) - except Exception: - logger.warning("Semantic note search failed, falling back to keyword search", exc_info=True) + # Skip RAG auto-injection on the first turn of a seeded article discussion. + # The article body is already the sole context the user wants — pulling in + # unrelated orphan notes tricks the model into summarizing those instead. + # Follow-up turns keep RAG on because by then the user's own messages drive + # the query rather than the generic seed prompt. + from fabledassistant.services.article_context import ARTICLE_DISCUSS_SEED + _skip_rag_for_article_seed = user_message.strip() == ARTICLE_DISCUSS_SEED - if not found_scored: + if not _skip_rag_for_article_seed: + try: + from fabledassistant.services.embeddings import semantic_search_notes + for score, note in await semantic_search_notes( + user_id, user_message, exclude_ids=search_exclude or None, limit=8, + project_id=effective_project_id, + orphan_only=orphan_only, + ): + found_scored.append((score, note)) + except Exception: + logger.warning("Semantic note search failed, falling back to keyword search", exc_info=True) + + if not found_scored and not _skip_rag_for_article_seed: keywords = _extract_keywords(user_message) if keywords: try: