fix/ check

#12
by sofhiaazzhr - opened
REPO_STATUS.md CHANGED
@@ -148,7 +148,7 @@ Two facts to internalise:
148
  - **Unstructured RAG** over PGVector.
149
  - **Analytics tools:** 4 registered composite `analyze_*` (descriptive, aggregate, correlation, trend) + 4 data-access tools (check_data, check_knowledge, retrieve_data, retrieve_knowledge). Four further composites (comparison, contribution, profile, segment) exist in code but are **not registered** with the Planner.
150
  - **Versioned report generation** from persisted records.
151
- - **Analysis sessions:** data-first creation gate (β‰₯1 bound source), per-analysis data-source binding (#10).
152
  - **Langfuse tracing** (PII-masked), **Redis caching**, **pooled DB engines** + speculative prewarm.
153
 
154
  ---
@@ -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.
@@ -334,7 +339,7 @@ Python is consumer-only). State **re-verified against the Go source 2026-06-29**
334
  **Python-owned**; its finalized schema goes to Harry so the dedorch migration creates it post-cutover.
335
  - **Connection-string cutover DONE (2026-07-01).** Python's `postgres_connstring` now points at
336
  **dedorch** and reads the Go-migrated tables directly. Every ORM model Python reads (`analyses`,
337
- `data_sources`, `analyses_messages`, `data_catalog`) has been reconciled to its dedorch shape.
338
  **`init_db()` is now skipped by default** (`settings.skip_init_db` defaults **True**): its privileged
339
  DDL (`ALTER TABLE rooms …`, index creation) fails on Go-owned tables
340
  (`InsufficientPrivilegeError: must be owner of table rooms`). Skipping is safe β€” Go migration `0001`
@@ -369,9 +374,10 @@ records-based report; floor: β‰₯1 `analyze_*` success). Wiring Go β†’ Python is
369
  `cryptography.fernet.InvalidToken` β€” whose `str()` is **empty**, so it logged as `error=""` and
370
  masqueraded as a DB-connection failure (the executor now logs `repr(e)` to expose it). Tell-apart:
371
  a valid-but-wrong key β†’ `InvalidToken`; a malformed key β†’ a non-empty `ValueError` at cipher build.
372
- - **Never-throw seams** are pervasive (tool invoker, query service, executors, state/binding reads,
373
  record persistence, report summary). Failures degrade into soft output rather than raising β€” good
374
- for UX, but they can mask real breakage (e.g. a binding silently fail-opening to the full catalog).
 
375
  - **Prompts** live in `src/config/prompts/*.md`. `chatbot_system.md` has `guardrails.md` appended so
376
  guardrails win on conflict.
377
  - **Tests** are gitignored (team decision) β€” run them locally.
 
148
  - **Unstructured RAG** over PGVector.
149
  - **Analytics tools:** 4 registered composite `analyze_*` (descriptive, aggregate, correlation, trend) + 4 data-access tools (check_data, check_knowledge, retrieve_data, retrieve_knowledge). Four further composites (comparison, contribution, profile, segment) exist in code but are **not registered** with the Planner.
150
  - **Versioned report generation** from persisted records.
151
+ - **Analysis sessions:** data-first creation gate (β‰₯1 bound source); each turn reads the analysis-scope catalog so it sees only that analysis's bound sources.
152
  - **Langfuse tracing** (PII-masked), **Redis caching**, **pooled DB engines** + speculative prewarm.
153
 
154
  ---
 
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, Go-owned)* | per-analysis binding table. **Python no longer reads or writes it** β€” bindings live in Go's `analyses.data_bind`, which Go materializes into the analysis-scope `data_catalog` row; Python scopes off that row. The table exists (Go migration) but Python is fully decoupled β€” do **not** drop it manually | Go migration | β€” (unused by Python) |
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.
 
339
  **Python-owned**; its finalized schema goes to Harry so the dedorch migration creates it post-cutover.
340
  - **Connection-string cutover DONE (2026-07-01).** Python's `postgres_connstring` now points at
341
  **dedorch** and reads the Go-migrated tables directly. Every ORM model Python reads (`analyses`,
342
+ `analyses_messages`, `data_catalog`) has been reconciled to its dedorch shape.
343
  **`init_db()` is now skipped by default** (`settings.skip_init_db` defaults **True**): its privileged
344
  DDL (`ALTER TABLE rooms …`, index creation) fails on Go-owned tables
345
  (`InsufficientPrivilegeError: must be owner of table rooms`). Skipping is safe β€” Go migration `0001`
 
374
  `cryptography.fernet.InvalidToken` β€” whose `str()` is **empty**, so it logged as `error=""` and
375
  masqueraded as a DB-connection failure (the executor now logs `repr(e)` to expose it). Tell-apart:
376
  a valid-but-wrong key β†’ `InvalidToken`; a malformed key β†’ a non-empty `ValueError` at cipher build.
377
+ - **Never-throw seams** are pervasive (tool invoker, query service, executors, state/catalog reads,
378
  record persistence, report summary). Failures degrade into soft output rather than raising β€” good
379
+ for UX, but they can mask real breakage (e.g. a missing analysis-scope catalog silently falling
380
+ back to the whole user catalog).
381
  - **Prompts** live in `src/config/prompts/*.md`. `chatbot_system.md` has `guardrails.md` appended so
382
  guardrails win on conflict.
383
  - **Tests** are gitignored (team decision) β€” run them locally.
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
@@ -182,13 +178,17 @@ class ChatHandler:
182
  self._document_retriever = RetrievalRouter()
183
  return self._document_retriever
184
 
185
- def _get_check_invoker(self, user_id: str) -> Any:
186
- """Build the per-request data-access invoker for the `check` skill."""
 
 
 
 
187
  if self._check_invoker_factory is not None:
188
  return self._check_invoker_factory(user_id)
189
  from ..tools.data_access import DataAccessToolInvoker
190
 
191
- return DataAccessToolInvoker(user_id, self._get_catalog_reader())
192
 
193
  def _get_ps_agent(self) -> ProblemStatementAgent:
194
  if self._ps_agent is None:
@@ -207,29 +207,6 @@ class ChatHandler:
207
  self._state_store = AnalysisStateStore()
208
  return self._state_store
209
 
210
- def _get_binding_store(self) -> Any:
211
- if self._binding_store is None:
212
- from .binding_store import AnalysisDataSourceStore
213
-
214
- self._binding_store = AnalysisDataSourceStore()
215
- return self._binding_store
216
-
217
- async def _bound_source_ids(self, analysis_id: str | None) -> set[str]:
218
- """#10: the catalog source_ids this analysis is bound to (empty = unscoped).
219
-
220
- Fail-open: no analysis_id, no binding rows (legacy room / FE not sending
221
- ids), or a read error β†’ empty set, which the caller treats as "whole
222
- catalog". Used to build a `_ScopedCatalogReader` so the Planner AND the
223
- data-access tools (which re-read the catalog themselves) see the same scope.
224
- """
225
- if not analysis_id:
226
- return set()
227
- try:
228
- return set(await self._get_binding_store().get(analysis_id))
229
- except Exception as e: # noqa: BLE001 β€” never block the query on this
230
- logger.warning("binding read failed β€” unscoped", analysis_id=analysis_id, error=str(e))
231
- return set()
232
-
233
  async def _load_analysis_state(self, analysis_id: str | None) -> AnalysisState:
234
  """Load Analysis State for the Help skill; fail closed to a not-validated stub.
235
 
@@ -426,14 +403,20 @@ class ChatHandler:
426
  # re-fetched from the catalog DB 4-5x across the slow-path run. This
427
  # collapses those to one round-trip per source_hint and pins a single
428
  # consistent snapshot for plan + execution.
429
- from ..catalog.reader import MemoizingCatalogReader
430
-
431
- req_reader = MemoizingCatalogReader(self._get_catalog_reader())
432
- # #10: scope every catalog read β€” the Planner's AND the data-access
433
- # tools' own re-reads β€” to the analysis's bound sources, so binding
434
- # is a boundary, not just a planner hint (T-B). Fail-open (T-C).
435
- bound = await self._bound_source_ids(analysis_id)
436
- reader = _ScopedCatalogReader(req_reader, bound) if bound else req_reader
 
 
 
 
 
 
437
  catalog = await reader.read(user_id, "structured")
438
  # structured_flow always runs the slow analytical path (the
439
  # ENABLE_SLOW_PATH flag was removed 2026-07-02).
@@ -477,8 +460,19 @@ class ChatHandler:
477
  return
478
  elif intent == "check":
479
  try:
 
 
 
 
 
 
 
 
 
480
  # Wrap the check invoker so its check_* tool calls land in the trace.
481
- invoker = TraceabilityToolInvoker(self._get_check_invoker(user_id), pad)
 
 
482
  # Detect from the ORIGINAL message (not `rewritten`, which the
483
  # router normalizes to English) so the deterministic check reply
484
  # matches the user's language like the other paths.
@@ -772,31 +766,6 @@ class ChatHandler:
772
  yield {"event": "done", "data": ""}
773
 
774
 
775
- class _ScopedCatalogReader:
776
- """Wraps a CatalogReader, restricting `structured` reads to an analysis's bound
777
- sources (#10).
778
-
779
- Scoping lives here β€” not at a single call site β€” so the Planner AND the
780
- data-access tools (which re-read the catalog themselves) see the same scoped
781
- view; otherwise binding is only a hint to the Planner while the executor runs
782
- against the full catalog. Fail-open: an empty or fully-disjoint binding yields
783
- the whole catalog, so a stale / cross-source binding degrades instead of
784
- emptying the catalog. Only `structured` reads are scoped (all #10 binds today);
785
- `unstructured` / retrieval reads pass through.
786
- """
787
-
788
- def __init__(self, inner: Any, bound: set[str]) -> None:
789
- self._inner = inner
790
- self._bound = bound
791
-
792
- async def read(self, user_id: str, source_hint: str) -> Any:
793
- catalog = await self._inner.read(user_id, source_hint)
794
- if not self._bound or source_hint != "structured":
795
- return catalog
796
- scoped = [s for s in catalog.sources if s.source_id in self._bound]
797
- return catalog.model_copy(update={"sources": scoped or catalog.sources})
798
-
799
-
800
  def _normalize_chunks(raw: Any) -> list[DocumentChunk]:
801
  """Convert whatever the retriever returns into list[DocumentChunk].
802
 
 
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
 
178
  self._document_retriever = RetrievalRouter()
179
  return self._document_retriever
180
 
181
+ def _get_check_invoker(self, user_id: str, catalog_reader: Any = None) -> Any:
182
+ """Build the per-request data-access invoker for the `check` skill.
183
+
184
+ `catalog_reader` lets the caller scope the read (e.g. to the analysis
185
+ catalog); defaults to the user-scope reader.
186
+ """
187
  if self._check_invoker_factory is not None:
188
  return self._check_invoker_factory(user_id)
189
  from ..tools.data_access import DataAccessToolInvoker
190
 
191
+ return DataAccessToolInvoker(user_id, catalog_reader or self._get_catalog_reader())
192
 
193
  def _get_ps_agent(self) -> ProblemStatementAgent:
194
  if self._ps_agent is None:
 
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).
 
460
  return
461
  elif intent == "check":
462
  try:
463
+ # Scope check to the analysis catalog: it holds only this room's
464
+ # bound sources and their real names (a DB shows as "xl test", not
465
+ # the user-scope `postgres_<hash>` placeholder). Falls back to the
466
+ # user-scope reader when the analysis has no catalog row.
467
+ from ..catalog.reader import AnalysisScopedCatalogReader
468
+
469
+ scoped_reader = AnalysisScopedCatalogReader(
470
+ self._get_catalog_reader(), analysis_id
471
+ )
472
  # Wrap the check invoker so its check_* tool calls land in the trace.
473
+ invoker = TraceabilityToolInvoker(
474
+ self._get_check_invoker(user_id, scoped_reader), pad
475
+ )
476
  # Detect from the ORIGINAL message (not `rewritten`, which the
477
  # router normalizes to English) so the deterministic check reply
478
  # matches the user's language like the other paths.
 
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/handlers/check.py CHANGED
@@ -179,14 +179,19 @@ def render_tool_output(out: ToolOutput, reply_language: str = "English") -> str:
179
  return f"{header}\n{separator}\n{body}"
180
 
181
 
182
- def _render_source_list(out: ToolOutput, reply_language: str) -> str:
 
 
 
 
183
  """Render a check_data/check_knowledge *listing* as a bullet list, not a table.
184
 
185
  One bullet per source: `- name β€” Type (N tables)`. The type + table-count
186
  annotation is only added for structured sources (file vs database); documents
187
  are all "unstructured", so the section header already says so β€” just the name.
188
- Returns '' when there are no rows. (The column-level schema drill-down still
189
- renders as a table via `render_tool_output` β€” that data is genuinely tabular.)
 
190
  """
191
  if out.kind == "error":
192
  return _s(reply_language)["lookup_error"].format(error=out.error)
@@ -197,6 +202,8 @@ def _render_source_list(out: ToolOutput, reply_language: str) -> str:
197
 
198
  idx = {c: i for i, c in enumerate(columns)}
199
  type_labels = _SOURCE_TYPE_LABELS.get(reply_language, _SOURCE_TYPE_LABELS["English"])
 
 
200
 
201
  def _table_word(n: int) -> str:
202
  if reply_language == "English":
@@ -207,16 +214,70 @@ def _render_source_list(out: ToolOutput, reply_language: str) -> str:
207
  for row in rows:
208
  name = str(row[idx["name"]]) if "name" in idx else ""
209
  st = str(row[idx["source_type"]]) if "source_type" in idx else ""
 
210
  annotation = ""
211
  if st and st != "unstructured":
212
  annotation = type_labels.get(st, st)
213
  if "table_count" in idx:
214
  tc = row[idx["table_count"]]
215
  annotation += f" ({tc} {_table_word(int(tc))})"
216
- items.append(f"- {name} β€” {annotation}" if annotation else f"- {name}")
 
 
 
 
 
 
 
 
 
 
 
 
217
  return "\n".join(items)
218
 
219
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
220
  def _matched_source_ids(message: str, inventory: ToolOutput) -> list[str]:
221
  """All source_ids whose name appears as a whole word in the message.
222
 
@@ -246,13 +307,16 @@ def _matched_source_ids(message: str, inventory: ToolOutput) -> list[str]:
246
 
247
 
248
  def _render_helicopter(
249
- data_out: ToolOutput, knowledge_out: ToolOutput, reply_language: str = "English"
 
 
 
250
  ) -> str:
251
  """Stitch structured + document inventory into one helicopter-view reply."""
252
  strings = _s(reply_language)
253
  parts: list[str] = []
254
 
255
- data_list = _render_source_list(data_out, reply_language)
256
  if data_list:
257
  parts.append(f"{strings['structured']}:\n{data_list}")
258
 
@@ -555,7 +619,8 @@ async def run_check(
555
  inventory = await invoker.invoke("check_data", {})
556
  if inventory.kind == "error":
557
  return render_tool_output(inventory, reply_language)
558
- listing = _render_source_list(inventory, reply_language)
 
559
  if not listing:
560
  return _no_match
561
  n = len(inventory.rows or [])
@@ -566,4 +631,5 @@ async def run_check(
566
  invoker.invoke("check_data", {}),
567
  invoker.invoke("check_knowledge", {}),
568
  )
569
- return _render_helicopter(data_out, knowledge_out, reply_language)
 
 
179
  return f"{header}\n{separator}\n{body}"
180
 
181
 
182
+ def _render_source_list(
183
+ out: ToolOutput,
184
+ reply_language: str,
185
+ db_tables: dict[str, list[tuple[str, Any]]] | None = None,
186
+ ) -> str:
187
  """Render a check_data/check_knowledge *listing* as a bullet list, not a table.
188
 
189
  One bullet per source: `- name β€” Type (N tables)`. The type + table-count
190
  annotation is only added for structured sources (file vs database); documents
191
  are all "unstructured", so the section header already says so β€” just the name.
192
+ When `db_tables` supplies a database's table names, they are nested as sub-
193
+ bullets (capped at `_INVENTORY_TABLE_CAP`) so a DB isn't an opaque "N tables".
194
+ Returns '' when there are no rows.
195
  """
196
  if out.kind == "error":
197
  return _s(reply_language)["lookup_error"].format(error=out.error)
 
202
 
203
  idx = {c: i for i, c in enumerate(columns)}
204
  type_labels = _SOURCE_TYPE_LABELS.get(reply_language, _SOURCE_TYPE_LABELS["English"])
205
+ sc = _sc(reply_language)
206
+ db_tables = db_tables or {}
207
 
208
  def _table_word(n: int) -> str:
209
  if reply_language == "English":
 
214
  for row in rows:
215
  name = str(row[idx["name"]]) if "name" in idx else ""
216
  st = str(row[idx["source_type"]]) if "source_type" in idx else ""
217
+ sid = str(row[idx["source_id"]]) if "source_id" in idx else ""
218
  annotation = ""
219
  if st and st != "unstructured":
220
  annotation = type_labels.get(st, st)
221
  if "table_count" in idx:
222
  tc = row[idx["table_count"]]
223
  annotation += f" ({tc} {_table_word(int(tc))})"
224
+
225
+ head = f"- {name} β€” {annotation}" if annotation else f"- {name}"
226
+ tables = db_tables.get(sid) if st == "schema" else None
227
+ if tables:
228
+ # Uncapped for now: list every table of the database, so a follow-up
229
+ # "what are the other tables?" is inherently already answered.
230
+ lines = [head + ":"]
231
+ for tname, rc in tables:
232
+ suffix = f" ({rc} {sc['rows_word']})" if rc else ""
233
+ lines.append(f" - {tname}{suffix}")
234
+ items.append("\n".join(lines))
235
+ else:
236
+ items.append(head)
237
  return "\n".join(items)
238
 
239
 
240
+ def _distinct_tables(out: ToolOutput) -> list[tuple[str, Any]]:
241
+ """(table_name, row_count) pairs from a check_data(source_id) output, in order."""
242
+ if out.kind != "table":
243
+ return []
244
+ cols = out.columns or []
245
+ idx = {c: i for i, c in enumerate(cols)}
246
+ if "table_name" not in idx:
247
+ return []
248
+ seen: dict[str, Any] = {}
249
+ for r in out.rows or []:
250
+ tname = str(r[idx["table_name"]])
251
+ if tname not in seen:
252
+ seen[tname] = r[idx["table_row_count"]] if "table_row_count" in idx else None
253
+ return list(seen.items())
254
+
255
+
256
+ async def _fetch_db_tables(
257
+ inventory: ToolOutput, invoker: ToolInvoker
258
+ ) -> dict[str, list[tuple[str, Any]]]:
259
+ """Drill each database source in an inventory for its table names + row counts.
260
+
261
+ Only `schema` (database) sources are drilled β€” tabular files are always a
262
+ single table, so their `(N tabel)` line already says everything. Returns a
263
+ map keyed by source_id for `_render_source_list` to nest.
264
+ """
265
+ cols = inventory.columns or []
266
+ idx = {c: i for i, c in enumerate(cols)}
267
+ if "source_type" not in idx or "source_id" not in idx:
268
+ return {}
269
+ dbs = [r for r in (inventory.rows or []) if str(r[idx["source_type"]]) == "schema"]
270
+ if not dbs:
271
+ return {}
272
+ outs = await asyncio.gather(
273
+ *(invoker.invoke("check_data", {"source_id": str(r[idx["source_id"]])}) for r in dbs)
274
+ )
275
+ return {
276
+ str(r[idx["source_id"]]): _distinct_tables(o)
277
+ for r, o in zip(dbs, outs, strict=True)
278
+ }
279
+
280
+
281
  def _matched_source_ids(message: str, inventory: ToolOutput) -> list[str]:
282
  """All source_ids whose name appears as a whole word in the message.
283
 
 
307
 
308
 
309
  def _render_helicopter(
310
+ data_out: ToolOutput,
311
+ knowledge_out: ToolOutput,
312
+ reply_language: str = "English",
313
+ db_tables: dict[str, list[tuple[str, Any]]] | None = None,
314
  ) -> str:
315
  """Stitch structured + document inventory into one helicopter-view reply."""
316
  strings = _s(reply_language)
317
  parts: list[str] = []
318
 
319
+ data_list = _render_source_list(data_out, reply_language, db_tables)
320
  if data_list:
321
  parts.append(f"{strings['structured']}:\n{data_list}")
322
 
 
619
  inventory = await invoker.invoke("check_data", {})
620
  if inventory.kind == "error":
621
  return render_tool_output(inventory, reply_language)
622
+ db_tables = await _fetch_db_tables(inventory, invoker)
623
+ listing = _render_source_list(inventory, reply_language, db_tables)
624
  if not listing:
625
  return _no_match
626
  n = len(inventory.rows or [])
 
631
  invoker.invoke("check_data", {}),
632
  invoker.invoke("check_knowledge", {}),
633
  )
