mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-14 22:52:15 +02:00
feat: add report versioning support with report_group_id
This commit is contained in:
parent
e7a73d0570
commit
adeef35443
10 changed files with 344 additions and 48 deletions
|
|
@ -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 $$;
|
||||
"""
|
||||
)
|
||||
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
]
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue