feat: add report versioning support with report_group_id

This commit is contained in:
Anish Sarkar 2026-02-12 03:19:38 +05:30
parent e7a73d0570
commit adeef35443
10 changed files with 344 additions and 48 deletions

View file

@ -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 $$;
"""
)

View file

@ -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 = <previous report_id>
User: "Add a cost analysis section to the report" parent_report_id = <previous report_id>
User: "Rewrite the report in a more formal tone" parent_report_id = <previous 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

View file

@ -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"),

View file

@ -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,
}

View file

@ -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:

View file

@ -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",

View file

@ -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

View file

@ -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
]

View file

@ -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<typeof ReportContentResponseSchema>;
type VersionInfo = z.infer<typeof VersionInfoSchema>;
/**
* 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<VersionInfo[]>([]);
// 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<unknown>(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 */}
<div className="flex items-center justify-between px-4 py-2 shrink-0">
<div className="flex items-center">
<Button
variant="outline"
size="sm"
onClick={handleCopy}
className="h-7 min-w-[80px] px-2.5 py-4 text-xs gap-1.5 rounded-r-none border-r-0"
>
{copied ? (
<CheckIcon className="size-3.5" />
) : (
<ClipboardIcon className="size-3.5" />
)}
{copied ? "Copied" : "Copy"}
</Button>
<DropdownMenu modal={insideDrawer ? false : undefined}>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-7 py-4 px-1.5 rounded-l-none"
>
<ChevronDownIcon className="size-3" />
<span className="sr-only">Download options</span>
</Button>
</DropdownMenuTrigger>
<div className="flex items-center gap-2">
{/* Copy button */}
<Button
variant="outline"
size="sm"
onClick={handleCopy}
className="h-8 min-w-[80px] px-3.5 py-4 text-[15px]"
>
{copied ? "Copied" : "Copy"}
</Button>
{/* Export dropdown */}
<DropdownMenu modal={insideDrawer ? false : undefined}>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
size="sm"
className="h-8 px-3.5 py-4 text-[15px] gap-1.5"
>
Export
<ChevronDownIcon className="size-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className={`min-w-[180px]${insideDrawer ? " z-[100]" : ""}`}>
<DropdownMenuItem onClick={() => handleExport("md")}>
Download Markdown
@ -306,7 +328,32 @@ function ReportPanelContent({
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</DropdownMenu>
{/* Version switcher — only shown when multiple versions exist */}
{versions.length > 1 && (
<div className="flex items-center gap-1">
<div className="flex items-center gap-0.5 rounded-lg border bg-muted/30 p-0.5">
{versions.map((v, i) => (
<button
key={v.id}
type="button"
onClick={() => setActiveReportId(v.id)}
className={`px-2 py-0.5 rounded-md text-xs font-medium transition-colors ${
v.id === activeReportId
? "bg-primary text-primary-foreground shadow-sm"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
}`}
>
v{i + 1}
</button>
))}
</div>
<span className="text-[10px] text-muted-foreground tabular-nums ml-1">
{activeVersionIndex + 1} of {versions.length}
</span>
</div>
)}
</div>
{onClose && (
<Button
@ -430,4 +477,3 @@ export function ReportPanel() {
return <MobileReportDrawer />;
}

View file

@ -2,7 +2,7 @@
import { makeAssistantToolUI } from "@assistant-ui/react";
import { useAtomValue, useSetAtom } from "jotai";
import { FileTextIcon } from "lucide-react";
import { Dot, FileTextIcon } from "lucide-react";
import { useParams, usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import { z } from "zod";
@ -21,6 +21,7 @@ const GenerateReportArgsSchema = z.object({
source_content: z.string(),
report_style: z.string().nullish(),
user_instructions: z.string().nullish(),
parent_report_id: z.number().nullish(),
});
const GenerateReportResultSchema = z.object({
@ -43,6 +44,15 @@ const ReportMetadataResponseSchema = z.object({
section_count: z.number().nullish(),
})
.nullish(),
report_group_id: z.number().nullish(),
versions: z
.array(
z.object({
id: z.number(),
created_at: z.string().nullish(),
})
)
.nullish(),
});
/**
@ -117,11 +127,12 @@ function ReportCard({
const [metadata, setMetadata] = useState<{
title: string;
wordCount: number | null;
}>({ title, wordCount: wordCount ?? null });
versionLabel: string | null;
}>({ title, wordCount: wordCount ?? null, versionLabel: null });
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Fetch lightweight metadata (title + counts only, no content)
// Fetch lightweight metadata (title + counts + version info)
useEffect(() => {
let cancelled = false;
const fetchMetadata = async () => {
@ -142,10 +153,20 @@ function ReportCard({
"Report generation failed"
);
} else {
// Determine version label from versions array
let versionLabel: string | null = null;
const versions = parsed.data.versions;
if (versions && versions.length > 1) {
const idx = versions.findIndex((v) => v.id === reportId);
if (idx >= 0) {
versionLabel = `version ${idx + 1}`;
}
}
setMetadata({
title: parsed.data.title || title,
wordCount:
parsed.data.report_metadata?.word_count ?? wordCount ?? null,
versionLabel,
});
}
}
@ -200,6 +221,8 @@ function ReportCard({
<>
{metadata.wordCount != null &&
`${metadata.wordCount.toLocaleString()} words`}
{metadata.wordCount != null && metadata.versionLabel && <Dot className="inline size-4" />}
{metadata.versionLabel}
</>
)}
</p>
@ -284,4 +307,3 @@ export const GenerateReportToolUI = makeAssistantToolUI<
return <ReportErrorState title={topic} error="Missing report ID" />;
},
});