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
]