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: