Files
FabledGrimoire/src/fabledassistant/models/rss_feed.py
T
bvandeusenandClaude Opus 4.6 ba90ad8132 feat(article-discuss): unify /news + briefing entry points, persist summaries to RAG
Both the /news discuss button and the briefing discuss button now call a
shared seed_article_discussion() helper that stages the synthetic
read_article tool exchange and the conversational seed prompt — behavior
stays byte-identical across entry points. /news also auto-starts
generation so the chat screen lands on an in-flight stream.

First assistant reply in a seeded article conversation is persisted as a
Note (tags: article-summary + article topics) and backlinked via
rss_items.discussion_note_id, so the knowledge base stops being amnesiac
about articles the user has engaged with.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-14 07:54:24 -04:00

97 lines
4.0 KiB
Python

from datetime import datetime, timezone
from sqlalchemy import ARRAY, BigInteger, DateTime, ForeignKey, Index, Integer, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column, relationship
from fabledassistant.models import Base
class RssFeed(Base):
__tablename__ = "rss_feeds"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"))
url: Mapped[str] = mapped_column(Text)
title: Mapped[str] = mapped_column(Text, default="")
category: Mapped[str | None] = mapped_column(Text, nullable=True)
last_fetched_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
items: Mapped[list["RssItem"]] = relationship(
back_populates="feed", cascade="all, delete-orphan"
)
__table_args__ = (
UniqueConstraint("user_id", "url", name="uq_rss_feeds_user_url"),
Index("ix_rss_feeds_user_id", "user_id"),
)
def to_dict(self) -> dict:
return {
"id": self.id,
"url": self.url,
"title": self.title,
"category": self.category,
"last_fetched_at": self.last_fetched_at.isoformat() if self.last_fetched_at else None,
}
class RssItem(Base):
__tablename__ = "rss_items"
id: Mapped[int] = mapped_column(primary_key=True)
feed_id: Mapped[int] = mapped_column(Integer, ForeignKey("rss_feeds.id", ondelete="CASCADE"))
guid: Mapped[str] = mapped_column(Text)
title: Mapped[str] = mapped_column(Text, default="")
url: Mapped[str] = mapped_column(Text, default="")
published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
# Truncated to 2000 chars to keep DB size reasonable
content: Mapped[str] = mapped_column(Text, default="")
# Full trafilatura-extracted article body, populated lazily on first
# discuss-click / enrichment pass. Nullable — most items never get this
# cached. Expires naturally with the item (90-day retention).
content_full: Mapped[str | None] = mapped_column(Text, nullable=True)
# Map-reduced conversation-ready context derived from content_full. See
# services/article_context.py — populated on first discuss click so
# repeat clicks skip both the fetch and the LLM map step.
context_prepared: Mapped[str | None] = mapped_column(Text, nullable=True)
content_fetched_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
fetched_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
topics: Mapped[list[str]] = mapped_column(
ARRAY(Text), nullable=False, default=list, server_default="{}"
)
classified_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
# Note persisting the first-click discussion summary. Set by the article
# discussion pipeline once the seeded chat completes its first assistant
# reply; links back into RAG so re-discussing the same article lands the
# prior summary in context.
discussion_note_id: Mapped[int | None] = mapped_column(
BigInteger, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True
)
feed: Mapped["RssFeed"] = relationship(back_populates="items")
__table_args__ = (
UniqueConstraint("feed_id", "guid", name="uq_rss_items_feed_guid"),
Index("ix_rss_items_feed_id", "feed_id"),
Index("ix_rss_items_published_at", "published_at"),
)
def to_dict(self) -> dict:
return {
"id": self.id,
"feed_id": self.feed_id,
"guid": self.guid,
"title": self.title,
"url": self.url,
"published_at": self.published_at.isoformat() if self.published_at else None,
"content": self.content,
"topics": self.topics or [],
"classified_at": self.classified_at.isoformat() if self.classified_at else None,
}