Rifqi Hafizuddin commited on
Commit
c333ec8
·
1 Parent(s): efc0af7

Scope structured_flow + report to analysis catalog; drop dead data_sources binding

Browse files

Read the analysis-scope data_catalog row (bound db+file sources, real names)
for structured_flow and the report appendix, with a user-scope fallback —
matching Sofia's check change. Fixes structured_flow being silently unscoped
(the data_sources binding table was never written: /analysis/create is Go-owned
and unwired, so it was always empty and fail-opened to the full user catalog)
and the synthetic postgres_<hash> names in traceability sources + the report.

Removes the now-dead #10 binding infra: data_sources table/model,
AnalysisDataSourceStore, _ScopedCatalogReader, _bound_source_ids, _read_binding,
and the unwired src/api/v1/analysis.py. Drop the empty table in Neon:
DROP TABLE IF EXISTS data_sources

REPO_STATUS.md CHANGED
@@ -185,7 +185,7 @@ unless `SKIP_INIT_DB=true`.
185
  | `report_inputs` *(was `analysis_records`)* | jsonb `AnalysisRecord`, one per slow-path run; **Python-owned** | slow path | ReportGenerator, report readiness |
186
  | `analyses` *(dedorch, plural)* | uuid `id`, `user_id`, `analysis_title`, `objective`, `business_questions` jsonb, `status` (active\|inactive), `data_bind`(+`data_bind_version`), `report_id`, `report_collection` — **defined by Go migrations**; `problem_statement`/`problem_validated`/`owner_id` already **dropped** there (`0003`/`0004`) | Go `/api/v1/analyses`; Python state store | gate (no-op), Help, report |
187
  | `reports` *(dedorch)* | uuid, `analysis_id`, `user_id`, `title` + markdown `content` + `version` (UNIQUE per analysis) | Go + Python ReportStore | report API |
188
- | `data_sources` *(dedorch)* | per-analysis binding; `reference_id` = catalog source_id; `type document\|database` | Go `/analyses/{id}/data-bind` (+ Python `/analysis/create`) | structured-flow scoping, report appendix |
189
  | `analyses_messages` *(dedorch)* | the analysis chat room (`role ∈ user\|ai`); replaces deprecated `rooms`/`chat_messages` | Go `/analyses/{id}/messages` | Python chat path **not yet migrated here** (§12) |
190
 
191
  > ✅ **Python ORM ↔ dedorch drift — reconciled 2026-07-01.** `AnalysisStateRow` (`analyses`) dropped
@@ -260,10 +260,15 @@ Fernet-decrypt creds (with owner check) → `asyncio.to_thread` (30s timeout)
260
  (read-only + statement_timeout) → 10k row cap. Defense-in-depth: IR validation + compiler whitelist
261
  + sqlglot guard + read-only session + LIMIT/timeout.
262
 
263
- ### Data-source binding (#10) — `src/agents/binding_store.py`
264
- At `/analysis/create`, chosen `data_source_ids` become `data_sources` rows. On a `structured_flow`
265
- turn the catalog reader is wrapped so the Planner and the tools' re-reads see the same scoped
266
- catalog. **Fail-open**: empty/disjoint binding whole catalog.
 
 
 
 
 
267
 
268
  ### Tool layer — `src/tools/data_access.py`, `src/agents/planner/registry.py`
269
  `DataAccessToolInvoker` implements the never-throw tool seam for the 4 data-access tools.
 
185
  | `report_inputs` *(was `analysis_records`)* | jsonb `AnalysisRecord`, one per slow-path run; **Python-owned** | slow path | ReportGenerator, report readiness |
186
  | `analyses` *(dedorch, plural)* | uuid `id`, `user_id`, `analysis_title`, `objective`, `business_questions` jsonb, `status` (active\|inactive), `data_bind`(+`data_bind_version`), `report_id`, `report_collection` — **defined by Go migrations**; `problem_statement`/`problem_validated`/`owner_id` already **dropped** there (`0003`/`0004`) | Go `/api/v1/analyses`; Python state store | gate (no-op), Help, report |
187
  | `reports` *(dedorch)* | uuid, `analysis_id`, `user_id`, `title` + markdown `content` + `version` (UNIQUE per analysis) | Go + Python ReportStore | report API |
188
+ | ~~`data_sources`~~ *(dropped from Python)* | per-analysis binding table; **no longer read or written by Python** the writer (`/analysis/create`) was Go-owned/unwired so it was always empty. Scoping now reads the analysis-scope `data_catalog` row instead | — | — |
189
  | `analyses_messages` *(dedorch)* | the analysis chat room (`role ∈ user\|ai`); replaces deprecated `rooms`/`chat_messages` | Go `/analyses/{id}/messages` | Python chat path **not yet migrated here** (§12) |
190
 
191
  > ✅ **Python ORM ↔ dedorch drift — reconciled 2026-07-01.** `AnalysisStateRow` (`analyses`) dropped
 
260
  (read-only + statement_timeout) → 10k row cap. Defense-in-depth: IR validation + compiler whitelist
261
  + sqlglot guard + read-only session + LIMIT/timeout.
262
 
263
+ ### Analysis-scoped catalog reads — `src/catalog/reader.py::AnalysisScopedCatalogReader`
264
+ An analysis is scoped to the sources the user picked by reading the **analysis-scope** catalog
265
+ (`data_catalog` `scope_type='analysis'`, Go-materialized with the bound db + file sources under
266
+ their real names). On a `structured_flow` turn the catalog reader is wrapped so the Planner and the
267
+ tools' re-reads see the same analysis-scoped snapshot; `check` and the report's data-source appendix
268
+ read it too. **Fail-open**: no analysis-scope row → user-scope catalog. The old `data_sources`
269
+ binding table + `AnalysisDataSourceStore`/`_ScopedCatalogReader` (#10) were **removed** — the writer
270
+ (`/analysis/create`) is Go-owned/unwired, so the table was always empty and its consumers fail-opened
271
+ to the whole (mis-named) user catalog.
272
 
273
  ### Tool layer — `src/tools/data_access.py`, `src/agents/planner/registry.py`
274
  `DataAccessToolInvoker` implements the never-throw tool seam for the 4 data-access tools.
main.py CHANGED
@@ -15,7 +15,7 @@ from slowapi.errors import RateLimitExceeded
15
  # from src.api.v1.users import router as users_router # unwired: login moved off Python
16
  # from src.api.v1.db_client import router as db_client_router # unwired: Go registers DB client
17
  # from src.api.v1.data_catalog import router as data_catalog_router # unwired: Go handles the catalog
18
- # from src.api.v1.analysis import router as analysis_router # unwired: Go owns create/update analysis
19
  # from src.api.v1.chat import router as chat_router # unwired: replaced by /api/v2/chat/stream
20
  # NOTE: src.api.v1.chat module still imported by v2 chat + /tools/help — keep the file.
21
  from src.api.v1.report import router as report_router
@@ -63,7 +63,6 @@ app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
63
  # app.include_router(room_router) # unwired: replaced by analysis_id
64
  # app.include_router(db_client_router) # unwired: Go registers DB client
65
  # app.include_router(data_catalog_router) # unwired: Go handles the catalog
66
- # app.include_router(analysis_router) # unwired: Go owns create/update analysis
67
  # app.include_router(chat_router) # unwired: v2 chat replaces it (drops v1 cache ops routes)
68
  app.include_router(report_router)
69
  app.include_router(tools_router)
 
15
  # from src.api.v1.users import router as users_router # unwired: login moved off Python
16
  # from src.api.v1.db_client import router as db_client_router # unwired: Go registers DB client
17
  # from src.api.v1.data_catalog import router as data_catalog_router # unwired: Go handles the catalog
18
+ # NOTE: src.api.v1.analysis was DELETED (Go owns analysis + its data_sources binding).
19
  # from src.api.v1.chat import router as chat_router # unwired: replaced by /api/v2/chat/stream
20
  # NOTE: src.api.v1.chat module still imported by v2 chat + /tools/help — keep the file.
21
  from src.api.v1.report import router as report_router
 
63
  # app.include_router(room_router) # unwired: replaced by analysis_id
64
  # app.include_router(db_client_router) # unwired: Go registers DB client
65
  # app.include_router(data_catalog_router) # unwired: Go handles the catalog
 
66
  # app.include_router(chat_router) # unwired: v2 chat replaces it (drops v1 cache ops routes)
67
  app.include_router(report_router)
68
  app.include_router(tools_router)
src/agents/binding_store.py DELETED
@@ -1,34 +0,0 @@
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
-
8
- Fail-open by convention at the call sites: an empty binding (legacy room, or the FE
9
- not yet sending ids) means "no restriction" — fall back to the whole catalog. Mirrors
10
- `AnalysisStateStore`: each call opens its own `AsyncSession`.
11
- """
12
-
13
- from __future__ import annotations
14
-
15
- from sqlalchemy import select
16
-
17
- from src.db.postgres.connection import AsyncSessionLocal
18
- from src.db.postgres.models import AnalysisDataSourceRow
19
- from src.middlewares.logging import get_logger
20
-
21
- logger = get_logger("binding_store")
22
-
23
-
24
- class AnalysisDataSourceStore:
25
- """Read the bound catalog `source_id`s for an analysis."""
26
-
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
- )
34
- return list(result.scalars().all())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/agents/chat_handler.py CHANGED
@@ -109,7 +109,6 @@ class ChatHandler:
109
  ps_agent: ProblemStatementAgent | None = None,
110
  help_agent: HelpAgent | None = None,
111
  state_store: Any | None = None,
112
- binding_store: Any | None = None,
113
  input_guard: InputGuard | None = None,
114
  enable_gate: bool = False,
115
  enable_tracing: bool = False,
@@ -138,9 +137,6 @@ class ChatHandler:
138
  # `help` skill: LLM guide that reads the Analysis State + chat history.
139
  self._help_agent = help_agent
140
  self._state_store = state_store
141
- # `#10` data-source binding: scopes structured_flow's catalog to the sources
142
- # the analysis is bound to. Injectable for tests; fail-open when absent.
143
- self._binding_store = binding_store
144
  # Input guard: screens each message for prompt-injection / secret-extraction /
145
  # abuse BEFORE the router. Injectable for tests; lazily built in production.
146
  self._input_guard = input_guard
@@ -211,29 +207,6 @@ class ChatHandler:
211
  self._state_store = AnalysisStateStore()
212
  return self._state_store
213
 
214
- def _get_binding_store(self) -> Any:
215
- if self._binding_store is None:
216
- from .binding_store import AnalysisDataSourceStore
217
-
218
- self._binding_store = AnalysisDataSourceStore()
219
- return self._binding_store
220
-
221
- async def _bound_source_ids(self, analysis_id: str | None) -> set[str]:
222
- """#10: the catalog source_ids this analysis is bound to (empty = unscoped).
223
-
224
- Fail-open: no analysis_id, no binding rows (legacy room / FE not sending
225
- ids), or a read error → empty set, which the caller treats as "whole
226
- catalog". Used to build a `_ScopedCatalogReader` so the Planner AND the
227
- data-access tools (which re-read the catalog themselves) see the same scope.
228
- """
229
- if not analysis_id:
230
- return set()
231
- try:
232
- return set(await self._get_binding_store().get(analysis_id))
233
- except Exception as e: # noqa: BLE001 — never block the query on this
234
- logger.warning("binding read failed — unscoped", analysis_id=analysis_id, error=str(e))
235
- return set()
236
-
237
  async def _load_analysis_state(self, analysis_id: str | None) -> AnalysisState:
238
  """Load Analysis State for the Help skill; fail closed to a not-validated stub.
239
 
@@ -430,14 +403,20 @@ class ChatHandler:
430
  # re-fetched from the catalog DB 4-5x across the slow-path run. This
431
  # collapses those to one round-trip per source_hint and pins a single
432
  # consistent snapshot for plan + execution.
433
- from ..catalog.reader import MemoizingCatalogReader
434
-
435
- req_reader = MemoizingCatalogReader(self._get_catalog_reader())
436
- # #10: scope every catalog read — the Planner's AND the data-access
437
- # tools' own re-reads — to the analysis's bound sources, so binding
438
- # is a boundary, not just a planner hint (T-B). Fail-open (T-C).
439
- bound = await self._bound_source_ids(analysis_id)
440
- reader = _ScopedCatalogReader(req_reader, bound) if bound else req_reader
 
 
 
 
 
 
441
  catalog = await reader.read(user_id, "structured")
442
  # structured_flow always runs the slow analytical path (the
443
  # ENABLE_SLOW_PATH flag was removed 2026-07-02).
@@ -787,31 +766,6 @@ class ChatHandler:
787
  yield {"event": "done", "data": ""}
788
 
789
 
790
- class _ScopedCatalogReader:
791
- """Wraps a CatalogReader, restricting `structured` reads to an analysis's bound
792
- sources (#10).
793
-
794
- Scoping lives here — not at a single call site — so the Planner AND the
795
- data-access tools (which re-read the catalog themselves) see the same scoped
796
- view; otherwise binding is only a hint to the Planner while the executor runs
797
- against the full catalog. Fail-open: an empty or fully-disjoint binding yields
798
- the whole catalog, so a stale / cross-source binding degrades instead of
799
- emptying the catalog. Only `structured` reads are scoped (all #10 binds today);
800
- `unstructured` / retrieval reads pass through.
801
- """
802
-
803
- def __init__(self, inner: Any, bound: set[str]) -> None:
804
- self._inner = inner
805
- self._bound = bound
806
-
807
- async def read(self, user_id: str, source_hint: str) -> Any:
808
- catalog = await self._inner.read(user_id, source_hint)
809
- if not self._bound or source_hint != "structured":
810
- return catalog
811
- scoped = [s for s in catalog.sources if s.source_id in self._bound]
812
- return catalog.model_copy(update={"sources": scoped or catalog.sources})
813
-
814
-
815
  def _normalize_chunks(raw: Any) -> list[DocumentChunk]:
816
  """Convert whatever the retriever returns into list[DocumentChunk].
817
 
 
109
  ps_agent: ProblemStatementAgent | None = None,
110
  help_agent: HelpAgent | None = None,
111
  state_store: Any | None = None,
 
112
  input_guard: InputGuard | None = None,
113
  enable_gate: bool = False,
114
  enable_tracing: bool = False,
 
137
  # `help` skill: LLM guide that reads the Analysis State + chat history.
138
  self._help_agent = help_agent
139
  self._state_store = state_store
 
 
 
140
  # Input guard: screens each message for prompt-injection / secret-extraction /
141
  # abuse BEFORE the router. Injectable for tests; lazily built in production.
142
  self._input_guard = input_guard
 
207
  self._state_store = AnalysisStateStore()
208
  return self._state_store
209
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
210
  async def _load_analysis_state(self, analysis_id: str | None) -> AnalysisState:
211
  """Load Analysis State for the Help skill; fail closed to a not-validated stub.
212
 
 
403
  # re-fetched from the catalog DB 4-5x across the slow-path run. This
404
  # collapses those to one round-trip per source_hint and pins a single
405
  # consistent snapshot for plan + execution.
406
+ from ..catalog.reader import (
407
+ AnalysisScopedCatalogReader,
408
+ MemoizingCatalogReader,
409
+ )
410
+
411
+ # Scope every catalog read the Planner's AND the data-access tools'
412
+ # own re-reads — to the analysis-scope catalog: Go materializes it with
413
+ # exactly this analysis's bound db + file sources under their real
414
+ # names. Falls back to the user-scope catalog when no analysis row
415
+ # exists. Memoized so plan + execution share one snapshot.
416
+ scoped = AnalysisScopedCatalogReader(
417
+ self._get_catalog_reader(), analysis_id
418
+ )
419
+ reader = MemoizingCatalogReader(scoped)
420
  catalog = await reader.read(user_id, "structured")
421
  # structured_flow always runs the slow analytical path (the
422
  # ENABLE_SLOW_PATH flag was removed 2026-07-02).
 
766
  yield {"event": "done", "data": ""}
767
 
768
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
769
  def _normalize_chunks(raw: Any) -> list[DocumentChunk]:
770
  """Convert whatever the retriever returns into list[DocumentChunk].
771
 
src/agents/report/generator.py CHANGED
@@ -133,15 +133,14 @@ def _collect_method_steps(records: list[AnalysisRecord]) -> list[TaskSummary]:
133
 
134
 
135
  def _build_data_sources(
136
- records: list[AnalysisRecord], catalog, bound_ids: list[str] | None = None
137
  ) -> list[DataSourceRef]:
138
  """Freeze real catalog metadata for the sources this analysis used.
139
 
140
- When the analysis has a data-source binding (#10), the candidate set is scoped
141
- to the bound sources first (fail-open if the binding doesn't intersect the
142
- catalog). Within that set, matches catalog sources against the records'
143
- (narrative) `data_used` by name/id; falls back to all (bound) sources, then to
144
- bare `data_used` strings if no catalog is available — so the section is always
145
  populated, best-effort.
146
  """
147
  if catalog is None or not catalog.sources:
@@ -153,9 +152,6 @@ def _build_data_sources(
153
  return [DataSourceRef(source_id=d, name=d, source_type="", detail={}) for d in seen]
154
 
155
  candidates = catalog.sources
156
- if bound_ids:
157
- scoped = [s for s in candidates if s.source_id in set(bound_ids)]
158
- candidates = scoped or candidates # fail-open if binding doesn't match catalog
159
 
160
  def _ref(s) -> DataSourceRef:
161
  return DataSourceRef(
@@ -337,12 +333,10 @@ class ReportGenerator:
337
  record_store=None,
338
  structured_chain: Runnable | None = None,
339
  catalog_store=None,
340
- binding_store=None,
341
  ) -> None:
342
  self._record_store = record_store
343
  self._chain = structured_chain
344
  self._catalog_store = catalog_store
345
- self._binding_store = binding_store
346
 
347
  def _ensure_record_store(self):
348
  if self._record_store is None:
@@ -383,9 +377,8 @@ class ReportGenerator:
383
  caveats = _collect_notes(records, "caveats")
384
  open_questions = _collect_notes(records, "open_questions")
385
  method_steps = _collect_method_steps(records)
386
- bound_ids = await self._read_binding(analysis_id)
387
  data_sources = _build_data_sources(
388
- records, await self._read_catalog(user_id), bound_ids
389
  )
390
  executive_summary = await self._summarize(ps, findings, caveats)
391
 
@@ -414,30 +407,21 @@ class ReportGenerator:
414
  )
415
  return report
416
 
417
- async def _read_catalog(self, user_id: str | None):
418
- if not user_id:
419
- return None
 
420
  try:
421
- return await self._ensure_catalog_store().get(user_id)
 
 
 
 
 
422
  except Exception as exc: # data_sources falls back; never break the report
423
  logger.warning("catalog read failed; data_sources will fall back", error=str(exc))
424
  return None
425
 
426
- def _ensure_binding_store(self):
427
- if self._binding_store is None:
428
- from ..binding_store import AnalysisDataSourceStore
429
-
430
- self._binding_store = AnalysisDataSourceStore()
431
- return self._binding_store
432
-
433
- async def _read_binding(self, analysis_id: str) -> list[str]:
434
- """Bound source ids for the analysis (#10). Never-throw → [] (unscoped)."""
435
- try:
436
- return await self._ensure_binding_store().get(analysis_id)
437
- except Exception as exc: # data_sources falls back to whole catalog
438
- logger.warning("binding read failed; data_sources unscoped", error=str(exc))
439
- return []
440
-
441
  async def _summarize(
442
  self, ps: ProblemStatement, findings: list[ReportFinding], caveats: list[AttributedNote]
443
  ) -> str:
 
133
 
134
 
135
  def _build_data_sources(
136
+ records: list[AnalysisRecord], catalog
137
  ) -> list[DataSourceRef]:
138
  """Freeze real catalog metadata for the sources this analysis used.
139
 
140
+ `catalog` is the analysis-scope catalog already restricted to this analysis's
141
+ bound sources so every source in it is a candidate. Matches candidates against
142
+ the records' (narrative) `data_used` by name/id; falls back to all sources, then
143
+ to bare `data_used` strings if no catalog is available so the section is always
 
144
  populated, best-effort.
145
  """
146
  if catalog is None or not catalog.sources:
 
152
  return [DataSourceRef(source_id=d, name=d, source_type="", detail={}) for d in seen]
153
 
154
  candidates = catalog.sources
 
 
 
155
 
156
  def _ref(s) -> DataSourceRef:
157
  return DataSourceRef(
 
333
  record_store=None,
334
  structured_chain: Runnable | None = None,
335
  catalog_store=None,
 
336
  ) -> None:
337
  self._record_store = record_store
338
  self._chain = structured_chain
339
  self._catalog_store = catalog_store
 
340
 
341
  def _ensure_record_store(self):
342
  if self._record_store is None:
 
377
  caveats = _collect_notes(records, "caveats")
378
  open_questions = _collect_notes(records, "open_questions")
379
  method_steps = _collect_method_steps(records)
 
380
  data_sources = _build_data_sources(
381
+ records, await self._read_catalog(user_id, analysis_id)
382
  )
383
  executive_summary = await self._summarize(ps, findings, caveats)
384
 
 
407
  )
408
  return report
409
 
410
+ async def _read_catalog(self, user_id: str | None, analysis_id: str | None):
411
+ """Prefer the analysis-scope catalog (this analysis's bound sources + their
412
+ real names); fall back to the user-scope catalog when the analysis has no row
413
+ (legacy / unbound)."""
414
  try:
415
+ store = self._ensure_catalog_store()
416
+ if analysis_id:
417
+ cat = await store.get_by_analysis(analysis_id)
418
+ if cat is not None:
419
+ return cat
420
+ return await store.get(user_id) if user_id else None
421
  except Exception as exc: # data_sources falls back; never break the report
422
  logger.warning("catalog read failed; data_sources will fall back", error=str(exc))
423
  return None
424
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
425
  async def _summarize(
426
  self, ps: ProblemStatement, findings: list[ReportFinding], caveats: list[AttributedNote]
427
  ) -> str:
src/api/v1/analysis.py DELETED
@@ -1,174 +0,0 @@
1
- """Analysis session API — create a new analysis (the per-session workspace).
2
-
3
- An analysis IS the chat session: the `analysis_states` row and the chat `rooms`
4
- row share one id (`analysis_id == room_id`), so the existing `room_id` on the chat
5
- request doubles as the `analysis_id`. Creating an analysis enforces the data-first
6
- gate (>=1 bound source) and seeds the state with a title + an optional problem
7
- statement (validated later by the Problem Statement skill).
8
- """
9
-
10
- import uuid
11
-
12
- from fastapi import APIRouter, Depends, HTTPException
13
- from pydantic import BaseModel, Field
14
- from sqlalchemy import select
15
- from sqlalchemy.ext.asyncio import AsyncSession
16
-
17
- from src.db.postgres.connection import get_db
18
- from src.db.postgres.models import AnalysisDataSourceRow, AnalysisStateRow, Room
19
- from src.middlewares.logging import get_logger, log_execution
20
-
21
- logger = get_logger("analysis_api")
22
-
23
- router = APIRouter(prefix="/api/v1", tags=["Analysis"])
24
-
25
-
26
- def _serialize_state(row: AnalysisStateRow, data_source_ids: list[str]) -> dict:
27
- """The full analysis payload: the 8 state fields + the bound source ids."""
28
- return {
29
- "id": row.id,
30
- "analysis_title": row.analysis_title,
31
- "problem_statement": row.problem_statement,
32
- "problem_validated": row.problem_validated,
33
- "user_id": row.user_id,
34
- "report_id": row.report_id,
35
- "data_source_ids": data_source_ids,
36
- "created_at": row.created_at.isoformat() if row.created_at else None,
37
- "updated_at": row.updated_at.isoformat() if row.updated_at else None,
38
- }
39
-
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"
69
- problem_statement: str = ""
70
- data_source_ids: list[str] = Field(default_factory=list)
71
-
72
-
73
- @router.post("/analysis/create")
74
- @log_execution(logger)
75
- async def create_analysis(
76
- request: CreateAnalysisRequest,
77
- db: AsyncSession = Depends(get_db),
78
- ):
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(
88
- status_code=400,
89
- detail="An analysis requires at least one bound data source.",
90
- )
91
-
92
- analysis_id = str(uuid.uuid4())
93
- # The analysis IS the session: state row + chat room + source bindings share one
94
- # id, created atomically in one transaction.
95
- state_row = AnalysisStateRow(
96
- id=analysis_id,
97
- user_id=request.user_id,
98
- analysis_title=request.analysis_title,
99
- problem_statement=request.problem_statement,
100
- problem_validated=False,
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
-
124
- logger.info(
125
- "analysis created",
126
- analysis_id=analysis_id,
127
- user_id=request.user_id,
128
- sources=len(bound_ids),
129
- )
130
- return {
131
- "status": "success",
132
- "message": "Analysis created successfully",
133
- "data": _serialize_state(state_row, bound_ids),
134
- }
135
-
136
-
137
- @router.get("/analysis")
138
- @log_execution(logger)
139
- async def list_analyses(user_id: str, db: AsyncSession = Depends(get_db)):
140
- """List a user's analyses, most-recently-updated first (Analysis sidebar).
141
-
142
- Summary fields only (no per-row source bindings — fetch those via the detail
143
- endpoint) to keep the list a single query.
144
- """
145
- result = await db.execute(
146
- select(AnalysisStateRow)
147
- .where(AnalysisStateRow.user_id == user_id)
148
- .order_by(AnalysisStateRow.updated_at.desc())
149
- )
150
- rows = result.scalars().all()
151
- return {
152
- "status": "success",
153
- "data": [
154
- {
155
- "id": r.id,
156
- "analysis_title": r.analysis_title,
157
- "problem_validated": r.problem_validated,
158
- "report_id": r.report_id,
159
- "updated_at": r.updated_at.isoformat() if r.updated_at else None,
160
- }
161
- for r in rows
162
- ],
163
- }
164
-
165
-
166
- @router.get("/analysis/{analysis_id}")
167
- @log_execution(logger)
168
- async def get_analysis(analysis_id: str, db: AsyncSession = Depends(get_db)):
169
- """Read one analysis's state + bound data sources (the FE workspace render)."""
170
- row = await db.get(AnalysisStateRow, analysis_id)
171
- if row is None:
172
- raise HTTPException(status_code=404, detail=f"Analysis {analysis_id!r} not found.")
173
- data_source_ids = await _bound_source_ids(db, analysis_id)
174
- return {"status": "success", "data": _serialize_state(row, data_source_ids)}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
src/db/postgres/init_db.py CHANGED
@@ -3,7 +3,6 @@
3
  from sqlalchemy import text
4
  from src.db.postgres.connection import engine, Base
5
  from src.db.postgres.models import (
6
- AnalysisDataSourceRow,
7
  ReportInputRow,
8
  AnalysisReportRow,
9
  AnalysisStateRow,
 
3
  from sqlalchemy import text
4
  from src.db.postgres.connection import engine, Base
5
  from src.db.postgres.models import (
 
6
  ReportInputRow,
7
  AnalysisReportRow,
8
  AnalysisStateRow,
src/db/postgres/models.py CHANGED
@@ -238,28 +238,6 @@ class AnalysisStateRow(Base):
238
  )
239
 
240
 
241
- class AnalysisDataSourceRow(Base):
242
- """Per-analysis data-source binding (#10) — dedorch `data_sources` (Go-owned).
243
-
244
- Which catalog sources an analysis is scoped to. `reference_id` is the catalog
245
- `Source.source_id`; `type`/`name` snapshot the source kind + label. Written at
246
- `/analysis/create`; read by `structured_flow` scoping + the report appendix.
247
- `source_metadata` maps to the `metadata` column (`metadata` is reserved by the
248
- declarative API). Class name kept; table + shape changed for dedorch.
249
- """
250
- __tablename__ = "data_sources"
251
-
252
- id = Column(UUID(as_uuid=False), primary_key=True)
253
- analysis_id = Column(UUID(as_uuid=False), nullable=False, index=True)
254
- type = Column(String, nullable=False)
255
- name = Column(String, nullable=False)
256
- reference_id = Column(String, nullable=False) # == catalog Source.source_id
257
- bound_by = Column(String, nullable=False)
258
- bound_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
259
- source_metadata = Column("metadata", JSONB, nullable=True)
260
- created_at = Column(DateTime(timezone=True), nullable=False, server_default=func.now())
261
-
262
-
263
  class AnalysesMessageRow(Base):
264
  """One conversation message — dedorch `analyses_messages` (Go-owned table).
265
 
 
238
  )
239
 
240
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
241
  class AnalysesMessageRow(Base):
242
  """One conversation message — dedorch `analyses_messages` (Go-owned table).
243