ishaq101's picture
feat/Analysis State & Report Rework (#4)
0e02a0f
Raw
History Blame Contribute Delete
4.35 kB
"""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,
)
# 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",
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]