refactor: hard-cut RSS infrastructure (scope C)

Removes the entire RSS feature surface — feeds, items, embeddings, reactions,
discussion-note flow, briefing news context, settings, env-vars, and DB
tables. Keeps the URL-generic article-reader (the read_article LLM tool)
under a clean module so the LLM can still fetch arbitrary article content
from URLs the user provides.

Backend:
- New services/article_fetcher.py — single source of trafilatura URL→text
- New services/tools/article.py — read_article tool (was nested under tools/rss)
- Delete services/rss.py, rss_classifier.py, rss_filtering.py, article_context.py
- Delete services/tools/rss.py
- Delete models/rss_feed.py (RssFeed, RssItem), models/rss_item_embedding.py
- services/embeddings.py: drop upsert/semantic_search/backfill RSS helpers
- services/llm.py: remove _build_briefing_article_context, briefing-conv branch,
  ARTICLE_DISCUSS_SEED skip-RAG branch; drop get_rss_items / add_rss_feed from
  the actions list
- services/generation_task.py: drop _maybe_save_article_discussion_note + caller
- routes/chat.py: drop /api/chat/from-article/<id> endpoint
- routes/journal.py: re-import via web.py refactor (article_fetcher path)
- services/tools/__init__.py: register `article`, drop `rss`
- services/tools/_registry.py: drop the requires=='rss' check
- app.py: drop backfill_rss_item_embeddings + backfill_rss_article_content tasks
- config.py: prose-only edit (no env var change — RSS env vars were never first-class)

Frontend:
- stores/settings.ts: drop rssEnabled
- SettingsView.vue: drop the RSS-classification mention
- api/client.ts: drop openArticleInChat (the from-article endpoint is gone)

Tests:
- Delete tests/test_rss_service.py, test_news_api.py, test_article_reading.py

Migration:
- 0042_drop_rss: DROP TABLE rss_item_embeddings, rss_item_reactions, rss_items,
  rss_feeds; DELETE settings rows for rss_enabled / briefing_*_topics

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-04-26 12:33:30 -04:00
parent cacfcac86a
commit dbd9f00061
26 changed files with 150 additions and 2029 deletions
@@ -37,84 +37,6 @@ _TOOL_CALL_MARKER = re.compile(r"^\s*\[TOOL_CALLS\]\s*", re.IGNORECASE)
DB_FLUSH_INTERVAL = 5.0 # seconds between partial DB flushes
async def _maybe_save_article_discussion_note(
user_id: int, conv_id: int, reply_content: str,
) -> None:
"""Persist a seeded article-discussion's first reply as a Note.
Fires after ``run_generation`` completes. Looks for a synthetic
read_article seed message on the conversation; if found AND the linked
``rss_items`` row has no ``discussion_note_id`` yet, saves ``reply_content``
as a Note, tags it, and writes the backlink. Subsequent discuss clicks on
the same article are a no-op (already linked).
Failures are logged and swallowed — the chat UI should never break because
Note persistence hit a snag.
"""
try:
if not reply_content or not reply_content.strip():
return
from sqlalchemy import select as _select
from fabledassistant.models.conversation import Message as _Message
from fabledassistant.models.rss_feed import RssItem as _RssItem
from fabledassistant.services.notes import create_note
async with async_session() as session:
result = await session.execute(
_select(_Message)
.where(_Message.conversation_id == conv_id)
.order_by(_Message.id.asc())
)
messages = result.scalars().all()
seed_meta = None
for m in messages:
meta = m.msg_metadata or {}
if meta.get("article_seed") and meta.get("rss_item_id"):
seed_meta = meta
break
if seed_meta is None:
return
item_id = int(seed_meta["rss_item_id"])
item = await session.get(_RssItem, item_id)
if item is None or item.discussion_note_id is not None:
return
article_title = (item.title or "Untitled article").strip()
article_url = item.url
article_topics = list(item.topics or [])
note_title = f"Article: {article_title}"[:200]
body_parts = [f"**Source:** {article_url}"] if article_url else []
body_parts.append(reply_content.strip())
note_body = "\n\n".join(body_parts)
tags = ["article-summary"] + [t for t in article_topics if t]
note = await create_note(
user_id=user_id,
title=note_title,
body=note_body,
tags=tags,
entity_meta={
"source": "article_discussion",
"rss_item_id": item_id,
"url": article_url,
"conversation_id": conv_id,
},
)
async with async_session() as session:
fresh = await session.get(_RssItem, item_id)
if fresh is not None and fresh.discussion_note_id is None:
fresh.discussion_note_id = note.id
await session.commit()
logger.info(
"Saved article-discussion summary as note %d for rss_item %d (conv %d)",
note.id, item_id, conv_id,
)
except Exception:
logger.warning(
"Failed to persist article-discussion note for conv %d",
conv_id, exc_info=True,
)
# Human-readable labels for each tool, shown in the status indicator
_TOOL_LABELS: dict[str, str] = {
"create_note": "Creating note/task",
@@ -593,28 +515,16 @@ async def run_generation(
msg_count = len(non_system)
should_gen_title = not conv_title or (msg_count > 0 and msg_count % 10 == 0)
# Persist article-discussion seed conversations as a Note on their
# first assistant reply. This makes "Discuss" summaries part of RAG
# so the knowledge base stops being amnesiac about articles the user
# has already engaged with. The hook detects a seeded conversation by
# finding a synthetic read_article assistant message whose
# msg_metadata carries ``article_seed: True`` and whose rss_items row
# has no discussion_note_id yet. Fire-and-forget so the done event
# lands immediately.
asyncio.create_task(_maybe_save_article_discussion_note(
user_id, conv_id, buf.content_so_far,
))
if should_gen_title:
# Feed the title model the *raw* conversation turns only — never
# the post-build_context ``messages`` list. ``build_context``
# prepends RAG snippets, RSS excerpts, URL content, and briefing
# article dumps INTO the user message string itself, so filtering
# by role="user" downstream still surfaces that noise as the
# "user's message". That pollution caused wildly-wrong titles
# (bug #109) — the small background model was staring at article
# excerpts instead of what the user actually typed. Pass the
# original history + the raw user_content + the assistant reply.
# prepends RAG snippets and URL content INTO the user message
# string itself, so filtering by role="user" downstream still
# surfaces that noise as the "user's message". That pollution
# caused wildly-wrong titles (bug #109) — the small background
# model was staring at article excerpts instead of what the user
# actually typed. Pass the original history + the raw user_content
# + the assistant reply.
title_messages: list[dict] = [
{"role": m["role"], "content": m.get("content") or ""}
for m in history