634
+ db_tables = await _fetch_db_tables(data_out, invoker)
635
+ return _render_helicopter(data_out, knowledge_out, reply_language, db_tables)
src/agents/planner/validator.py CHANGED
@@ -19,6 +19,7 @@ from ...catalog.models import Catalog
19
  from ...query.ir.models import QueryIR
20
  from ...query.ir.repair import IRRepairer
21
  from ...query.ir.validator import IRValidationError, IRValidator
 
22
  from .contracts import ToolRegistry
23
  from .errors import PlannerValidationError
24
  from .inputs import Constraints
@@ -105,6 +106,33 @@ class PlannerValidator:
105
  f"{sorted(unknown)} (allowed: {sorted(allowed)})"
106
  )
107
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
108
  # Check 3 β€” concrete source_id args must exist in the catalog.
109
  src = call.args.get("source_id")
110
  if isinstance(src, str) and not _is_placeholder(src):
 
19
  from ...query.ir.models import QueryIR
20
  from ...query.ir.repair import IRRepairer
21
  from ...query.ir.validator import IRValidationError, IRValidator
22
+ from ...tools.analytics.aggregation import SUPPORTED_AGGS
23
  from .contracts import ToolRegistry
24
  from .errors import PlannerValidationError
25
  from .inputs import Constraints
 
