RICS / backend /api /routes /report.py
StormShadow308's picture
Add demo documentation and Docker setup for v2 report generation system
aad7814
Raw
History Blame Contribute Delete
5.23 kB
"""Report generation: JSON preview and DOCX export from surveyor notes."""
from __future__ import annotations
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Response, status
from pydantic import BaseModel, Field, model_validator
from backend.api.deps import get_current_tenant
from backend.core import pii_scrubber, report_assembler, section_mapper, template_discoverer
from backend.core.interference import resolve_interference_level
from backend.core.photo_store import draft_section_photo_paths
from backend.core.rag_store import TIER_REFERENCE, get_rag_store
from backend.models.schema import TemplateSchema
router = APIRouter(prefix="/api/report", tags=["report"])
_DOCX_MIME = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
class GenerateRequest(BaseModel):
raw_notes: str = Field(
default="",
description="Surveyor field notes (any format). May be empty when using photos only.",
)
property_type: str = Field(
min_length=1,
description="Generic property descriptor, e.g. semi-detached house.",
)
tenure: str = Field(min_length=1, description="e.g. freehold or leasehold.")
title: str = Field(default="Survey Report", max_length=200)
include_footer: bool = Field(default=False)
report_draft_id: str | None = Field(
default=None,
description=(
"Draft id from POST /api/report/drafts. When set, selected section photos "
"(max 2 per section) are analyzed and mapped into the relevant sections."
),
)
survey_level: int = Field(
default=3,
ge=1,
le=3,
description=(
"RICS survey product tier (1/2/3). When interference_level is omitted, "
"Level 1→minimum, Level 2→medium, Level 3→maximum composition."
),
)
interference_level: Literal["minimum", "medium", "maximum"] | None = Field(
default=None,
description=(
"Report composition depth. When omitted, derived from survey_level. "
"minimum: baseline preservation with in-place fact updates. "
"medium: technical in-place edit on past report baseline. "
"maximum: full long-form narrative edit retaining baseline scaffolding."
),
)
@model_validator(mode="after")
def notes_or_draft(self) -> GenerateRequest:
if not self.raw_notes.strip() and not self.report_draft_id:
raise ValueError("raw_notes or report_draft_id is required")
return self
def _require_generation_ready(
tenant_id: str,
interference_level: Literal["minimum", "medium", "maximum"],
) -> TemplateSchema:
schema = template_discoverer.ensure_canonical_schema(tenant_id)
store = get_rag_store()
if store.count(tenant_id, TIER_REFERENCE) == 0:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
"No past reports have been ingested. Upload reference reports "
f"for {interference_level}-AI mapping."
),
)
return schema
@router.post("/preview")
def preview(
req: GenerateRequest,
tenant_id: str = Depends(get_current_tenant),
) -> dict:
il = resolve_interference_level(req.interference_level, req.survey_level)
schema = _require_generation_ready(tenant_id, il)
result = section_mapper.generate_report(
tenant_id,
req.raw_notes,
property_type=req.property_type,
tenure=req.tenure,
interference_level=il,
survey_level=req.survey_level,
report_draft_id=req.report_draft_id,
)
return report_assembler.to_preview(result, schema)
@router.post("/generate")
def generate(
req: GenerateRequest,
tenant_id: str = Depends(get_current_tenant),
) -> Response:
il = resolve_interference_level(req.interference_level, req.survey_level)
schema = _require_generation_ready(tenant_id, il)
result = section_mapper.generate_report(
tenant_id,
req.raw_notes,
property_type=req.property_type,
tenure=req.tenure,
interference_level=il,
survey_level=req.survey_level,
report_draft_id=req.report_draft_id,
)
section_photos = draft_section_photo_paths(
tenant_id,
req.report_draft_id,
schema.section_ids(),
)
try:
data = report_assembler.to_docx(
result,
schema,
title=req.title,
include_footer=req.include_footer,
section_photo_paths=section_photos,
)
except pii_scrubber.PiiDetectedError:
for s in result.sections:
s.text = pii_scrubber.scrub(s.text).text
result.unassigned_text = pii_scrubber.scrub(result.unassigned_text).text
data = report_assembler.to_docx(
result,
schema,
title=req.title,
include_footer=req.include_footer,
section_photo_paths=section_photos,
)
return Response(
content=data,
media_type=_DOCX_MIME,
headers={"Content-Disposition": 'attachment; filename="survey_report_draft.docx"'},
)