| """Report API (KM-644) — the dedicated "Generate Report" surface. |
| |
| NOT a chat route. The frontend button calls these endpoints directly (pr/5: regrouped |
| under /tools — Go owns the analysis lifecycle, Python only generates): |
| POST /api/v1/tools/report generate a new version for a session |
| GET /api/v1/tools/report/{analysis_id} list a session's report versions |
| GET /api/v1/tools/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/tools", tags=["Tools"]) |
|
|
| _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: |
| """Freeze the analysis goal into the report's snapshot. |
| |
| Bridges the 2026-06-24 AnalysisState rework: prefer the new `objective` + |
| `business_questions` fields when the state carries them, else fall back to the |
| legacy free-text `problem_statement`. So the report works both before and after |
| the state-model migration lands (#4 / dedorch #3). |
| """ |
| if state is None: |
| return ProblemStatement() |
| objective = getattr(state, "objective", "") or getattr(state, "problem_statement", "") or "" |
| business_questions = list(getattr(state, "business_questions", []) or []) |
| return ProblemStatement(objective=objective, business_questions=business_questions) |
|
|
|
|
| async def _resolve_user_name(user_id: str) -> str | None: |
| """Best-effort display name (`users.fullname`) for the report's "generated by". |
| |
| Never-throw: a missing user or read error falls back to None, so the generator |
| shows the raw `user_id`. Resolving it here keeps the report self-contained (#19); |
| swap to a Go-passed display name later if the team prefers. |
| """ |
| try: |
| from src.db.postgres.connection import AsyncSessionLocal |
| from src.db.postgres.models import User |
|
|
| async with AsyncSessionLocal() as session: |
| user = await session.get(User, user_id) |
| return user.fullname if user is not None else None |
| except Exception as e: |
| logger.warning("report: user name resolve failed", user_id=user_id, error=str(e)) |
| return None |
|
|
|
|
| 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() |
| ) |
| 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) |
| user_name = await _resolve_user_name(user_id) |
| report = await _generator.generate( |
| analysis_id, user_id, problem_statement=problem_statement, user_name=user_name |
| ) |
| 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 |
|
|