106
  f"{sorted(unknown)} (allowed: {sorted(allowed)})"
107
  )
108
 
109
+ # Check 8c β€” analyze_aggregate: every aggregation FUNCTION must be one
110
+ # the tool supports. Check 8a only validates arg *names* (`aggregations`
111
+ # is allowed); it never looks at the function *values* inside the dict,
112
+ # so an unsupported func like `std` otherwise passes validation and only
113
+ # fails at execution β€” too late for a corrective retry, so the task
114
+ # reaches the Assembler as a silent failure. Catch it here so the planner
115
+ # is re-prompted to degrade to a supported function (e.g. `mean`).
116
+ if call.tool == "analyze_aggregate":
117
+ aggs = call.args.get("aggregations")
118
+ if isinstance(aggs, dict):
119
+ bad = sorted(
120
+ {
121
+ f
122
+ for funcs in aggs.values()
123
+ for f in ([funcs] if isinstance(funcs, str) else funcs or [])
124
+ if f not in SUPPORTED_AGGS
125
+ }
126
+ )
127
+ if bad:
128
+ raise PlannerValidationError(
129
+ f"task {task.id}: analyze_aggregate has unsupported "
130
+ f"aggregation function(s) {bad} (supported: "
131
+ f"{sorted(SUPPORTED_AGGS)}). Use a supported function "
132
+ "(e.g. mean/median); for the spread of a whole column "
133
+ "use analyze_descriptive instead."
134
+ )
135
+
136
  # Check 3 β€” concrete source_id args must exist in the catalog.
137
  src = call.args.get("source_id")
138
  if isinstance(src, str) and not _is_placeholder(src):
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/catalog/reader.py CHANGED
@@ -7,12 +7,24 @@ Catalog-level search is added later if catalog grows past the limit.
7
  from datetime import UTC, datetime
8
  from typing import Literal
9
 
10
- from .models import Catalog
11
  from .store import CatalogStore
12
 
13
  SourceHint = Literal["chat", "unstructured", "structured"]
14
 
15
 
 
 
 
 
 
 
 
 
 
 
 
 
16
  class CatalogReader:
17
  """Loads the user's catalog and filters by source_hint.
18
 
@@ -30,14 +42,7 @@ class CatalogReader:
30
  if catalog is None:
31
  return Catalog(user_id=user_id, generated_at=datetime.now(UTC))
32
 
33
- if source_hint == "chat":
34
- filtered: list = []
35
- elif source_hint == "structured":
36
- filtered = [s for s in catalog.sources if s.source_type in {"schema", "tabular"}]
37
- else: # "unstructured"
38
- filtered = [s for s in catalog.sources if s.source_type == "unstructured"]
39
-
40
- return catalog.model_copy(update={"sources": filtered})
41
 
42
 
43
  class MemoizingCatalogReader(CatalogReader):
@@ -66,3 +71,42 @@ class MemoizingCatalogReader(CatalogReader):
66
  cached = await self._inner.read(user_id, source_hint)
67
  self._cache[source_hint] = cached
68
  return cached
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7
  from datetime import UTC, datetime
8
  from typing import Literal
9
 
10
+ from .models import Catalog, Source
11
  from .store import CatalogStore
12
 
13
  SourceHint = Literal["chat", "unstructured", "structured"]
14
 
15
 
16
+ def _filter_sources(catalog: Catalog, source_hint: SourceHint) -> Catalog:
17
+ """Return a copy of `catalog` keeping only the sources matching `source_hint`."""
18
+ filtered: list[Source]
19
+ if source_hint == "chat":
20
+ filtered = []
21
+ elif source_hint == "structured":
22
+ filtered = [s for s in catalog.sources if s.source_type in {"schema", "tabular"}]
23
+ else: # "unstructured"
24
+ filtered = [s for s in catalog.sources if s.source_type == "unstructured"]
25
+ return catalog.model_copy(update={"sources": filtered})
26
+
27
+
28
  class CatalogReader:
29
  """Loads the user's catalog and filters by source_hint.
