File size: 13,193 Bytes
0721bb4
 
3bacc1d
 
49b0848
 
 
 
 
0721bb4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49b0848
 
 
 
 
0721bb4
 
 
3bacc1d
 
 
 
0721bb4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0e02a0f
 
 
 
 
 
 
 
0721bb4
0e02a0f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0721bb4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49b0848
 
 
 
 
 
0721bb4
 
 
 
 
 
 
49b0848
 
0721bb4
 
 
 
 
 
0066161
0721bb4
 
 
 
 
 
 
 
 
 
 
0e02a0f
0721bb4
49b0848
 
 
 
 
0721bb4
 
 
 
 
 
 
 
 
 
0e02a0f
 
 
 
 
 
 
 
0721bb4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49b0848
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0721bb4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
"""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}/records    list analysis records (curation)
  GET  /api/v1/tools/report/{analysis_id}/readiness  readiness signal (FE delta guard)
  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 (
    AnalysisRecordEntry,
    ReportReadinessResponse,
    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."),
    exclude_record_ids: list[str] = Query(
        default=[],
        description="Record ids to leave out of this version (curation; repeat the "
        "param per id). Excluded runs are listed in the report's Excluded Analyses "
        "section. Get the ids from GET /tools/report/{analysis_id}/records.",
    ),
):
    """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). Excluding every
    substantive record 409s (nothing left to report).
    """
    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,
            exclude_record_ids=exclude_record_ids,
        )
    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
    ]


# ⚠️ Route order: these two literal-suffix routes MUST stay registered BEFORE
# `/report/{analysis_id}/{version}` β€” FastAPI matches in registration order, and the
# `{version}` route would swallow `/records` / `/readiness` and 422 on int coercion
# (no fall-through to a later route).


@router.get(
    "/report/{analysis_id}/records",
    response_model=list[AnalysisRecordEntry],
    summary="List a session's analysis records (for report curation)",
    response_description="Persisted analysis runs, oldest-first. Empty if none yet.",
)
@log_execution(logger)
async def list_analysis_records(analysis_id: str):
    """Return the persisted analysis runs a report would be built from.

    The FE shows this list before generating so the user can deselect runs; the
    chosen ids go to POST /tools/report as `exclude_record_ids`.
    """
    from src.agents.report.readiness import has_successful_analysis
    from src.agents.slow_path.store import PostgresReportInputStore

    try:
        records = await PostgresReportInputStore().list_for_analysis(analysis_id)
    except Exception as e:
        logger.error("record list failed", analysis_id=analysis_id, error=str(e))
        raise HTTPException(
            status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
            detail=f"Failed to list analysis records: {e}",
        ) from e

    return [
        AnalysisRecordEntry(
            record_id=r.record_id,
            goal_restated=r.goal_restated,
            created_at=r.created_at,
            substantive=has_successful_analysis(r),
            findings_count=len(r.findings),
        )
        for r in records
    ]


@router.get(
    "/report/{analysis_id}/readiness",
    response_model=ReportReadinessResponse,
    summary="Report-readiness signal for an analysis session",
    response_description="Whether a report can be generated now, with the gaps if not.",
)
@log_execution(logger)
async def get_report_readiness(analysis_id: str):
    """Deterministic readiness signal for the FE's Generate-Report button.

    Same producer as Help's readiness signal (`is_report_ready`), including the
    advisory delta-since-report check β€” so the button, Help, and this endpoint can
    never disagree. POST itself only enforces the floor (a new version is always
    allowed, decision 4A); `missing` here may name the delta gap as a soft warning.
    """
    from src.agents.gate import stub_analysis_state
    from src.agents.report.readiness import is_report_ready

    state = await _load_state(analysis_id)
    readiness = await is_report_ready(analysis_id, state or stub_analysis_state())
    return ReportReadinessResponse(ready=readiness.ready, missing=readiness.missing)


@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