diff --git a/surfsense_backend/alembic/versions/99_add_reports_table.py b/surfsense_backend/alembic/versions/99_add_reports_table.py new file mode 100644 index 000000000..437783942 --- /dev/null +++ b/surfsense_backend/alembic/versions/99_add_reports_table.py @@ -0,0 +1,88 @@ +"""Add reports table + +Revision ID: 99 +Revises: 98 +Create Date: 2026-02-11 + +Adds report_status enum and reports table for storing generated Markdown reports. +""" + +from collections.abc import Sequence + +from alembic import op + +revision: str = "99" +down_revision: str | None = "98" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # Create the report_status enum type + op.execute( + """ + DO $$ BEGIN + CREATE TYPE report_status AS ENUM ('ready', 'failed'); + EXCEPTION + WHEN duplicate_object THEN null; + END $$; + """ + ) + + # Create the reports table + op.execute( + """ + CREATE TABLE IF NOT EXISTS reports ( + id SERIAL PRIMARY KEY, + title VARCHAR(500) NOT NULL, + content TEXT, + report_metadata JSONB, + status report_status NOT NULL DEFAULT 'ready', + report_style VARCHAR(100), + search_space_id INTEGER NOT NULL + REFERENCES searchspaces(id) ON DELETE CASCADE, + thread_id INTEGER + REFERENCES new_chat_threads(id) ON DELETE SET NULL, + created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() + ); + """ + ) + + # Add indexes + op.execute( + """ + CREATE INDEX IF NOT EXISTS ix_reports_status + ON reports(status); + """ + ) + + op.execute( + """ + CREATE INDEX IF NOT EXISTS ix_reports_search_space_id + ON reports(search_space_id); + """ + ) + + op.execute( + """ + CREATE INDEX IF NOT EXISTS ix_reports_thread_id + ON reports(thread_id); + """ + ) + + op.execute( + """ + CREATE INDEX IF NOT EXISTS ix_reports_created_at + ON reports(created_at); + """ + ) + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS ix_reports_created_at") + op.execute("DROP INDEX IF EXISTS ix_reports_thread_id") + op.execute("DROP INDEX IF EXISTS ix_reports_search_space_id") + op.execute("DROP INDEX IF EXISTS ix_reports_status") + op.execute("DROP TABLE IF EXISTS reports") + op.execute("DROP TYPE IF EXISTS report_status") + diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index 90c839980..72ce50ebc 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -100,6 +100,11 @@ class PodcastStatus(str, Enum): FAILED = "failed" +class ReportStatus(str, Enum): + READY = "ready" + FAILED = "failed" + + class DocumentStatus: """ Helper class for document processing status (stored as JSONB). @@ -1031,6 +1036,42 @@ class Podcast(BaseModel, TimestampMixin): thread = relationship("NewChatThread") +class Report(BaseModel, TimestampMixin): + """Report model for storing generated Markdown reports.""" + + __tablename__ = "reports" + + title = Column(String(500), nullable=False) + content = Column(Text, nullable=True) # Markdown body + report_metadata = Column(JSONB, nullable=True) # section headings, word count, etc. + status = Column( + SQLAlchemyEnum( + ReportStatus, + name="report_status", + create_type=False, + values_callable=lambda x: [e.value for e in x], + ), + nullable=False, + default=ReportStatus.READY, + server_default="ready", + index=True, + ) + report_style = Column(String(100), nullable=True) # e.g. "executive_summary", "deep_research" + + search_space_id = Column( + Integer, ForeignKey("searchspaces.id", ondelete="CASCADE"), nullable=False + ) + search_space = relationship("SearchSpace", back_populates="reports") + + thread_id = Column( + Integer, + ForeignKey("new_chat_threads.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + thread = relationship("NewChatThread") + + class ImageGenerationConfig(BaseModel, TimestampMixin): """ Dedicated configuration table for image generation models. @@ -1185,6 +1226,12 @@ class SearchSpace(BaseModel, TimestampMixin): order_by="Podcast.id.desc()", cascade="all, delete-orphan", ) + reports = relationship( + "Report", + back_populates="search_space", + order_by="Report.id.desc()", + cascade="all, delete-orphan", + ) image_generations = relationship( "ImageGeneration", back_populates="search_space",