30
 
 
42
  if catalog is None:
43
  return Catalog(user_id=user_id, generated_at=datetime.now(UTC))
44
 
45
+ return _filter_sources(catalog, source_hint)
 
 
 
 
 
 
 
46
 
47
 
48
  class MemoizingCatalogReader(CatalogReader):
 
71
  cached = await self._inner.read(user_id, source_hint)
72
  self._cache[source_hint] = cached
73
  return cached
74
+
75
+
76
+ class AnalysisScopedCatalogReader(CatalogReader):
77
+ """Reads the analysis-scope catalog, falling back to the user-scope reader.
78
+
79
+ Used by the `check` skill so "what data do I have" inside a room reflects
80
+ that analysis's bound sources β€” structured AND documents β€” with their real
81
+ names. A database shows as "xl test" (analysis-scope) instead of the
82
+ auto-generated `postgres_<hash>` placeholder, and documents show at all
83
+ (the user-scope catalog holds no `unstructured` sources, so reading them from
84
+ user-scope always came back empty). When the analysis has no catalog row
85
+ (legacy / not yet bound) or the read fails, it degrades to the wrapped
86
+ user-scope reader, so unbound rooms behave exactly as before.
87
+ """
88
+
89
+ def __init__(self, inner: CatalogReader, analysis_id: str | None) -> None:
90
+ # `inner` is a real CatalogReader (constructed at the check call site), so
91
+ # its `_store` is the live CatalogStore we need for the analysis read.
92
+ super().__init__(inner._store)
93
+ self._inner = inner
94
+ self._analysis_id = analysis_id
95
+
96
+ async def read(self, user_id: str, source_hint: SourceHint) -> Catalog:
97
+ # Read analysis-scope for BOTH structured and unstructured. Verified via
98
+ # the dedorch `data_catalog` table: the analysis-scope rows carry the real
99
+ # DB names AND the room's documents (`source_type='unstructured'`), whereas
100
+ # the user-scope rows hold only structured sources with `postgres_<hash>`
101
+ # placeholder names and NO documents at all β€” so reading documents from
102
+ # user-scope always returned empty ("not listed"). Fall back to the
103
+ # user-scope reader only when the analysis has no catalog row (legacy /
104
+ # unbound room).
105
+ if self._analysis_id:
106
+ try:
107
+ catalog = await self._store.get_by_analysis(self._analysis_id)
108
+ except Exception: # noqa: BLE001 β€” never block check on the analysis read
109
+ catalog = None
110
+ if catalog is not None:
111
+ return _filter_sources(catalog, source_hint)
112
+ return await self._inner.read(user_id, source_hint)
src/catalog/store.py CHANGED
@@ -43,6 +43,28 @@ class CatalogStore:
43
  # edges so the planner and validator agree. No-op once Go emits real FKs.
44
  return infer_foreign_keys(Catalog.model_validate(row))
45
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  async def upsert(self, catalog: Catalog) -> None:
47
  # Legacy: Go's catalog.Service owns catalog writes now. Kept working (and
48
  # reconciled to the dedorch shape) but no longer on any live Python path.
@@ -86,7 +108,9 @@ class CatalogStore:
86
  return
87
  filtered = [s for s in existing.sources if s.source_id != source_id]
88
  if len(filtered) == len(existing.sources):
89
- logger.info("remove_source: source not in catalog", user_id=user_id, source_id=source_id)
 
 
90
  return
91
  await self.upsert(existing.model_copy(update={"sources": filtered}))
92
  logger.info("remove_source: source removed", user_id=user_id, source_id=source_id)
 
43
  # edges so the planner and validator agree. No-op once Go emits real FKs.
44
  return infer_foreign_keys(Catalog.model_validate(row))
45
 
46
+ async def get_by_analysis(self, analysis_id: str) -> Catalog | None:
47
+ """Read the `scope_type='analysis'` catalog row for an analysis.
48
+
49
+ Distinct from `get()` (which reads the user-scope row): the analysis-scope
50
+ payload carries the sources actually bound to this analysis AND their
51
+ real names (a database is named e.g. "xl test" here, vs the auto-generated
52
+ `postgres_<hash>` placeholder in the user-scope row). Returns None when the
53
+ analysis has no catalog row (legacy / not yet bound), so callers fall back
54
+ to the user-scope catalog.
55
+ """
56
+ async with AsyncSessionLocal() as session:
57
+ result = await session.execute(
58
+ select(CatalogRow.catalog_payload).where(
59
+ CatalogRow.analysis_id == analysis_id,
60
+ CatalogRow.scope_type == "analysis",
61
+ )
62
+ )
63
+ row = result.scalar_one_or_none()
64
+ if row is None:
65
+ return None
66
+ return infer_foreign_keys(Catalog.model_validate(row))
67
+
68
  async def upsert(self, catalog: Catalog) -> None:
