Rifqi Hafizuddin
[NOTICKET] feat(slow-path): persist AnalysisRecord via PostgresAnalysisStore
09f8451 | """AnalysisStore — 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. | |
| - `NullAnalysisStore` logs and stores nothing (kept for tests / when persistence | |
| is intentionally disabled). | |
| - `PostgresAnalysisStore` writes one `analysis_records` 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 AnalysisRecordRow | |
| from src.middlewares.logging import get_logger | |
| from .schemas import AnalysisRecord | |
| logger = get_logger("analysis_store") | |
| class AnalysisStore(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 NullAnalysisStore: | |
| """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 — NullAnalysisStore)", | |
| 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 PostgresAnalysisStore: | |
| """Writes/reads `analysis_records` 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(AnalysisRecordRow).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, | |
| ) | |
| # Re-running the same plan id-collides only if record_id repeats; | |
| # treat that as idempotent (overwrite) rather than erroring the user. | |
| stmt = stmt.on_conflict_do_update( | |
| index_elements=[AnalysisRecordRow.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: # never break the user's answer (§8.3) | |
| 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(AnalysisRecordRow.data) | |
| .where(AnalysisRecordRow.analysis_id == analysis_id) | |
| .order_by(AnalysisRecordRow.created_at.asc()) | |
| ) | |
| rows = result.scalars().all() | |
| return [AnalysisRecord.model_validate(row) for row in rows] | |