Rifqi Hafizuddin commited on
Commit ·
c333ec8
1
Parent(s): efc0af7
Scope structured_flow + report to analysis catalog; drop dead data_sources binding
Browse filesRead 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 +10 -5
- main.py +1 -2
- src/agents/binding_store.py +0 -34
- src/agents/chat_handler.py +14 -60
- src/agents/report/generator.py +16 -32
- src/api/v1/analysis.py +0 -174
- src/db/postgres/init_db.py +0 -1
- src/db/postgres/models.py +0 -22
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` *(
|
| 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 |
-
###
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
#
|
| 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
|
| 434 |
-
|
| 435 |
-
|
| 436 |
-
|
| 437 |
-
|
| 438 |
-
#
|
| 439 |
-
|
| 440 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 137 |
) -> list[DataSourceRef]:
|
| 138 |
"""Freeze real catalog metadata for the sources this analysis used.
|
| 139 |
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 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
|
| 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 |
-
|
| 419 |
-
|
|
|
|
| 420 |
try:
|
| 421 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|