69
  # Legacy: Go's catalog.Service owns catalog writes now. Kept working (and
70
  # reconciled to the dedorch shape) but no longer on any live Python path.
 
108
  return
109
  filtered = [s for s in existing.sources if s.source_id != source_id]
110
  if len(filtered) == len(existing.sources):
111
+ logger.info(
112
+ "remove_source: source not in catalog", user_id=user_id, source_id=source_id
113
+ )
114
  return
115
  await self.upsert(existing.model_copy(update={"sources": filtered}))
116
  logger.info("remove_source: source removed", user_id=user_id, source_id=source_id)
src/config/prompts/assembler.md CHANGED
@@ -25,9 +25,17 @@ You produce two things in one structured object:
25
  and values present in the task results. **Never invent, estimate, or extrapolate
26
  a number** that is not in the results. If the data does not answer part of the
27
  question, say so.
28
- 2. **Report what failed.** Some tasks may have `status: partial` or `failure`. Do
29
- not pretend they succeeded. Briefly state what could not be completed and how it
30
- limits the answer; put unresolved items in `open_questions`.
 
 
 
 
 
 
 
 
31
  3. **Render, don't recompute.** Build markdown tables from the structured task
32
  outputs as they are. Do not do your own arithmetic beyond trivially restating a
33
  value already computed.
 
25
  and values present in the task results. **Never invent, estimate, or extrapolate
26
  a number** that is not in the results. If the data does not answer part of the
27
  question, say so.
28
+ 2. **Report what failed β€” in plain terms, and still answer.** Some tasks may have
29
+ `status: partial` or `failure`. Do not pretend they succeeded β€” but do not lead with
30
+ the failure either. **First** give the most useful answer the SUCCESSFUL tasks
31
+ support; **then** state, in business language, what could not be determined and how
32
+ it limits the answer; put unresolved items in `open_questions`. **Never expose the
33
+ internal cause** of a failure β€” no "the tool failed", "could not compute", "technical
34
+ error", task ids, or function/tool names. Describe the limit by what it *means for the
35
+ reader*, not by what broke internally. E.g. write "a formal significance test was not
36
+ run, so this shows the difference in averages but not whether it is statistically
37
+ significant" β€” NOT "the calculation of the score distribution failed". A narrower,
38
+ honest answer beats an apology.
39
  3. **Render, don't recompute.** Build markdown tables from the structured task
40
  outputs as they are. Do not do your own arithmetic beyond trivially restating a
41
  value already computed.
src/config/prompts/help.md CHANGED
@@ -1,4 +1,9 @@
1
- <!-- help.md Β· v4 Β· Help skill prompt.
 
 
 
 
 
2
  v4 (2026-07-03): reply language relaxed from hard-"only" to DEFAULT + explicit-request
3
  exception β€” an explicit user request ("jawab dalam bahasa Inggris") now overrides the
4
  detected [Reply language]; anti-drift default (incl. synthetic-trigger protection) is
@@ -75,6 +80,35 @@ Do not over-promise the report's depth.
75
  > chat skill to fix it; gently suggest they set the objective + business questions in the New
76
  > Analysis form.
77
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
78
  ## How-to phrasing (degrade gracefully)
79
 
80
  - **Via chat / skills** β€” write these **accurately and specifically**; they are stable (e.g. "type your question in the chat", "run `/report`").
@@ -102,6 +136,7 @@ spam, no overselling. A few sentences is usually enough.
102
  - Never suggest an action that the signals say isn't available or isn't ready.
103
  - One step at a time β€” give the next step, not the whole roadmap.
104
  - When you suggest questions, **dedupe against `chat_history`** β€” only propose analyses not yet run that move the goal forward; a question that already has an answer adds no fresh evidence.
 
105
  - No markdown headers or code fences in your reply; short prose (and an inline `/command` or a tiny bullet list) is fine.
106
 
107
  ## Examples
 
1
+ <!-- help.md Β· v5 Β· Help skill prompt.
2
+ v5 (2026-07-07): added the "Capability boundary" section β€” Help now only suggests
3
+ analyses the live tools can actually deliver (descriptive, group-by, correlation, trend)
4
+ and must NOT suggest significance tests, forecasting/modeling, causal claims, clustering/
5
+ segmentation, or share-of-total. Fixes Help recommending a statistical-significance
6
+ question the system has no tool for (the user copy-pasted the suggestion β†’ dead end).
7
  v4 (2026-07-03): reply language relaxed from hard-"only" to DEFAULT + explicit-request
8
  exception β€” an explicit user request ("jawab dalam bahasa Inggris") now overrides the
9
  detected [Reply language]; anti-drift default (incl. synthetic-trigger protection) is
 
80
  > chat skill to fix it; gently suggest they set the objective + business questions in the New
81
  > Analysis form.
82
 
