RICS / app /api /export.py
StormShadow308's picture
feat: async pipeline, job queue, generation hardening, and docs
732b14f
Raw
History Blame Contribute Delete
5.71 kB
"""Report export endpoint — download the assembled report as a DOCX file.
GET /reports/{report_id}/export?format=docx
→ streams a professional RICS-formatted ``.docx`` file.
The endpoint reuses the exact same section-loading logic as
GET /reports/{report_id}/sections so the exported document always matches
what the user sees on screen.
"""
from __future__ import annotations
import json
import logging
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import Response
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.rate_limit import check_read
from app.db.database import get_db
from app.db.models import Report, ReportSection, ReportSectionPhoto
from app.generator.docx_builder import build_docx
from app.models.schemas import Provenance, SectionPayload, WritingStyleProfile
from app.storage.photo_paths import resolve_report_photo_path
logger = logging.getLogger(__name__)
router = APIRouter()
_DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
@router.get("/reports/{report_id}/export")
async def export_report(
report_id: str,
request: Request,
fmt: str = Query(default="docx", alias="format", pattern="^docx$"),
db: AsyncSession = Depends(get_db),
_: None = Depends(check_read),
) -> Response:
"""Stream the assembled report as a downloadable DOCX file.
All sections are fetched from the database and rendered into a
professional RICS-styled document. Sections that have not been
generated yet are replaced with a placeholder paragraph.
Args:
report_id: UUID of the report to export.
request: Provides ``state.tenant_id``.
fmt: Output format — currently only ``docx`` is supported.
db: Injected async database session.
Returns:
A binary ``Response`` with DOCX content and appropriate
``Content-Disposition`` header for browser downloads.
Raises:
HTTPException 404: Report not found or belongs to a different tenant.
HTTPException 400: Unsupported export format (only ``docx`` accepted).
"""
tenant_id: str = request.state.tenant_id
report = await db.get(Report, report_id)
if report is None or report.tenant_id != tenant_id:
raise HTTPException(status_code=404, detail="Report not found")
# Load all sections from the database
result = await db.execute(
select(ReportSection).where(ReportSection.report_id == report_id)
)
sections_orm = result.scalars().all()
sections: dict[str, SectionPayload] = {}
for s in sections_orm:
raw = json.loads(s.provenance or "[]")
if isinstance(raw, dict):
sources = raw.get("sources", [])
meta = raw.get("meta", {})
else:
sources = raw
meta = {}
style_profile: WritingStyleProfile | None = None
raw_sp = meta.get("style_profile")
if isinstance(raw_sp, dict):
try:
style_profile = WritingStyleProfile(**raw_sp)
except Exception:
style_profile = None
prov_list: list[Provenance] = []
for p in sources:
if not isinstance(p, dict) or "doc_id" not in p or "chunk_id" not in p or "score" not in p:
continue
try:
prov_list.append(Provenance.model_validate(p))
except Exception: # noqa: BLE001
continue
sections[s.section_code] = SectionPayload(
text=s.text,
confidence=s.confidence,
provenance=prov_list,
cached=bool(s.cached),
mode=meta.get("mode", "generate"),
style_profile=style_profile,
ai_level=meta.get("ai_level"),
ai_percent=meta.get("ai_percent"),
ai_transparency=meta.get("ai_transparency")
if isinstance(meta.get("ai_transparency"), dict)
else None,
pipeline=meta.get("pipeline"),
fallback_used=meta.get("fallback_used"),
inspector=meta.get("inspector") if isinstance(meta.get("inspector"), dict) else None,
)
if not sections:
raise HTTPException(
status_code=422,
detail="No sections have been generated yet. Generate at least one section before exporting.",
)
photos_res = await db.execute(
select(ReportSectionPhoto).where(
ReportSectionPhoto.tenant_id == tenant_id,
ReportSectionPhoto.report_id == report_id,
)
)
photo_rows = photos_res.scalars().all()
photo_paths_by_section: dict[str, list[str]] = {}
for p in photo_rows:
resolved = resolve_report_photo_path(
p.file_path,
tenant_id=tenant_id,
report_id=report_id,
section_code=p.section_code,
photo_id=p.id,
)
if resolved:
photo_paths_by_section.setdefault(p.section_code, []).append(resolved)
logger.info(
"Exporting report=%s as %s for tenant=%s (%d sections)",
report_id,
fmt,
tenant_id,
len(sections),
)
docx_bytes = build_docx(
sections=sections,
report_id=report_id,
tenant_id=tenant_id,
survey_level=report.survey_level,
section_photo_paths=photo_paths_by_section,
)
safe_report_id = report_id.replace("-", "")[:12]
filename = f"RICS_Survey_Report_{safe_report_id}.docx"
return Response(
content=docx_bytes,
media_type=_DOCX_MIME,
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
)