"""Report API (KM-644) — the dedicated "Generate Report" surface. NOT a chat route. The frontend button calls these endpoints directly: POST /report generate a new version for a session GET /report/{analysis_id} list a session's report versions GET /report/{analysis_id}/{ver} fetch one version Generation reads persisted AnalysisRecords + Problem Statement, makes one LLM call (the executive summary), and persists an immutable versioned artifact. The ReportGenerator + ReportStore are process singletons (the generator caches its LLM chain warm across requests, like ChatHandler). Note (T-E): AnalysisRecords are only persisted by the slow path, so reports require `ENABLE_SLOW_PATH=on`. With it off, no records exist and generation 409s — by design, not a bug. POST gates on the same floor as Help's readiness signal (validated goal + ≥1 substantive analysis) so the button and Help never disagree. """ from fastapi import APIRouter, HTTPException, Query, status from src.agents.report.errors import ReportError from src.agents.report.generator import ReportGenerator from src.agents.report.schemas import AnalysisReport, ProblemStatement from src.agents.report.store import ReportStore from src.middlewares.logging import get_logger, log_execution from src.models.api.report import ReportVersionEntry logger = get_logger("report_api") router = APIRouter(prefix="/api/v1", tags=["Report"]) _generator = ReportGenerator() _store = ReportStore() async def _load_state(analysis_id: str): """Load the AnalysisState (for the floor gate + problem statement). Never-throw.""" try: from src.agents.state_store import AnalysisStateStore return await AnalysisStateStore().get(analysis_id) except Exception as e: # noqa: BLE001 — never block report generation on this logger.warning("report: state load failed", analysis_id=analysis_id, error=str(e)) return None def _problem_statement_from(state) -> ProblemStatement: """Map the analysis's free-text problem statement into the report's structured PS.""" if state is None or not state.problem_statement: return ProblemStatement() return ProblemStatement(objective=state.problem_statement) async def _record_report_on_state(analysis_id: str, report_id: str) -> None: """Write the new `report_id` back onto the Analysis State (never-throw). Closes the loop so Help's `has_report` and the readiness delta-check can see that a report exists. A missing state row / write error must not fail a report that already generated and persisted. """ try: from src.agents.state_store import AnalysisStateStore await AnalysisStateStore().update(analysis_id, report_id=report_id) except Exception as e: # noqa: BLE001 logger.warning( "report: report_id write-back failed", analysis_id=analysis_id, error=str(e) ) @router.post( "/report", response_model=AnalysisReport, status_code=status.HTTP_201_CREATED, summary="Generate a new report version for an analysis session", responses={ 201: {"description": "A new versioned report was generated and persisted."}, 409: {"description": "No analyses recorded for this session yet — nothing to report."}, 500: {"description": "Report generation or persistence failed."}, }, ) @log_execution(logger) async def generate_report( analysis_id: str = Query(..., description="The analysis session to report on."), user_id: str = Query(..., description="Owner of the analysis session."), ): """Generate, persist, and return a new report version. Each call produces a new version (V1, V2, …) that snapshots the records and Problem Statement it used. Server-side gate: the report **floor** — a validated goal + ≥1 substantive analysis — the same floor Help's readiness signal uses, so the button and Help can't disagree (T-D). The delta-since-report check is NOT applied here: a new version is always allowed (decision 4A). """ from src.agents.gate import stub_analysis_state from src.agents.report.readiness import report_floor state = await _load_state(analysis_id) floor_missing, _ = await report_floor( analysis_id, state or stub_analysis_state(problem_validated=False) ) if floor_missing: raise HTTPException( status_code=status.HTTP_409_CONFLICT, detail="Not ready to generate a report — still needs " + ", ".join(floor_missing) + ".", ) try: problem_statement = _problem_statement_from(state) report = await _generator.generate( analysis_id, user_id, problem_statement=problem_statement ) except ReportError as e: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) from e except Exception as e: logger.error("report generation failed", analysis_id=analysis_id, error=str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Report generation failed: {e}", ) from e try: saved = await _store.save(report) except Exception as e: logger.error("report persist failed", analysis_id=analysis_id, error=str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Report persistence failed: {e}", ) from e await _record_report_on_state(analysis_id, saved.report_id) return saved @router.get( "/report/{analysis_id}", response_model=list[ReportVersionEntry], summary="List a session's report versions", response_description="Version metadata, oldest-first. Empty if none generated yet.", ) @log_execution(logger) async def list_report_versions(analysis_id: str): """Return version metadata for a session (for the Analysis-menu sidebar).""" try: reports = await _store.list_for_analysis(analysis_id) except Exception as e: logger.error("report list failed", analysis_id=analysis_id, error=str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to list reports: {e}", ) from e return [ ReportVersionEntry( report_id=r.report_id, version=r.version, generated_at=r.generated_at, record_count=len(r.record_ids), ) for r in reports ] @router.get( "/report/{analysis_id}/{version}", response_model=AnalysisReport, summary="Fetch one report version", responses={404: {"description": "No report at that version for this session."}}, ) @log_execution(logger) async def get_report_version(analysis_id: str, version: int): """Return the full content of a specific report version.""" try: report = await _store.get(analysis_id, version) except Exception as e: logger.error("report fetch failed", analysis_id=analysis_id, version=version, error=str(e)) raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to fetch report: {e}", ) from e if report is None: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"No report v{version} for analysis {analysis_id!r}.", ) return report