Rifqi Hafizuddin Claude Opus 4.8 commited on
Commit
4f0ff12
·
1 Parent(s): 12152cd

[KM-652] refactor(db): align analysis-family models to the dedorch schema

Browse files

Repoint 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 CHANGED
@@ -1,7 +1,7 @@
1
  """AnalysisDataSourceStore — read per-analysis data-source bindings (#10).
2
 
3
- The join table `analysis_data_sources(analysis_id, source_id)` records which catalog
4
- sources an analysis is scoped to. It's written atomically 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,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.source_id).where(
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
  )
src/agents/report/store.py CHANGED
@@ -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
- user_id=report.user_id,
 
 
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.data)
80
  .where(AnalysisReportRow.analysis_id == analysis_id)
81
  .order_by(AnalysisReportRow.version.asc())
82
  )
83
  rows = result.scalars().all()
84
- return [AnalysisReport.model_validate(row) for row in rows]
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.data).where(
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 AnalysisReport.model_validate(row)
 
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)
src/agents/state_store.py CHANGED
@@ -35,7 +35,7 @@ def _row_to_state(row: AnalysisStateRow) -> AnalysisState:
35
 
36
 
37
  class AnalysisStateStore:
38
- """Read/write `analysis_states` rows, keyed by the shared session id."""
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
- "analysis_states row missing — update skipped",
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
src/api/v1/analysis.py CHANGED
@@ -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.source_id).where(
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 `analysis_data_sources` rows (#10, Option A:
67
- analysis-owned join table) in the same transaction as the state + room, so the
68
- analysis is scoped to exactly the sources the user picked. `structured_flow` and
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 composite PK (analysis_id,
90
- # source_id) would otherwise reject a repeated source.
 
91
  bound_ids = list(dict.fromkeys(request.data_source_ids))
 
92
  for source_id in bound_ids:
93
- db.add(AnalysisDataSourceRow(analysis_id=analysis_id, source_id=source_id))
 
 
 
 
 
 
 
 
 
 
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
 
src/db/postgres/models.py CHANGED
@@ -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 (KM-644).
155
 
156
- `data` holds the full Pydantic AnalysisReport
157
- (src/agents/report/schemas.py:AnalysisReport) serialized via
158
- `model_dump(mode="json")`; the read path rehydrates with
159
- `AnalysisReport.model_validate(...)`. Versions accumulate per analysis session;
160
- `(analysis_id, version)` is unique. Versioning is serialized by a per-analysis
161
- advisory lock in `ReportStore` — the unique constraint is the backstop.
162
  """
163
- __tablename__ = "analysis_reports"
164
 
165
- id = Column(String, primary_key=True) # AnalysisReport.report_id
166
- analysis_id = Column(String, nullable=False, index=True)
167
- user_id = Column(String, index=True)
 
 
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 — shared id with `rooms` (analysis_id == room_id).
179
 
180
- One session = one analysis = one conversation: `id` is the same id as the chat
181
- `rooms` row, so the existing `room_id` on the chat request doubles as the
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__ = "analysis_states"
187
 
188
- id = Column(String, primary_key=True) # == rooms.id (shared session id)
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(String, nullable=True)
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): which catalog sources an analysis is
202
- scoped to.
203
 
204
- Written atomically with the state + room at `/analysis/create`; read by the
205
- `structured_flow` catalog scoping (chat) and the report's data-source appendix.
206
- Composite PK `(analysis_id, source_id)` a source is bound at most once per
207
- analysis. v1 binds whole sources (source-level granularity).
 
208
  """
209
- __tablename__ = "analysis_data_sources"
210
-
211
- analysis_id = Column(String, primary_key=True, index=True) # == analysis_states.id
212
- source_id = Column(String, primary_key=True) # == catalog Source.source_id
213
- created_at = Column(DateTime(timezone=True), server_default=func.now())
 
 
 
 
 
 
 
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())