[KM-652] refactor(db): align analysis-family models to the dedorch schema
Browse filesRepoint our SQLAlchemy models to the Go-owned dedorch tables (Go owns migrations;
we are consumer-only). Base tables already matched; only the analysis family differed.
- analysis_states -> `analysis`: table rename; id/report_id -> Postgres uuid.
- analysis_data_sources -> `data_sources`: richer shape (id, type, name,
reference_id, bound_by, bound_at, metadata, created_at). binding_store reads
`reference_id`; /analysis/create snapshots each source's type+name from the
catalog (fail-open). `metadata` mapped as `source_metadata` (reserved name).
- analysis_reports -> `reports`: flatten to title + content (rendered markdown) +
generated_at + version; drop user_id/jsonb data. ReportStore writes title+content
and rebuilds a minimal AnalysisReport on read (rendered_markdown = content;
structured fields empty — markdown-only per the 2026-06-23 checkpoint).
Deferred (pending lead): analysis_records has no dedorch table, so the report
generator / record persistence / readiness floor are untouched and POST /report
stays blocked until records get a home. The connstring cutover (+ SKIP_INIT_DB) is
a separate coordinated step — these renames only resolve against dedorch.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- src/agents/binding_store.py +3 -3
- src/agents/report/store.py +28 -6
- src/agents/state_store.py +2 -2
- src/api/v1/analysis.py +35 -8
- src/db/postgres/models.py +39 -38
|
@@ -1,7 +1,7 @@
|
|
| 1 |
"""AnalysisDataSourceStore — read per-analysis data-source bindings (#10).
|
| 2 |
|
| 3 |
-
The
|
| 4 |
-
|
| 5 |
store is the read seam for the two consumers — `structured_flow` catalog scoping and
|
| 6 |
the report's data-source appendix.
|
| 7 |
|
|
@@ -27,7 +27,7 @@ class AnalysisDataSourceStore:
|
|
| 27 |
async def get(self, analysis_id: str) -> list[str]:
|
| 28 |
async with AsyncSessionLocal() as session:
|
| 29 |
result = await session.execute(
|
| 30 |
-
select(AnalysisDataSourceRow.
|
| 31 |
AnalysisDataSourceRow.analysis_id == analysis_id
|
| 32 |
)
|
| 33 |
)
|
|
|
|
| 1 |
"""AnalysisDataSourceStore — read per-analysis data-source bindings (#10).
|
| 2 |
|
| 3 |
+
The dedorch `data_sources` table records which catalog sources an analysis is scoped
|
| 4 |
+
to (`reference_id` = the catalog source id). It's written at `/analysis/create`; this
|
| 5 |
store is the read seam for the two consumers — `structured_flow` catalog scoping and
|
| 6 |
the report's data-source appendix.
|
| 7 |
|
|
|
|
| 27 |
async def get(self, analysis_id: str) -> list[str]:
|
| 28 |
async with AsyncSessionLocal() as session:
|
| 29 |
result = await session.execute(
|
| 30 |
+
select(AnalysisDataSourceRow.reference_id).where(
|
| 31 |
AnalysisDataSourceRow.analysis_id == analysis_id
|
| 32 |
)
|
| 33 |
)
|
|
@@ -35,6 +35,27 @@ def _lock_key(analysis_id: str) -> int:
|
|
| 35 |
return int.from_bytes(digest[:8], "big", signed=True)
|
| 36 |
|
| 37 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
class ReportStore:
|
| 39 |
"""Read/write versioned reports keyed by `analysis_id`."""
|
| 40 |
|
|
@@ -59,9 +80,10 @@ class ReportStore:
|
|
| 59 |
AnalysisReportRow(
|
| 60 |
id=report.report_id,
|
| 61 |
analysis_id=report.analysis_id,
|
| 62 |
-
|
|
|
|
|
|
|
| 63 |
version=report.version,
|
| 64 |
-
data=report.model_dump(mode="json"),
|
| 65 |
)
|
| 66 |
)
|
| 67 |
# leaving session.begin() commits, which releases the advisory lock
|
|
@@ -76,17 +98,17 @@ class ReportStore:
|
|
| 76 |
async def list_for_analysis(self, analysis_id: str) -> list[AnalysisReport]:
|
| 77 |
async with AsyncSessionLocal() as session:
|
| 78 |
result = await session.execute(
|
| 79 |
-
select(AnalysisReportRow
|
| 80 |
.where(AnalysisReportRow.analysis_id == analysis_id)
|
| 81 |
.order_by(AnalysisReportRow.version.asc())
|
| 82 |
)
|
| 83 |
rows = result.scalars().all()
|
| 84 |
-
return [
|
| 85 |
|
| 86 |
async def get(self, analysis_id: str, version: int) -> AnalysisReport | None:
|
| 87 |
async with AsyncSessionLocal() as session:
|
| 88 |
result = await session.execute(
|
| 89 |
-
select(AnalysisReportRow
|
| 90 |
AnalysisReportRow.analysis_id == analysis_id,
|
| 91 |
AnalysisReportRow.version == version,
|
| 92 |
)
|
|
@@ -94,4 +116,4 @@ class ReportStore:
|
|
| 94 |
row = result.scalar_one_or_none()
|
| 95 |
if row is None:
|
| 96 |
return None
|
| 97 |
-
return
|
|
|
|
| 35 |
return int.from_bytes(digest[:8], "big", signed=True)
|
| 36 |
|
| 37 |
|
| 38 |
+
def _report_title(report: AnalysisReport) -> str:
|
| 39 |
+
"""Title for the dedorch `reports.title` column — the goal, else a generic label."""
|
| 40 |
+
objective = (report.problem_statement.objective or "").strip()
|
| 41 |
+
return objective[:200] if objective else "Analysis Report"
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
def _row_to_report(row) -> AnalysisReport:
|
| 45 |
+
"""Rebuild a minimal AnalysisReport from the flat dedorch row.
|
| 46 |
+
|
| 47 |
+
dedorch stores markdown only, so structured fields (findings/caveats/…) come back
|
| 48 |
+
empty; `rendered_markdown` carries the content the FE renders/downloads.
|
| 49 |
+
"""
|
| 50 |
+
return AnalysisReport(
|
| 51 |
+
report_id=row.id,
|
| 52 |
+
analysis_id=row.analysis_id,
|
| 53 |
+
version=row.version,
|
| 54 |
+
generated_at=row.generated_at,
|
| 55 |
+
rendered_markdown=row.content,
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
class ReportStore:
|
| 60 |
"""Read/write versioned reports keyed by `analysis_id`."""
|
| 61 |
|
|
|
|
| 80 |
AnalysisReportRow(
|
| 81 |
id=report.report_id,
|
| 82 |
analysis_id=report.analysis_id,
|
| 83 |
+
title=_report_title(report),
|
| 84 |
+
content=report.rendered_markdown or "",
|
| 85 |
+
generated_at=report.generated_at,
|
| 86 |
version=report.version,
|
|
|
|
| 87 |
)
|
| 88 |
)
|
| 89 |
# leaving session.begin() commits, which releases the advisory lock
|
|
|
|
| 98 |
async def list_for_analysis(self, analysis_id: str) -> list[AnalysisReport]:
|
| 99 |
async with AsyncSessionLocal() as session:
|
| 100 |
result = await session.execute(
|
| 101 |
+
select(AnalysisReportRow)
|
| 102 |
.where(AnalysisReportRow.analysis_id == analysis_id)
|
| 103 |
.order_by(AnalysisReportRow.version.asc())
|
| 104 |
)
|
| 105 |
rows = result.scalars().all()
|
| 106 |
+
return [_row_to_report(row) for row in rows]
|
| 107 |
|
| 108 |
async def get(self, analysis_id: str, version: int) -> AnalysisReport | None:
|
| 109 |
async with AsyncSessionLocal() as session:
|
| 110 |
result = await session.execute(
|
| 111 |
+
select(AnalysisReportRow).where(
|
| 112 |
AnalysisReportRow.analysis_id == analysis_id,
|
| 113 |
AnalysisReportRow.version == version,
|
| 114 |
)
|
|
|
|
| 116 |
row = result.scalar_one_or_none()
|
| 117 |
if row is None:
|
| 118 |
return None
|
| 119 |
+
return _row_to_report(row)
|
|
@@ -35,7 +35,7 @@ def _row_to_state(row: AnalysisStateRow) -> AnalysisState:
|
|
| 35 |
|
| 36 |
|
| 37 |
class AnalysisStateStore:
|
| 38 |
-
"""Read/write `
|
| 39 |
|
| 40 |
async def get(self, analysis_id: str) -> AnalysisState | None:
|
| 41 |
async with AsyncSessionLocal() as session:
|
|
@@ -113,7 +113,7 @@ class AnalysisStateStore:
|
|
| 113 |
row = await session.get(AnalysisStateRow, analysis_id)
|
| 114 |
if row is None:
|
| 115 |
logger.warning(
|
| 116 |
-
"
|
| 117 |
analysis_id=analysis_id,
|
| 118 |
)
|
| 119 |
return None
|
|
|
|
| 35 |
|
| 36 |
|
| 37 |
class AnalysisStateStore:
|
| 38 |
+
"""Read/write the dedorch `analysis` table, keyed by the shared session id."""
|
| 39 |
|
| 40 |
async def get(self, analysis_id: str) -> AnalysisState | None:
|
| 41 |
async with AsyncSessionLocal() as session:
|
|
|
|
| 113 |
row = await session.get(AnalysisStateRow, analysis_id)
|
| 114 |
if row is None:
|
| 115 |
logger.warning(
|
| 116 |
+
"analysis row missing — update skipped",
|
| 117 |
analysis_id=analysis_id,
|
| 118 |
)
|
| 119 |
return None
|
|
@@ -40,13 +40,29 @@ def _serialize_state(row: AnalysisStateRow, data_source_ids: list[str]) -> dict:
|
|
| 40 |
|
| 41 |
async def _bound_source_ids(db: AsyncSession, analysis_id: str) -> list[str]:
|
| 42 |
result = await db.execute(
|
| 43 |
-
select(AnalysisDataSourceRow.
|
| 44 |
AnalysisDataSourceRow.analysis_id == analysis_id
|
| 45 |
)
|
| 46 |
)
|
| 47 |
return list(result.scalars().all())
|
| 48 |
|
| 49 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
class CreateAnalysisRequest(BaseModel):
|
| 51 |
user_id: str
|
| 52 |
analysis_title: str = "New analysis"
|
|
@@ -63,10 +79,9 @@ async def create_analysis(
|
|
| 63 |
"""Create a new analysis session: one shared id for its state + chat room.
|
| 64 |
|
| 65 |
Data-first gate (decision #2): an analysis requires >=1 bound data source.
|
| 66 |
-
The bound sources are persisted as `
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
the report read this binding back.
|
| 70 |
"""
|
| 71 |
if not request.data_source_ids:
|
| 72 |
raise HTTPException(
|
|
@@ -86,11 +101,23 @@ async def create_analysis(
|
|
| 86 |
)
|
| 87 |
db.add(Room(id=analysis_id, user_id=request.user_id, title=request.analysis_title))
|
| 88 |
db.add(state_row)
|
| 89 |
-
# dict.fromkeys dedupes while preserving order
|
| 90 |
-
#
|
|
|
|
| 91 |
bound_ids = list(dict.fromkeys(request.data_source_ids))
|
|
|
|
| 92 |
for source_id in bound_ids:
|
| 93 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
await db.commit()
|
| 95 |
await db.refresh(state_row)
|
| 96 |
|
|
|
|
| 40 |
|
| 41 |
async def _bound_source_ids(db: AsyncSession, analysis_id: str) -> list[str]:
|
| 42 |
result = await db.execute(
|
| 43 |
+
select(AnalysisDataSourceRow.reference_id).where(
|
| 44 |
AnalysisDataSourceRow.analysis_id == analysis_id
|
| 45 |
)
|
| 46 |
)
|
| 47 |
return list(result.scalars().all())
|
| 48 |
|
| 49 |
|
| 50 |
+
async def _sources_by_id(user_id: str) -> dict:
|
| 51 |
+
"""Catalog sources keyed by source_id, to resolve `type`/`name` on binding.
|
| 52 |
+
|
| 53 |
+
Never-throw: missing catalog / read error → empty map, and binding rows fall back
|
| 54 |
+
to type='unknown' / name=reference_id.
|
| 55 |
+
"""
|
| 56 |
+
try:
|
| 57 |
+
from src.catalog.store import CatalogStore
|
| 58 |
+
|
| 59 |
+
catalog = await CatalogStore().get(user_id)
|
| 60 |
+
except Exception as e: # noqa: BLE001 — binding must not fail on catalog read
|
| 61 |
+
logger.warning("analysis: catalog read failed for binding", user_id=user_id, error=str(e))
|
| 62 |
+
return {}
|
| 63 |
+
return {s.source_id: s for s in catalog.sources} if catalog else {}
|
| 64 |
+
|
| 65 |
+
|
| 66 |
class CreateAnalysisRequest(BaseModel):
|
| 67 |
user_id: str
|
| 68 |
analysis_title: str = "New analysis"
|
|
|
|
| 79 |
"""Create a new analysis session: one shared id for its state + chat room.
|
| 80 |
|
| 81 |
Data-first gate (decision #2): an analysis requires >=1 bound data source.
|
| 82 |
+
The bound sources are persisted as dedorch `data_sources` rows (#10) in the same
|
| 83 |
+
transaction as the state + room, so the analysis is scoped to exactly the sources
|
| 84 |
+
the user picked. `structured_flow` and the report read this binding back.
|
|
|
|
| 85 |
"""
|
| 86 |
if not request.data_source_ids:
|
| 87 |
raise HTTPException(
|
|
|
|
| 101 |
)
|
| 102 |
db.add(Room(id=analysis_id, user_id=request.user_id, title=request.analysis_title))
|
| 103 |
db.add(state_row)
|
| 104 |
+
# dict.fromkeys dedupes while preserving order. Each binding row snapshots the
|
| 105 |
+
# source's type + name from the catalog (reference_id = catalog source id);
|
| 106 |
+
# bound_at/created_at default to now() in dedorch.
|
| 107 |
bound_ids = list(dict.fromkeys(request.data_source_ids))
|
| 108 |
+
src_by_id = await _sources_by_id(request.user_id)
|
| 109 |
for source_id in bound_ids:
|
| 110 |
+
src = src_by_id.get(source_id)
|
| 111 |
+
db.add(
|
| 112 |
+
AnalysisDataSourceRow(
|
| 113 |
+
id=str(uuid.uuid4()),
|
| 114 |
+
analysis_id=analysis_id,
|
| 115 |
+
type=src.source_type if src else "unknown",
|
| 116 |
+
name=src.name if src else source_id,
|
| 117 |
+
reference_id=source_id,
|
| 118 |
+
bound_by=request.user_id,
|
| 119 |
+
)
|
| 120 |
+
)
|
| 121 |
await db.commit()
|
| 122 |
await db.refresh(state_row)
|
| 123 |
|
|
@@ -10,9 +10,8 @@ from sqlalchemy import (
|
|
| 10 |
Integer,
|
| 11 |
String,
|
| 12 |
Text,
|
| 13 |
-
UniqueConstraint,
|
| 14 |
)
|
| 15 |
-
from sqlalchemy.dialects.postgresql import JSONB
|
| 16 |
from sqlalchemy.orm import relationship
|
| 17 |
from sqlalchemy.sql import func
|
| 18 |
|
|
@@ -151,46 +150,42 @@ class AnalysisRecordRow(Base):
|
|
| 151 |
|
| 152 |
|
| 153 |
class AnalysisReportRow(Base):
|
| 154 |
-
"""One immutable row per generated report version (
|
| 155 |
|
| 156 |
-
`
|
| 157 |
-
|
| 158 |
-
`
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
advisory lock in `ReportStore` — the unique constraint is the backstop.
|
| 162 |
"""
|
| 163 |
-
__tablename__ = "
|
| 164 |
|
| 165 |
-
id = Column(
|
| 166 |
-
analysis_id = Column(
|
| 167 |
-
|
|
|
|
|
|
|
| 168 |
version = Column(Integer, nullable=False)
|
| 169 |
-
data = Column(JSONB, nullable=False)
|
| 170 |
-
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
| 171 |
-
|
| 172 |
-
__table_args__ = (
|
| 173 |
-
UniqueConstraint("analysis_id", "version", name="uq_analysis_version"),
|
| 174 |
-
)
|
| 175 |
|
| 176 |
|
| 177 |
class AnalysisStateRow(Base):
|
| 178 |
-
"""Per-analysis session state —
|
| 179 |
|
| 180 |
-
One session = one analysis = one conversation
|
| 181 |
-
|
| 182 |
-
`analysis_id`. The orchestrator gate + Help skill read this every turn;
|
| 183 |
`problem_validated` gates structured analysis; the Problem Statement skill flips
|
| 184 |
-
it; `report_id` is null until a report exists.
|
|
|
|
|
|
|
| 185 |
"""
|
| 186 |
-
__tablename__ = "
|
| 187 |
|
| 188 |
-
id = Column(
|
| 189 |
analysis_title = Column(String, nullable=False, default="New analysis")
|
| 190 |
problem_statement = Column(Text, nullable=False, default="")
|
| 191 |
problem_validated = Column(Boolean, nullable=False, default=False)
|
| 192 |
owner_id = Column(String, nullable=False, index=True)
|
| 193 |
-
report_id = Column(
|
| 194 |
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
| 195 |
updated_at = Column(
|
| 196 |
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
|
@@ -198,16 +193,22 @@ class AnalysisStateRow(Base):
|
|
| 198 |
|
| 199 |
|
| 200 |
class AnalysisDataSourceRow(Base):
|
| 201 |
-
"""Per-analysis data-source binding (#10)
|
| 202 |
-
scoped to.
|
| 203 |
|
| 204 |
-
|
| 205 |
-
`
|
| 206 |
-
|
| 207 |
-
|
|
|
|
| 208 |
"""
|
| 209 |
-
__tablename__ = "
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
Integer,
|
| 11 |
String,
|
| 12 |
Text,
|
|
|
|
| 13 |
)
|
| 14 |
+
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
| 15 |
from sqlalchemy.orm import relationship
|
| 16 |
from sqlalchemy.sql import func
|
| 17 |
|
|
|
|
| 150 |
|
| 151 |
|
| 152 |
class AnalysisReportRow(Base):
|
| 153 |
+
"""One immutable row per generated report version — dedorch `reports` (Go-owned).
|
| 154 |
|
| 155 |
+
dedorch stores the rendered markdown `content` + `title` + `version` (no jsonb
|
| 156 |
+
snapshot — markdown-only per the 2026-06-23 checkpoint). The read path rebuilds a
|
| 157 |
+
minimal `AnalysisReport` (structured fields empty; `rendered_markdown` = content).
|
| 158 |
+
Versions accumulate per analysis; versioning is serialized by a per-analysis
|
| 159 |
+
advisory lock in `ReportStore`. Class name kept; table + shape changed for dedorch.
|
|
|
|
| 160 |
"""
|
| 161 |
+
__tablename__ = "reports"
|
| 162 |
|
| 163 |
+
id = Column(UUID(as_uuid=False), primary_key=True) # AnalysisReport.report_id (uuid)
|
| 164 |
+
analysis_id = Column(UUID(as_uuid=False), nullable=False, index=True)
|
| 165 |
+
title = Column(String, nullable=False)
|
| 166 |
+
content = Column(Text, nullable=False) # rendered markdown
|
| 167 |
+
generated_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
| 168 |
version = Column(Integer, nullable=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 169 |
|
| 170 |
|
| 171 |
class AnalysisStateRow(Base):
|
| 172 |
+
"""Per-analysis session state — the dedorch `analysis` table (Go-owned migration).
|
| 173 |
|
| 174 |
+
One session = one analysis = one conversation; `id` is the shared session id
|
| 175 |
+
(canonical UUID). The orchestrator gate + Help skill read this every turn;
|
|
|
|
| 176 |
`problem_validated` gates structured analysis; the Problem Statement skill flips
|
| 177 |
+
it; `report_id` is null until a report exists. `id`/`report_id` are Postgres
|
| 178 |
+
`uuid` in dedorch, so they bind as UUID (canonical-string in/out). Class name
|
| 179 |
+
kept as `AnalysisStateRow`; only the table + id types changed for dedorch.
|
| 180 |
"""
|
| 181 |
+
__tablename__ = "analysis"
|
| 182 |
|
| 183 |
+
id = Column(UUID(as_uuid=False), primary_key=True) # shared session id (uuid)
|
| 184 |
analysis_title = Column(String, nullable=False, default="New analysis")
|
| 185 |
problem_statement = Column(Text, nullable=False, default="")
|
| 186 |
problem_validated = Column(Boolean, nullable=False, default=False)
|
| 187 |
owner_id = Column(String, nullable=False, index=True)
|
| 188 |
+
report_id = Column(UUID(as_uuid=False), nullable=True)
|
| 189 |
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
| 190 |
updated_at = Column(
|
| 191 |
DateTime(timezone=True), server_default=func.now(), onupdate=func.now()
|
|
|
|
| 193 |
|
| 194 |
|
| 195 |
class AnalysisDataSourceRow(Base):
|
| 196 |
+
"""Per-analysis data-source binding (#10) — dedorch `data_sources` (Go-owned).
|
|
|
|
| 197 |
|
| 198 |
+
Which catalog sources an analysis is scoped to. `reference_id` is the catalog
|
| 199 |
+
`Source.source_id`; `type`/`name` snapshot the source kind + label. Written at
|
| 200 |
+
`/analysis/create`; read by `structured_flow` scoping + the report appendix.
|
| 201 |
+
`source_metadata` maps to the `metadata` column (`metadata` is reserved by the
|
| 202 |
+
declarative API). Class name kept; table + shape changed for dedorch.
|
| 203 |
"""
|
| 204 |
+
__tablename__ = "data_sources"
|
| 205 |
+
|
| 206 |
+
id = Column(UUID(as_uuid=False), primary_key=True)
|
| 207 |
+
analysis_id = Column(UUID(as_uuid=False), nullable=False, index=True)
|
| 208 |
+
type = Column(String, nullable=False)
|
| 209 |
+
name = Column(String, nullable=False)
|
| 210 |
+
reference_id = Column(String, nullable=False) # == catalog Source.source_id
|
| 211 |
+
bound_by = Column(String, nullable=False)
|
| 212 |
+
bound_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
| 213 |
+
source_metadata = Column("metadata", JSONB, nullable=True)
|
| 214 |
+
created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
|