83
+ ## Capability boundary β€” only suggest analyses the tools can actually do
84
+
85
+ Every question you propose must be answerable by the system's live analysis capabilities.
86
+ Suggesting an analysis the tools cannot perform sets the user up to fail: they copy your
87
+ suggestion, ask it, and hit a dead end. Stay strictly inside this list.
88
+
89
+ **You MAY suggest** (supported):
90
+ - Descriptive summaries of a column β€” average, median, spread, min/max, distribution.
91
+ - Group-by breakdowns β€” a total, average, or count of a metric **per category**. This is also
92
+ how to compare groups (e.g. "the average retention for online vs offline").
93
+ - Correlation / relationship between numeric columns ("which factors relate to exam score?").
94
+ - Trends over time.
95
+ - Inventory β€” what data / tables / documents exist.
96
+
97
+ **You must NOT suggest** (no tool exists β€” do not propose these even when they fit the goal):
98
+ - **Statistical significance / hypothesis tests** β€” never use "significant", "statistically
99
+ significant", "significant difference", t-test, ANOVA, or p-value. To compare groups, suggest
100
+ a group-by average that shows the gap (e.g. "compare the average retention of online vs
101
+ offline"), NOT "is the difference significant".
102
+ - Predictive modeling, forecasting, or regression models.
103
+ - Causal claims β€” avoid "cause", "impact of", "effect of" framed as causation; keep it to
104
+ relationship / correlation.
105
+ - Clustering or segmentation into discovered groups.
106
+ - Share-of-total / contribution breakdowns.
107
+
108
+ When a goal naturally invites a forbidden analysis (e.g. the user wants to know if a gap is
109
+ "real"), degrade the suggestion to the nearest supported one β€” the group-by average that shows
110
+ the gap β€” rather than promising the unsupported analysis.
111
+
112
  ## How-to phrasing (degrade gracefully)
113
 
114
  - **Via chat / skills** β€” write these **accurately and specifically**; they are stable (e.g. "type your question in the chat", "run `/report`").
 
136
  - Never suggest an action that the signals say isn't available or isn't ready.
137
  - One step at a time β€” give the next step, not the whole roadmap.
138
  - When you suggest questions, **dedupe against `chat_history`** β€” only propose analyses not yet run that move the goal forward; a question that already has an answer adds no fresh evidence.
139
+ - **Stay inside the Capability boundary above** β€” never propose significance tests, forecasting, modeling, causal claims, clustering/segmentation, or share-of-total; there is no tool for them, so suggesting them sends the user into a dead end.
140
  - No markdown headers or code fences in your reply; short prose (and an inline `/command` or a tiny bullet list) is fine.
141
 
142
  ## Examples
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
 
src/tools/analytics/aggregation.py CHANGED
@@ -44,8 +44,12 @@ def _clean(value: object) -> object:
44
  # Final destination is ToolSpec.description once the wrapper layer is built.
45
  DESCRIPTION = """\
46
  Summary: Group-by aggregation. Splits rows by one or more key columns and \
47
- computes aggregates per group (sum, mean, count, min, max, median, nunique). \
48
- Returns one row per group.
 
 
 
 
49
 
50
  USE WHEN the question groups a metric by a category β€” the tell-tale sign is \
51
  "per"/"each"/"by" a dimension. Trigger words: "per/each" (per/tiap), "by" \
@@ -57,6 +61,15 @@ DON'T USE WHEN:
57
  - it splits a single total into shares -> analyze_contribution
58
  - the grouping is over time periods -> analyze_trend
59
 
 
 
 
 
 
 
 
 
 
60
  Example questions:
61
  - "total revenue per region"
62
  - "average order value by customer segment"
 
44
  # Final destination is ToolSpec.description once the wrapper layer is built.
45
  DESCRIPTION = """\
46
  Summary: Group-by aggregation. Splits rows by one or more key columns and \
47
+ computes aggregates per group. Returns one row per group.
48
+
49
+ SUPPORTED FUNCTIONS β€” use ONLY these: sum, mean, count, min, max, median, \
50
+ nunique. Standard deviation / variance are NOT available here: for the spread of \
51
+ a whole column use analyze_descriptive (whole-column only β€” spread PER GROUP is \
52
+ not available in v1). NEVER pass std / var / stdev to this tool; it will fail.
53
 
54
  USE WHEN the question groups a metric by a category β€” the tell-tale sign is \
55
  "per"/"each"/"by" a dimension. Trigger words: "per/each" (per/tiap), "by" \
 
61
  - it splits a single total into shares -> analyze_contribution
62
  - the grouping is over time periods -> analyze_trend
63
 
64
+ GRACEFUL DEGRADE β€” if a tool named above (e.g. analyze_comparison) is NOT present \
65
+ in the "Available tools" list, do NOT fake it with an unsupported aggregation \
66
+ (like std) and do NOT abandon the task. Degrade to an answer that stays on the \
67
+ SAME question: group the measure by the group column with `mean` (optionally \
68
+ min/max/median) so each group's level and their difference are still shown. Do \
69
+ NOT switch to an unrelated tool just to produce output. The Assembler will note \
70
+ that a significance test / per-group spread was not computed β€” a narrower, true \
71
+ answer beats a failed one.
72
+
73
  Example questions:
74
  - "total revenue per region"
75
  - "average order value by customer segment"