Merge pull request 'Release v26.04.14.2 — Discuss failure mode fixes' (#33) from dev into main

This commit was merged in pull request #33.
This commit is contained in:
2026-04-15 01:45:36 +00:00
4 changed files with 72 additions and 21 deletions
+8 -2
View File
@@ -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)
+14 -2
View File
@@ -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)
@@ -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",
+20 -11
View File
@@ -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: