| """`is_report_ready` — deterministic report-readiness signal (seam #5, KM-652). |
| |
| The Help skill asks "can the user generate a report yet?" before it offers that as |
| a next step. This is the producer of that answer; Help only *consumes* it (see |
| `handlers/help.ReportReadiness`). No LLM — readiness is a fact about persisted state, |
| not a judgement. |
| |
| The rule mirrors what makes a real report non-empty and worth generating, so Help can |
| never suggest an action that would 409 or produce a duplicate: |
| 1. (removed 2026-06-24) a validated problem statement — the report no longer gates on |
| the goal (now user-entered `objective` + `business_questions`, no agent validation). |
| 2. at least one **substantive** persisted `AnalysisRecord` — a record whose |
| *analysis* task succeeded. A failed run still persists a record WITH findings |
| (they narrate the failure), and data-access tasks (check_/retrieve_) succeed even |
| when the analysis fails — so neither "has findings" nor "any task succeeded" is |
| enough. We require a genuine analysis tool (analyze_*) to have completed. We count |
| *results*, not chat turns. |
| 3. delta-since-report — if a report already exists (`state.report_id`), only ready |
| when there's a substantive analysis newer than the latest report; otherwise the |
| new report would be identical. |
| |
| `missing` names whichever criterion is absent, so Help can tell the user the next gap |
| to fill (the team values `missing` over the bare boolean). Bias is anti-false-positive |
| (report is also button-triggered): a record-store read failure fails **closed** |
| (not ready); a report-store read failure during the delta check fails **open** (we |
| can't prove staleness, and the button is always there). |
| |
| NOT in scope (deferred, pending the readiness eval set): semantic *alignment* of the |
| analyses to the problem statement and *depth*/variety scoring — both need an LLM judge |
| and shouldn't sit in the per-turn Help hot path until eval justifies the cost. |
| """ |
|
|
| from __future__ import annotations |
|
|
| from datetime import UTC, datetime |
| from typing import TYPE_CHECKING |
|
|
| from src.middlewares.logging import get_logger |
|
|
| from ..handlers.help import ReportReadiness |
|
|
| if TYPE_CHECKING: |
| from ..gate import AnalysisState |
|
|
| logger = get_logger("report_readiness") |
|
|
| |
| |
| _MISSING_ANALYSIS = "at least one completed analysis" |
| _MISSING_DELTA = "a new analysis since the last report" |
|
|
|
|
| def _default_record_store(): |
| from ..slow_path.store import PostgresReportInputStore |
|
|
| return PostgresReportInputStore() |
|
|
|
|
| def _default_report_store(): |
| from .store import ReportStore |
|
|
| return ReportStore() |
|
|
|
|
| def _is_newer(a: datetime, b: datetime) -> bool: |
| """True if `a` is later than `b`, tolerating naive/aware mismatch (assume UTC).""" |
| if a.tzinfo is None: |
| a = a.replace(tzinfo=UTC) |
| if b.tzinfo is None: |
| b = b.replace(tzinfo=UTC) |
| return a > b |
|
|
|
|
| def has_successful_analysis(record) -> bool: |
| """True if the record has at least one *result-producing* task that succeeded. |
| |
| A failed run still writes findings (narrating the failure) and its data-access |
| tasks (check_/retrieve_) succeed, so we can't key on findings or on "any task |
| succeeded". A completed analysis tool (analyze_*) — or, since W2 charts |
| (2026-07-14), a completed `render_chart`, whose viz-tail upstream necessarily |
| computed the numbers being charted — is the real "we produced a result" |
| signal. A chart-only session therefore satisfies the report floor. |
| """ |
| return any( |
| t.status == "success" |
| and any(tool.startswith("analyze") or tool == "render_chart" for tool in t.tools_used) |
| for t in record.tasks_run |
| ) |
|
|
|
|
| async def report_floor( |
| analysis_id: str | None, |
| state: AnalysisState, |
| *, |
| record_store=None, |
| ) -> tuple[list[str], list]: |
| """The report **floor**: ≥1 substantive analysis. |
| |
| Returns `(missing, substantive_records)`. This is the shared gate both the Help |
| readiness signal AND the report API enforce, so the button and Help can't drift |
| (T-D / T11). |
| |
| CHANGED 2026-06-24: the `problem_validated` precondition was dropped — analysis is no |
| longer gated on a validated goal (now user-entered `objective` + `business_questions`, |
| no agent validation), so the only floor is "is there anything worth reporting". The |
| delta-since-report check stays advisory and lives only in `is_report_ready`; the |
| report button is always allowed to cut a new version (decision 4A). Fails closed |
| (counts as missing analysis) on a record-store read error. `record_store` is |
| injectable for tests. `state` stays in the signature (callers + the `is_report_ready` |
| delta check use it). |
| """ |
| missing: list[str] = [] |
|
|
| substantive: list = [] |
| if analysis_id: |
| try: |
| store = record_store or _default_record_store() |
| records = await store.list_for_analysis(analysis_id) |
| substantive = [r for r in records if has_successful_analysis(r)] |
| except Exception as exc: |
| logger.warning( |
| "report_floor: record store read failed — not ready", |
| analysis_id=analysis_id, |
| error=str(exc), |
| ) |
| return [_MISSING_ANALYSIS], [] |
|
|
| if not substantive: |
| missing.append(_MISSING_ANALYSIS) |
| return missing, substantive |
|
|
|
|
| async def is_report_ready( |
| analysis_id: str | None, |
| state: AnalysisState, |
| *, |
| record_store=None, |
| report_store=None, |
| ) -> ReportReadiness: |
| """Return whether a report can be generated for this analysis, and the gaps if not. |
| |
| `record_store` / `report_store` are injectable for tests; they default to the |
| real Postgres stores. |
| """ |
| missing, substantive = await report_floor( |
| analysis_id, state, record_store=record_store |
| ) |
|
|
| if not substantive: |
| |
| return ReportReadiness(ready=not missing, missing=missing) |
|
|
| |
| |
| if state.report_id: |
| last_report_at: datetime | None = None |
| try: |
| rstore = report_store or _default_report_store() |
| reports = await rstore.list_for_analysis(analysis_id) |
| last_report_at = max((r.generated_at for r in reports), default=None) |
| except Exception as exc: |
| logger.warning( |
| "is_report_ready: report store read failed — skipping delta check", |
| analysis_id=analysis_id, |
| error=str(exc), |
| ) |
| if last_report_at is not None and not any( |
| _is_newer(r.created_at, last_report_at) for r in substantive |
| ): |
| missing.append(_MISSING_DELTA) |
|
|
| return ReportReadiness(ready=not missing, missing=missing) |
|
|