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:
@@ -623,7 +623,7 @@ async def build_context(
|
||||
"search_projects", "create_milestone", "update_milestone", "list_milestones",
|
||||
"save_person", "save_place", "create_list", "add_to_list", "clear_checked_items",
|
||||
"set_rag_scope", "get_profile", "update_profile", "get_weather", "calculate",
|
||||
"get_rss_items", "add_rss_feed", "read_article",
|
||||
"read_article",
|
||||
]
|
||||
if has_caldav:
|
||||
actions.extend(["create_event", "list_events", "search_events", "update_event", "delete_event", "list_calendars"])
|
||||
@@ -683,8 +683,8 @@ async def build_context(
|
||||
# --- System message: stable content only ---
|
||||
# Workspace context and history summary stay here because they carry
|
||||
# behavioural instructions / conversational state, not retrieved content.
|
||||
# Everything retrieval-based (RAG notes, RSS, URL content, current note,
|
||||
# briefing articles) goes into the user turn below so the system message
|
||||
# Everything retrieval-based (RAG notes, URL content, current note)
|
||||
# goes into the user turn below so the system message
|
||||
# prefix stays byte-for-byte identical across requests, enabling Ollama's
|
||||
# KV prefix cache to fire reliably.
|
||||
|
||||
@@ -726,25 +726,6 @@ async def build_context(
|
||||
f"\n\n--- Earlier Conversation ---\n{history_summary}\n--- End Earlier Conversation ---"
|
||||
)
|
||||
|
||||
# Detect briefing conversation — used for both system prompt instruction and article injection
|
||||
_is_briefing_conv = False
|
||||
if conv_id is not None:
|
||||
from fabledassistant.models import async_session as _async_session
|
||||
from fabledassistant.models.conversation import Conversation as _Conversation
|
||||
async with _async_session() as _sess:
|
||||
_conv = await _sess.get(_Conversation, conv_id)
|
||||
if _conv and getattr(_conv, "conversation_type", None) == "briefing":
|
||||
_is_briefing_conv = True
|
||||
|
||||
if _is_briefing_conv:
|
||||
system_content += (
|
||||
"\n\nYou are in a briefing conversation. "
|
||||
"The conversation history contains today's briefing — news stories, weather, and tasks. "
|
||||
"When the user asks about a topic, person, or event from the briefing, answer directly "
|
||||
"from the conversation history and the article context that follows. "
|
||||
"Do NOT search the web for information that is already present in the briefing."
|
||||
)
|
||||
|
||||
context_meta: dict = {
|
||||
"context_note_id": None,
|
||||
"context_note_title": None,
|
||||
@@ -780,27 +761,18 @@ 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
|
||||
|
||||
# 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
|
||||
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 _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:
|
||||
if not found_scored:
|
||||
keywords = _extract_keywords(user_message)
|
||||
if keywords:
|
||||
try:
|
||||
@@ -890,12 +862,6 @@ async def build_context(
|
||||
f"--- Content from {url} ---\n{content}\n--- End URL Content ---"
|
||||
)
|
||||
|
||||
# Briefing article context for follow-up Q&A
|
||||
if _is_briefing_conv:
|
||||
article_context = await _build_briefing_article_context(conv_id) # type: ignore[arg-type]
|
||||
if article_context:
|
||||
user_context_parts.append(article_context.strip())
|
||||
|
||||
# Build final user message — context prefix (if any) followed by the actual message
|
||||
if user_context_parts:
|
||||
user_turn = "\n\n".join(user_context_parts) + "\n\n" + user_message
|
||||
@@ -906,72 +872,3 @@ async def build_context(
|
||||
messages.extend(history)
|
||||
messages.append({"role": "user", "content": user_turn})
|
||||
return messages, context_meta
|
||||
|
||||
|
||||
async def _build_briefing_article_context(conv_id: int) -> str:
|
||||
"""Fetch article content from today's briefing message and return a
|
||||
formatted context block for injection into the system prompt.
|
||||
|
||||
Looks at the most recent assistant briefing messages for rss_item_ids
|
||||
in their metadata, then loads those items from the DB.
|
||||
Capped at 10 articles × 500 chars to keep token use reasonable.
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
from sqlalchemy import select, text as _text
|
||||
|
||||
from fabledassistant.models import async_session as _async_session
|
||||
from fabledassistant.models.conversation import Message
|
||||
|
||||
async with _async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Message)
|
||||
.where(
|
||||
Message.conversation_id == conv_id,
|
||||
Message.role == "assistant",
|
||||
)
|
||||
.order_by(Message.created_at.desc())
|
||||
.limit(10)
|
||||
)
|
||||
messages = result.scalars().all()
|
||||
|
||||
rss_item_ids: list[int] = []
|
||||
for msg in messages:
|
||||
meta = msg.msg_metadata or {}
|
||||
if isinstance(meta, str):
|
||||
try:
|
||||
meta = _json.loads(meta)
|
||||
except Exception:
|
||||
continue
|
||||
ids = meta.get("rss_item_ids") or []
|
||||
if ids:
|
||||
rss_item_ids = ids
|
||||
break
|
||||
|
||||
if not rss_item_ids:
|
||||
return ""
|
||||
|
||||
async with _async_session() as session:
|
||||
result = await session.execute(
|
||||
_text("""
|
||||
SELECT i.title, i.url, i.content, f.title AS feed_title
|
||||
FROM rss_items i
|
||||
JOIN rss_feeds f ON f.id = i.feed_id
|
||||
WHERE i.id = ANY(:ids)
|
||||
ORDER BY i.published_at DESC NULLS LAST
|
||||
LIMIT 10
|
||||
""").bindparams(ids=rss_item_ids[:10])
|
||||
)
|
||||
rows = result.mappings().all()
|
||||
|
||||
if not rows:
|
||||
return ""
|
||||
|
||||
lines = ["\n\nARTICLE CONTEXT (source articles from today's briefing):"]
|
||||
for row in rows:
|
||||
lines.append(f"\n[{row['feed_title']}] {row['title']}")
|
||||
if row["url"]:
|
||||
lines.append(f"URL: {row['url']}")
|
||||
if row["content"]:
|
||||
lines.append(row["content"][:500])
|
||||
return "\n".join(lines)
|
||||
|
||||
Reference in New Issue
Block a user