feat: RSS embeddings, semantic news in chat, article-to-chat, richer briefings

- Embed RSS items at fetch time (nomic-embed-text); backfill at startup
- Semantic news search injected into chat system prompt ("Recent News You've Seen")
  when items match query above 0.55 cosine threshold (independent of note RAG)
- "Discuss in chat" button on news cards — creates a seeded conversation with
  the article title + full content, navigates directly to the new chat
- Briefing compilation now passes 500-char article excerpts (not just headlines)
  to the LLM and uses 8192 num_ctx to accommodate the larger prompt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-30 15:12:38 -04:00
parent dba41879ed
commit a773c11aa0
11 changed files with 327 additions and 8 deletions
+125
View File
@@ -1,6 +1,7 @@
"""Semantic note search via Ollama embedding model (nomic-embed-text).
Embeddings are stored in the note_embeddings table (one row per note).
RSS item embeddings are stored in rss_item_embeddings (one row per item).
All search operations degrade gracefully — if the embedding model is
unavailable the callers fall back to keyword search.
"""
@@ -8,6 +9,7 @@ unavailable the callers fall back to keyword search.
import asyncio
import logging
import math
from datetime import datetime, timedelta, timezone
import httpx
from sqlalchemy import delete, select
@@ -16,6 +18,8 @@ from fabledassistant.config import Config
from fabledassistant.models import async_session
from fabledassistant.models.embedding import NoteEmbedding
from fabledassistant.models.note import Note
from fabledassistant.models.rss_feed import RssItem
from fabledassistant.models.rss_item_embedding import RssItemEmbedding
logger = logging.getLogger(__name__)
@@ -24,6 +28,10 @@ logger = logging.getLogger(__name__)
# 0.45 keeps only genuinely relevant notes; lower values like 0.30 let in
# loosely-related results that pad the sidebar without adding real value.
_SIMILARITY_THRESHOLD = 0.45
_RSS_SIMILARITY_THRESHOLD = 0.55
_RSS_SEARCH_LIMIT = 3
_RSS_SEARCH_DAYS = 30
_RSS_SNIPPET_CHARS = 500
async def get_embedding(text: str, model: str | None = None) -> list[float]:
@@ -172,3 +180,120 @@ async def backfill_note_embeddings() -> None:
await asyncio.sleep(0.05) # gentle pacing
logger.info("Embedding backfill complete: %d/%d notes embedded", success, len(notes_to_embed))
# ── RSS item embeddings ───────────────────────────────────────────────────────
async def upsert_rss_item_embedding(item_id: int, user_id: int, title: str, content: str) -> None:
"""Generate and persist an embedding for an RSS item. Safe to fire-and-forget."""
text = f"{title}\n{content}".strip()
if not text:
return
try:
embedding = await get_embedding(text)
except Exception:
logger.debug("Skipping embedding for RSS item %d — model unavailable", item_id)
return
try:
async with async_session() as session:
await session.execute(
delete(RssItemEmbedding).where(RssItemEmbedding.rss_item_id == item_id)
)
session.add(RssItemEmbedding(rss_item_id=item_id, user_id=user_id, embedding=embedding))
await session.commit()
logger.debug("Upserted embedding for RSS item %d", item_id)
except Exception:
logger.warning("Failed to persist embedding for RSS item %d", item_id, exc_info=True)
async def semantic_search_rss_items(
user_id: int,
query_vector: list[float],
limit: int = _RSS_SEARCH_LIMIT,
days: int = _RSS_SEARCH_DAYS,
) -> list[tuple[float, RssItem]]:
"""Return up to *limit* (score, RssItem) pairs most relevant to *query_vector*.
Only considers items fetched within the last *days* days.
Returns an empty list on any error.
"""
since = datetime.now(timezone.utc) - timedelta(days=days)
try:
async with async_session() as session:
stmt = (
select(RssItemEmbedding, RssItem)
.join(RssItem, RssItemEmbedding.rss_item_id == RssItem.id)
.where(
RssItemEmbedding.user_id == user_id,
RssItem.fetched_at >= since,
)
)
rows = list((await session.execute(stmt)).all())
except Exception:
logger.warning("Failed to query RSS item embeddings", exc_info=True)
return []
if not rows:
return []
scored: list[tuple[float, RssItem]] = []
for rie, item in rows:
try:
sim = _cosine_similarity(query_vector, rie.embedding)
except Exception:
continue
if sim >= _RSS_SIMILARITY_THRESHOLD:
scored.append((sim, item))
scored.sort(key=lambda x: x[0], reverse=True)
return scored[:limit]
async def backfill_rss_item_embeddings() -> None:
"""Generate embeddings for all RSS items that don't have one yet.
Runs as a background task at startup. Adds a small sleep between items
to avoid overwhelming Ollama.
"""
try:
async with async_session() as session:
existing = {
row[0]
for row in (
await session.execute(select(RssItemEmbedding.rss_item_id))
).fetchall()
}
result = await session.execute(
select(RssItem.id, RssItem.feed_id, RssItem.title, RssItem.content)
)
items_to_embed = [row for row in result.fetchall() if row[0] not in existing]
except Exception:
logger.warning("RSS embedding backfill: failed to query items", exc_info=True)
return
if not items_to_embed:
logger.info("RSS embedding backfill: all items already have embeddings")
return
# Resolve user_id per feed_id
try:
from fabledassistant.models.rss_feed import RssFeed
async with async_session() as session:
result = await session.execute(select(RssFeed.id, RssFeed.user_id))
feed_user_map = {fid: uid for fid, uid in result.fetchall()}
except Exception:
logger.warning("RSS embedding backfill: failed to load feed user map", exc_info=True)
return
logger.info("RSS embedding backfill: generating embeddings for %d items", len(items_to_embed))
success = 0
for item_id, feed_id, title, content in items_to_embed:
user_id = feed_user_map.get(feed_id)
if user_id is None:
continue
await upsert_rss_item_embedding(item_id, user_id, title or "", content or "")
success += 1
await asyncio.sleep(0.05)
logger.info("RSS embedding backfill complete: %d/%d items embedded", success, len(items_to_embed))