| """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: |
| 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: |
| 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 |
|
|