diff --git a/surfsense_backend/alembic/versions/100_add_report_group_id.py b/surfsense_backend/alembic/versions/100_add_report_group_id.py new file mode 100644 index 000000000..cfcbb0635 --- /dev/null +++ b/surfsense_backend/alembic/versions/100_add_report_group_id.py @@ -0,0 +1,70 @@ +"""Add report_group_id for report versioning + +Revision ID: 100 +Revises: 99 +Create Date: 2026-02-11 + +Adds report_group_id column to reports table for grouping report versions. +Reports with the same report_group_id are versions of the same report. +For the first version (v1), report_group_id equals the report's own id. +Migration is idempotent — safe to re-run. +""" + +from collections.abc import Sequence + +from alembic import op + +revision: str = "100" +down_revision: str | None = "99" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + # Add report_group_id column (idempotent) + op.execute( + """ + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'reports' AND column_name = 'report_group_id' + ) THEN + ALTER TABLE reports ADD COLUMN report_group_id INTEGER; + END IF; + END $$; + """ + ) + + # Backfill existing reports: set report_group_id = id (each is its own v1) + op.execute( + """ + UPDATE reports SET report_group_id = id WHERE report_group_id IS NULL; + """ + ) + + # Create index (idempotent) + op.execute( + """ + CREATE INDEX IF NOT EXISTS ix_reports_report_group_id + ON reports(report_group_id); + """ + ) + + +def downgrade() -> None: + op.execute("DROP INDEX IF EXISTS ix_reports_report_group_id") + op.execute( + """ + DO $$ + BEGIN + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'reports' AND column_name = 'report_group_id' + ) THEN + ALTER TABLE reports DROP COLUMN report_group_id; + END IF; + END $$; + """ + ) + diff --git a/surfsense_backend/app/agents/new_chat/tools/report.py b/surfsense_backend/app/agents/new_chat/tools/report.py index eb104f3b8..90c6404da 100644 --- a/surfsense_backend/app/agents/new_chat/tools/report.py +++ b/surfsense_backend/app/agents/new_chat/tools/report.py @@ -22,7 +22,7 @@ from app.services.llm_service import get_document_summary_llm logger = logging.getLogger(__name__) -# Prompt template for report generation +# Prompt template for report generation (new report from scratch) _REPORT_PROMPT = """You are an expert report writer. Generate a well-structured, comprehensive Markdown report based on the provided information. **Topic:** {topic} @@ -31,6 +31,8 @@ _REPORT_PROMPT = """You are an expert report writer. Generate a well-structured, {user_instructions_section} +{previous_version_section} + **Source Content:** {source_content} @@ -94,6 +96,7 @@ def create_generate_report_tool( source_content: str, report_style: str = "detailed", user_instructions: str | None = None, + parent_report_id: int | None = None, ) -> dict[str, Any]: """ Generate a structured Markdown report from provided content. @@ -106,6 +109,29 @@ def create_generate_report_tool( - "Make a research report on..." - "Summarize this into a report" + VERSIONING — parent_report_id: + - Set parent_report_id ONLY when the user explicitly asks to MODIFY, + REVISE, IMPROVE, or UPDATE an existing report that was already + generated in this conversation. + - The value must be the report_id from a previous generate_report + result in this same conversation. + - Do NOT set parent_report_id when: + * The user asks for a report on a NEW/DIFFERENT topic + * The user says "generate another report" (new report, not a revision) + * There is no prior report to reference + - When parent_report_id is set, the previous report's content will be + used as a base. Your user_instructions should describe WHAT TO CHANGE. + + Examples of when to SET parent_report_id: + User: "Make that report shorter" → parent_report_id = + User: "Add a cost analysis section to the report" → parent_report_id = + User: "Rewrite the report in a more formal tone" → parent_report_id = + + Examples of when to LEAVE parent_report_id as None: + User: "Generate a report on climate change" → parent_report_id = None (new topic) + User: "Write me a report about the budget" → parent_report_id = None (new topic) + User: "Create another report, this time about marketing" → parent_report_id = None + Args: topic: A short, concise title for the report (maximum 8 words). Keep it brief and descriptive — e.g. "AI in Healthcare Analysis: A Comprehensive Report" instead of "Comprehensive Analysis of Artificial Intelligence Applications in Modern Healthcare Systems". source_content: The text content to base the report on. This MUST be comprehensive and include: @@ -114,7 +140,8 @@ def create_generate_report_tool( * You can combine both: conversation context + search results for richer reports * The more detailed the source_content, the better the report quality report_style: Style of the report. Options: "detailed", "executive_summary", "deep_research", "brief". Default: "detailed" - user_instructions: Optional specific instructions for the report (e.g., "focus on financial impacts", "include recommendations") + user_instructions: Optional specific instructions for the report (e.g., "focus on financial impacts", "include recommendations"). When revising an existing report (parent_report_id is set), this should describe the changes to make. + parent_report_id: Optional ID of a previously generated report to revise. When set, the new report is created as a new version in the same version group. The previous report's content is included as context for the LLM to refine. Returns: A dictionary containing: @@ -124,6 +151,24 @@ def create_generate_report_tool( - word_count: Number of words in the report - message: Status message (or "error" field if failed) """ + # Resolve the parent report and its group (if versioning) + parent_report: Report | None = None + report_group_id: int | None = None + + if parent_report_id: + parent_report = await db_session.get(Report, parent_report_id) + if parent_report: + report_group_id = parent_report.report_group_id + logger.info( + f"[generate_report] Creating new version from parent {parent_report_id} " + f"(group {report_group_id})" + ) + else: + logger.warning( + f"[generate_report] parent_report_id={parent_report_id} not found, " + "creating standalone report" + ) + async def _save_failed_report(error_msg: str) -> int | None: """Persist a failed report row so the error is visible later.""" try: @@ -137,10 +182,15 @@ def create_generate_report_tool( report_style=report_style, search_space_id=search_space_id, thread_id=thread_id, + report_group_id=report_group_id, ) db_session.add(failed_report) await db_session.commit() await db_session.refresh(failed_report) + # If this is a new group (v1 failed), set group to self + if not failed_report.report_group_id: + failed_report.report_group_id = failed_report.id + await db_session.commit() logger.info( f"[generate_report] Saved failed report {failed_report.id}: {error_msg}" ) @@ -169,10 +219,20 @@ def create_generate_report_tool( f"**Additional Instructions:** {user_instructions}" ) + # If revising, include previous version content + previous_version_section = "" + if parent_report and parent_report.content: + previous_version_section = ( + "**Previous Version of This Report (refine this based on the instructions above — " + "preserve structure and quality, apply only the requested changes):**\n\n" + f"{parent_report.content}" + ) + prompt = _REPORT_PROMPT.format( topic=topic, report_style=report_style, user_instructions_section=user_instructions_section, + previous_version_section=previous_version_section, source_content=source_content[:100000], # Cap source content ) @@ -203,13 +263,20 @@ def create_generate_report_tool( report_style=report_style, search_space_id=search_space_id, thread_id=thread_id, + report_group_id=report_group_id, # None for v1, inherited for v2+ ) db_session.add(report) await db_session.commit() await db_session.refresh(report) + # If this is a brand-new report (v1), set report_group_id = own id + if not report.report_group_id: + report.report_group_id = report.id + await db_session.commit() + logger.info( - f"[generate_report] Created report {report.id}: " + f"[generate_report] Created report {report.id} " + f"(group={report.report_group_id}): " f"{metadata.get('word_count', 0)} words, " f"{metadata.get('section_count', 0)} sections" ) @@ -235,4 +302,3 @@ def create_generate_report_tool( } return generate_report - diff --git a/surfsense_backend/app/db.py b/surfsense_backend/app/db.py index 506fa49ca..864eb7980 100644 --- a/surfsense_backend/app/db.py +++ b/surfsense_backend/app/db.py @@ -1046,6 +1046,10 @@ class Report(BaseModel, TimestampMixin): ) search_space = relationship("SearchSpace", back_populates="reports") + # Versioning: reports sharing the same report_group_id are versions of the same report. + # For v1, report_group_id = the report's own id (set after insert). + report_group_id = Column(Integer, nullable=True, index=True) + thread_id = Column( Integer, ForeignKey("new_chat_threads.id", ondelete="SET NULL"), diff --git a/surfsense_backend/app/routes/public_chat_routes.py b/surfsense_backend/app/routes/public_chat_routes.py index 865a83629..9afcbc188 100644 --- a/surfsense_backend/app/routes/public_chat_routes.py +++ b/surfsense_backend/app/routes/public_chat_routes.py @@ -127,16 +127,25 @@ async def get_public_report_content( Get report content from a public chat snapshot. No authentication required - the share_token provides access. - Returns report content including title, markdown body, and metadata. + Returns report content including title, markdown body, metadata, and versions. """ + from app.services.public_chat_service import get_snapshot_report_versions + report_info = await get_snapshot_report(session, share_token, report_id) if not report_info: raise HTTPException(status_code=404, detail="Report not found") + # Get version siblings from the same snapshot + versions = await get_snapshot_report_versions( + session, share_token, report_info.get("report_group_id") + ) + return { "id": report_info.get("original_id"), "title": report_info.get("title"), "content": report_info.get("content"), "report_metadata": report_info.get("report_metadata"), + "report_group_id": report_info.get("report_group_id"), + "versions": versions, } diff --git a/surfsense_backend/app/routes/reports_routes.py b/surfsense_backend/app/routes/reports_routes.py index 25764d88d..49a74b658 100644 --- a/surfsense_backend/app/routes/reports_routes.py +++ b/surfsense_backend/app/routes/reports_routes.py @@ -29,6 +29,7 @@ from app.db import ( get_async_session, ) from app.schemas import ReportContentRead, ReportRead +from app.schemas.reports import ReportVersionInfo from app.users import current_active_user from app.utils.rbac import check_search_space_access @@ -70,6 +71,24 @@ async def _get_report_with_access( return report +async def _get_version_siblings( + session: AsyncSession, + report: Report, +) -> list[ReportVersionInfo]: + """Get all versions in the same report group, ordered by created_at.""" + if not report.report_group_id: + # Legacy report without group — it's the only version + return [ReportVersionInfo(id=report.id, created_at=report.created_at)] + + result = await session.execute( + select(Report.id, Report.created_at) + .filter(Report.report_group_id == report.report_group_id) + .order_by(Report.created_at.asc()) + ) + rows = result.all() + return [ReportVersionInfo(id=row[0], created_at=row[1]) for row in rows] + + # --------------------------------------------------------------------------- # Routes # --------------------------------------------------------------------------- @@ -144,10 +163,20 @@ async def read_report_content( user: User = Depends(current_active_user), ): """ - Get full Markdown content of a report. + Get full Markdown content of a report, including version siblings. """ try: - return await _get_report_with_access(report_id, session, user) + report = await _get_report_with_access(report_id, session, user) + versions = await _get_version_siblings(session, report) + + return ReportContentRead( + id=report.id, + title=report.title, + content=report.content, + report_metadata=report.report_metadata, + report_group_id=report.report_group_id, + versions=versions, + ) except HTTPException: raise except SQLAlchemyError: diff --git a/surfsense_backend/app/schemas/__init__.py b/surfsense_backend/app/schemas/__init__.py index 376b55407..05c069a01 100644 --- a/surfsense_backend/app/schemas/__init__.py +++ b/surfsense_backend/app/schemas/__init__.py @@ -59,7 +59,7 @@ from .new_llm_config import ( NewLLMConfigUpdate, ) from .podcasts import PodcastBase, PodcastCreate, PodcastRead, PodcastUpdate -from .reports import ReportBase, ReportContentRead, ReportRead +from .reports import ReportBase, ReportContentRead, ReportRead, ReportVersionInfo from .rbac_schemas import ( InviteAcceptRequest, InviteAcceptResponse, @@ -190,6 +190,7 @@ __all__ = [ "ReportBase", "ReportContentRead", "ReportRead", + "ReportVersionInfo", "RoleCreate", "RoleRead", "RoleUpdate", diff --git a/surfsense_backend/app/schemas/reports.py b/surfsense_backend/app/schemas/reports.py index 90add2b04..9909d8601 100644 --- a/surfsense_backend/app/schemas/reports.py +++ b/surfsense_backend/app/schemas/reports.py @@ -22,6 +22,17 @@ class ReportRead(BaseModel): title: str report_style: str | None = None report_metadata: dict[str, Any] | None = None + report_group_id: int | None = None + created_at: datetime + + class Config: + from_attributes = True + + +class ReportVersionInfo(BaseModel): + """Lightweight version entry for the version switcher UI.""" + + id: int created_at: datetime class Config: @@ -35,7 +46,8 @@ class ReportContentRead(BaseModel): title: str content: str | None = None report_metadata: dict[str, Any] | None = None + report_group_id: int | None = None + versions: list[ReportVersionInfo] = [] class Config: from_attributes = True - diff --git a/surfsense_backend/app/services/public_chat_service.py b/surfsense_backend/app/services/public_chat_service.py index cb8fb9830..9c4ed0c7c 100644 --- a/surfsense_backend/app/services/public_chat_service.py +++ b/surfsense_backend/app/services/public_chat_service.py @@ -344,6 +344,8 @@ async def _get_report_for_snapshot( "title": report.title, "content": report.content, "report_metadata": report.report_metadata, + "report_group_id": report.report_group_id, + "created_at": report.created_at.isoformat() if report.created_at else None, } @@ -715,6 +717,9 @@ async def clone_from_snapshot( ) session.add(new_report) await session.flush() + # For cloned reports, set report_group_id = own id + # (each cloned report starts as its own v1) + new_report.report_group_id = new_report.id report_id_mapping[old_report_id] = new_report.id if old_report_id and old_report_id in report_id_mapping: @@ -790,3 +795,35 @@ async def get_snapshot_report( return report return None + + +async def get_snapshot_report_versions( + session: AsyncSession, + share_token: str, + report_group_id: int | None, +) -> list[dict]: + """ + Get all report versions in the same group from a snapshot. + + Returns a list of lightweight version entries (id + created_at) + for the version switcher UI, sorted by original_id (insertion order). + """ + if not report_group_id: + return [] + + snapshot = await get_snapshot_by_token(session, share_token) + if not snapshot: + return [] + + reports = snapshot.snapshot_data.get("reports", []) + siblings = [ + r for r in reports if r.get("report_group_id") == report_group_id + ] + + # Sort by original_id (ascending = insertion order ≈ created_at order) + siblings.sort(key=lambda r: r.get("original_id", 0)) + + return [ + {"id": r.get("original_id"), "created_at": r.get("created_at")} + for r in siblings + ] diff --git a/surfsense_web/components/report-panel/report-panel.tsx b/surfsense_web/components/report-panel/report-panel.tsx index 8215b6140..1a13e1e00 100644 --- a/surfsense_web/components/report-panel/report-panel.tsx +++ b/surfsense_web/components/report-panel/report-panel.tsx @@ -2,9 +2,7 @@ import { useAtomValue, useSetAtom } from "jotai"; import { - CheckIcon, ChevronDownIcon, - ClipboardIcon, FileTextIcon, XIcon, } from "lucide-react"; @@ -32,6 +30,14 @@ import { useMediaQuery } from "@/hooks/use-media-query"; import { baseApiService } from "@/lib/apis/base-api.service"; import { authenticatedFetch } from "@/lib/auth-utils"; +/** + * Zod schema for a single version entry + */ +const VersionInfoSchema = z.object({ + id: z.number(), + created_at: z.string().nullish(), +}); + /** * Zod schema for the report content API response */ @@ -48,9 +54,12 @@ const ReportContentResponseSchema = z.object({ section_count: z.number().nullish(), }) .nullish(), + report_group_id: z.number().nullish(), + versions: z.array(VersionInfoSchema).nullish(), }); type ReportContentResponse = z.infer; +type VersionInfo = z.infer; /** * Shimmer loading skeleton for report panel @@ -117,7 +126,16 @@ function ReportPanelContent({ const [copied, setCopied] = useState(false); const [exporting, setExporting] = useState<"pdf" | "docx" | "md" | null>(null); - // Fetch report content + // Version state + const [activeReportId, setActiveReportId] = useState(reportId); + const [versions, setVersions] = useState([]); + + // Reset active version when the external reportId changes (e.g. clicking a different card) + useEffect(() => { + setActiveReportId(reportId); + }, [reportId]); + + // Fetch report content (re-runs when activeReportId changes for version switching) useEffect(() => { let cancelled = false; const fetchContent = async () => { @@ -125,8 +143,8 @@ function ReportPanelContent({ setError(null); try { const url = shareToken - ? `/api/v1/public/${shareToken}/reports/${reportId}/content` - : `/api/v1/reports/${reportId}/content`; + ? `/api/v1/public/${shareToken}/reports/${activeReportId}/content` + : `/api/v1/reports/${activeReportId}/content`; const rawData = await baseApiService.get(url); if (cancelled) return; const parsed = ReportContentResponseSchema.safeParse(rawData); @@ -139,6 +157,10 @@ function ReportPanelContent({ ); } else { setReportContent(parsed.data); + // Update versions from the response + if (parsed.data.versions && parsed.data.versions.length > 0) { + setVersions(parsed.data.versions); + } } } else { console.warn( @@ -162,7 +184,7 @@ function ReportPanelContent({ return () => { cancelled = true; }; - }, [reportId, shareToken]); + }, [activeReportId, shareToken]); // Copy markdown content const handleCopy = useCallback(async () => { @@ -200,7 +222,7 @@ function ReportPanelContent({ URL.revokeObjectURL(url); } else { const response = await authenticatedFetch( - `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/reports/${reportId}/export?format=${format}`, + `${process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL}/api/v1/reports/${activeReportId}/export?format=${format}`, { method: "GET" } ); @@ -224,7 +246,7 @@ function ReportPanelContent({ setExporting(null); } }, - [reportId, title, reportContent?.content] + [activeReportId, title, reportContent?.content] ); @@ -248,35 +270,35 @@ function ReportPanelContent({ ); } + const activeVersionIndex = versions.findIndex((v) => v.id === activeReportId); + return ( <> {/* Action bar */}
-
- - - - - +
+ {/* Copy button */} + + + {/* Export dropdown */} + + + + handleExport("md")}> Download Markdown @@ -306,7 +328,32 @@ function ReportPanelContent({ )} - + + + {/* Version switcher — only shown when multiple versions exist */} + {versions.length > 1 && ( +
+
+ {versions.map((v, i) => ( + + ))} +
+ + {activeVersionIndex + 1} of {versions.length} + +
+ )}
{onClose && (