ishaq101's picture
/fix v2 chat stream api update to analyses_messages (#7)
0066161
Raw
History Blame Contribute Delete
9.76 kB
"""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)
)
@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
# ⚠️ 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
@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