8205590f8d
The Discuss button on news cards was producing one-shot replies because the model got the whole trafilatura blob dropped into history with a canned "summarize and discuss this article" prompt — no length guard, no prep, no invitation to converse. Large articles got silently truncated by Ollama; small articles got a tepid reply. This reworks discuss_article around a three-layer cache: context_prepared → content_full → fresh trafilatura fetch First click on a small article fetches once, writes through to both caches, and passes the body straight into the synthetic read_article tool-result. First click on a large article additionally runs a parallel map step (services/article_context.py) that chunks the body on paragraph boundaries, summarizes each ~8k chunk to ~300 words of dense factual prose via the background model, and concatenates the summaries under section headers — all pinned to num_ctx=16384 so the map step doesn't itself fall victim to silent truncation. Repeat clicks on either path skip straight to the chat turn. The canned summary prompt is replaced with a conversational seed that invites the user into an actual discussion rather than a one-shot synopsis, matching the goal of "have a conversation about an article, not just read it." discuss_topic is intentionally left untouched — it's the multi-article aggregation path and needs a separate rework. Follow-up task will decide whether to retire it or rework it on the cached-context approach. Closes task #106. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
318 lines
12 KiB
Python
318 lines
12 KiB
Python
"""RSS feed service: fetch, parse with feedparser, and cache items to DB."""
|
|
|
|
import asyncio
|
|
import logging
|
|
from datetime import datetime, timezone
|
|
|
|
import feedparser
|
|
import html2text
|
|
import httpx
|
|
from sqlalchemy import select, text
|
|
|
|
from fabledassistant.models import async_session
|
|
from fabledassistant.models.rss_feed import RssFeed, RssItem
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Keep only items from the last N days to avoid unbounded growth
|
|
ITEM_MAX_AGE_DAYS = 90
|
|
|
|
_h2t = html2text.HTML2Text()
|
|
_h2t.ignore_links = True
|
|
_h2t.ignore_images = True
|
|
_h2t.ignore_emphasis = False
|
|
_h2t.body_width = 0 # No line-wrapping
|
|
|
|
|
|
def _html_to_text(html: str) -> str:
|
|
"""Convert HTML to clean plain text via html2text."""
|
|
if not html or "<" not in html:
|
|
return html
|
|
try:
|
|
return _h2t.handle(html).strip()
|
|
except Exception:
|
|
return html
|
|
|
|
|
|
async def get_or_fetch_full_article(item: RssItem) -> str | None:
|
|
"""Return the full article body, fetching+caching on miss.
|
|
|
|
Checks ``item.content_full`` first — populated either by the enrichment
|
|
pass at feed-ingest time or by a previous discuss-click. On miss, fetches
|
|
via ``_fetch_full_article`` and writes through. Returns ``None`` only if
|
|
the fetch itself fails; ``item.content_full == ""`` is still a cache hit.
|
|
|
|
Callers must pass an RssItem attached to an open session if they want
|
|
the write-through to persist — otherwise the fetched text is returned
|
|
but the cache stays empty and the next click will re-fetch.
|
|
"""
|
|
if item.content_full is not None:
|
|
return item.content_full
|
|
if not item.url:
|
|
return None
|
|
text = await _fetch_full_article(item.url)
|
|
if text is None:
|
|
return None
|
|
async with async_session() as session:
|
|
fresh = await session.get(RssItem, item.id)
|
|
if fresh is not None:
|
|
fresh.content_full = text
|
|
fresh.content_fetched_at = datetime.now(timezone.utc)
|
|
await session.commit()
|
|
return text
|
|
|
|
|
|
async def _fetch_full_article(url: str) -> str | None:
|
|
"""Fetch a URL and extract its main article text via trafilatura.
|
|
|
|
Returns clean plain text, or None if extraction fails or yields nothing useful.
|
|
Runs trafilatura in a thread executor since it does synchronous HTML parsing.
|
|
"""
|
|
try:
|
|
async with httpx.AsyncClient(timeout=15.0, follow_redirects=True, headers={
|
|
"User-Agent": "Mozilla/5.0 (compatible; FabledAssistant/1.0; +https://fabledsword.com)",
|
|
}) as client:
|
|
resp = await client.get(url)
|
|
resp.raise_for_status()
|
|
raw_html = resp.text
|
|
except Exception:
|
|
logger.debug("Failed to fetch article URL %s", url)
|
|
return None
|
|
|
|
loop = asyncio.get_event_loop()
|
|
try:
|
|
import trafilatura
|
|
text = await loop.run_in_executor(
|
|
None,
|
|
lambda: trafilatura.extract(
|
|
raw_html,
|
|
include_comments=False,
|
|
include_tables=True,
|
|
favor_recall=True,
|
|
),
|
|
)
|
|
return text or None
|
|
except Exception:
|
|
logger.debug("trafilatura extraction failed for %s", url, exc_info=True)
|
|
return None
|
|
|
|
|
|
def extract_item(entry) -> dict:
|
|
"""Extract a clean item dict from a feedparser entry object."""
|
|
# Prefer full content over summary (feedparser uses a list of Content objects)
|
|
content = ""
|
|
raw_content = getattr(entry, "content", None)
|
|
if isinstance(raw_content, list) and raw_content:
|
|
content = raw_content[0].value
|
|
else:
|
|
content = entry.get("summary", "")
|
|
content = _html_to_text(content)
|
|
|
|
pub = None
|
|
if entry.published_parsed:
|
|
try:
|
|
pub = datetime(*entry.published_parsed[:6], tzinfo=timezone.utc)
|
|
except Exception:
|
|
pass
|
|
|
|
return {
|
|
"guid": entry.get("id", entry.get("link", "")),
|
|
"title": entry.get("title", ""),
|
|
"url": entry.get("link", ""),
|
|
"content": content,
|
|
"published_at": pub,
|
|
}
|
|
|
|
|
|
async def _parse_feed(url: str) -> feedparser.FeedParserDict:
|
|
"""Run feedparser in a thread executor so we don't block the event loop."""
|
|
loop = asyncio.get_event_loop()
|
|
return await loop.run_in_executor(None, feedparser.parse, url)
|
|
|
|
|
|
async def fetch_and_cache_feed(feed_id: int, url: str) -> int:
|
|
"""
|
|
Fetch a feed URL, parse it, and upsert new items into rss_items.
|
|
Returns the number of new items stored.
|
|
"""
|
|
scheme = url.split("://")[0].lower() if "://" in url else ""
|
|
if scheme not in ("http", "https"):
|
|
logger.warning("Blocked RSS fetch with non-http(s) scheme: %s", url[:80])
|
|
return 0
|
|
try:
|
|
parsed = await _parse_feed(url)
|
|
except Exception:
|
|
logger.warning("Failed to fetch RSS feed %s", url, exc_info=True)
|
|
return 0
|
|
|
|
if parsed.bozo and not parsed.entries:
|
|
logger.warning("Malformed RSS feed %s: %s", url, parsed.bozo_exception)
|
|
return 0
|
|
|
|
new_count = 0
|
|
feed_user_id: int | None = None
|
|
|
|
async with async_session() as session:
|
|
for entry in parsed.entries:
|
|
item_data = extract_item(entry)
|
|
if not item_data["guid"]:
|
|
continue
|
|
# Check if already stored
|
|
existing = await session.execute(
|
|
select(RssItem).where(
|
|
RssItem.feed_id == feed_id,
|
|
RssItem.guid == item_data["guid"],
|
|
)
|
|
)
|
|
if existing.scalars().first() is not None:
|
|
continue
|
|
item = RssItem(
|
|
feed_id=feed_id,
|
|
**item_data,
|
|
)
|
|
session.add(item)
|
|
new_count += 1
|
|
|
|
# Update last_fetched_at on the feed
|
|
feed_row = await session.get(RssFeed, feed_id)
|
|
if feed_row:
|
|
feed_row.last_fetched_at = datetime.now(timezone.utc)
|
|
feed_user_id = feed_row.user_id
|
|
# Auto-populate title from feed metadata if blank
|
|
if not feed_row.title and parsed.feed.get("title"):
|
|
feed_row.title = parsed.feed.title[:200]
|
|
|
|
await session.commit()
|
|
|
|
# Collect IDs of unclassified items after commit.
|
|
# We query classified_at IS NULL (not just the items inserted above) because
|
|
# classification is best-effort and may have failed on previous fetches.
|
|
# Re-queuing all unclassified items for this feed on each fetch is intentional:
|
|
# it provides automatic retry without a separate retry loop. The classifier
|
|
# only writes to items it successfully classifies, so already-classified items
|
|
# are not re-processed (they have classified_at set).
|
|
unclassified_ids: list[int] = []
|
|
new_item_data: list[tuple[int, str, str]] = [] # (id, title, content) for embedding
|
|
if new_count > 0:
|
|
result = await session.execute(
|
|
select(RssItem.id, RssItem.title, RssItem.content, RssItem.classified_at).where(
|
|
RssItem.feed_id == feed_id,
|
|
)
|
|
)
|
|
for row in result.fetchall():
|
|
item_id, title, content, classified_at = row
|
|
if classified_at is None:
|
|
unclassified_ids.append(item_id)
|
|
new_item_data.append((item_id, title or "", content or ""))
|
|
|
|
# Prune old items to keep DB tidy
|
|
await _prune_old_items(feed_id)
|
|
|
|
# Fire-and-forget classification for unclassified items
|
|
if unclassified_ids and feed_user_id is not None:
|
|
from fabledassistant.services.rss_classifier import classify_and_store
|
|
asyncio.create_task(classify_and_store(unclassified_ids, feed_user_id))
|
|
|
|
# Collect (id, url) for newly inserted items to enrich with full article text
|
|
new_items_for_enrichment: list[tuple[int, str]] = []
|
|
if new_count > 0:
|
|
async with async_session() as session:
|
|
result = await session.execute(
|
|
select(RssItem.id, RssItem.url).where(
|
|
RssItem.feed_id == feed_id,
|
|
RssItem.url != "",
|
|
).order_by(RssItem.fetched_at.desc()).limit(new_count)
|
|
)
|
|
new_items_for_enrichment = list(result.fetchall())
|
|
|
|
# Fire-and-forget: fetch full article text, then re-embed with richer content
|
|
if new_items_for_enrichment and feed_user_id is not None:
|
|
from fabledassistant.services.embeddings import upsert_rss_item_embedding
|
|
|
|
async def _enrich_and_embed() -> None:
|
|
for item_id, article_url in new_items_for_enrichment:
|
|
full_text = await _fetch_full_article(article_url)
|
|
if full_text and len(full_text) > 200:
|
|
async with async_session() as session:
|
|
item = await session.get(RssItem, item_id)
|
|
if item:
|
|
item.content = full_text
|
|
# Populate the discuss-click cache too so the
|
|
# first click skips straight to the map-reduce
|
|
# step without re-fetching.
|
|
item.content_full = full_text
|
|
item.content_fetched_at = datetime.now(timezone.utc)
|
|
await session.commit()
|
|
await upsert_rss_item_embedding(
|
|
item_id, feed_user_id, item.title or "", item.content
|
|
)
|
|
else:
|
|
# Enrich failed — still embed with RSS-provided content
|
|
async with async_session() as session:
|
|
item = await session.get(RssItem, item_id)
|
|
if item:
|
|
await upsert_rss_item_embedding(
|
|
item_id, feed_user_id, item.title or "", item.content or ""
|
|
)
|
|
await asyncio.sleep(0.5) # Polite pacing between article fetches
|
|
|
|
asyncio.create_task(_enrich_and_embed())
|
|
elif new_item_data and feed_user_id is not None:
|
|
# No URLs to enrich — embed with RSS content only
|
|
from fabledassistant.services.embeddings import upsert_rss_item_embedding
|
|
|
|
async def _embed_only() -> None:
|
|
for item_id, title, content in new_item_data:
|
|
await upsert_rss_item_embedding(item_id, feed_user_id, title, content)
|
|
await asyncio.sleep(0.05)
|
|
|
|
asyncio.create_task(_embed_only())
|
|
|
|
return new_count
|
|
|
|
|
|
async def _prune_old_items(feed_id: int) -> None:
|
|
"""Delete items older than ITEM_MAX_AGE_DAYS from a feed."""
|
|
async with async_session() as session:
|
|
await session.execute(
|
|
text("""
|
|
DELETE FROM rss_items
|
|
WHERE feed_id = :feed_id
|
|
AND published_at < NOW() - INTERVAL '90 days'
|
|
""").bindparams(feed_id=feed_id)
|
|
)
|
|
await session.commit()
|
|
|
|
|
|
async def get_recent_items(user_id: int, limit: int = 20) -> list[dict]:
|
|
"""Return the most recent RSS items across all of a user's feeds."""
|
|
async with async_session() as session:
|
|
result = await session.execute(
|
|
select(RssItem, RssFeed.title.label("feed_title"))
|
|
.join(RssFeed, RssItem.feed_id == RssFeed.id)
|
|
.where(RssFeed.user_id == user_id)
|
|
.order_by(RssItem.published_at.desc().nullslast())
|
|
.limit(limit)
|
|
)
|
|
rows = result.all()
|
|
|
|
return [
|
|
{**item.to_dict(), "feed_title": feed_title}
|
|
for item, feed_title in rows
|
|
]
|
|
|
|
|
|
async def refresh_all_feeds(user_id: int) -> dict[int, int]:
|
|
"""Fetch all feeds for a user. Returns {feed_id: new_items_count}."""
|
|
async with async_session() as session:
|
|
result = await session.execute(
|
|
select(RssFeed).where(RssFeed.user_id == user_id)
|
|
)
|
|
feeds = list(result.scalars().all())
|
|
|
|
results = {}
|
|
for feed in feeds:
|
|
count = await fetch_and_cache_feed(feed.id, feed.url)
|
|
results[feed.id] = count
|
|
return results
|