| """ReportInputStore — the seam the slow path persists its AnalysisRecord through. |
| |
| The Assembler produces an `AnalysisRecord` (the faithful, structured record of a |
| run — §8.3, INV-4). Persisting it is a separate concern from streaming the answer, |
| so it sits behind this seam. `generate_report` later reads records back by |
| `analysis_id` (oldest-first) and renders from them — never from chat history. |
| |
| - `NullReportInputStore` logs and stores nothing (kept for tests / when persistence |
| is intentionally disabled). |
| - `PostgresReportInputStore` writes one `report_inputs` row per run in the catalog |
| DB (Neon `dataeyond`, `settings.postgres_connstring`). |
| |
| `save` must never raise on the caller's path — a persistence failure must not break |
| the user's answer (§8.3). `list_for_analysis` is a read for the report generator and |
| is allowed to surface errors to its caller. |
| """ |
|
|
| from __future__ import annotations |
|
|
| from typing import Protocol, runtime_checkable |
|
|
| from sqlalchemy import select |
| from sqlalchemy.dialects.postgresql import insert |
|
|
| from src.db.postgres.connection import AsyncSessionLocal |
| from src.db.postgres.models import ReportInputRow |
| from src.middlewares.logging import get_logger |
|
|
| from .schemas import AnalysisRecord |
|
|
| logger = get_logger("analysis_store") |
|
|
|
|
| @runtime_checkable |
| class ReportInputStore(Protocol): |
| """Persist + read completed analyses. |
| |
| `save` must never raise on the caller's path. `list_for_analysis` returns the |
| records for one analysis session, oldest-first (the order the report renders in). |
| """ |
|
|
| async def save(self, record: AnalysisRecord) -> None: ... |
|
|
| async def list_for_analysis(self, analysis_id: str) -> list[AnalysisRecord]: ... |
|
|
|
|
| class NullReportInputStore: |
| """No-op store: logs the record, persists nothing. Reads return empty.""" |
|
|
| async def save(self, record: AnalysisRecord) -> None: |
| logger.info( |
| "analysis_record produced (not persisted — NullReportInputStore)", |
| record_id=record.record_id, |
| plan_id=record.plan_id, |
| n_tasks=len(record.tasks_run), |
| ) |
|
|
| async def list_for_analysis(self, analysis_id: str) -> list[AnalysisRecord]: |
| return [] |
|
|
|
|
| class PostgresReportInputStore: |
| """Writes/reads `report_inputs` jsonb rows in the catalog DB. |
| |
| Mirrors `CatalogStore`: each call opens its own `AsyncSession`. One row per |
| record (vs. one-per-user for the catalog) since records accumulate per analysis. |
| """ |
|
|
| async def save(self, record: AnalysisRecord) -> None: |
| try: |
| payload = record.model_dump(mode="json") |
| async with AsyncSessionLocal() as session: |
| stmt = insert(ReportInputRow).values( |
| id=record.record_id, |
| analysis_id=record.analysis_id, |
| user_id=record.user_id, |
| plan_id=record.plan_id, |
| data=payload, |
| created_at=record.created_at, |
| ) |
| |
| |
| stmt = stmt.on_conflict_do_update( |
| index_elements=[ReportInputRow.id], |
| set_={"data": stmt.excluded.data}, |
| ) |
| await session.execute(stmt) |
| await session.commit() |
| logger.info( |
| "analysis_record persisted", |
| record_id=record.record_id, |
| analysis_id=record.analysis_id, |
| user_id=record.user_id, |
| ) |
| except Exception as exc: |
| logger.error( |
| "analysis_record persist failed", |
| record_id=record.record_id, |
| error=str(exc), |
| ) |
|
|
| async def list_for_analysis(self, analysis_id: str) -> list[AnalysisRecord]: |
| async with AsyncSessionLocal() as session: |
| result = await session.execute( |
| select(ReportInputRow.data) |
| .where(ReportInputRow.analysis_id == analysis_id) |
| .order_by(ReportInputRow.created_at.asc()) |
| ) |
| rows = result.scalars().all() |
| return [AnalysisRecord.model_validate(row) for row in rows] |
|
|