fix/ check
#12
by sofhiaazzhr - opened
- REPO_STATUS.md +15 -9
- main.py +1 -2
- src/agents/binding_store.py +0 -34
- src/agents/chat_handler.py +33 -64
- src/agents/handlers/check.py +74 -8
- src/agents/planner/validator.py +28 -0
- src/agents/report/generator.py +16 -32
- src/api/v1/analysis.py +0 -174
- src/catalog/reader.py +53 -9
- src/catalog/store.py +25 -1
- src/config/prompts/assembler.md +11 -3
- src/config/prompts/help.md +36 -1
- src/db/postgres/init_db.py +0 -1
- src/db/postgres/models.py +0 -22
- src/tools/analytics/aggregation.py +15 -2
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)
|
| 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
|
| 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.
|
|
@@ -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 |
-
`
|
| 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/
|
| 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
|
|
|
|
| 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 |
-
#
|
| 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
|
| 430 |
-
|
| 431 |
-
|
| 432 |
-
|
| 433 |
-
|
| 434 |
-
#
|
| 435 |
-
|
| 436 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 189 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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,
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
|
|
| 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
|
| 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/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 |
-
|
| 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(
|
|
|
|
|
|
|
| 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
|
| 29 |
-
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 Β·
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 48 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"
|