Rifqi Hafizuddin
[KM-652] fix(report,chat): align report gate with readiness + cleanup
9c06bea
Raw
History Blame
7.48 kB
"""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