Rifqi Hafizuddin
[NOTICKET] fix(db): reconcile analyses schema + migrate chat to analyses_messages
283eb0e | """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") | |
| # pr/5 Phase 2: report regrouped under the tools surface (path → /api/v1/tools/report). | |
| # Prefix change moves all three routes at once; same functionality, new home. The | |
| # "Tools" tag groups it with /tools/list + /tools/help in Swagger. | |
| 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: # 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: | |
| """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: # noqa: BLE001 — never block a report on the name lookup | |
| 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: # noqa: BLE001 | |
| logger.warning( | |
| "report: report_id write-back failed", analysis_id=analysis_id, error=str(e) | |
| ) | |
| 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 | |
| # ⚠️ TRANSITIONAL — Go is to own ALL writes: | |
| # the report becomes a content-only skill (FE → Go → Python) and Go persists to the | |
| # `reports`/`analyses` tables. Until Go exposes those write endpoints, Python still | |
| # self-persists here: | |
| # _store.save(report) → inserts the versioned `reports` row | |
| # _record_report_on_state(...) → writes report_id back onto the `analyses` row | |
| # Remove both (return `report` content only) once Go's report-write + state-write | |
| # endpoints land. | |
| 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 | |
| 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 | |
| ] | |
| 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 | |