ishaq101's picture
/fix change model to gpt 5.4 mini (#17)
d65c41d
|
Raw
History Blame Contribute Delete
38.2 kB

Data Eyond β€” Python Agentic Service: Current Status

Audience: teammates onboarding onto the Python repo (Agentic-Service-Data-Eyond-Catalog). Scope: what the code does right now (branch pr/4, ticket KM-652). Describes current state only β€” no roadmap or to-dos. Snapshot date: 2026-06-25. Data-layer reconcile 2026-07-01: Β§8/Β§12 updated β€” dedorch cutover done, data_catalog model reconciled. Query-path fix 2026-07-02: Β§8/Β§13 β€” dedorch catalogs ship no FKs β†’ Python infers them (fk_inference.py); shared-Fernet-key gotcha documented. Agent-quality fixes 2026-07-08 (pr/13): from the scoped live-test review β€” the planner gains an explicit infeasible outcome (TaskList.infeasible_reason β†’ deterministic EN/ID data-gap reply via refusals.data_gap_message; no more force-mapping absent measures like pa AS "revenue"), the IR validator rejects bare selects under group_by (self-corrects via the planner retry), analyze_trend handles integer year/month columns (was collapsing every row into one 1970-01 bucket), planner few-shots add top-N (Example G) + infeasible (Example H), numeric catalog sample_values are base64-decoded at read (catalog/sample_decode.py β€” stopgap for Go's byte-marshaling; primary fix is Go-side), traceability no longer emits null source rows for failed retrievals, and check_data hides -1 row counts. Report v2 + analyze_merge planner support 2026-07-09 (pr/13): Sofia's analyze_merge tool (8abf635, KM-703) is now planner-supported (_validate_data_source guards data_right, two-retrieveβ†’merge few-shot Example I, planner.md "Two measures per entity" rule); the report gains per-business-question answers (bq_answers β€” drafted by the SAME single LLM call, index-based record refs, deterministic fallback unchanged), "Attempted, Unresolved" + "Excluded Analyses" sections (failed runs are no longer silently dropped), evidence tables copied from results_snapshot (table-kind outputs, ≀3/record ≀10 rows ≀8 cols, check_* skipped), normalized caveat dedupe with caps (12/10), and single-language output via detect_reply_language; the report surface adds GET /tools/report/{analysis_id}/records (curation list), GET …/readiness (FE delta guard), and exclude_record_ids on POST β€” see API_CONTRACT_BE_PYTHON.md. Report compaction 2026-07-09 (pr/13): the rendered markdown drops the "Notes & Limitations", "Attempted, Unresolved", and "How This Was Analyzed" sections (team decision β€” compact report; render blocks commented out in report/generator.py, not deleted). The JSON body keeps caveats/open_questions/unresolved/method_steps and the curation/records endpoints are unchanged. Traceability data_used layer 2026-07-13 (pr/15): GET /api/v1/traceability gains a resolved, user-facing data_used[] block (one per retrieve_data call) β€” real source/table/column names, joins, plain-language filters, and result columns split into read-from-data vs computed (with formula, e.g. total_revenue = SUM(line_total), so an alias is never shown as a real column). Ids are kept but machine-only (FE must not render). Also adds tool_calls[].summary, and sources[] now carry source_name + every table touched. Deterministic catalog resolution (new src/traceability/resolve.py), no LLM, never-throw; catalog threaded to the scratchpad at the slow-path composition root. Additive/non-breaking; contract + TRACEABILITY_FE_HANDOFF.md updated. Spine v2 W2+W1 2026-07-13 (pr/16): Β§6/Β§7/Β§8/Β§9/Β§12 β€” render_chart tool lands (first of the render_* family: deterministic Plotly-JSON dataeyond.chart.v1 envelope, hand-built, no plotly dependency; planner-selected only on an explicit chart ask, EN/ID) + Python-owned message_charts store + GET /api/v1/charts (FE fetches on done, same pattern as traceability; empty list = valid 200); planner gains a named recipe table + viz few-shots (Example J tail, Example K viz-infeasible) + validator Check 10 (render_chart.data must reference a table-producing task); and the slow path gains the S1a quality checkpoint (slow_path/checkpoint.py, 0 LLM, never-throw, between runner and assembler: CK1 all-failed β†’ deterministic honest-failure answer with no assembler call, CK2 empty retrieve + downstream, CK3 10k-cap truncation, CK4 single trend bucket, CK5 all-null column, CK6 chart-spec sanity; flags render as an "Execution assessment" block in the assembler input and every flag logs repair_candidate β€” the S1b evidence base). Design + handoff doc: SPINE_V2_PLAN.md. LLM model + env rename 2026-07-14 (pr/17): Β§2/Β§3/Β§9/Β§13 β€” the generation LLM is now Azure GPT-5.4-mini (deployment gpt-5.4-mini), not GPT-4o, and the settings quad is renamed azureai__*__4o β†’ azureai__*__54m across all 9 LLM call sites. Hard rename, no __4o fallback β€” an environment that still sets only __4o resolves to empty strings and fails on the first LLM call, by design: the silent-wrong-model drift is exactly how HF stayed on GPT-4o while local ran 5.4-mini (identical question, identical catalog, divergent planner output β€” HF hallucinated catalog ids, local planned correctly). Deploying requires all four __54m vars set in the HF Space secrets. Cross-repo update 2026-06-29: Β§2/Β§8/Β§11/Β§12 re-verified against the Go source (Orchestrator-Agent-Service), not its docs. The Go service has moved well past its own (uncommitted, stale) design docs: it now hosts the dedorch SQL migrations in-repo and a full /api/v1/analyses + /api/v1/skills REST surface. Go does not call Python yet β€” those skills are placeholders (see Β§12).

This file is grounded in the source, not the older design docs. Where the two disagree, the code wins β€” see Β§11 Doc-vs-code. REPO_CONTEXT.md / ARCHITECTURE.md are the original Phase-2 design docs and are stale on the router, joins, and the analysis/report stack.

🚧 Direction update 2026-06-30 (pr/5 β€” DECIDED Β· IN PROGRESS). The 30 June checkpoint locked a restructure (contract: API_ENDPOINTS_RESTRUCTURE.md; live tracker: DEV_PLAN Β§0). Python is becoming a generation/AI-only service β€” Go owns the full analysis lifecycle and the data-plane endpoints. Scope:

  • Unwired from main + Swagger (router files kept, not deleted): analysis CRUD, room, db_client, document, data_catalog, users/login. βœ… DONE β€” KM-686, commit 0b2d678 (so the Β§7 rows for these are now commented out of main.py).
  • AI surface that stays live: chat β†’ POST /api/v2/chat/stream (explicit analysis_id, not room_id); the skills regroup under /api/v1/tools/ (list Β· help Β· report); plus a new GET /api/v1/traceability (user-facing provenance per answer, backed by a Python-owned message_traceability store β€” renamed from observability, KM-691). βœ… built.
  • Only chat/stream moves to /api/v2; everything else stays /api/v1.

Β§2/Β§4/Β§7 below still describe the pre-restructure wiring except the unwire above, which has landed.


1. The product in one paragraph

Data Eyond is an "AI data scientist" for business analytics, modelled on CRISP-DM (Business Understanding β†’ Data Understanding β†’ Preparation β†’ Modeling β†’ Evaluation β†’ Deployment). It targets executives doing self-serve deep-dives and analysts offloading routine work. A user defines a goal, connects data (DB or files), asks natural-language analytical questions, and gets CRISP-DM-structured answers that can be exported as a versioned report. The aim is "junior data scientist that hands back a decision-ready deliverable," not "chatbot over a database."


2. Three repos, one hard ownership rule

Request flow is FE β†’ Go β†’ Python. The FE never calls Python directly except for chat streaming.

Repo Role We edit?
Python β€” Agentic-Service-Data-Eyond-Catalog (this repo) The agentic LLM service: router, gate, skills, slow analytical path, structured query engine, unstructured RAG, report generation, analysis-session state. FastAPI + async SQLAlchemy + LangChain + Azure GPT-5.4-mini (was GPT-4o until 2026-07-14). Yes β€” the only repo we edit.
Go β€” Orchestrator-Agent-Service Gateway / data plane: auth/JWT, documents (Azure Blob + CSV/XLSXβ†’Parquet + embeddings), database_clients (Fernet creds), catalog ingestion (moved into Go, KM-578/590), all dedorch SQL migrations (now embedded in the Go repo: internal/repository/postgres/migrations/0001–0004), and the full analysis-lifecycle REST surface (/api/v1/analyses CRUD + messages + reports, /api/v1/skills). The interview agent and chat-rooms are deprecated β†’ HTTP 410 (internal/api/deprecation.go). Reference only.
FE β€” E2E-Frontend-Data-Eyond React/Vite SPA. Talks to Go for everything and to Python only for chat streaming. Reference only.

Β» pr/5 (decided, not yet in code): Python's non-AI endpoints (analysis CRUD, room, document, db_client, data_catalog, users/login) are being unwired β€” Python keeps only the generation/AI surface (chat, tools: help/report/list, traceability). See the Direction-update banner.

Shared infra: Postgres (app tables + data_catalog jsonb + PGVector langchain_pg_embedding), Azure Blob, and (Python-only) Redis.


3. Tech stack & how to run

  • Python 3.12, FastAPI, uvicorn, sse-starlette
  • Async SQLAlchemy 2.0 + asyncpg (Postgres); psycopg3 for the PGVector engine
  • LangChain + langchain-openai (Azure OpenAI GPT-5.4-mini, deployment gpt-5.4-mini; env quad azureai__*__54m β€” renamed from __4o 2026-07-14) + langchain-postgres (PGVector)
  • Redis (response + retrieval cache), Azure Blob (uploads + Parquet)
  • pandas / pyarrow, sqlglot, pydantic v2, structlog, slowapi, langfuse
  • DB connectors: psycopg2, pymysql, pymssql, sqlalchemy-bigquery, snowflake-sqlalchemy

Run (Linux/Docker): uv run --no-sync uvicorn main:app --host 0.0.0.0 --port 7860 Run (Windows): uv run --no-sync python run.py (sets WindowsSelectorEventLoopPolicy for psycopg3 async β€” don't call uvicorn directly on Windows).

Tests live locally and are gitignored. Run with ./.venv/Scripts/python.exe -m pytest.


4. Chat request lifecycle

Entry: POST /api/v1/chat/stream (src/api/v1/chat.py) β†’ ChatHandler.handle(...) (src/agents/chat_handler.py). One shared ChatHandler per process keeps the Azure clients warm.

Β» pr/5: this endpoint moves to POST /api/v2/chat/stream with an explicit analysis_id field (replacing room_id), and the traceability detail (planning / tool I/O / sources) moves out of the stream to a separate GET /api/v1/traceability call. See the Direction-update banner.

POST /chat/stream { user_id, room_id, message }
  β”‚  (analysis_id == room_id β€” one session = one analysis = one chat room)
  β”œβ”€ Redis response-cache check (1h TTL, key chat:{room}:{user}:{message})  ── hit β†’ replay
  β”œβ”€ greeting/farewell short-circuit (_fast_intent, EN+ID)                  ── hit β†’ canned reply
  β”œβ”€ load last-10 history
  └─ ChatHandler.handle:
       1. classify β†’ RouterDecision               [1 LLM call]
       2. ensure analysis-state row (get-or-create, idempotent)
       3. emit `intent` (internal; gates caching), then dispatch:
            chat              β†’ ChatbotAgent β†’ SSE
            help              β†’ HelpAgent (state + history + readiness) β†’ SSE
            check             β†’ check_data/check_knowledge tool β†’ rendered table  [no LLM]
            unstructured_flow β†’ DocumentRetriever (PGVector RAG) β†’ ChatbotAgent β†’ SSE
            structured_flow   β†’ CatalogReader β†’ (slow path | QueryService) β†’ SSE
       4. SSE events: intent (internal), sources, chunk, status, done | error

Only the chat intent is cached (stateless). Messages persist on done.

The router emits 5 intents now. The problem_statement skill and the problem_validated gate were removed 2026-06-25 (KM-652) β€” the analysis goal is two user-entered fields (objective + business_questions) captured at onboarding, with no agent validation.


5. Report lifecycle

The report is a dedicated API, not a chat route (src/api/v1/report.py):

POST /report?analysis_id&user_id
  β”œβ”€ load analysis state; enforce the report FLOOR
  β”‚     (β‰₯1 substantive analyze_* success) β†’ else 409
  β”œβ”€ ReportGenerator.generate (src/agents/report/generator.py):
  β”‚     read persisted AnalysisRecords (list_for_analysis)
  β”‚     deterministically assemble findings / caveats / open-questions /
  β”‚       data-source appendix / CRISP-DM method appendix  (copied verbatim)
  β”‚     ONE LLM call β†’ executive summary only (deterministic fallback on failure)
  β”‚     render markdown
  β”œβ”€ ReportStore.save: advisory-locked version assignment β†’ dedorch `reports`
  └─ write report_id back onto analysis state

GET /report/{analysis_id}        β†’ list versions (oldest-first)
GET /report/{analysis_id}/{ver}  β†’ fetch one version

Two facts to internalise:

  • Records only exist on the slow path. The slow path is now always on for structured_flow (the ENABLE_SLOW_PATH flag was removed 2026-07-02), so every structured question persists a record. Reports still 409 until at least one analyze_* task has actually succeeded (chat/help/check/unstructured turns write no record).
  • dedorch reports stores markdown only. Structured report fields are computed at generation, rendered into rendered_markdown, and only the markdown is persisted; on read-back the structured fields come back empty.

6. Feature list (what's built)

  • 6-intent handler router (chat/help/check/unstructured_flow/structured_flow/out_of_scope, the last added 2026-07-03) with history-aware query rewriting (EN/ID).
  • Skills: help (LLM, state-aware next-step guidance), check (no-LLM data/document inventory). (The problem_statement skill and the problem_validated gate were removed 2026-06-25 β€” KM-652; gate.py kept as a no-op seam, problem_statement.py kept but unwired.)
  • Slow analytical path: Planner β†’ TaskRunner β†’ S1a quality checkpoint (0 LLM, added 2026-07-13) β†’ Assembler (static plan, degrade-and-continue, 3 LLM calls fixed; an all-failed run now short-circuits to a deterministic honest-failure answer β€” 2 calls).
  • Structured query engine: catalog-driven JSON IR β†’ deterministic SQL/pandas compiler β†’ read-only executor, with single-level FK joins (DB sources only).
  • Unstructured RAG over PGVector.
  • Analytics tools: 6 registered (5 composite analyze_* β€” descriptive, aggregate, correlation, trend, merge β€” plus render_chart, added 2026-07-13) + 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 (W3, deferred).
  • Charts (S2, 2026-07-13; updated 2026-07-14): planner-selected render_chart (only on an explicit chart ask, EN/ID) builds a dataeyond.chart.v1 Plotly-JSON envelope; persisted to Python-owned message_charts before done, fetched via GET /api/v1/charts?message_id= (tri-state status marker: success/empty/not_found). SSE stays text-only. Reports embed charts too (2026-07-14): the generator copies chart envelopes from results_snapshot into an ## EDA section as ```plotly fenced blocks (FE hook renders them); a successful render_chart now counts toward the report floor (has_successful_analysis).
  • Versioned report generation from persisted records.
  • 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.
  • Langfuse tracing (PII-masked), Redis caching, pooled DB engines + speculative prewarm.

7. API surface (this repo)

βœ… pr/5 restructure IN CODE (table refreshed 2026-07-13). The banner that stood here ("decided, not yet in code") is done: chat lives at /api/v2/chat/stream, the skills regrouped under /api/v1/tools/*, traceability and (2026-07-13) charts are mounted, and the analysis-CRUD / room / users / document / db_client / data_catalog routers are unwired from main + Swagger (files kept, commented mounts). Table below is the live surface (main.py mounts).

Endpoint Purpose Caller
POST /api/v2/chat/stream Main chat SSE (analysis_id; router β†’ dispatch) FE β†’ Go β†’ Python
GET /api/v1/tools/list Slash-command catalog (static, cacheable) Go caches it for the FE "/" menu
POST /api/v1/tools/help State-aware help skill FE β†’ Go β†’ Python
POST /api/v1/tools/report (+ GET …/records Β· …/readiness Β· …/{analysis_id}/{version} GETs) Report generate / curate / readiness / fetch FE β†’ Go (report button)
GET /api/v1/traceability Per-turn provenance (fetched on done) FE β†’ Go β†’ Python
GET /api/v1/charts?message_id= Per-turn render_chart envelopes (fetched on done); always 200 with status: success|empty|not_found β€” added 2026-07-13, reshaped per lead review 2026-07-14 FE β†’ Go β†’ Python
users Β· room Β· document Β· db_client Β· data_catalog Β· v1 chat Β· analysis-CRUD routers Unwired (files kept in tree, not mounted) β€”

8. Data model

SQLAlchemy models in src/db/postgres/models.py. Created on startup by init_db() unless SKIP_INIT_DB=true.

Table Shape Written by Read by
users, rooms, chat_messages, message_sources base app chat endpoint, Go chat history
documents, databases uploads + DB creds (Fernet-encrypted) Go ingestion executor cred resolution
data_catalog (dedorch, Go-owned) id uuid, scope_type ('user'|'analysis'), user_id, analysis_id, catalog_payload jsonb (the Catalog: Source β†’ Table β†’ Column), schema_version, generated_at, updated_at; partial-unique on user_id WHERE scope_type='user' Go catalog.Service (all writes: DB/file ingestion) CatalogReader β†’ CatalogStore (read-only), planner, tools
langchain_pg_embedding PGVector document chunks Go ingestion DocumentRetriever
report_inputs (was analysis_records) jsonb AnalysisRecord, one per slow-path run; Python-owned slow path ReportGenerator, report readiness
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
reports (dedorch) uuid, analysis_id, user_id, title + markdown content + version (UNIQUE per analysis) Go + Python ReportStore report API
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)
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)
message_traceability (Python-owned) one jsonb TraceabilityPayload per assistant turn (PK message_id); flushed before done chat pipeline (KM-691) GET /api/v1/traceability
message_charts (Python-owned, added 2026-07-13) one row per render_chart chart β€” spec jsonb holds the full dataeyond.chart.v1 envelope; keyed (analysis_id, message_id), multiple rows per turn allowed; written before done, never-throw slow-path chart persist (chat_handler._run_slow_path) GET /api/v1/charts

βœ… Python ORM ↔ dedorch drift β€” reconciled 2026-07-01. AnalysisStateRow (analyses) dropped problem_statement/problem_validated and added objective/business_questions (Harry's #3); data_catalog was the last stale model. Its Catalog ORM (old user_id-PK + data jsonb) is now the dedorch shape (id PK, scope_type, catalog_payload), and CatalogStore reads catalog_payload WHERE scope_type='user' (matching Go's catalog.Service). This closed a live bug: the check skill / CatalogReader still selected the dropped data_catalog.data column, so every catalog read 500'd after the cutover ("what data do I have" β†’ "Sorry, I couldn't look that up: column data_catalog.data does not exist"). Python's catalog write methods (upsert/ remove_source/StructuredPipeline) were reconciled but are now legacy β€” Go owns ingestion.

Catalog shape (the jsonb in data_catalog): Catalog β†’ Source[ {source_id, source_type ∈ schema|tabular|unstructured, name, location_ref} β†’ Table[ {table_id, name, row_count, foreign_keys[]} β†’ Column[ {column_id, name, data_type, nullable, pii_flag, sample_values|null, stats} ] ] ]. PII columns have sample_values: null so real values never enter prompts.

⚠️ dedorch catalogs ship empty foreign_keys (Go's introspection drops FK constraints), yet the IR validator only allows FK-backed joins β€” so every cross-table question failed validation until 2026-07-02. src/catalog/fk_inference.py (wired into CatalogStore.get) now infers the obvious <base>_id β†’ <table>.id edges at read time: conservative (single unambiguous target, matching data_type, schema sources only) and self-disabling once any real FK is present. It's a stopgap β€” the durable fix is Go emitting real FKs during introspection.

QueryIR shape (src/query/ir/models.py): { source_id, table_id, joins[], select[], filters[], group_by[], order_by[], limit }. Joins are single-level equi-joins to a related table in the same source, FK-backed, DB sources only.


9. Subsystems (where the code lives)

Router β€” src/agents/orchestration.py

One structured-output LLM call (GPT-5.4-mini) β†’ RouterDecision{intent, rewritten_query, confidence}, intent ∈ {chat, help, check, unstructured_flow, structured_flow} (problem_statement removed 2026-06-25). It's a handler classifier: structured_flow = slow path, unstructured_flow = fast RAG; the data-modality mix on the slow path is the Planner's job. Prompt: src/config/prompts/intent_router.md.

Gate β€” src/agents/gate.py

Neutered 2026-06-25 (KM-652): gate() now passes every intent through unchanged β€” the problem_validated redirect was removed (the goal is user-entered, no agent validation). The function + AnalysisState contract are kept as a no-op seam; the call site in chat_handler.handle is commented out. AnalysisState still carries (id, analysis_title, problem_statement, problem_validated, owner_id, report_id, created_at, updated_at) until the dedorch state migration (#3/#4) renames it.

Skills β€” src/agents/handlers/

  • help.py β€” LLM (streamed). A consistency guard derives the allowed actions from state (mirrors the gate) and feeds them to the prompt, so Help can't suggest a report when the goal isn't validated or there's nothing to report. Consumes a deterministic readiness signal.
  • check.py β€” no LLM. Keyword cues route to check_data, check_knowledge, or both (helicopter view, concurrent). Renders tool tables to markdown.
  • problem_statement.py β€” unwired 2026-06-25 (no longer routed to; file kept intact). Was an LLM drafter that validated a goal and wrote problem_validated.

Slow path β€” src/agents/slow_path/ + src/agents/planner/

  • Planner (planner/service.py) β€” 1 LLM call β†’ TaskList (DAG of tool-call chains). 8-check validator with re-prompt retry (max 3). BusinessContext is a stub (planner/business_context.py), which is why the slow path stays opt-in.
  • TaskRunner (slow_path/task_runner.py) β€” deterministic, 0 LLM. Wave-based execution, ${t<id>} placeholder resolution (Pattern A), never-throw invocation, degrade-and-continue (failed task β†’ dependents skipped, independent branches run). No replanning.
  • Quality checkpoint (S1a) (slow_path/checkpoint.py, added 2026-07-13) β€” deterministic, 0 LLM, never-throw inspection between runner and assembler. CK1 all-failed β†’ the coordinator returns a deterministic honest-failure answer (refusals.run_failure_message, EN/ID) with no assembler call and a non-substantive record; CK2 empty retrieve (+ transitive dependents), CK3 10k-cap truncation, CK4 single trend bucket, CK5 all-null column consumed, CK6 chart-spec sanity (Β§4.6 of SPINE_V2_PLAN). Degraded flags render as an "# Execution assessment" block in the assembler's human content; every flag logs repair_candidate via structlog (the gated-S1b evidence base). A clean run renders nothing β€” zero behavior change.
  • Assembler (slow_path/assembler.py) β€” 1 LLM call authoring only the narrative; code copies the structured results_snapshot / tasks_run from the run state into the AnalysisRecord (the report's source of truth).

Streaming + persistence: chat_handler._run_slow_path bridges per-stage progress to SSE status events, prewarms the DB engine in parallel with planning, emits the answer, then persists the record stamped with user_id + analysis_id, and (2026-07-13) any kind="chart" outputs to message_charts β€” both never-throw, both before done.

Structured query engine β€” src/query/

QueryService.run (query/service.py): plan β†’ validate β†’ retry(3) β†’ dispatch β†’ execute; never raises (errors land in QueryResult.error). IRValidator (query/ir/validator.py) checks source/table/column existence, op/agg whitelists, type compatibility, limit cap, and FK-backed joins (DB only). DbExecutor (query/executor/db.py): SqlCompiler β†’ sqlglot SELECT-only guard β†’ Fernet-decrypt creds (with owner check) β†’ asyncio.to_thread (30s timeout) β†’ pooled engine (read-only + statement_timeout) β†’ 10k row cap. Defense-in-depth: IR validation + compiler whitelist

  • sqlglot guard + read-only session + LIMIT/timeout.

Analysis-scoped catalog reads β€” src/catalog/reader.py::AnalysisScopedCatalogReader

An analysis is scoped to the sources the user picked by reading the analysis-scope catalog (data_catalog scope_type='analysis', Go-materialized with the bound db + file sources under their real names). On a structured_flow turn the catalog reader is wrapped so the Planner and the tools' re-reads see the same analysis-scoped snapshot; check and the report's data-source appendix read it too. Fail-open: no analysis-scope row β†’ user-scope catalog. The old data_sources binding table + AnalysisDataSourceStore/_ScopedCatalogReader (#10) were removed β€” the writer (/analysis/create) is Go-owned/unwired, so the table was always empty and its consumers fail-opened to the whole (mis-named) user catalog.

Tool layer β€” src/tools/data_access.py, src/agents/planner/registry.py

DataAccessToolInvoker implements the never-throw tool seam for the 4 data-access tools. retrieve_data runs a pre-built IR (validate → dispatch → execute, skipping the planner) and coerces Decimal→float — the Pattern A handoff the analyze_* tools consume. The planner registry composes a local data-access spec stub (name-checked against DATA_ACCESS_TOOLS) with the real analytics_registry(). 2026-07-13: analytics_registry() also exposes render_chart (src/tools/analytics/visualization.py, category analytics.visualization, output_kind="chart" — ToolOutput.kind gained "chart"): a pure spec builder mapping a table to a Plotly-JSON envelope (bar/line/pie/scatter, fixed house style preset, no plotly import); the planner validator's Check 10 forces its data to reference a table-producing task.

Report β€” src/agents/report/

generator.py reads records, deterministically assembles structured fields, 1 LLM call for the executive summary; store.py versions under an advisory lock and persists markdown to dedorch reports; readiness.py defines the report floor (β‰₯1 successful analyze_* or, since 2026-07-14, render_chart β€” a chart-only session is substantive; the problem_validated precondition was dropped 2026-06-25) shared by the report API and the Help readiness signal so the two can't disagree. 2026-07-14: the report embeds charts β€” _collect_charts copies dataeyond.chart.v1 envelopes verbatim (INV-4) from results_snapshot into AnalysisReport.charts, rendered as ```plotly fenced blocks in the ## EDA section (fence content = the full v1 envelope, pretty-printed β€” the shape the FE's fence hook parses, verified 2026-07-14).

Observability β€” Langfuse

The endpoint's ChatHandler runs with enable_tracing=True. One trace per request groups router/planner/assembler/chatbot + tool spans. PII policy: router/planner unmasked (PII-safe summaries); assembler/chatbot masked (see real rows); tool spans carry name + arg keys + row counts only.


10. Feature flags

Flag Where Default Effect
ENABLE_SLOW_PATH β€” removed 2026-07-02 Flag deleted. structured_flow now always runs Planner/TaskRunner/Assembler (the single-query QueryService fast path was retired from the chat handler), so records always persist. extra="allow" ignores a stale ENABLE_SLOW_PATH left in any .env.
ENABLE_GATE settings.enable_gate off Deprecated 2026-06-25 β€” gate neutered; the flag has no effect. Kept to avoid .env churn.
SKIP_INIT_DB settings.skip_init_db (.env/env) on Skip init_db() on startup β€” the dedorch cutover switch. Defaults TRUE (Go owns the dedorch schema); set false only for a local Python-owned DB.
enable_tracing hardcoded True in chat.py on (endpoint) Langfuse tracing.

11. Where the older docs are stale

Trust the code. The original Phase-2 docs (ARCHITECTURE.md, REPO_CONTEXT.md) and the Go repo's copies disagree with the current code on:

Topic Old docs Current code
Router 3-way source_hint (chat/unstructured/structured) Flat 5-intent RouterDecision (was 6; problem_statement removed 2026-06-25)
Joins in IR "single-table only; deferred" Single-level FK-backed joins (DB sources only)
Analysis / report / gate / slow path "Phase 2 spine only" All built and present
analysis_id open question resolved: analysis_id == room_id
Report source (newer invariant) "from records, never chat history" confirmed: generator reads AnalysisRecords
Go service scope "interview agent + ingestion; dedorch migrations live outside the repos" Go now hosts the dedorch migrations in-repo + a full /api/v1/analyses + /api/v1/skills REST surface; interview/rooms deprecated (410). (Go's own PROJECT_SUMMARY.md/REPO_CONTEXT.md are uncommitted + stale.)

12. dedorch migration β€” current state

The Python DB has moved from dataeyond β†’ dedorch (cutover 2026-07-01; Go owns dedorch migrations; Python is consumer-only). State re-verified against the Go source 2026-06-29:

  • The dedorch migrations now live IN the Go repo β€” embedded SQL at internal/repository/postgres/migrations/0001_create_core_schema.sql … 0004_replace_chat_with_analysis_scope.sql, run on startup by RunMigrations. (This corrects the earlier note that the migrations were invisible / asserted only by Python docstrings.) The full schema is now readable there.
  • Go owns the analysis family end-to-end. analyses / analyses_messages / reports / data_sources / message_sources / data_catalog are created by Go migrations and served by a full REST surface: internal/api/analysis.go (CRUD + data-bind w/ optimistic expected_version
    • messages + reports) and internal/api/skills.go. analyses already has the pivot shape (objective + business_questions, status, data_bind/_version, report_collection) and has dropped problem_statement/problem_validated/owner_id. Migration 0004 renames the legacy rooms/chat_messages/interview_* tables to zdeprecated_*.
  • report_inputs (the slow-path structured output, formerly analysis_records) stays Python-owned; its finalized schema goes to Harry so the dedorch migration creates it post-cutover. Same pattern for message_traceability (created manually 2026-07-06) and message_charts (created manually 2026-07-13, DDL in SPINE_V2_PLAN.md Β§4.4; live e2e verified same day β€” Harry's migration handoff for both is still the open item).
  • Connection-string cutover DONE (2026-07-01). Python's postgres_connstring now points at dedorch and reads the Go-migrated tables directly. Every ORM model Python reads (analyses, analyses_messages, data_catalog) has been reconciled to its dedorch shape. init_db() is now skipped by default (settings.skip_init_db defaults True): its privileged DDL (ALTER TABLE rooms …, index creation) fails on Go-owned tables (InsufficientPrivilegeError: must be owner of table rooms). Skipping is safe β€” Go migration 0001 already provides the vector extension + the langchain FTS index. Set SKIP_INIT_DB=false (.env or env) only for a local Python-owned DB. report_inputs is not in any Go migration yet (#22) β€” create it in dedorch before enabling the slow path, else report/slow-path writes fail (chat path unaffected).

⚠️ Integration gap (verified β€” the big one). Go's /api/v1/analyses and /api/v1/skills (help / report) are placeholders that return dummy data β€” the SendMessage / GenerateReport handlers and the skills handler explicitly note "placeholder integrasi backend agentic … will be replaced by the external skills service." Go currently never calls Python's /chat/stream, /report, or any skill (no outbound HTTP to the agentic service exists in the Go source). So today there are two parallel, unconnected analysis stacks: Go's self-contained placeholder lifecycle (gate: β‰₯3 user messages; AI replies are canned) and Python's real agentic spine (router β†’ slow path β†’ records-based report; floor: β‰₯1 analyze_* success). Wiring Go β†’ Python is the open integration work (DEV_PLAN #7/#18/#25), plus reconciling the two different report gates.


13. Conventions & gotchas

  • Two Postgres engines: app engine + a separate PGVector engine (prepared_statement_cache_size=0) because PGVector emits multi-statement strings asyncpg rejects.
  • Identifiers vs values: identifiers come from the catalog and are inlined as quoted; filter values are always parameterized.
  • Settings aliases: .env uses double-underscore names (azureai__api_key__54m); Settings exposes them as azureai_api_key_54m.
  • LLM env quad renamed __4o β†’ __54m (2026-07-14). The generation LLM is GPT-5.4-mini; the four vars are azureai__api_key__54m, azureai__endpoint__url__54m, azureai__deployment__name__54m, azureai__api__version__54m. Hard rename β€” no __4o fallback, so an environment missing the new names fails loudly instead of silently serving a different model (which is exactly how HF drifted onto 4o while local ran 5.4-mini).
  • Shared Fernet key across repos (gotcha). User DB credentials in databases are written + encrypted by Go and decrypted by Python; both read the same env var dataeyond__db__credential__key (Go: configs/app.yaml β†’ credentials.fernet_key). The two deployments MUST hold the identical value or Python's decrypt throws cryptography.fernet.InvalidToken β€” whose str() is empty, so it logged as error="" and masqueraded as a DB-connection failure (the executor now logs repr(e) to expose it). Tell-apart: a valid-but-wrong key β†’ InvalidToken; a malformed key β†’ a non-empty ValueError at cipher build.
  • Storage-provider parity with Go (gotcha, found 2026-07-13). Go's data plane uploads tabular parquet to Supabase S3 and writes location_ref: object_storage://…; Python's TabularExecutor picks its download backend from settings.storage_provider (azure_blob | supabase_s3, blank β†’ Azure legacy). If the .env still says azure_blob, every tabular retrieve_data fails with an Azure BlobNotFound β€” and the never-throw path degrades it into an honest "data not available" answer, so it masquerades as a data problem. Tell-apart: BlobNotFound + location_ref starting object_storage:// β‡’ env gap; set storage_provider=supabase_s3 + the five supabase_s3_* values (match Go's data plane).
  • Never-throw seams are pervasive (tool invoker, query service, executors, state/catalog reads, record persistence, report summary). Failures degrade into soft output rather than raising β€” good for UX, but they can mask real breakage (e.g. a missing analysis-scope catalog silently falling back to the whole user catalog).
  • Prompts live in src/config/prompts/*.md. chatbot_system.md has guardrails.md appended so guardrails win on conflict.
  • Tests are gitignored (team decision) β€” run them locally.