From 05fe7000c5b7ebf67edc07f0cce2e5f91e660bec Mon Sep 17 00:00:00 2001
From: buenosairesam
The shape mirrors the JD's three building blocks - (text-to-SQL, reasoning agents, planners) as three distinct - layers rather than one omnibus prompt. That gives every part a name - and a tight contract, and lets each Analysis pick the simplest - reasoning pattern (CoT, ReAct, plan-and-execute) for what it does - instead of forcing one over everything. + (text-to-SQL, reasoning agents, planners) as distinct layers + rather than one omnibus prompt — Plan (L3), Analyses (L2), + and deterministic atomic actions (L1) — resting on a recon + foundation (L0): the dataset's typed knowledge graph. That gives every + part a name and a tight contract, and lets each Analysis pick the + simplest reasoning pattern (CoT, ReAct, plan-and-execute) for what it + does instead of forcing one over everything. +
++ The load-bearing decision: the LLM never authors SQL. It picks a + typed shape from finite, recon-derived sets; the composer walks recon and + emits the query deterministically. Column–table binding, joins, and + quoting are graph traversals, not model guesses — so a question the system + can't express fails cleanly instead of producing plausible-but-wrong SQL.
Three layers — Plan composes Analyses; Analyses are mini-agents; Tools are deterministic.
+Four layers — Plan composes Analyses; Analyses are mini-agents; atomic actions are deterministic; recon is the foundation they all consult.
text_to_sql, execute_sql, retrieve_schema, retrieve_metric_definition, python_sandbox. No LLM reasoning inside. Called from inside Analyses.run(args, question) → Finding. The LLM work lives here: choosing a typed Pick and interpreting rows, never writing SQL.compose(pick, recon) → SQL authors the query by walking recon's join graph; execute_sql runs it read-only with a row cap and timeout. Invalid SQL isn't expressible — the composer binds columns and joins from recon, not from model output.
+ The layers read general → specific (Plan → Analyses → atomic
+ actions), but execution is a graph, not a one-way cascade. Later steps
+ depend on earlier intermediate results: drill_down
+ loops, choosing its next dimension from the previous slice's rows; a
+ step's fallback Analysis fires based on whether the primary's
+ Finding came back empty or errored. And L0 recon is consulted at every
+ level — the planner reads its brief names, the Pick
+ validates against it, the composer walks it. So the layers are
+ interconnected through both the data they share (recon) and the results
+ they pass forward.
+
L3 — one LLM call that decides which Analyses to run.
- The planner gets the user question, the metric catalog, a schema
- overview, and the catalog of Analyses. It returns a structured plan:
- an ordered list of Analysis invocations with arguments and an intended
- interpretation. It does not call tools or query data itself — it's a
- composition step.
+ The planner (api/plan/planner.py) gets the user question
+ plus three context blocks rendered from recon: brief table names, brief
+ metric names, and the catalog of Analyses with their args_schema.
+ Deliberately no DDL — it decides which Analyses to run, not how to
+ query. One LLM call returns a structured Plan.
+
Each step in the plan carries:
+analysis — which Analysis to invoke (must be in the registry).args — the arguments dict, shaped by that Analysis's args_schema.why — a one-line rationale, surfaced in the live trace.fallback — an optional alternate Analysis the runtime runs if the primary returns empty or errors.+ The planner never calls a tool or touches data — it's a pure composition + step. The runtime (below) walks the steps in order.
-To be expanded as the planner is built out.
L2 — each Analysis is its own mini-agent.
- Analyses are the agent layer. Each one wraps a small piece of analytical
- intent (compare two periods, drill down by dimension,
- find outliers) and decides how to satisfy it — sometimes one
- LLM call is enough, sometimes a ReAct loop over Tool results is the
- right fit. The Analysis base class only enforces a uniform external
- contract (run(state) → Finding); the internal pattern is
- a per-Analysis decision.
+ Analyses are the agent layer. Each wraps a piece of analytical intent and
+ decides how to satisfy it — sometimes one LLM call is enough, sometimes a
+ ReAct loop over query results is the right fit. The base class
+ (api/analyses/base.py) enforces only a uniform external
+ contract, run(args, question) → Finding; the internal
+ pattern is a per-Analysis decision and no framework types leak out.
Per-Analysis pattern table coming as each Analysis is implemented.
+
+ Within an Analysis, a free-form question becomes a typed Pick
+ via one LLM call (pick_for_question) over recon-narrowed
+ candidate sets — that is the only place the model makes a structural
+ choice, and it chooses from finite known sets, not free text. The
+ Pick then goes to the deterministic composer (L1).
+
pick → compose → execute → interpret. For single-query lookups and aggregations.compose ×2 → execute ×2 → interpret the pair.decide_next (LLM) picks a dimension from the candidate list → build Pick → compose → execute → loop on the slice's results. Zero LLM in the slice path itself — only the dimension choice is a model decision, and it's guarded against the candidate list.L1 — deterministic primitives.
+L1 — deterministic, no LLM inside. The composer is the SQL author.
- Tools have no LLM reasoning inside them. text_to_sql is
- the one exception that uses an LLM, but tightly: schema-RAG context,
- sqlglot validation, retry on parse/runtime errors. execute_sql
- runs against Postgres read-only with a row cap and timeout.
- retrieve_schema and retrieve_metric_definition
- power the semantic layer. python_sandbox handles stats
- work that's awkward in SQL.
+ There is no LLM in this layer — and notably no text_to_sql
+ tool. An earlier design had the model author SQL with schema-RAG context,
+ sqlglot validation, and retries; it was removed once the composer landed
+ (see Decisions). The two atomic actions are:
+
compose(pick, recon) → SQL (api/composer/). Builds one sqlglot expression tree from a typed Pick by walking recon: resolve the metric's table, bind each group-by/filter column to its owning table, compute join paths, render filters and dates, emit quoted SQL. Split by concern — aliases, joins, filters, metrics, dates, order, encodings. If a Pick can't be expressed against recon it raises PickValidationError; it never fabricates SQL.execute_sql(sql) → rows (api/tools/execute_sql.py). Runs read-only against Postgres (SELECT/WITH only) with a row cap and a statement timeout, returning columns + rows + truncation metadata.+ Because column–table binding and joins come from recon rather than model + output, the composer cannot emit SQL that references a column that + doesn't exist. The validator that used to catch the model's mistakes after + the fact is demoted to a paranoid self-check on the composer. +
+L0 — the dataset's typed knowledge graph, and the only dataset-specific surface.
+
+ Recon (api/recon/) is the foundation every other layer
+ consults. It's built in two stages and cached as recon.json:
+
extracted_schema.json (auto, gitignored).api/datasets/<name>/ (schema_docs.yaml descriptions + relationships, metrics.yaml SQL fragments, optional encodings) merges onto the extracted schema.
+ The merged Recon exposes the queries downstream code needs
+ without going back to the model: resolve_column (bare or
+ table.col → owning table), join_path (shortest
+ table sequence between two tables), the metric catalog, and storage
+ encodings (e.g. how a YYMMDD-int column coerces to a date).
+ Rebuild with make recon after changing the warehouse or the
+ YAML.
+
+ Everything dataset-specific lives here. The Plan, Analyses, and atomic
+ actions reference no dataset name — point them at a different
+ api/datasets/<name>/ and the same machinery answers
+ questions over a new warehouse.
Per-Tool documentation coming as each Tool is implemented.
- Concretely: api/runtime/ contains the langgraph builder
- and event tap. api/plan/, api/analyses/, and
- api/tools/ contain the domain code — and those files
- don't expose langgraph types in their public interfaces.
+ Concretely the graph is small and linear:
+
START → plan → execute → synthesize → END
+
+ plan runs the planner; execute walks the plan
+ steps, dispatching each to its Analysis (and to a fallback Analysis when
+ the primary returns empty or errors); synthesize folds the
+ Findings into one answer. The whole run streams SSE events
+ (run_start, plan_ready,
+ analysis_start/end, tool_call_start/end,
+ analysis_fallback) that drive the live trace UI, and every
+ LLM call opens a named Langfuse generation span.
+
+ api/runtime/runner.py is the one place langgraph appears —
+ StateGraph is imported there and nowhere else.
+ api/plan/, api/analyses/,
+ api/composer/, and api/tools/ contain the
+ domain code, and none of those files expose langgraph types in their
+ public interfaces.
def/schema-as-constraint.md.)plan/, analyses/, tools/ own the interesting code and are readable without reading langgraph.financial as the warehouse. Fits Nivii's stated Finance vertical; ships with golden SQL pairs so the eval set is free and verifiable.lng project; reached over WireGuard. Same env var works in dev and any future deploy.BIRD financial dev set, run by evals/run_evals.py, results pushed to Langfuse tagged eval.
BIRD financial dev set, run by api/evals/run_evals.py (make evals), traces pushed to Langfuse.
To be populated as the agent stack lands.
+
+ BIRD ships golden question/SQL pairs, so the eval set is free and
+ verifiable. Each run replays questions through the full stack
+ (plan → analyses → compose → execute) and records the trace in Langfuse
+ for inspection — token usage per layer, which Analysis fired, the composed
+ SQL. The composer refactor's own check is the unit suite
+ (uv run pytest): golden SQL strings plus validation
+ rejections, so structural correctness is pinned independent of the model.
+
{{ shortPreview(t) }}
- {{ JSON.stringify(t.output.preview, null, 2) }}
- {{ shortPreview(t) }}
+ {{ JSON.stringify(t.output.preview, null, 2) }}
+