Rifqi Hafizuddin
[NOTICKET] feat: bound catalog render + blob read; scope charts/traceability; mark degraded seams
0cb7d53 | """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") | |
| 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, user_id: str | None = None | |
| ) -> 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, user_id: str | None = None | |
| ) -> 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, | |
| ) | |
| # 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=[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: # never break the user's answer (§8.3) | |
| logger.error( | |
| "analysis_record persist failed", | |
| degraded_seam="report_input_persist", | |
| record_id=record.record_id, | |
| error=repr(exc), | |
| ) | |
| async def list_for_analysis( | |
| self, analysis_id: str, user_id: str | None = None | |
| ) -> list[AnalysisRecord]: | |
| """Records for one analysis, oldest-first. | |
| `user_id` scopes the read to the analysis's owner (2026-07-23). Optional so | |
| the unthreaded call sites (`report_floor`, `GET …/records`, `GET …/readiness` | |
| — none of which currently receive a user_id) keep working; those endpoints | |
| gaining the parameter is the remaining half of the change. | |
| """ | |
| async with AsyncSessionLocal() as session: | |
| where = [ReportInputRow.analysis_id == analysis_id] | |
| if user_id is not None: | |
| where.append(ReportInputRow.user_id == user_id) | |
| result = await session.execute( | |
| select(ReportInputRow.data) | |
| .where(*where) | |
| .order_by(ReportInputRow.created_at.asc()) | |
| ) | |
| rows = result.scalars().all() | |
| return [AnalysisRecord.model_validate(row) for row in rows] | |