Spaces:
Runtime error
Runtime error
| """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." | |
| ), | |
| ) | |
| 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 | |
| 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) | |
| 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"'}, | |
| ) | |