verbose live UI + tool-level SSE events + Groq default + regression tests

This commit is contained in:
2026-06-03 05:04:29 -03:00
parent 131f4d9b86
commit e124a8a7d9
69 changed files with 3030 additions and 137 deletions

15
.dockerignore Normal file
View File

@@ -0,0 +1,15 @@
.git
.venv
__pycache__
*.pyc
.pytest_cache
.claude
def
ui/node_modules
ui/framework/node_modules
ui/app/node_modules
ui/app/dist
.data
ctrl/seed/data
api/evals/data
node_modules

View File

@@ -4,6 +4,9 @@
# Warehouse
DATABASE_URL=postgresql://nvi:nvi@postgres:5432/nvi
# Active dataset (selects api/datasets/<name>/ + Postgres schema + sqlite file).
NVI_DATASET=financial
# LLM
ANTHROPIC_API_KEY=
ANTHROPIC_MODEL=claude-sonnet-4-6

5
.gitignore vendored
View File

@@ -13,3 +13,8 @@ dist
# soleprint-ui framework is propagated by spr; not tracked in nvi.
ui/framework
# Data: BIRD financial SQLite (~70MB) downloaded by ctrl/seed/download.sh,
# and the BIRD golden eval set. Never committed.
ctrl/seed/data
api/evals/data

View File

@@ -1,4 +1,4 @@
.PHONY: kind tilt-up tilt-down seed evals \
.PHONY: kind tilt-up tilt-down seed seed-fetch seed-push evals test \
compose-up compose-down compose-clean compose-seed compose-evals \
docs-graphs
@@ -17,11 +17,29 @@ tilt-up:
tilt-down:
cd ctrl && tilt down $(KCTX)
seed:
seed-fetch:
bash ctrl/seed/download.sh
# Copy the downloaded SQLite into the api pod. The file is excluded from the
# docker build context (.dockerignore) to keep the image small, so we push
# it explicitly the first time.
seed-push:
@POD=$$(kubectl $(KCTX) $(KNS) get pod -l app=api -o jsonpath='{.items[0].metadata.name}'); \
echo "pushing financial.sqlite → $$POD"; \
kubectl $(KCTX) $(KNS) exec $$POD -- mkdir -p /app/seed/data; \
kubectl $(KCTX) $(KNS) cp ctrl/seed/data/financial.sqlite $$POD:/app/seed/data/financial.sqlite
seed: seed-fetch seed-push
kubectl $(KCTX) $(KNS) exec deploy/api -- uv run python -m seed.load_bird
evals:
kubectl $(KCTX) $(KNS) exec deploy/api -- uv run python -m evals.run_evals
kubectl $(KCTX) $(KNS) exec deploy/api -- uv run python -m api.evals.run_evals
# Unit tests run on the host venv (no postgres/llm calls needed).
# Ensures dev extras are installed first; uv is idempotent on no-ops.
test:
uv sync --extra dev
uv run pytest -q
# ── docker-compose (alternative path) ──────────────────────
@@ -34,11 +52,11 @@ compose-down:
compose-clean:
$(COMPOSE) down -v
compose-seed:
compose-seed: seed-fetch
$(COMPOSE) exec api uv run python -m seed.load_bird
compose-evals:
$(COMPOSE) exec api uv run python -m evals.run_evals
$(COMPOSE) exec api uv run python -m api.evals.run_evals
# ── docs ───────────────────────────────────────────────────

0
api/analyses/__init__.py Normal file
View File

27
api/analyses/base.py Normal file
View File

@@ -0,0 +1,27 @@
"""Analysis base class.
An Analysis is a self-contained mini-agent. It owns its reasoning pattern
(CoT, ReAct, plan-and-execute) and exposes a uniform external contract:
`run(args, question) -> Finding`. The runtime composes Analyses; it doesn't
know how each one thinks internally.
Public surface is framework-free — no langgraph types leak out.
"""
from __future__ import annotations
from typing import Any, ClassVar
from api.analyses.types import Finding
class Analysis:
"""Subclass and set `name`, `description`, `args_schema`; then implement run()."""
name: ClassVar[str] = ""
description: ClassVar[str] = ""
# JSON-schema-ish description of the args dict — the planner reads this.
args_schema: ClassVar[dict[str, Any]] = {}
async def run(self, args: dict[str, Any], question: str) -> Finding:
raise NotImplementedError

View File

@@ -0,0 +1,141 @@
"""compare_periods Analysis — CoT with two queries.
Both SQL queries are generated in one LLM call (paired prompt), then both
executed, then a single interpretation pass diffs them. Single round-trip
per LLM step → cheap and bounded latency.
"""
from __future__ import annotations
import json
import logging
import re
from typing import Any
from api import langfuse_client as lf
from api.analyses.base import Analysis
from api.analyses.types import Finding
from api.llm import chat
from api.prompts import load, render
from api.runtime import events
from api.tools.execute_sql import execute_sql
from api.tools.schema import load_schema
logger = logging.getLogger("nvi.analyses.compare_periods")
class ComparePeriods(Analysis):
name = "compare_periods"
description = (
"Compare a metric across two time windows (e.g. Q2 vs Q3, 1995 vs 1996). "
"Generates two parallel SQL queries and diffs the results."
)
args_schema = {
"question": {"type": "string", "description": "The metric / scope to compare."},
"period_a": {"type": "string", "description": "First period label, e.g. '1995' or 'Q2 1996'."},
"period_b": {"type": "string", "description": "Second period label, e.g. '1996' or 'Q3 1996'."},
}
async def run(self, args: dict[str, Any], question: str) -> Finding:
sub_q = args.get("question") or question
period_a = args.get("period_a", "")
period_b = args.get("period_b", "")
with lf.span("analysis.compare_periods", input=args) as span:
try:
await events.publish_current(events.tool_call_start(
"generate_pair", input={"question": sub_q, "period_a": period_a, "period_b": period_b},
))
pair = _generate_pair(sub_q, period_a, period_b)
await events.publish_current(events.tool_call_end(
"generate_pair",
output={"a": {"label": pair["a"]["label"], "sql": pair["a"]["sql"]},
"b": {"label": pair["b"]["label"], "sql": pair["b"]["sql"]}},
))
await events.publish_current(events.tool_call_start("execute_sql", input={"label": pair["a"]["label"], "sql": pair["a"]["sql"]}))
res_a = execute_sql(pair["a"]["sql"])
await events.publish_current(events.tool_call_end(
"execute_sql",
output={"label": pair["a"]["label"], "row_count": res_a.row_count,
"preview": res_a.as_dicts()[:5]},
))
await events.publish_current(events.tool_call_start("execute_sql", input={"label": pair["b"]["label"], "sql": pair["b"]["sql"]}))
res_b = execute_sql(pair["b"]["sql"])
await events.publish_current(events.tool_call_end(
"execute_sql",
output={"label": pair["b"]["label"], "row_count": res_b.row_count,
"preview": res_b.as_dicts()[:5]},
))
interpret_system = load("compare_periods.interpret")
interpret_user = render(
"compare_periods.interpret.user",
question=sub_q,
label_a=pair["a"]["label"],
label_b=pair["b"]["label"],
rows_a=repr(res_a.as_dicts()[:20]),
rows_b=repr(res_b.as_dicts()[:20]),
)
await events.publish_current(events.llm_call(
"compare_periods.interpret",
system_len=len(interpret_system),
user_len=len(interpret_user),
))
summary = chat(system=interpret_system, user=interpret_user, max_tokens=512)
except Exception as e:
logger.exception("compare_periods failed")
await events.publish_current(events.tool_call_end("compare_periods", error=str(e)))
span.update(output={"error": str(e)})
return Finding(analysis=self.name, summary="", error=str(e))
span.update(output={"summary": summary})
return Finding(
analysis=self.name,
summary=summary,
rows=[
{"period": pair["a"]["label"], **r} for r in res_a.as_dicts()[:20]
] + [
{"period": pair["b"]["label"], **r} for r in res_b.as_dicts()[:20]
],
sql=[pair["a"]["sql"], pair["b"]["sql"]],
metadata={
"label_a": pair["a"]["label"],
"label_b": pair["b"]["label"],
"row_count_a": res_a.row_count,
"row_count_b": res_b.row_count,
},
)
def _generate_pair(question: str, period_a: str, period_b: str) -> dict[str, dict[str, str]]:
schema = load_schema()
text = chat(
system=load("compare_periods.pair"),
user=render(
"compare_periods.pair.user",
question=question,
period_a=period_a or "(infer)",
period_b=period_b or "(infer)",
schema_block=schema.render_tables(),
metrics_block=schema.render_metrics(),
),
max_tokens=1024,
)
obj = json.loads(_extract_json(text))
for k in ("a", "b"):
if k not in obj or "sql" not in obj[k] or "label" not in obj[k]:
raise ValueError(f"pair generator returned malformed payload: missing {k}")
obj[k]["sql"] = obj[k]["sql"].strip().rstrip(";").strip()
return obj
def _extract_json(text: str) -> str:
m = re.search(r"```(?:json)?\s*(\{.*\})\s*```", text, re.DOTALL)
if m:
return m.group(1)
start, end = text.find("{"), text.rfind("}")
if start >= 0 and end > start:
return text[start:end + 1]
return text

View File

@@ -0,0 +1,81 @@
"""direct_answer Analysis — CoT, one-shot.
Pattern: text-to-SQL → execute → ask the LLM to interpret the rows. No loop.
For questions that resolve to a single SQL query and a short interpretation.
"""
from __future__ import annotations
import logging
from typing import Any
from api import langfuse_client as lf
from api.analyses.base import Analysis
from api.analyses.types import Finding
from api.llm import chat
from api.prompts import load, render
from api.runtime import events
from api.tools.execute_sql import execute_sql
from api.tools.text_to_sql import text_to_sql
logger = logging.getLogger("nvi.analyses.direct_answer")
class DirectAnswer(Analysis):
name = "direct_answer"
description = (
"Answer a question that resolves to a single SQL query. Best for "
"lookup / aggregation questions with a single, well-defined metric."
)
args_schema = {
"question": {"type": "string", "description": "Refined question this Analysis should answer."},
"hint_tables": {"type": "array", "items": "string", "description": "Tables the planner thinks are relevant (optional)."},
}
async def run(self, args: dict[str, Any], question: str) -> Finding:
sub_q = args.get("question") or question
hint_tables = args.get("hint_tables")
with lf.span("analysis.direct_answer", input={"question": sub_q}) as span:
try:
await events.publish_current(events.tool_call_start("text_to_sql", input={"question": sub_q}))
t2s = text_to_sql(sub_q, hint_tables=hint_tables)
await events.publish_current(events.tool_call_end(
"text_to_sql", output={"sql": t2s.sql, "tables": t2s.used_tables},
))
await events.publish_current(events.tool_call_start("execute_sql", input={"sql": t2s.sql}))
result = execute_sql(t2s.sql)
await events.publish_current(events.tool_call_end(
"execute_sql",
output={"row_count": result.row_count, "truncated": result.truncated,
"preview": result.as_dicts()[:5]},
))
interpret_system = load("direct_answer.interpret")
interpret_user = render(
"direct_answer.interpret.user",
question=sub_q,
sql=t2s.sql,
rows=repr(result.as_dicts()[:20]),
)
await events.publish_current(events.llm_call(
"direct_answer.interpret",
system_len=len(interpret_system),
user_len=len(interpret_user),
))
summary = chat(system=interpret_system, user=interpret_user, max_tokens=512)
except Exception as e:
logger.exception("direct_answer failed")
await events.publish_current(events.tool_call_end("direct_answer", error=str(e)))
span.update(output={"error": str(e)})
return Finding(analysis=self.name, summary="", error=str(e))
span.update(output={"summary": summary, "rows": result.row_count})
return Finding(
analysis=self.name,
summary=summary,
rows=result.as_dicts()[:20],
sql=[t2s.sql],
metadata={"row_count": result.row_count, "truncated": result.truncated},
)

30
api/analyses/registry.py Normal file
View File

@@ -0,0 +1,30 @@
"""Analysis registry — the planner reads this to know what's available."""
from __future__ import annotations
from typing import Any
from api.analyses.base import Analysis
from api.analyses.compare_periods import ComparePeriods
from api.analyses.direct_answer import DirectAnswer
REGISTRY: dict[str, Analysis] = {
DirectAnswer.name: DirectAnswer(),
ComparePeriods.name: ComparePeriods(),
}
def get(name: str) -> Analysis | None:
return REGISTRY.get(name)
def catalog() -> list[dict[str, Any]]:
"""Plain-dict catalog for prompt injection into the planner."""
return [
{
"name": a.name,
"description": a.description,
"args_schema": a.args_schema,
}
for a in REGISTRY.values()
]

17
api/analyses/types.py Normal file
View File

@@ -0,0 +1,17 @@
"""Public data shapes for the Analyses layer."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
@dataclass
class Finding:
"""Structured output every Analysis returns."""
analysis: str
summary: str
rows: list[dict[str, Any]] = field(default_factory=list)
sql: list[str] = field(default_factory=list)
error: str | None = None
metadata: dict[str, Any] = field(default_factory=dict)

View File

@@ -6,10 +6,26 @@ from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str = "postgresql://nvi:nvi@postgres:5432/nvi"
# Active dataset. Matches: api/datasets/<name>/, the Postgres schema, and
# ctrl/seed/data/<name>.sqlite. Restart the api to switch.
dataset: str = "financial"
# LLM provider. Supported: "groq", "anthropic", "openai".
llm_provider: str = "groq"
anthropic_api_key: str = ""
anthropic_model: str = "claude-sonnet-4-6"
langfuse_host: str = "http://lng.local.ar:3000"
# Groq is OpenAI-API-compatible; we hit it via the openai SDK with a base_url override.
groq_api_key: str = ""
groq_model: str = "llama-3.3-70b-versatile"
groq_base_url: str = "https://api.groq.com/openai/v1"
openai_api_key: str = ""
openai_model: str = "gpt-4o-mini"
openai_base_url: str = "https://api.openai.com/v1"
langfuse_host: str = "http://lng.local.ar"
langfuse_public_key: str = ""
langfuse_secret_key: str = ""

23
api/datasets/README.md Normal file
View File

@@ -0,0 +1,23 @@
# Datasets
One subdirectory per dataset. The folder name is the dataset's identity —
used as:
- the `NVI_DATASET` env var value that selects it,
- the Postgres schema where its tables live,
- the SQLite filename in `ctrl/seed/data/<name>.sqlite` that the loader
reads from.
## Adding a new dataset
1. Place the source SQLite at `ctrl/seed/data/<name>.sqlite` (or adapt
`ctrl/seed/download.sh` to fetch it).
2. Create `api/datasets/<name>/`:
- `schema_docs.yaml` — table and column descriptions.
- `metrics.yaml` — canonical SQL for business terms.
3. Set `NVI_DATASET=<name>` and restart the api.
4. `make seed` will load `<name>.sqlite` into Postgres schema `<name>`.
The Plan/Analyses/Tools/Runtime code references no dataset name; only the
loader (`api.tools.schema.load_schema`) and the engine (`api.tools.db`)
consult the active-dataset setting.

40
api/datasets/__init__.py Normal file
View File

@@ -0,0 +1,40 @@
"""Dataset registry — the only dataset-specific surface in api/.
Each subdirectory is one dataset. The folder name doubles as the Postgres
schema name, the SQLite filename under `ctrl/seed/data/<name>.sqlite`, and
the value of the `NVI_DATASET` env var that selects it.
Inside each dataset folder:
schema_docs.yaml — human descriptions for tables/columns
metrics.yaml — canonical SQL fragments for business terms
Both files are consumed by `api.tools.schema.load_schema()`. The Plan,
Analyses, Tools, and Runtime code reference no dataset name — they read
the active dataset through `settings.dataset`.
"""
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
from typing import Any
import yaml
from api.config import get_settings
DATASETS_DIR = Path(__file__).resolve().parent
def active_dir() -> Path:
return DATASETS_DIR / get_settings().dataset
def available() -> list[str]:
return sorted(p.name for p in DATASETS_DIR.iterdir() if p.is_dir() and not p.name.startswith("_"))
@lru_cache(maxsize=None)
def read_yaml(dataset: str, filename: str) -> dict[str, Any]:
"""Load one YAML file from a dataset folder. Cached per (dataset, file)."""
return yaml.safe_load((DATASETS_DIR / dataset / filename).read_text()) or {}

View File

@@ -0,0 +1,50 @@
# Domain metrics for the financial schema. The planner reads this so it can
# resolve a business term ("default rate", "loan volume") to a canonical SQL
# expression instead of guessing.
schema: financial
metrics:
loan_default_rate:
description: Fraction of finished loans that ended in default (status 'B'). Excludes still-running loans.
sql: "AVG(CASE WHEN status = 'B' THEN 1.0 WHEN status = 'A' THEN 0.0 END)"
from_table: loan
filter: "status IN ('A','B')"
unit: ratio
at_risk_loan_share:
description: Of currently running loans, the share that are in debt (status 'D').
sql: "AVG(CASE WHEN status = 'D' THEN 1.0 WHEN status = 'C' THEN 0.0 END)"
from_table: loan
filter: "status IN ('C','D')"
unit: ratio
loan_volume:
description: Total CZK lent.
sql: "SUM(amount)"
from_table: loan
unit: CZK
average_loan_amount:
description: Mean loan size in CZK.
sql: "AVG(amount)"
from_table: loan
unit: CZK
transaction_volume:
description: Total CZK moved through transactions (credits + debits, absolute).
sql: "SUM(amount)"
from_table: trans
unit: CZK
active_accounts:
description: Distinct accounts with at least one transaction in the period.
sql: "COUNT(DISTINCT account_id)"
from_table: trans
unit: count
unemployment_rate_96:
description: District unemployment rate in 1996 (already a percentage).
sql: "A13"
from_table: district
unit: percent

View File

@@ -0,0 +1,93 @@
# Per-table / per-column human descriptions for the BIRD `financial` schema
# (PKDD'99 Czech bank). The text-to-SQL tool prepends the relevant subset to
# the LLM prompt so it can disambiguate cryptic column names like A2, A3, etc.
schema: financial
tables:
account:
description: One row per bank account. The pivot table — most facts hang off account_id.
columns:
account_id: Surrogate primary key for the account.
district_id: Geographic district the account belongs to (joins to district).
frequency: Statement issuance frequency. "POPLATEK MESICNE" = monthly, "POPLATEK TYDNE" = weekly, "POPLATEK PO OBRATU" = on transaction.
date: Date the account was opened (YYMMDD as integer).
client:
description: One row per bank customer.
columns:
client_id: Surrogate primary key for the client.
gender: '"M" or "F". Derived from birth_number.'
birth_date: Date of birth, normalised. Original birth_number encoded gender by adding 50 to the month field for women.
district_id: District the client lives in (joins to district).
disp:
description: Disposition — many-to-many link between client and account. Type tells you whether the client is the OWNER or just a USER of the account.
columns:
disp_id: Surrogate key.
client_id: Joins to client.
account_id: Joins to account.
type: '"OWNER" or "DISPONENT" (USER). Only owners can request loans / cards.'
district:
description: Demographic and economic data for each Czech district. The A-columns are infamous — most analysis questions referencing demographics use these.
columns:
district_id: Surrogate key.
A2: District name.
A3: Region (a grouping of districts).
A4: Number of inhabitants.
A5: Number of municipalities with population < 499.
A6: Number of municipalities with population 500-1999.
A7: Number of municipalities with population 2000-9999.
A8: Number of municipalities with population > 10000.
A9: Number of cities.
A10: Ratio of urban inhabitants (percent).
A11: Average salary (in CZK).
A12: Unemployment rate in 1995 (percent). NULL means missing data.
A13: Unemployment rate in 1996 (percent).
A14: Number of entrepreneurs per 1000 inhabitants.
A15: Number of crimes committed in 1995. NULL means missing data.
A16: Number of crimes committed in 1996.
loan:
description: One row per loan granted to an account.
columns:
loan_id: Surrogate key.
account_id: The account the loan was granted to.
date: Loan grant date (YYMMDD as integer).
amount: Total amount of the loan (CZK).
duration: Loan duration in months.
payments: Monthly payment (CZK).
status: Loan status code. "A" = contract finished, no problems. "B" = contract finished, loan not paid (DEFAULT). "C" = running contract, payments on time. "D" = running contract, client in debt (AT RISK). Defaults are A,B finished or B,D paid-flag attached.
card:
description: One row per credit/debit card issued to a disposition.
columns:
card_id: Surrogate key.
disp_id: The disposition (and via it, the account) the card belongs to.
type: '"junior", "classic", or "gold".'
issued: Date the card was issued.
trans:
description: Transaction history. Largest table by far (~1M rows). Every credit, debit, transfer.
columns:
trans_id: Surrogate key.
account_id: The account the transaction belongs to.
date: Transaction date (YYMMDD as integer).
type: '"PRIJEM" (credit) or "VYDAJ" (debit).'
operation: Mode of operation, e.g. "VKLAD" (cash credit), "VYBER" (cash withdrawal), "PREVOD Z UCTU" (transfer from another bank), etc.
amount: Amount (CZK).
balance: Account balance after the transaction.
k_symbol: Characterisation of the payment. "POJISTNE" = insurance, "SLUZBY" = household, "UROK" = interest credited, "SANKC. UROK" = penalty interest, "SIPO" = household payment, "DUCHOD" = old-age pension, "UVER" = loan payment.
bank: Counterparty bank code (only set for inter-bank transfers).
account: Counterparty account number (only set for inter-bank transfers).
"order":
description: Standing payment orders (recurring debits). Note the table name collides with a SQL keyword and must be quoted.
columns:
order_id: Surrogate key.
account_id: The originating account.
bank_to: Recipient bank.
account_to: Recipient account.
amount: Order amount (CZK).
k_symbol: Purpose, same vocabulary as trans.k_symbol.

0
api/evals/__init__.py Normal file
View File

11
api/evals/run_evals.py Normal file
View File

@@ -0,0 +1,11 @@
"""BIRD `financial` golden-set runner.
Stub — the eval harness lands once the Plan + Analyses + Tools are in place.
"""
def main() -> None:
raise SystemExit("api/evals/run_evals.py is a stub; agent stack not yet built")
if __name__ == "__main__":
main()

87
api/langfuse_client.py Normal file
View File

@@ -0,0 +1,87 @@
"""Langfuse SDK wrapper — single source of truth for the client and spans.
Used by every layer (tools, analyses, plan, runtime, evals), which is why it
lives at the api/ root rather than under any one layer.
Gracefully returns no-op spans when Langfuse keys aren't configured, so local
dev without Langfuse still works.
"""
from __future__ import annotations
import logging
from contextlib import contextmanager
from typing import Any, Iterator
from api.config import get_settings
logger = logging.getLogger("nvi.langfuse")
_client: Any = None
def get_client() -> Any | None:
global _client
if _client is not None:
return _client
s = get_settings()
if not s.langfuse_public_key or not s.langfuse_secret_key:
return None
try:
from langfuse import Langfuse
_client = Langfuse(
public_key=s.langfuse_public_key,
secret_key=s.langfuse_secret_key,
host=s.langfuse_host,
)
return _client
except Exception as e:
logger.warning("langfuse init failed: %s", e)
return None
class _NullSpan:
"""No-op stand-in when Langfuse isn't configured."""
def update(self, **_: Any) -> None: ...
def end(self, **_: Any) -> None: ...
def __enter__(self) -> "_NullSpan": return self
def __exit__(self, *_: Any) -> None: ...
@contextmanager
def span(
name: str,
*,
as_type: str = "span",
input: Any = None,
metadata: dict[str, Any] | None = None,
) -> Iterator[Any]:
"""Open a Langfuse observation; yields the span object or a no-op.
Exceptions raised by the *body* of the `with` block propagate up
untouched. Only Langfuse's own setup failures fall through to a no-op
span — we never want to mask a real error.
"""
lf = get_client()
if lf is None:
yield _NullSpan()
return
try:
cm = lf.start_as_current_observation(
name=name, as_type=as_type, input=input, metadata=metadata or {}
)
except Exception as e:
logger.warning("langfuse setup for %s failed: %s", name, e)
yield _NullSpan()
return
with cm as s:
yield s
def flush() -> None:
lf = get_client()
if lf is not None:
try:
lf.flush()
except Exception:
pass

91
api/llm.py Normal file
View File

@@ -0,0 +1,91 @@
"""LLM client — single `chat()` entry point that dispatches to the active provider.
Provider selection is via `settings.llm_provider`. Supported:
- "groq" — hits Groq via the openai SDK (groq_base_url + groq_api_key).
- "anthropic" — native Anthropic SDK.
- "openai" — openai SDK with the standard base_url.
The Anthropic and OpenAI/Groq clients are cached so HTTP connection pools
are reused across calls.
"""
from __future__ import annotations
from functools import lru_cache
from typing import Callable
from anthropic import Anthropic
from openai import OpenAI
from api.config import get_settings
# ── Provider clients (cached) ──
@lru_cache(maxsize=1)
def _anthropic() -> Anthropic:
return Anthropic(api_key=get_settings().anthropic_api_key)
@lru_cache(maxsize=1)
def _groq() -> OpenAI:
s = get_settings()
return OpenAI(api_key=s.groq_api_key, base_url=s.groq_base_url)
@lru_cache(maxsize=1)
def _openai() -> OpenAI:
s = get_settings()
return OpenAI(api_key=s.openai_api_key, base_url=s.openai_base_url)
# ── Per-provider chat impls ──
def _chat_anthropic(system: str, user: str, max_tokens: int) -> str:
s = get_settings()
msg = _anthropic().messages.create(
model=s.anthropic_model,
max_tokens=max_tokens,
system=system,
messages=[{"role": "user", "content": user}],
)
return "".join(b.text for b in msg.content if getattr(b, "type", None) == "text").strip()
def _chat_openai_compat(client: OpenAI, model: str, system: str, user: str, max_tokens: int) -> str:
resp = client.chat.completions.create(
model=model,
max_tokens=max_tokens,
messages=[
{"role": "system", "content": system},
{"role": "user", "content": user},
],
)
return (resp.choices[0].message.content or "").strip()
def _chat_groq(system: str, user: str, max_tokens: int) -> str:
return _chat_openai_compat(_groq(), get_settings().groq_model, system, user, max_tokens)
def _chat_openai(system: str, user: str, max_tokens: int) -> str:
return _chat_openai_compat(_openai(), get_settings().openai_model, system, user, max_tokens)
_PROVIDERS: dict[str, Callable[[str, str, int], str]] = {
"groq": _chat_groq,
"anthropic": _chat_anthropic,
"openai": _chat_openai,
}
# ── Public surface ──
def chat(*, system: str, user: str, max_tokens: int = 1024) -> str:
provider = get_settings().llm_provider.lower()
impl = _PROVIDERS.get(provider)
if impl is None:
raise ValueError(
f"unknown llm_provider {provider!r}; supported: {sorted(_PROVIDERS)}"
)
return impl(system, user, max_tokens)

View File

@@ -1,14 +1,14 @@
"""FastAPI entry point for nvi.
Endpoints:
GET /healthz — liveness / readiness probe
GET /healthz — liveness probe
POST /ask — submit a question, returns run_id
GET /runs/{run_id}/stream — SSE stream of node-state events
GET /runs/{run_id}/stream — SSE stream of run events
GET /runs/{run_id} — final state snapshot
Stubs only at this point — the Plan/Analyses/Tools/runtime are next.
"""
from __future__ import annotations
import asyncio
import logging
import uuid
@@ -20,12 +20,15 @@ from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
from api.runtime import events
from api.runtime.runner import run as run_graph
logger = logging.getLogger("nvi")
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
# ── In-memory run store (single-process; replace with redis if we ever scale) ──
# In-memory run store. Each entry holds final-state + lifecycle metadata; the
# event queue is owned by api.runtime.events.
runs: dict[str, dict] = {}
RUN_TTL_SECONDS = 3600
RUN_CLEANUP_INTERVAL = 300
@@ -52,7 +55,6 @@ async def lifespan(_app: FastAPI):
app = FastAPI(title="nvi", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
@@ -66,8 +68,6 @@ async def healthz():
return {"status": "ok", "runs_in_memory": len(runs)}
# ── Ask ──
class AskRequest(BaseModel):
question: str
@@ -77,14 +77,31 @@ async def ask(req: AskRequest):
run_id = uuid.uuid4().hex[:8]
now = datetime.now(timezone.utc)
runs[run_id] = {
"status": "pending",
"status": "running",
"question": req.question,
"created_at": now.timestamp(),
"events": [],
}
# Real runtime invocation lands here once it exists.
events.open_run(run_id)
async def _drive():
try:
final = await run_graph(run_id, req.question)
runs[run_id] = {
**runs[run_id],
"status": "error" if final.get("error") else "completed",
"plan_rationale": final.get("plan_rationale"),
"plan_steps": final.get("plan_steps", []),
"findings": final.get("findings", []),
"answer": final.get("answer", ""),
"error": final.get("error"),
}
except Exception as e:
logger.exception("run %s failed at top level", run_id)
runs[run_id] = {**runs[run_id], "status": "error", "error": str(e)}
asyncio.create_task(_drive())
logger.info("ask_received run_id=%s q=%r", run_id, req.question)
return {"run_id": run_id, "status": "pending"}
return {"run_id": run_id, "status": "running"}
@app.get("/runs/{run_id}")
@@ -98,11 +115,4 @@ async def get_run(run_id: str):
async def stream_run(run_id: str):
if run_id not in runs:
raise HTTPException(404, detail=f"Run {run_id} not found")
async def event_stream():
# Placeholder: real implementation will tail the runtime's event queue.
yield f"event: hello\ndata: {{\"run_id\": \"{run_id}\"}}\n\n"
await asyncio.sleep(0.1)
yield "event: end\ndata: {}\n\n"
return StreamingResponse(event_stream(), media_type="text/event-stream")
return StreamingResponse(events.stream(run_id), media_type="text/event-stream")

0
api/plan/__init__.py Normal file
View File

52
api/plan/planner.py Normal file
View File

@@ -0,0 +1,52 @@
"""Plan layer — one LLM call that selects Analyses for a question.
Public surface is framework-free. Returns a `Plan` that the runtime iterates.
"""
from __future__ import annotations
import json
import logging
import re
from typing import Any
from api import langfuse_client as lf
from api.analyses.registry import catalog
from api.llm import chat
from api.plan.types import Plan
from api.prompts import load, render
from api.tools.schema import load_schema
logger = logging.getLogger("nvi.plan.planner")
def plan(question: str) -> Plan:
schema = load_schema()
user = render(
"planner.user",
question=question,
table_names=", ".join(schema.table_names()),
metrics_block=schema.render_metrics(),
catalog=json.dumps(catalog(), indent=2),
)
with lf.span("plan", as_type="generation", input={"question": question}) as span:
result = Plan.from_dict(
_parse(chat(system=load("planner.system"), user=user, max_tokens=1024))
)
span.update(output={
"rationale": result.rationale,
"steps": [s.to_dict() for s in result.steps],
})
if not result.steps:
raise ValueError("planner returned empty plan")
return result
def _parse(text: str) -> dict[str, Any]:
m = re.search(r"```(?:json)?\s*(\{.*\})\s*```", text, re.DOTALL)
raw = m.group(1) if m else text
start, end = raw.find("{"), raw.rfind("}")
if start < 0 or end <= start:
raise ValueError(f"planner returned no JSON: {text[:200]!r}")
return json.loads(raw[start:end + 1])

37
api/plan/types.py Normal file
View File

@@ -0,0 +1,37 @@
"""Public data shapes for the Plan layer."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
@dataclass
class PlanStep:
analysis: str
args: dict[str, Any] = field(default_factory=dict)
why: str = ""
@classmethod
def from_dict(cls, raw: dict[str, Any]) -> "PlanStep":
return cls(
analysis=raw["analysis"],
args=raw.get("args") or {},
why=raw.get("why", ""),
)
def to_dict(self) -> dict[str, Any]:
return {"analysis": self.analysis, "args": self.args, "why": self.why}
@dataclass
class Plan:
rationale: str
steps: list[PlanStep]
@classmethod
def from_dict(cls, raw: dict[str, Any]) -> "Plan":
return cls(
rationale=raw.get("rationale", ""),
steps=[PlanStep.from_dict(s) for s in raw.get("steps", [])],
)

31
api/prompts/__init__.py Normal file
View File

@@ -0,0 +1,31 @@
"""Prompt loader + renderer.
Every prompt — system or user — lives as a `.txt` file in this directory.
Two ways to consume them:
- `load(name)` returns the file's text unchanged. Use for system prompts
(and any user prompt that has no placeholders).
- `render(name, **vars)` loads then runs `str.format(**vars)` on it. Use
for user prompts that interpolate question/SQL/rows/findings/etc.
Placeholders are standard Python format syntax: `{name}`. Literal braces
must be doubled: `{{` / `}}`.
"""
from __future__ import annotations
from functools import lru_cache
from pathlib import Path
from typing import Any
PROMPTS_DIR = Path(__file__).resolve().parent
@lru_cache(maxsize=None)
def load(name: str) -> str:
"""Read the named prompt file (no substitution)."""
return (PROMPTS_DIR / f"{name}.txt").read_text(encoding="utf-8").strip()
def render(name: str, /, **vars: Any) -> str:
"""Load a prompt and substitute `{placeholder}` values from kwargs."""
return load(name).format(**vars)

View File

@@ -0,0 +1,5 @@
Task: compare two SQL result sets representing the same metric across two periods.
Output: two to four sentences. Lead with the most material difference and cite specific numbers from both periods (e.g. "61 in 1995 → 87 in 1996, +43%"). No preamble.
If the rows look essentially unchanged, say so plainly. If one period is empty, call that out.

View File

@@ -0,0 +1,7 @@
Question: {question}
Period A ({label_a}):
{rows_a}
Period B ({label_b}):
{rows_b}

View File

@@ -0,0 +1,16 @@
Task: build two parallel Postgres SELECT queries that compute the same metric across two different time windows, so an analyst can compare them.
Output: a JSON object with exactly this shape, in one fenced ```json block:
{
"a": {"label": "<short period label, e.g. '1995'>", "sql": "<SELECT …>"},
"b": {"label": "<short period label, e.g. '1996'>", "sql": "<SELECT …>"}
}
Constraints:
- The two queries must return result sets with the same columns and shape — otherwise the diff is meaningless.
- Dates are stored as YYMMDD integers; convert with TO_DATE(LPAD(date::text, 6, '0'), 'YYMMDD') when filtering by date.
- Quote `"order"` if you reference it.
- search_path is `financial`; don't qualify table names with the schema.
- Read-only.
If either period is given as "(infer)", pick a sensible window from the question and reflect it in the label.

View File

@@ -0,0 +1,10 @@
Question: {question}
Period A: {period_a}
Period B: {period_b}
Schema:
{schema_block}
Metrics:
{metrics_block}

View File

@@ -0,0 +1,5 @@
Task: answer the analyst's question given the SQL that was run and the result rows.
Output: one to three sentences that directly answer the question, citing specific numbers from the rows. No preamble ("Here is…", "Based on the results…"), no SQL block, no enumeration of every row.
If the result is empty or doesn't actually answer the question as asked, say so plainly and stop.

View File

@@ -0,0 +1,7 @@
Question: {question}
SQL:
{sql}
Rows (up to 20):
{rows}

View File

@@ -0,0 +1,17 @@
Task: pick which Analyses to run to answer the analyst's question.
Given the question, the list of warehouse tables, the metric catalog, and the catalog of available Analyses (each with a name, description, and args schema), choose the smallest set of Analyses whose combined output will produce a defensible answer.
Output: a JSON object with exactly this shape, in one fenced ```json block:
{
"rationale": "<one sentence on why this plan>",
"steps": [
{"analysis": "<analysis_name>", "args": { ... }, "why": "<one sentence>"}
]
}
Rules:
- Only use analyses that appear in the catalog. If none fits the question well, pick `direct_answer`.
- The `args` keys must match the analysis's args_schema; additional keys are ignored, missing optional ones are fine.
- Most questions are single-step. Only chain analyses when the second genuinely needs the first's output.
- Comparing two periods is ONE `compare_periods` step, not two `direct_answer` steps.

View File

@@ -0,0 +1,9 @@
Question: {question}
Warehouse tables: {table_names}
Metric catalog:
{metrics_block}
Analysis catalog:
{catalog}

View File

@@ -0,0 +1,7 @@
Task: write a final answer for the analyst given the original question and a list of Analysis findings.
Each finding has an Analysis name, its one-line interpretation, and the SQL it ran.
Output: a single coherent paragraph of two to five sentences that directly answers the question, weaving together the relevant findings. Cite specific numbers. Don't list the SQL, don't enumerate the Analyses by name, don't add any "Summary:" header or preamble.
If every finding errored, say plainly that the question couldn't be answered and briefly mention why (e.g. "the planner picked an Analysis that couldn't resolve the metric").

View File

@@ -0,0 +1,4 @@
Question: {question}
Findings:
{findings_block}

View File

@@ -0,0 +1,15 @@
Task: translate the analyst's question into a single Postgres SELECT (or WITH … SELECT) against the BIRD `financial` schema (PKDD'99 Czech bank).
Output: exactly one fenced ```sql block, nothing else — no prose, no second block, no trailing semicolon required.
Constraints:
- search_path is set to `financial`; don't qualify table names with the schema.
- Quote identifiers that collide with SQL keywords (notably `"order"`).
- Dates in the source are YYMMDD integers (e.g. 950315 → 1995-03-15). Convert with TO_DATE(LPAD(date::text, 6, '0'), 'YYMMDD') whenever you need a real date for filtering, comparison, or display.
- Loan `status` values: 'A' finished OK, 'B' finished defaulted, 'C' running OK, 'D' running in debt.
- Transaction `type` values: 'PRIJEM' credit, 'VYDAJ' debit.
- Read-only: never emit DDL or DML.
When the question maps to a metric in the catalog you're given, copy the metric's SQL fragment verbatim — don't paraphrase it.
If the question is ambiguous, pick the most defensible interpretation and proceed; don't ask for clarification.

View File

@@ -0,0 +1,10 @@
Question:
{question}
Schema (only the tables that look relevant; you may use any of these):
{schema_block}
Metric catalog (copy the SQL verbatim when the question maps to one):
{metrics_block}
Return only the SQL in a ```sql fenced block.{retry_hint}

0
api/runtime/__init__.py Normal file
View File

12
api/runtime/context.py Normal file
View File

@@ -0,0 +1,12 @@
"""Per-run context — exposes the active run_id to code deep in the call stack
without threading it through every signature.
Set by the runtime at run entry; read by Analyses and helper functions so they
can publish trace events without needing the run_id passed in explicitly.
"""
from __future__ import annotations
from contextvars import ContextVar
current_run_id: ContextVar[str | None] = ContextVar("nvi.current_run_id", default=None)

133
api/runtime/events.py Normal file
View File

@@ -0,0 +1,133 @@
"""Per-run event bus + factories + SSE formatting.
Tiny in-memory queue keyed by run_id. The runtime publishes on each
transition; the FastAPI SSE endpoint subscribes and forwards.
Event payloads are constructed via the module-level factory functions below
so the dict shape lives in one place and is grep-able.
"""
from __future__ import annotations
import asyncio
import json
from typing import Any
from api.analyses.types import Finding
from api.runtime.context import current_run_id
# run_id -> queue of events. Events are plain dicts.
_queues: dict[str, asyncio.Queue[dict[str, Any] | None]] = {}
# ── Queue management ──
def open_run(run_id: str) -> asyncio.Queue:
q: asyncio.Queue = asyncio.Queue()
_queues[run_id] = q
return q
async def publish(run_id: str, event: dict[str, Any]) -> None:
q = _queues.get(run_id)
if q is not None:
await q.put(event)
async def publish_current(event: dict[str, Any]) -> None:
"""Publish to the run identified by the `current_run_id` contextvar.
No-op when called outside a run."""
rid = current_run_id.get()
if rid is not None:
await publish(rid, event)
async def close(run_id: str) -> None:
q = _queues.get(run_id)
if q is not None:
await q.put(None)
def drop(run_id: str) -> None:
_queues.pop(run_id, None)
# ── SSE formatting + streaming ──
def sse_format(event: dict[str, Any]) -> str:
kind = event.get("type", "message")
payload = json.dumps({k: v for k, v in event.items() if k != "type"}, default=str)
return f"event: {kind}\ndata: {payload}\n\n"
async def stream(run_id: str):
"""Async generator producing SSE-formatted strings until the run ends."""
q = _queues.get(run_id)
if q is None:
yield sse_format({"type": "error", "message": f"unknown run_id {run_id}"})
return
try:
while True:
event = await q.get()
if event is None:
yield sse_format({"type": "end"})
return
yield sse_format(event)
finally:
drop(run_id)
# ── Event factories ──
# Each returns the dict payload to pass into `publish()` /
# `publish_current()`. Names mirror the `type` field for grep-ability.
def run_start(question: str) -> dict[str, Any]:
return {"type": "run_start", "question": question}
def run_end(*, answer: str | None = None, error: str | None = None) -> dict[str, Any]:
return {"type": "run_end", "answer": answer or "", "error": error}
def plan_ready(rationale: str, steps: list[dict[str, Any]]) -> dict[str, Any]:
return {"type": "plan_ready", "rationale": rationale, "steps": steps}
def node_update(node: str, update: Any) -> dict[str, Any]:
return {
"type": "node_update",
"node": node,
"keys": sorted(update.keys()) if isinstance(update, dict) else [],
}
def analysis_start(name: str, args: dict[str, Any], why: str) -> dict[str, Any]:
return {"type": "analysis_start", "analysis": name, "args": args, "why": why}
def analysis_end(name: str, finding: Finding) -> dict[str, Any]:
return {
"type": "analysis_end",
"analysis": name,
"summary": finding.summary,
"error": finding.error,
"row_count": len(finding.rows),
}
def synth_start() -> dict[str, Any]:
return {"type": "synth_start"}
def tool_call_start(tool: str, *, input: Any = None) -> dict[str, Any]:
return {"type": "tool_call_start", "tool": tool, "input": input}
def tool_call_end(tool: str, *, output: Any = None, error: str | None = None) -> dict[str, Any]:
return {"type": "tool_call_end", "tool": tool, "output": output, "error": error}
def llm_call(label: str, *, system_len: int, user_len: int) -> dict[str, Any]:
"""Lightweight breadcrumb for an LLM call. Doesn't carry the prompt
contents (those go through Langfuse) — just the fact and rough size."""
return {"type": "llm_call", "label": label, "system_chars": system_len, "user_chars": user_len}

139
api/runtime/runner.py Normal file
View File

@@ -0,0 +1,139 @@
"""langgraph wiring — the one place the framework appears.
Graph: START → plan → execute → synthesize → END
`execute` iterates Plan steps and dispatches each to its Analysis. Sequential
for now; swap to a fan-out with parallel nodes once we want to parallelise.
"""
from __future__ import annotations
import dataclasses
import logging
from typing import Any
from langgraph.graph import END, START, StateGraph
from api import langfuse_client as lf
from api.analyses.registry import REGISTRY as ANALYSES
from api.analyses.types import Finding
from api.llm import chat
from api.plan.planner import plan as run_planner
from api.prompts import load, render
from api.runtime import events
from api.runtime.context import current_run_id
from api.runtime.state import RunState
logger = logging.getLogger("nvi.runtime")
def _plan_node(state: RunState) -> dict[str, Any]:
plan_obj = run_planner(state["question"])
return {
"plan_rationale": plan_obj.rationale,
"plan_steps": [s.to_dict() for s in plan_obj.steps],
}
async def _execute_node(state: RunState) -> dict[str, Any]:
findings: list[dict[str, Any]] = []
for step in state.get("plan_steps", []):
name = step["analysis"]
analysis = ANALYSES.get(name)
await events.publish(
state["run_id"],
events.analysis_start(name, step.get("args", {}), step.get("why", "")),
)
if analysis is None:
f = Finding(analysis=name, summary="", error=f"unknown analysis {name!r}")
else:
try:
f = await analysis.run(step.get("args", {}), state["question"])
except Exception as e:
logger.exception("analysis %s raised", name)
f = Finding(analysis=name, summary="", error=str(e))
findings.append(dataclasses.asdict(f))
await events.publish(state["run_id"], events.analysis_end(name, f))
return {"findings": findings}
async def _synthesize_node(state: RunState) -> dict[str, Any]:
await events.publish(state["run_id"], events.synth_start())
findings = state.get("findings", [])
if not findings or all(f.get("error") for f in findings):
return {
"answer": "I couldn't answer this question — see the per-analysis errors above.",
"error": "all_analyses_failed",
}
findings_block = "\n\n".join(
f"[{f['analysis']}]\n"
f"summary: {f.get('summary') or '(none)'}\n"
f"error: {f.get('error') or '(none)'}\n"
f"sql: {f.get('sql')}"
for f in findings
)
with lf.span("synthesize", as_type="generation", input={"findings": len(findings)}) as span:
answer = chat(
system=load("synthesize.system"),
user=render(
"synthesize.user",
question=state["question"],
findings_block=findings_block,
),
max_tokens=512,
)
span.update(output={"answer": answer})
return {"answer": answer}
def build_graph():
g: StateGraph = StateGraph(RunState)
g.add_node("plan", _plan_node)
g.add_node("execute", _execute_node)
g.add_node("synthesize", _synthesize_node)
g.add_edge(START, "plan")
g.add_edge("plan", "execute")
g.add_edge("execute", "synthesize")
g.add_edge("synthesize", END)
return g.compile()
GRAPH = build_graph()
async def run(run_id: str, question: str) -> RunState:
"""Drive the graph end-to-end, publishing SSE events along the way."""
initial: RunState = {"run_id": run_id, "question": question}
final: RunState = initial
token = current_run_id.set(run_id)
try:
await events.publish(run_id, events.run_start(question))
async for event in GRAPH.astream(initial, stream_mode="updates"):
for node, update in event.items():
await events.publish(run_id, events.node_update(node, update))
if isinstance(update, dict):
final = {**final, **update}
if node == "plan" and isinstance(update, dict):
await events.publish(
run_id,
events.plan_ready(
update.get("plan_rationale", ""),
update.get("plan_steps", []),
),
)
await events.publish(
run_id,
events.run_end(answer=final.get("answer", ""), error=final.get("error")),
)
except Exception as e:
logger.exception("run %s failed", run_id)
await events.publish(run_id, events.run_end(error=str(e)))
final = {**final, "error": str(e)}
finally:
current_run_id.reset(token)
await events.close(run_id)
lf.flush()
return final

20
api/runtime/state.py Normal file
View File

@@ -0,0 +1,20 @@
"""Shared state passed between runtime nodes.
This is the one type langgraph sees. Domain code in api/plan, api/analyses,
api/tools never imports it — they take/return their own dataclasses.
"""
from __future__ import annotations
from typing import Annotated, Any, TypedDict
from operator import add
class RunState(TypedDict, total=False):
run_id: str
question: str
plan_rationale: str
plan_steps: list[dict[str, Any]] # serialised PlanStep
findings: Annotated[list[dict[str, Any]], add] # serialised Finding; concatenated by langgraph
answer: str
error: str

0
api/tools/__init__.py Normal file
View File

39
api/tools/db.py Normal file
View File

@@ -0,0 +1,39 @@
"""SQLAlchemy engine for the warehouse.
Per-connection defaults (search_path, statement_timeout) are baked into the
engine via libpq's `options` connect_arg so we never SET them at the SQL
layer. Read-only mode for query execution is requested via SQLAlchemy's
`execution_options(postgresql_readonly=True)`.
"""
from __future__ import annotations
from functools import lru_cache
from sqlalchemy import create_engine
from sqlalchemy.engine import Engine
from api.config import get_settings
SCHEMA = "financial"
DEFAULT_STATEMENT_TIMEOUT_MS = 10_000
@lru_cache(maxsize=1)
def get_engine() -> Engine:
url = get_settings().database_url
if url.startswith("postgresql://"):
url = url.replace("postgresql://", "postgresql+psycopg://", 1)
return create_engine(
url,
pool_pre_ping=True,
future=True,
# libpq -c options run before any SQL the app issues — no need to
# SET search_path / statement_timeout at execution time.
connect_args={
"options": (
f"-c search_path={SCHEMA},public "
f"-c statement_timeout={DEFAULT_STATEMENT_TIMEOUT_MS}"
),
},
)

55
api/tools/execute_sql.py Normal file
View File

@@ -0,0 +1,55 @@
"""Execute read-only SQL against the financial warehouse.
Defence in depth:
- Engine-level `search_path` and `statement_timeout` via libpq options in
`api.tools.db.get_engine` — no SQL-side SETs.
- `execution_options(postgresql_readonly=True, postgresql_deferrable=True)`
— SQLAlchemy emits `SET TRANSACTION READ ONLY DEFERRABLE` internally; we
don't compose a SQL string for it.
- Hard refusal of any statement that doesn't begin with SELECT or WITH.
- Row cap on the returned result set.
- No commit — read-only transactions roll back on context exit.
The SQL passed in IS a string (it's the LLM's output); nothing on the Python
side concatenates SQL fragments around it.
"""
from __future__ import annotations
import logging
from sqlalchemy import text
from api import langfuse_client as lf
from api.tools.db import get_engine
from api.tools.types import QueryResult
logger = logging.getLogger("nvi.tools.execute_sql")
MAX_ROWS = 1000
def execute_sql(sql: str) -> QueryResult:
sql_clean = sql.strip().rstrip(";").strip()
head = sql_clean.split(None, 1)[0].upper() if sql_clean else ""
if head not in {"SELECT", "WITH"}:
raise ValueError(f"refusing non-SELECT statement (starts with {head!r})")
engine = get_engine()
with lf.span("execute_sql", input={"sql": sql_clean}) as span:
ro_engine = engine.execution_options(
postgresql_readonly=True,
postgresql_deferrable=True,
)
with ro_engine.connect() as conn:
res = conn.execute(text(sql_clean))
columns = list(res.keys())
fetched = res.fetchmany(MAX_ROWS + 1)
truncated = len(fetched) > MAX_ROWS
rows = [list(r) for r in fetched[:MAX_ROWS]]
result = QueryResult(
columns=columns, rows=rows, row_count=len(rows), truncated=truncated
)
span.update(output={"row_count": result.row_count, "truncated": truncated})
return result

57
api/tools/schema.py Normal file
View File

@@ -0,0 +1,57 @@
"""Warehouse schema retrieval.
Physical layer via SQLAlchemy's Inspector against the active dataset's
Postgres schema; semantic layer (table/column descriptions, metric catalog)
from YAML files under `api/datasets/<name>/`.
Cached on first call — the schema is stable for a process's lifetime.
Restart the api to pick up a dataset switch or YAML edits.
"""
from __future__ import annotations
from functools import lru_cache
from typing import Any
from sqlalchemy import inspect
from api.config import get_settings
from api.datasets import read_yaml
from api.tools.db import get_engine
from api.tools.types import Column, Metric, SchemaContext, Table
@lru_cache(maxsize=1)
def load_schema() -> SchemaContext:
dataset = get_settings().dataset
docs = read_yaml(dataset, "schema_docs.yaml")
metrics_yaml = read_yaml(dataset, "metrics.yaml")
# Postgres schema name comes from the dataset id; the schema_docs file
# can override it, but normally they match.
schema_name: str = docs.get("schema", dataset)
table_descs: dict[str, dict[str, Any]] = docs.get("tables", {}) or {}
insp = inspect(get_engine())
tables: dict[str, Table] = {
name: Table(
name=name,
description=(desc := table_descs.get(name, {})).get("description"),
columns=[
Column.from_inspector(c, (desc.get("columns") or {}).get(c["name"]))
for c in insp.get_columns(name, schema=schema_name)
],
)
for name in insp.get_table_names(schema=schema_name)
}
metrics: dict[str, Metric] = {
name: Metric.from_spec(name, spec)
for name, spec in (metrics_yaml.get("metrics", {}) or {}).items()
}
return SchemaContext(schema=schema_name, tables=tables, metrics=metrics)
def get_metric(name: str) -> Metric | None:
return load_schema().metrics.get(name)

70
api/tools/text_to_sql.py Normal file
View File

@@ -0,0 +1,70 @@
"""LLM-driven NL → SQL with sqlglot validation and one retry."""
from __future__ import annotations
import logging
import re
import sqlglot
from api import langfuse_client as lf
from api.llm import chat
from api.prompts import load, render
from api.tools.schema import load_schema
from api.tools.types import T2SResult
logger = logging.getLogger("nvi.tools.text_to_sql")
def text_to_sql(question: str, *, hint_tables: list[str] | None = None) -> T2SResult:
schema = load_schema()
tables = hint_tables or schema.table_names()
system = load("text_to_sql.system")
def _user(retry_hint: str = "") -> str:
return render(
"text_to_sql.user",
question=question,
schema_block=schema.render_tables(tables),
metrics_block=schema.render_metrics(),
retry_hint=retry_hint,
)
with lf.span(
"text_to_sql",
as_type="generation",
input={"question": question, "hint_tables": hint_tables},
) as span:
sql = _extract_sql(chat(system=system, user=_user()))
try:
_validate(sql)
except Exception as e:
logger.info("t2s first attempt invalid (%s); retrying once", e)
hint = f"\n\nPrevious attempt failed parsing: {e}. Fix and return only the SQL."
sql = _extract_sql(chat(system=system, user=_user(hint)))
_validate(sql)
result = T2SResult(sql=sql, used_tables=_extract_tables(sql))
span.update(output={"sql": sql, "used_tables": result.used_tables})
return result
def _extract_sql(text: str) -> str:
m = re.search(r"```sql\s*(.*?)```", text, re.DOTALL | re.IGNORECASE)
if m:
return m.group(1).strip().rstrip(";").strip()
return text.strip().rstrip(";").strip()
def _validate(sql: str) -> None:
parsed = sqlglot.parse_one(sql, dialect="postgres")
if parsed is None:
raise ValueError("sqlglot returned no parse tree")
def _extract_tables(sql: str) -> list[str]:
try:
parsed = sqlglot.parse_one(sql, dialect="postgres")
return sorted({t.name for t in parsed.find_all(sqlglot.exp.Table)})
except Exception:
return []

128
api/tools/types.py Normal file
View File

@@ -0,0 +1,128 @@
"""Public data shapes for the Tools layer.
Behavior modules in api/tools/ import from here. Keeping types in one file
makes the layer's surface visible at a glance.
Secondary constructors (`from_inspector`, `from_spec`, …) live as
classmethods so call sites read as one line instead of multi-line kwarg
blocks.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
# ── Schema model ──
@dataclass
class Column:
name: str
sql_type: str
nullable: bool
description: str | None = None
@classmethod
def from_inspector(cls, info: dict[str, Any], description: str | None = None) -> "Column":
"""Build from a SQLAlchemy Inspector column dict."""
return cls(
name=info["name"],
sql_type=str(info["type"]).upper(),
nullable=bool(info["nullable"]),
description=description,
)
@dataclass
class Table:
name: str
description: str | None
columns: list[Column]
@dataclass
class Metric:
name: str
description: str
sql: str
from_table: str
filter: str | None
unit: str | None
@classmethod
def from_spec(cls, name: str, spec: dict[str, Any]) -> "Metric":
"""Build from a metrics.yaml entry."""
return cls(
name=name,
description=spec.get("description", ""),
sql=spec["sql"],
from_table=spec["from_table"],
filter=spec.get("filter"),
unit=spec.get("unit"),
)
@dataclass
class SchemaContext:
schema: str
tables: dict[str, Table]
metrics: dict[str, Metric]
def table_names(self) -> list[str]:
return sorted(self.tables)
def metric_names(self) -> list[str]:
return sorted(self.metrics)
def render_tables(self, names: list[str] | None = None) -> str:
"""CREATE TABLE-like rendering for prompt context."""
sel = [self.tables[n] for n in (names or self.table_names()) if n in self.tables]
out: list[str] = []
for t in sel:
header = f"-- {t.description}\n" if t.description else ""
cols: list[str] = []
for c in t.columns:
line = f' "{c.name}" {c.sql_type}'
if not c.nullable:
line += " NOT NULL"
if c.description:
line += f" -- {c.description}"
cols.append(line)
out.append(header + f'CREATE TABLE "{t.name}" (\n' + ",\n".join(cols) + "\n);")
return "\n\n".join(out)
def render_metrics(self) -> str:
if not self.metrics:
return "(no metrics defined)"
lines: list[str] = []
for m in self.metrics.values():
lines.append(
f"- {m.name} ({m.unit or 'unitless'}): {m.description}\n"
f" sql: {m.sql}\n"
f" from: {m.from_table}"
+ (f"\n filter: {m.filter}" if m.filter else "")
)
return "\n".join(lines)
# ── Query execution ──
@dataclass
class QueryResult:
columns: list[str]
rows: list[list[Any]]
row_count: int
truncated: bool
def as_dicts(self) -> list[dict[str, Any]]:
return [dict(zip(self.columns, r)) for r in self.rows]
# ── Text-to-SQL output ──
@dataclass
class T2SResult:
sql: str
used_tables: list[str]
explanation: str | None = None

View File

@@ -8,5 +8,6 @@ COPY pyproject.toml uv.lock* ./
RUN uv sync --no-dev --no-install-project --frozen 2>/dev/null || uv sync --no-dev --no-install-project
COPY api/ api/
COPY ctrl/seed/ seed/
CMD ["uv", "run", "uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

View File

@@ -25,23 +25,23 @@ docker_build(
'nvi-api',
context='..',
dockerfile='Dockerfile.api',
ignore=['.git', 'def', 'ui', '.claude', 'tests', '.venv', 'node_modules', '.data', 'docs'],
ignore=[
'.git', 'def', 'ui', '.claude', 'tests', '.venv', 'node_modules',
'.data', 'docs', 'ctrl/seed/data', 'api/evals/data',
],
live_update=[
sync('../api', '/app/api'),
sync('./seed', '/app/seed'),
],
)
# Vue UI — context is project root so the framework symlink resolves
# Vue UI — context is project root so the framework dir is in scope.
# No live_update: the image is multi-stage (vite build → static dist served
# by nginx), so source changes require a full image rebuild.
docker_build(
'nvi-ui',
context='..',
dockerfile='Dockerfile.ui',
live_update=[
sync('../ui/app/src', '/ui/app/src'),
sync('../ui/app/index.html', '/ui/app/index.html'),
sync('../ui/app/vite.config.ts', '/ui/app/vite.config.ts'),
sync('../ui/framework/src', '/ui/framework/src'),
],
)
# Docs — nginx serving pre-rendered HTML + Graphviz SVGs

View File

@@ -32,6 +32,11 @@ services:
environment: *common-env
ports:
- "8000:8000"
volumes:
# Bind-mount data dirs so `make seed-fetch` downloads on the host land
# in the container without rebuilding the image.
- ./seed/data:/app/seed/data
- ../api/evals/data:/app/api/evals/data
depends_on:
postgres:
condition: service_healthy

View File

@@ -7,13 +7,19 @@ data:
# Warehouse (in-cluster postgres)
DATABASE_URL: "postgresql://nvi:nvi@postgres:5432/nvi"
# Langfuse (lng cluster, reached over WireGuard from the host).
# Override LANGFUSE_HOST to your lng WireGuard endpoint; keys come from
# the "nvi" project created inside the lng Langfuse UI.
LANGFUSE_HOST: "http://lng.local.ar:3000"
LANGFUSE_PUBLIC_KEY: ""
LANGFUSE_SECRET_KEY: ""
# Active dataset. Selects api/datasets/<name>/, the Postgres schema, and
# ctrl/seed/data/<name>.sqlite. Restart the api to switch.
NVI_DATASET: "financial"
# LLM. Supported providers: groq, anthropic, openai.
LLM_PROVIDER: "groq"
GROQ_API_KEY: "REDACTED_GROQ_API_KEY"
GROQ_MODEL: "llama-3.3-70b-versatile"
# LLM
ANTHROPIC_API_KEY: ""
ANTHROPIC_MODEL: "claude-sonnet-4-6"
# Langfuse (nivii project in the lng cluster).
LANGFUSE_HOST: "http://lng.local.ar"
LANGFUSE_PUBLIC_KEY: "REDACTED_LANGFUSE_PUBLIC_KEY"
LANGFUSE_SECRET_KEY: "REDACTED_LANGFUSE_SECRET_KEY"

0
ctrl/seed/__init__.py Normal file
View File

44
ctrl/seed/download.sh Executable file
View File

@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# Downloads BIRD financial SQLite into ctrl/seed/data/financial.sqlite and
# the golden eval set into api/evals/data/. Runs on the HOST (the api image
# deliberately doesn't include the data). Override BIRD_URL if upstream
# moves.
#
# Layout inside the outer dev.zip (as of the 2024-06-27 dump):
# dev_<date>/
# dev.sql, dev.json, dev_tables.json, dev_tied_append.json
# dev_databases.zip → financial/financial.sqlite (and 10 other DBs)
set -euo pipefail
cd "$(dirname "$0")"
DEST="data/financial.sqlite"
EVAL_DIR="../../api/evals/data"
mkdir -p data "$EVAL_DIR"
if [ -f "$DEST" ]; then
echo "already have $DEST ($(du -h "$DEST" | cut -f1))"
exit 0
fi
URL="${BIRD_URL:-https://bird-bench.oss-cn-beijing.aliyuncs.com/dev.zip}"
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
OUTER="$TMP/dev.zip"
echo "fetching $URL (~330MB compressed; full dev set is ~3GB extracted)..."
curl -fL --progress-bar -o "$OUTER" "$URL"
echo "extracting outer archive..."
unzip -q "$OUTER" -d "$TMP"
ROOT=$(find "$TMP" -maxdepth 1 -type d -name 'dev_*' | head -1)
test -n "$ROOT" || { echo "could not locate dev_* root inside outer zip" >&2; exit 1; }
echo "extracting financial.sqlite from inner archive..."
unzip -q -j "$ROOT/dev_databases.zip" '*/financial/financial.sqlite' -d data/
echo "copying golden eval set to $EVAL_DIR/..."
cp "$ROOT/dev.json" "$ROOT/dev.sql" "$ROOT/dev_tables.json" "$EVAL_DIR/"
echo "ready:"
echo " $DEST ($(du -h "$DEST" | cut -f1))"
echo " $(ls -1 "$EVAL_DIR/" | wc -l) files in $EVAL_DIR/"

147
ctrl/seed/load_bird.py Normal file
View File

@@ -0,0 +1,147 @@
"""Load a BIRD SQLite dataset into Postgres.
Runs inside the api container (`make seed`). Reads which dataset to load
from `settings.dataset` (env var `NVI_DATASET`, default `financial`) and
expects the source SQLite at `ctrl/seed/data/<dataset>.sqlite` — produced
on the host by `ctrl/seed/download.sh` and reaching the container via
Tilt live_update sync (kind) or the docker-compose bind mount.
DDL is built from SQLAlchemy `MetaData` + `Table` + `Column` objects, not
string SQL. Bulk insert uses psycopg's COPY protocol via the engine's raw
connection — there's no SQLAlchemy equivalent, and INSERT for ~1M rows
would be orders of magnitude slower. Identifiers in the COPY statement
are rendered through SA's dialect-aware identifier preparer.
"""
from __future__ import annotations
import sqlite3
import sys
from pathlib import Path
from typing import Any
from sqlalchemy import (
BigInteger, Boolean, Column, Date, DateTime, Float, Integer, LargeBinary,
MetaData, Numeric, SmallInteger, Table, Text, text,
)
from sqlalchemy.engine import Engine
from api.config import get_settings
from api.tools.db import get_engine
# SQLite is type-affinity, not strict — pick a SQLAlchemy type per affinity.
# Anything unrecognised falls through to Text, which is always safe.
TYPE_MAP: dict[str, Any] = {
"INT": BigInteger,
"INTEGER": BigInteger,
"TINYINT": SmallInteger,
"SMALLINT": SmallInteger,
"MEDIUMINT": Integer,
"BIGINT": BigInteger,
"REAL": Float,
"DOUBLE": Float,
"FLOAT": Float,
"NUMERIC": Numeric,
"DECIMAL": Numeric,
"BOOLEAN": Boolean,
"DATE": Date,
"DATETIME": DateTime,
"TIMESTAMP": DateTime,
"TEXT": Text,
"CHAR": Text,
"VARCHAR": Text,
"CLOB": Text,
"BLOB": LargeBinary,
}
def _sa_type(sqlite_type: str):
base = sqlite_type.upper().split("(")[0].strip()
return TYPE_MAP.get(base, Text)
def _list_tables(src: sqlite3.Connection) -> list[str]:
return [
r[0] for r in src.execute(
"SELECT name FROM sqlite_master "
"WHERE type='table' AND name NOT LIKE 'sqlite_%' "
"ORDER BY name"
).fetchall()
]
def _discover_table(src: sqlite3.Connection, metadata: MetaData,
name: str, schema: str) -> Table:
cols = src.execute(f'PRAGMA table_info("{name}")').fetchall()
# PRAGMA returns (cid, name, type, notnull, dflt_value, pk).
sa_cols = [
Column(
c[1],
_sa_type(c[2]),
nullable=not c[3],
primary_key=bool(c[5]),
)
for c in cols
]
return Table(name, metadata, *sa_cols, schema=schema)
def _copy_table(engine: Engine, src: sqlite3.Connection, sa_table: Table) -> int:
"""Bulk-load via psycopg COPY. Identifiers go through SA's dialect preparer
so we never hand-quote names."""
prep = engine.dialect.identifier_preparer
table_ident = prep.format_table(sa_table) # "<schema>"."<table>"
col_list = ", ".join(prep.quote(c.name) for c in sa_table.columns)
src_ident = '"' + sa_table.name.replace('"', '""') + '"' # SQLite side
count = 0
raw = engine.raw_connection()
try:
cur = raw.cursor()
with cur.copy(f"COPY {table_ident} ({col_list}) FROM STDIN") as copy:
for row in src.execute(f"SELECT * FROM {src_ident}"):
copy.write_row(tuple(row))
count += 1
if count % 100_000 == 0:
print(f" {sa_table.name}: {count:,} rows...")
raw.commit()
finally:
raw.close()
return count
def main() -> None:
dataset = get_settings().dataset
sqlite_path = Path(f"seed/data/{dataset}.sqlite")
if not sqlite_path.exists():
sys.exit(
f"{dataset} SQLite not found at {sqlite_path}.\n"
f"Run `bash ctrl/seed/download.sh` on the host first."
)
engine = get_engine()
src = sqlite3.connect(sqlite_path)
src.row_factory = sqlite3.Row
prep = engine.dialect.identifier_preparer
schema_ident = prep.quote_schema(dataset)
with engine.begin() as conn:
conn.execute(text(f"DROP SCHEMA IF EXISTS {schema_ident} CASCADE"))
conn.execute(text(f"CREATE SCHEMA {schema_ident}"))
metadata = MetaData(schema=dataset)
tables = [_discover_table(src, metadata, t, dataset) for t in _list_tables(src)]
metadata.create_all(engine)
print(f"created {len(tables)} tables in schema {dataset!r}")
for sa_table in tables:
count = _copy_table(engine, src, sa_table)
print(f" loaded {sa_table.name}: {count:,} rows")
src.close()
print("done.")
if __name__ == "__main__":
main()

View File

@@ -8,9 +8,11 @@ dependencies = [
"pydantic>=2.0",
"pydantic-settings",
"anthropic",
"openai",
"langgraph",
"langchain-anthropic",
"langfuse",
"sqlalchemy>=2.0",
"psycopg[binary]",
"sqlglot",
"httpx",

0
tests/__init__.py Normal file
View File

60
tests/test_events.py Normal file
View File

@@ -0,0 +1,60 @@
"""Regression: event factory shapes (UI relies on these field names)."""
from api.analyses.types import Finding
from api.runtime import events
def test_run_start_shape():
e = events.run_start("why?")
assert e == {"type": "run_start", "question": "why?"}
def test_run_end_shape():
assert events.run_end(answer="42") == {"type": "run_end", "answer": "42", "error": None}
assert events.run_end(error="boom") == {"type": "run_end", "answer": "", "error": "boom"}
def test_plan_ready_shape():
e = events.plan_ready("rat", [{"analysis": "x", "args": {}, "why": ""}])
assert e["type"] == "plan_ready"
assert e["rationale"] == "rat"
assert isinstance(e["steps"], list)
def test_analysis_start_shape():
e = events.analysis_start("direct_answer", {"q": "?"}, "fits")
assert e == {
"type": "analysis_start",
"analysis": "direct_answer",
"args": {"q": "?"},
"why": "fits",
}
def test_analysis_end_uses_finding():
f = Finding(analysis="direct_answer", summary="ok", rows=[{"a": 1}], sql=["SELECT 1"])
e = events.analysis_end("direct_answer", f)
assert e["type"] == "analysis_end"
assert e["analysis"] == "direct_answer"
assert e["summary"] == "ok"
assert e["error"] is None
assert e["row_count"] == 1
def test_tool_call_shapes():
s = events.tool_call_start("text_to_sql", input={"q": "?"})
assert s == {"type": "tool_call_start", "tool": "text_to_sql", "input": {"q": "?"}}
e = events.tool_call_end("text_to_sql", output={"sql": "..."})
assert e == {"type": "tool_call_end", "tool": "text_to_sql", "output": {"sql": "..."}, "error": None}
def test_synth_start_shape():
assert events.synth_start() == {"type": "synth_start"}
def test_sse_format_round_trip():
payload = events.tool_call_start("t", input={"x": 1})
formatted = events.sse_format(payload)
assert formatted.startswith("event: tool_call_start\n")
assert "data: " in formatted
assert formatted.endswith("\n\n")

42
tests/test_imports.py Normal file
View File

@@ -0,0 +1,42 @@
"""Regression: every module imports cleanly.
Catches things like:
- the `api/semantic` → `api/datasets` rename leaving a stale import in schema.py
- the `api/tools/observability` → `api/langfuse_client` move
- the `api/anthropic_client` → `api/llm` move
"""
import importlib
import pytest
MODULES = [
"api.main",
"api.config",
"api.langfuse_client",
"api.llm",
"api.datasets",
"api.prompts",
"api.tools.db",
"api.tools.schema",
"api.tools.text_to_sql",
"api.tools.execute_sql",
"api.tools.types",
"api.analyses.base",
"api.analyses.types",
"api.analyses.direct_answer",
"api.analyses.compare_periods",
"api.analyses.registry",
"api.plan.planner",
"api.plan.types",
"api.runtime.runner",
"api.runtime.events",
"api.runtime.state",
"api.runtime.context",
"ctrl.seed.load_bird",
]
@pytest.mark.parametrize("name", MODULES)
def test_module_imports(name):
importlib.import_module(name)

View File

@@ -0,0 +1,41 @@
"""Regression: langfuse_client surface + span context manager behaviour.
Bugs captured:
1. AttributeError: module 'api.langfuse_client' has no attribute 'span'
— the module was at the old observability.py shape with `langfuse_span`.
2. RuntimeError: generator didn't stop after throw()
— the @contextmanager wrapped the user's `with` body in `try/except`
and yielded a second value on exception, which @contextmanager rejects.
"""
import pytest
from api import langfuse_client as lf
def test_public_surface():
assert callable(lf.span)
assert callable(lf.flush)
assert callable(lf.get_client)
def test_span_returns_nullspan_without_keys(monkeypatch):
# Ensure no keys → no client → null span.
from api.config import get_settings
get_settings.cache_clear()
monkeypatch.setenv("LANGFUSE_PUBLIC_KEY", "")
monkeypatch.setenv("LANGFUSE_SECRET_KEY", "")
# Reset cached client too
lf._client = None
with lf.span("t") as s:
assert isinstance(s, lf._NullSpan)
s.update(output={"x": 1}) # no-op should not raise
get_settings.cache_clear()
def test_span_body_exception_propagates():
"""The body's exception MUST propagate — wrapping it inside @contextmanager
causes 'generator didn't stop after throw()' if the except clause yields."""
with pytest.raises(ValueError, match="kaboom"):
with lf.span("t"):
raise ValueError("kaboom")

15
tests/test_llm.py Normal file
View File

@@ -0,0 +1,15 @@
"""Regression: LLM dispatcher exposes all three providers and rejects unknowns."""
import pytest
from api import llm
def test_providers_registered():
assert set(llm._PROVIDERS) == {"groq", "anthropic", "openai"}
def test_unknown_provider_raises(monkeypatch):
monkeypatch.setattr(llm.get_settings(), "llm_provider", "definitely-not-a-provider", raising=False)
with pytest.raises(ValueError, match="unknown llm_provider"):
llm.chat(system="s", user="u")

31
tests/test_plan_types.py Normal file
View File

@@ -0,0 +1,31 @@
"""Regression: Plan/PlanStep round-trip from_dict / to_dict."""
from api.plan.types import Plan, PlanStep
def test_planstep_roundtrip():
raw = {"analysis": "direct_answer", "args": {"question": "q"}, "why": "fits"}
step = PlanStep.from_dict(raw)
assert step.analysis == "direct_answer"
assert step.args == {"question": "q"}
assert step.why == "fits"
assert step.to_dict() == raw
def test_planstep_defaults_when_keys_missing():
step = PlanStep.from_dict({"analysis": "direct_answer"})
assert step.args == {}
assert step.why == ""
def test_plan_roundtrip():
raw = {
"rationale": "single step suffices",
"steps": [
{"analysis": "direct_answer", "args": {"question": "q"}, "why": "fits"},
],
}
plan = Plan.from_dict(raw)
assert plan.rationale == "single step suffices"
assert len(plan.steps) == 1
assert plan.steps[0].analysis == "direct_answer"

30
tests/test_prompts.py Normal file
View File

@@ -0,0 +1,30 @@
"""Regression: every prompt file loads, render() substitutes placeholders."""
from pathlib import Path
import pytest
from api.prompts import PROMPTS_DIR, load, render
PROMPT_NAMES = sorted(
p.stem for p in PROMPTS_DIR.iterdir() if p.suffix == ".txt"
)
@pytest.mark.parametrize("name", PROMPT_NAMES)
def test_prompt_loads(name):
text = load(name)
assert text # non-empty
def test_render_substitutes():
out = render("synthesize.user", question="how many?", findings_block="x: 1")
assert "how many?" in out
assert "x: 1" in out
assert "{question}" not in out and "{findings_block}" not in out
def test_render_unknown_placeholder_raises():
# str.format raises KeyError on missing keys
with pytest.raises(KeyError):
render("synthesize.user", question="q") # missing findings_block

View File

@@ -0,0 +1,46 @@
"""Regression: dataset YAML files parse and have the expected shape.
Bug captured: schema_docs.yaml had values that began with a quote character
(e.g. `gender: 'M' or 'F'.`), which YAML treats as a quoted scalar — anything
after the closing quote is a parse error.
"""
from pathlib import Path
import pytest
import yaml
DATASETS_DIR = Path(__file__).resolve().parent.parent / "api" / "datasets"
DATASETS = [p.name for p in DATASETS_DIR.iterdir() if p.is_dir() and not p.name.startswith("_")]
@pytest.mark.parametrize("dataset", DATASETS)
def test_schema_docs_parses(dataset):
docs = yaml.safe_load((DATASETS_DIR / dataset / "schema_docs.yaml").read_text())
assert docs is not None
assert "tables" in docs
assert isinstance(docs["tables"], dict) and docs["tables"]
@pytest.mark.parametrize("dataset", DATASETS)
def test_metrics_parses(dataset):
metrics = yaml.safe_load((DATASETS_DIR / dataset / "metrics.yaml").read_text())
assert metrics is not None
assert "metrics" in metrics
assert isinstance(metrics["metrics"], dict) and metrics["metrics"]
def test_financial_columns_with_quotes_survive_parse():
"""Specific regression: values that contain quoted codes (M, F, OWNER,
PRIJEM, etc.) must parse without truncation."""
docs = yaml.safe_load(
(DATASETS_DIR / "financial" / "schema_docs.yaml").read_text()
)
cols = docs["tables"]["client"]["columns"]
assert '"M"' in cols["gender"] and '"F"' in cols["gender"]
disp = docs["tables"]["disp"]["columns"]
assert '"OWNER"' in disp["type"] and '"DISPONENT"' in disp["type"]
trans = docs["tables"]["trans"]["columns"]
assert '"PRIJEM"' in trans["type"] and '"VYDAJ"' in trans["type"]

View File

@@ -0,0 +1,267 @@
import { ref, onUnmounted, computed } from 'vue'
import type { LogEntry } from 'soleprint-ui'
import { apiUrl } from '../config'
/* Mirror of the backend factories in api/runtime/events.py */
export type RunStatus = 'idle' | 'connecting' | 'streaming' | 'done' | 'error'
export interface PlanStep {
analysis: string
args: Record<string, any>
why: string
}
export interface GraphNode {
id: string // e.g. "plan", "direct_answer", "synthesize"
kind: 'plan' | 'analysis' | 'synthesize'
label: string
status: 'pending' | 'running' | 'done' | 'error'
detail?: string
}
export interface ToolCall {
tool: string
input?: any
output?: any
error?: string | null
/** ms since the run started */
t_started: number
t_ended?: number
}
export function useRunStream() {
const status = ref<RunStatus>('idle')
const runId = ref<string | null>(null)
const question = ref<string>('')
const rationale = ref<string>('')
const planSteps = ref<PlanStep[]>([])
const graphNodes = ref<GraphNode[]>([])
const log = ref<LogEntry[]>([])
const toolCalls = ref<ToolCall[]>([])
const answer = ref<string>('')
const error = ref<string | null>(null)
let es: EventSource | null = null
let t0 = 0
const elapsed = (): number => Math.round(performance.now() - t0)
function reset() {
runId.value = null
question.value = ''
rationale.value = ''
planSteps.value = []
graphNodes.value = []
log.value = []
toolCalls.value = []
answer.value = ''
error.value = null
status.value = 'idle'
}
function push(level: string, stage: string, msg: string) {
log.value.push({
level,
stage,
msg,
ts: new Date().toISOString().split('T')[1]!.split('.')[0]!,
})
}
function nodeBy(id: string): GraphNode | undefined {
return graphNodes.value.find(n => n.id === id)
}
function setNodeStatus(id: string, s: GraphNode['status'], detail?: string) {
const n = nodeBy(id)
if (n) {
n.status = s
if (detail !== undefined) n.detail = detail
}
}
async function ask(q: string): Promise<string | null> {
reset()
status.value = 'connecting'
question.value = q
t0 = performance.now()
let res: Response
try {
res = await fetch(apiUrl('/ask'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question: q }),
})
} catch (e: any) {
status.value = 'error'
error.value = String(e)
push('error', 'system', `network: ${e}`)
return null
}
if (!res.ok) {
status.value = 'error'
error.value = `${res.status} ${res.statusText}`
push('error', 'system', `submit: ${error.value}`)
return null
}
const { run_id } = await res.json()
runId.value = run_id
// Seed graph with the meta-nodes we know about; analysis nodes are added
// once the plan arrives.
graphNodes.value = [
{ id: 'plan', kind: 'plan', label: 'Plan', status: 'running' },
{ id: 'synthesize', kind: 'synthesize', label: 'Synthesize', status: 'pending' },
]
push('info', 'system', `ask submitted → run ${run_id}`)
openStream(run_id)
return run_id
}
function openStream(id: string) {
if (es) es.close()
es = new EventSource(apiUrl(`/runs/${id}/stream`))
status.value = 'streaming'
es.addEventListener('run_start', (e: MessageEvent) => {
const d = JSON.parse(e.data)
push('info', 'run', `start: ${d.question}`)
})
es.addEventListener('node_update', (e: MessageEvent) => {
const d = JSON.parse(e.data)
push('debug', 'graph', `${d.node}${d.keys?.join(', ')}`)
})
es.addEventListener('plan_ready', (e: MessageEvent) => {
const d = JSON.parse(e.data) as { rationale: string; steps: PlanStep[] }
rationale.value = d.rationale
planSteps.value = d.steps
// Replace placeholder analysis nodes with real ones from the plan.
const planNode = nodeBy('plan')!
planNode.status = 'done'
planNode.detail = d.rationale
const synth = graphNodes.value.find(n => n.id === 'synthesize')
graphNodes.value = [
planNode,
...d.steps.map((s, i): GraphNode => ({
id: `${s.analysis}#${i}`,
kind: 'analysis',
label: s.analysis,
status: 'pending',
detail: s.why,
})),
synth || { id: 'synthesize', kind: 'synthesize', label: 'Synthesize', status: 'pending' },
]
push('info', 'plan', `${d.steps.length} step(s): ${d.steps.map(s => s.analysis).join(' → ')}`)
})
es.addEventListener('analysis_start', (e: MessageEvent) => {
const d = JSON.parse(e.data) as { analysis: string; args: any; why: string }
// Mark the first matching pending analysis node as running.
const n = graphNodes.value.find(g => g.kind === 'analysis' && g.label === d.analysis && g.status === 'pending')
if (n) n.status = 'running'
push('info', d.analysis, `start: ${d.why || JSON.stringify(d.args)}`)
})
es.addEventListener('analysis_end', (e: MessageEvent) => {
const d = JSON.parse(e.data) as { analysis: string; summary: string; error: string | null; row_count: number }
const n = graphNodes.value.find(g => g.kind === 'analysis' && g.label === d.analysis && g.status === 'running')
if (n) {
n.status = d.error ? 'error' : 'done'
n.detail = d.error || d.summary
}
const level = d.error ? 'error' : 'info'
const msg = d.error ? `error: ${d.error}` : `done — ${d.row_count} rows · ${d.summary}`
push(level, d.analysis, msg)
})
es.addEventListener('tool_call_start', (e: MessageEvent) => {
const d = JSON.parse(e.data) as { tool: string; input: any }
toolCalls.value.push({ tool: d.tool, input: d.input, t_started: elapsed() })
const detail = d.tool === 'execute_sql' ? (d.input?.sql || '') :
d.tool === 'text_to_sql' ? (d.input?.question || '') :
JSON.stringify(d.input)
push('debug', d.tool, `${detail}`)
})
es.addEventListener('tool_call_end', (e: MessageEvent) => {
const d = JSON.parse(e.data) as { tool: string; output: any; error: string | null }
const call = [...toolCalls.value].reverse().find(c => c.tool === d.tool && c.t_ended === undefined)
if (call) {
call.output = d.output
call.error = d.error
call.t_ended = elapsed()
}
if (d.error) {
push('error', d.tool, `${d.error}`)
return
}
if (d.tool === 'text_to_sql' && d.output?.sql) {
push('info', d.tool, `${d.output.sql}`)
} else if (d.tool === 'execute_sql') {
const label = d.output?.label ? `${d.output.label}: ` : ''
push('info', d.tool, `${label}${d.output?.row_count} rows`)
} else if (d.tool === 'generate_pair') {
push('info', d.tool, `✓ a=${d.output?.a?.label} · b=${d.output?.b?.label}`)
} else {
push('info', d.tool, '✓')
}
})
es.addEventListener('llm_call', (e: MessageEvent) => {
const d = JSON.parse(e.data) as { label: string; system_chars: number; user_chars: number }
push('debug', d.label, `llm ← sys:${d.system_chars} usr:${d.user_chars}`)
})
es.addEventListener('synth_start', () => {
setNodeStatus('synthesize', 'running')
push('info', 'synthesize', '→ composing final answer')
})
es.addEventListener('run_end', (e: MessageEvent) => {
const d = JSON.parse(e.data) as { answer: string; error: string | null }
setNodeStatus('synthesize', d.error ? 'error' : 'done')
answer.value = d.answer || ''
if (d.error) {
error.value = d.error
push('error', 'run', `end: ${d.error}`)
} else {
push('info', 'run', `end · ${elapsed()}ms`)
}
})
es.addEventListener('end', () => {
status.value = error.value ? 'error' : 'done'
es?.close()
es = null
})
es.addEventListener('error', (e: MessageEvent) => {
// The browser also fires a generic `error` event on disconnect; only
// surface it when the run hasn't already ended.
if (status.value === 'streaming') {
status.value = 'error'
const msg = e?.data ? JSON.parse(e.data)?.message : 'stream error'
push('error', 'stream', msg)
}
})
}
function disconnect() {
if (es) { es.close(); es = null }
}
onUnmounted(disconnect)
const isRunning = computed(() => status.value === 'connecting' || status.value === 'streaming')
return {
status, runId, question, rationale, planSteps, graphNodes,
log, toolCalls, answer, error, isRunning,
ask, disconnect,
}
}

21
ui/app/src/config.ts Normal file
View File

@@ -0,0 +1,21 @@
/* API URL resolver.
*
* The SPA is served from `nvi.local.ar` (or `nivii.mcrn.ar` in AWS, or
* `localhost:5173` in `vite dev`). The API lives at the `api.` subdomain in
* production-style deploys; in dev the vite proxy forwards relative paths.
*/
export function apiUrl(path: string): string {
const { protocol, hostname } = window.location
if (hostname.endsWith('.local.ar')) {
return `${protocol}//api.${hostname}${path}`
}
if (hostname === 'nivii.mcrn.ar' || hostname === 'docs.nivii.mcrn.ar') {
return `https://api.nivii.mcrn.ar${path}`
}
// localhost (vite dev) or unrecognised host — rely on same-origin / proxy.
return path
}

View File

@@ -3,13 +3,12 @@ import { createRouter, createWebHistory } from 'vue-router'
import 'soleprint-ui/src/tokens.css'
import App from './App.vue'
import Ask from './pages/Ask.vue'
import RunInspector from './pages/RunInspector.vue'
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', component: Ask },
{ path: '/runs/:id', component: RunInspector, props: true },
{ path: '/:catchAll(.*)', redirect: '/' },
],
})

View File

@@ -1,80 +1,477 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { nextTick, ref, watch } from 'vue'
import { Panel } from 'soleprint-ui'
import { useRunStream } from '../composables/useRunStream'
const question = ref('Which districts had the sharpest rise in loan defaults?')
const submitting = ref(false)
const error = ref<string | null>(null)
const router = useRouter()
const question = ref('How many loans are currently in debt (status D)?')
const examples = [
'How many loans are currently in debt (status D)?',
'Which districts have the highest average loan amount?',
'Compare loan default rates between 1995 and 1996.',
'What share of accounts have at least one credit card?',
]
const {
status, runId, rationale, planSteps, graphNodes, log,
toolCalls, answer, error, isRunning, ask,
} = useRunStream()
const wrapLog = ref(true)
const logEl = ref<HTMLElement | null>(null)
const expanded = ref<Set<string>>(new Set())
function toggleNode(id: string) {
const s = new Set(expanded.value)
if (s.has(id)) s.delete(id)
else s.add(id)
expanded.value = s
}
watch(() => log.value.length, async () => {
await nextTick()
if (logEl.value) logEl.value.scrollTop = logEl.value.scrollHeight
})
async function submit() {
if (!question.value.trim()) return
submitting.value = true
error.value = null
try {
const res = await fetch('/ask', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question: question.value }),
})
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
const { run_id } = await res.json()
router.push(`/runs/${run_id}`)
} catch (e: any) {
error.value = e.message ?? String(e)
} finally {
submitting.value = false
if (!question.value.trim() || isRunning.value) return
await ask(question.value)
}
function pick(q: string) {
question.value = q
}
function statusOf(s: string): 'idle' | 'processing' | 'live' | 'error' {
if (s === 'streaming' || s === 'connecting') return 'processing'
if (s === 'done') return 'live'
if (s === 'error') return 'error'
return 'idle'
}
</script>
<template>
<div class="ask">
<h2>Ask a question</h2>
<textarea v-model="question" rows="3" :disabled="submitting" />
<div class="actions">
<button @click="submit" :disabled="submitting || !question.trim()">
{{ submitting ? 'Submitting…' : 'Ask' }}
<div class="ask-layout">
<!-- Left / top: ask + answer (the user's surface) -->
<div class="ask-pane">
<Panel title="Ask" :status="statusOf(status)">
<div class="ask-form">
<textarea v-model="question" rows="3" :disabled="isRunning"
placeholder="Ask a question about the financial warehouse…" />
<div class="ask-actions">
<span class="hint">{{ isRunning ? `running… ${runId}` : 'Examples:' }}</span>
<button class="run-btn" @click="submit" :disabled="isRunning || !question.trim()">
{{ isRunning ? 'Running' : 'Ask' }}
</button>
</div>
<p v-if="error" class="error">{{ error }}</p>
<div v-if="!isRunning" class="examples">
<button v-for="e in examples" :key="e" class="example" @click="pick(e)">
{{ e }}
</button>
</div>
</div>
</Panel>
<Panel title="Answer" :status="answer ? 'live' : (error ? 'error' : 'idle')">
<div v-if="error" class="error-block">{{ error }}</div>
<div v-else-if="answer" class="answer-block">{{ answer }}</div>
<div v-else-if="isRunning" class="muted">Composing…</div>
<div v-else class="muted">Ask a question to see the answer here.</div>
</Panel>
</div>
<!-- Right / bottom: live trace (the demo's surface) -->
<div class="trace-pane">
<Panel title="Plan" :status="statusOf(status)">
<div v-if="rationale" class="rationale">{{ rationale }}</div>
<div v-else class="muted small">Waiting for the planner</div>
<div class="graph">
<div v-for="(n, idx) in graphNodes" :key="n.id" class="graph-row">
<span class="connector" v-if="idx > 0"></span>
<div
:class="['node', n.kind, n.status, {
expanded: expanded.has(n.id),
expandable: !!n.detail,
}]"
@click="n.detail && toggleNode(n.id)"
:role="n.detail ? 'button' : undefined"
:tabindex="n.detail ? 0 : undefined"
@keydown.enter="n.detail && toggleNode(n.id)"
@keydown.space.prevent="n.detail && toggleNode(n.id)"
>
<div class="node-head">
<span class="chev" v-if="n.detail">{{ expanded.has(n.id) ? '' : '' }}</span>
<span class="dot" />
<span class="label">{{ n.label }}</span>
<span v-if="n.detail && !expanded.has(n.id)" class="detail-preview">{{ n.detail }}</span>
</div>
<div v-if="n.detail && expanded.has(n.id)" class="detail-full">{{ n.detail }}</div>
</div>
</div>
</div>
</Panel>
<Panel title="Tool calls" class="tools-panel">
<div v-if="toolCalls.length === 0" class="muted small">No tool calls yet.</div>
<div v-for="(t, i) in toolCalls" :key="i" :class="['tool', t.error ? 'err' : (t.t_ended ? 'done' : 'pending')]">
<div class="tool-head">
<span class="tool-name">{{ t.tool }}</span>
<span class="tool-time">{{ t.t_ended ? `${t.t_ended - t.t_started}ms` : '…' }}</span>
</div>
<pre v-if="t.tool === 'text_to_sql' && t.output?.sql" class="tool-body sql">{{ t.output.sql }}</pre>
<pre v-else-if="t.tool === 'execute_sql' && t.input?.sql" class="tool-body sql">{{ t.input.sql }}</pre>
<pre v-else-if="t.tool === 'generate_pair' && t.output" class="tool-body sql">A ({{ t.output.a?.label }}): {{ t.output.a?.sql }}
B ({{ t.output.b?.label }}): {{ t.output.b?.sql }}</pre>
<pre v-if="t.output?.preview && t.output.preview.length" class="tool-body rows">{{ JSON.stringify(t.output.preview, null, 2) }}</pre>
<div v-if="t.error" class="tool-error">{{ t.error }}</div>
</div>
</Panel>
<Panel title="Event log" class="log-panel">
<template #actions>
<label class="wrap-toggle">
<input type="checkbox" v-model="wrapLog" />
wrap
</label>
</template>
<div ref="logEl" :class="['log', { wrap: wrapLog }]">
<div v-for="(e, i) in log" :key="i" :class="['log-row', e.level]">
<span class="log-ts">{{ e.ts }}</span>
<span class="log-level">{{ e.level }}</span>
<span class="log-stage">{{ e.stage }}</span>
<span class="log-msg">{{ e.msg }}</span>
</div>
<div v-if="log.length === 0" class="muted small">No events yet.</div>
</div>
</Panel>
</div>
</div>
</template>
<style scoped>
.ask {
max-width: 720px;
.ask-layout {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
height: calc(100vh - 80px);
min-height: 0;
}
.ask-pane,
.trace-pane {
display: flex;
flex-direction: column;
gap: 12px;
overflow: auto;
min-height: 0;
}
.trace-pane {
border-left: var(--panel-border);
padding-left: 16px;
}
.trace-pane > .tools-panel {
flex: 1;
min-height: 0;
}
.trace-pane > .log-panel {
flex: 1;
min-height: 200px;
}
/* ── Ask form ── */
.ask-form {
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
textarea {
width: 100%;
background: var(--surface-2);
color: var(--text-primary);
border: var(--panel-border);
padding: 12px;
padding: 10px;
font-family: var(--font-mono);
font-size: 14px;
font-size: 13px;
resize: vertical;
}
.actions {
.ask-actions {
display: flex;
justify-content: flex-end;
align-items: center;
justify-content: space-between;
gap: 12px;
}
button {
padding: 8px 20px;
.hint {
font-family: var(--font-mono);
background: var(--accent-dim);
color: var(--text-primary);
border: var(--panel-border);
font-size: 11px;
color: var(--text-dim);
}
.run-btn {
background: var(--accent);
color: white;
border: none;
padding: 6px 18px;
font-family: var(--font-mono);
font-size: 12px;
cursor: pointer;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
.run-btn:hover { background: var(--accent-dim); }
.run-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.examples {
display: flex;
flex-direction: column;
gap: 4px;
}
.error {
.example {
text-align: left;
background: transparent;
border: none;
border-left: 2px solid var(--surface-3);
padding: 4px 10px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-dim);
cursor: pointer;
}
.example:hover {
color: var(--text-primary);
border-left-color: var(--accent);
}
/* ── Answer block ── */
.answer-block {
padding: 16px;
font-size: 14px;
color: var(--text-primary);
line-height: 1.6;
}
.error-block {
padding: 16px;
color: var(--status-error, #f55);
font-family: var(--font-mono);
font-size: 12px;
}
.muted {
padding: 16px;
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 12px;
}
.muted.small { padding: 8px 12px; font-size: 11px; }
/* ── Plan + graph ── */
.rationale {
padding: 10px 12px;
font-size: 12px;
color: var(--text-secondary);
border-bottom: 1px solid var(--surface-3);
font-style: italic;
}
.graph {
display: flex;
flex-direction: column;
padding: 10px 12px;
gap: 2px;
}
.graph-row {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.connector {
font-family: var(--font-mono);
color: var(--text-dim);
font-size: 10px;
margin-left: 6px;
}
.node {
display: flex;
flex-direction: column;
padding: 6px 10px;
background: var(--surface-2);
border: var(--panel-border);
font-family: var(--font-mono);
font-size: 12px;
width: 100%;
box-sizing: border-box;
}
.node.expandable { cursor: pointer; }
.node.expandable:hover { background: var(--surface-3, #1a2340); }
.node.expandable:focus-visible { outline: 1px solid var(--accent); outline-offset: -1px; }
.node-head {
display: flex;
align-items: center;
gap: 10px;
}
.node .chev {
color: var(--text-dim);
font-size: 10px;
width: 10px;
flex-shrink: 0;
}
.node .dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--status-idle, #555);
flex-shrink: 0;
}
.node.running .dot {
background: var(--status-processing, #ffc107);
box-shadow: 0 0 8px var(--status-processing, #ffc107);
animation: pulse 1s ease-in-out infinite;
}
.node.done .dot { background: var(--status-live, #00c853); }
.node.error .dot { background: var(--status-error, #ef4444); }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.node .label { font-weight: 500; }
.node .detail-preview {
margin-left: auto;
color: var(--text-dim);
font-size: 11px;
text-align: right;
max-width: 60%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.node .detail-full {
margin-top: 6px;
padding: 6px 0 2px 20px;
color: var(--text-secondary);
font-size: 11px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
border-top: 1px dashed var(--surface-3);
}
/* ── Tool call cards ── */
.tool {
margin: 8px;
border: var(--panel-border);
border-left-width: 3px;
background: var(--surface-2);
}
.tool.done { border-left-color: var(--status-live, #00c853); }
.tool.pending { border-left-color: var(--status-processing, #ffc107); }
.tool.err { border-left-color: var(--status-error, #ef4444); }
.tool-head {
display: flex;
justify-content: space-between;
padding: 6px 10px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
border-bottom: 1px solid var(--surface-3);
}
.tool-name { font-weight: 600; }
.tool-time { color: var(--text-dim); }
.tool-body {
margin: 0;
padding: 8px 10px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
white-space: pre-wrap;
word-break: break-word;
max-height: 200px;
overflow: auto;
}
.tool-body.sql { color: #7ab0ff; }
.tool-body.rows { color: var(--text-dim); }
.tool-error {
padding: 8px 10px;
color: var(--status-error, #f55);
font-family: var(--font-mono);
font-size: 11px;
}
/* ── Event log ── */
.wrap-toggle {
display: inline-flex;
align-items: center;
gap: 4px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-dim);
cursor: pointer;
user-select: none;
}
.wrap-toggle input { margin: 0; }
.log {
height: 100%;
overflow: auto;
padding: 4px 0;
font-family: var(--font-mono);
font-size: 11px;
line-height: 1.5;
}
.log-row {
display: grid;
grid-template-columns: 64px 52px 100px 1fr;
gap: 8px;
padding: 2px 10px;
align-items: baseline;
}
.log:not(.wrap) .log-msg {
white-space: pre;
overflow-x: auto;
}
.log.wrap .log-msg {
white-space: pre-wrap;
word-break: break-word;
}
.log-ts { color: var(--text-dim); }
.log-level {
text-transform: uppercase;
font-size: 10px;
letter-spacing: 0.5px;
}
.log-row.info .log-level { color: var(--text-secondary); }
.log-row.debug .log-level { color: var(--text-dim); }
.log-row.error .log-level { color: var(--status-error, #ef4444); }
.log-row.error .log-msg { color: var(--status-error, #ef4444); }
.log-row.warn .log-level { color: var(--status-processing, #ffc107); }
.log-stage { color: #7ab0ff; }
.log-msg { color: var(--text-primary); }
/* ── Mobile: stack vertically ── */
@media (max-width: 900px) {
.ask-layout {
grid-template-columns: 1fr;
height: auto;
}
.trace-pane {
border-left: none;
border-top: var(--panel-border);
padding-left: 0;
padding-top: 16px;
}
}
</style>

View File

@@ -1,47 +0,0 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
const props = defineProps<{ id: string }>()
const state = ref<any>(null)
const error = ref<string | null>(null)
onMounted(async () => {
try {
const res = await fetch(`/runs/${props.id}`)
if (!res.ok) throw new Error(`${res.status}`)
state.value = await res.json()
} catch (e: any) {
error.value = e.message ?? String(e)
}
})
</script>
<template>
<div class="inspector">
<h2>Run {{ id }}</h2>
<p v-if="error" class="error">{{ error }}</p>
<pre v-else-if="state">{{ JSON.stringify(state, null, 2) }}</pre>
<p v-else>Loading</p>
</div>
</template>
<style scoped>
.inspector {
display: flex;
flex-direction: column;
gap: 12px;
}
pre {
background: var(--surface-2);
color: var(--text-primary);
border: var(--panel-border);
padding: 12px;
font-family: var(--font-mono);
font-size: 12px;
overflow: auto;
}
.error {
color: var(--status-error, #f55);
}
</style>

143
uv.lock generated
View File

@@ -210,6 +210,73 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e7/c8/e2645aa8ed02fd4c7a2f59d68783b65b1f3cbdfe39a6308e156509d1fee8/googleapis_common_protos-1.75.0-py3-none-any.whl", hash = "sha256:961ed60399c457ceb0ee8f285a84c870aabc9c6a832b9d37bb281b5bebde43ed", size = 300631, upload-time = "2026-05-07T08:03:30.345Z" },
]
[[package]]
name = "greenlet"
version = "3.5.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/6d/6e/802acd792aebb2256fbbee8cacf2727faaeb6f240ac11008f09eae4414bc/greenlet-3.5.1.tar.gz", hash = "sha256:5a56aeb7d5d9cc4b3a735efb5095bd4b4f6f0e4f93e5ca876d0e2315137b7829", size = 197356, upload-time = "2026-05-20T15:05:03.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c4/37/4549f149c9797c21b32c2683c33522af22522099de128b2406672526d005/greenlet-3.5.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:fa4f98af3a528f0c3fd592a26df7f376f93329c8f4d987f6bb979057af8bf5e2", size = 286220, upload-time = "2026-05-20T13:07:28.463Z" },
{ url = "https://files.pythonhosted.org/packages/38/ff/a4f436709716965eaab9f36ea7b906c8a927fbe32fb1372a2071d964f6b1/greenlet-3.5.1-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ffea73584b216150eab159b6d12348fb253e68757974de1e2c40d8a318ac89ed", size = 601585, upload-time = "2026-05-20T14:00:06.141Z" },
{ url = "https://files.pythonhosted.org/packages/65/ad/54bc3fcee3ad368a61b19b67d88117f7a8c29727bf71fffdeda81fbd946e/greenlet-3.5.1-cp312-cp312-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1072b4f9edcc1e192d9283a66a3e68d6b84c561de33a83d7858beb9ba1effe10", size = 614215, upload-time = "2026-05-20T14:05:42.675Z" },
{ url = "https://files.pythonhosted.org/packages/7c/6c/de5b1b388cd2d9fbdfeab324863daba37d54e6e233ddbefd70b385a8c591/greenlet-3.5.1-cp312-cp312-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:89101bfd5011e069be974903cb3a4e4523845e4ece2d62dcd8d358933c0ef249", size = 620094, upload-time = "2026-05-20T14:09:09.18Z" },
{ url = "https://files.pythonhosted.org/packages/40/69/b91cda0647df839483201545913514c2827ebea5e5ccdf931842763bc127/greenlet-3.5.1-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:add5217d68b31130f0beca584d7fef4878327d2e31642b66618a14eef312b63b", size = 611358, upload-time = "2026-05-20T13:14:26.37Z" },
{ url = "https://files.pythonhosted.org/packages/4a/43/1204baffab8a6476464795a7ccf394a3248d4f22c9f87173a15b36b6d971/greenlet-3.5.1-cp312-cp312-manylinux_2_39_riscv64.whl", hash = "sha256:e6cd99ea59dd5d89f0c956606571d79bfe6f68c9eb7f4a4083a41a7f1587edee", size = 422782, upload-time = "2026-05-20T14:01:39.597Z" },
{ url = "https://files.pythonhosted.org/packages/59/90/3cf77e080350cd02fa307bb2abf05df48f4482c240275bbd2c203ba8bb1c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a5ea42a752d47a145eae922b605cd1634665ac3d5ec1e72402d5048e8d60d207", size = 1570475, upload-time = "2026-05-20T14:02:25.29Z" },
{ url = "https://files.pythonhosted.org/packages/65/2c/18cece62045e74598c3c393f70dce4a63f56222015ba29a5d4eeb04f764c/greenlet-3.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c5551170cf4f5ff5623e9af81323751979fee2c731e2287b61f73cd27257b823", size = 1635625, upload-time = "2026-05-20T13:14:34.027Z" },
{ url = "https://files.pythonhosted.org/packages/30/f5/310d104ddf41eb5a70f4c268d22508dfb0c3c8e86fec152be34d0d2ed819/greenlet-3.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:3c8bb982ad117d29478ef8f5533e97df21f1e2befd17a299257b0c96d1371c0b", size = 238791, upload-time = "2026-05-20T13:10:39.018Z" },
{ url = "https://files.pythonhosted.org/packages/62/90/ceca11f504cd23a8047a3dea31919adc48df9b626dd0c13f0d858734fdfd/greenlet-3.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:80eb4b04dadc4e67df3fae179a32c4706a3f495bc7f22fc8a81115d5f5512188", size = 235580, upload-time = "2026-05-20T13:08:45.056Z" },
{ url = "https://files.pythonhosted.org/packages/27/69/7f7e5372d998b81001899b1c0823c957aa413ba0f2662e65821611cc31e4/greenlet-3.5.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:51518ff74664078fc51bffcc6fc529b0df5ae58da192691cee765d45ce944a2b", size = 285060, upload-time = "2026-05-20T13:08:51.899Z" },
{ url = "https://files.pythonhosted.org/packages/b1/bf/387f9b6b865fd2ae0d0be09e0004827295a01b71be76ed350dd1e28a91a4/greenlet-3.5.1-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1ffdb3c0bb002c99cd8f298957e046c3dbf6006b5b7cdf11a4e19194624a0a0a", size = 604370, upload-time = "2026-05-20T14:00:07.492Z" },
{ url = "https://files.pythonhosted.org/packages/32/f5/169ce3d4e4c67291bd18f8cbe0299c9f3e45102c7f1fb3c14780c93e4532/greenlet-3.5.1-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7715a5a2c3378ba602c3a440558261e13a820bb53a82693aacd7b7f6d964e283", size = 616987, upload-time = "2026-05-20T14:05:44.237Z" },
{ url = "https://files.pythonhosted.org/packages/19/ba/c24110c55dffa55aa6e1d98b45310da33801aeba7686ff0190fe5d46fd32/greenlet-3.5.1-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d40a890035c0058cadbdc4af7569800fd28a0e527a0fdbb7b5f9418f176846ce", size = 622911, upload-time = "2026-05-20T14:09:10.598Z" },
{ url = "https://files.pythonhosted.org/packages/ee/e5/7f2e41d5273be07e77560d61ea4e56485b4d6c316d2a84518c62d1364061/greenlet-3.5.1-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc71ff466927a201b08305acac451ebe1aedfcea002f62f1f2f2ac2ac1e6a135", size = 613911, upload-time = "2026-05-20T13:14:27.539Z" },
{ url = "https://files.pythonhosted.org/packages/ec/7b/d20db2e8a5ad6c038702f3179b136f93f0a3d1a21a0c0777f3e470cdf4b2/greenlet-3.5.1-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:67821bb03e4e98664490edb787ff6af501194c29bbee0f5c1dfdcf1dc3d9d436", size = 425228, upload-time = "2026-05-20T14:01:40.837Z" },
{ url = "https://files.pythonhosted.org/packages/c5/a4/fbdc67579b73615a1f91615e814303cc71e06128f7baaba87be79b8fb90c/greenlet-3.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cd443683db272ebaaca03af98c0b063ab30db70ea8a31a1559f35e3f7b744ccd", size = 1570689, upload-time = "2026-05-20T14:02:27.225Z" },
{ url = "https://files.pythonhosted.org/packages/e6/b4/77abbe35078be39718a46cd49caf16bceb35662f97a34101dca28aa98e47/greenlet-3.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:089fff7a6ce8d9316d1f65ebc00273a56be258c1725b32b94de90a3a979557e1", size = 1635602, upload-time = "2026-05-20T13:14:36.344Z" },
{ url = "https://files.pythonhosted.org/packages/37/f7/129f27ca700845b8ee8ca88ce7f43435a1239c2eddb7677fc938822762cf/greenlet-3.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:110a1ca7b49b014b097f6078272c3f4ed31af45b254de5228b79adba879f6af9", size = 238683, upload-time = "2026-05-20T13:11:50.57Z" },
{ url = "https://files.pythonhosted.org/packages/6d/5c/a485a36e87df8d8fd0632ee01511244f5156a20ed3746cc6599340326395/greenlet-3.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:f16ba1efc0715b680a18b8123d90dad887c6112ae3555b4b5c32c149540c6b4e", size = 235499, upload-time = "2026-05-20T13:12:42.028Z" },
{ url = "https://files.pythonhosted.org/packages/8a/cb/c62454606daf5640369c94d8a9dd540599b1bfc090e2d2180cb77f4038d2/greenlet-3.5.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:d8ab31c9de8651a2facdd5c5bb0011f2380dd1a7af78ce2adf4b56095294fc07", size = 285579, upload-time = "2026-05-20T13:08:56.396Z" },
{ url = "https://files.pythonhosted.org/packages/ec/71/c4270398c2eba968a6071af1dfbdcaeee6ec1c24bc8b435b8cc452700da6/greenlet-3.5.1-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e300185139abc337ade480c327183adf42a875ac7181bfe66d7d4efea31fbea", size = 651106, upload-time = "2026-05-20T14:00:09.448Z" },
{ url = "https://files.pythonhosted.org/packages/1a/ab/71e34b78a44ec271fb5f550c17bc46d301ddc5953890d935f270b0dcdb5a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7ffdb990dcaa0234cf9845aead5df2e3c3a8b6507d409274dd87e0d5ab05ffc2", size = 663478, upload-time = "2026-05-20T14:05:45.88Z" },
{ url = "https://files.pythonhosted.org/packages/c6/2d/2d80842910da44f78c286532d084b8a5c3717c844ae80ceb3858738ae89a/greenlet-3.5.1-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6c09df69dc1712d131332054a858a3e5cca400967fa3a672e2324fbb0971448c", size = 667767, upload-time = "2026-05-20T14:09:12.15Z" },
{ url = "https://files.pythonhosted.org/packages/77/96/4efd6fa5c62c85426a0c19077a586258ebc3a2a146ff2493e4312a697a22/greenlet-3.5.1-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2f82b3597e9d83b63408affed0b48fd0f54935edac4302237b9a837be0dae33c", size = 660800, upload-time = "2026-05-20T13:14:29.129Z" },
{ url = "https://files.pythonhosted.org/packages/e9/d3/dad2eecedfbb1ed7050a20dcfae40c1442b74bc7423608be2c7e03ee7133/greenlet-3.5.1-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:a4764e0bfc6a4d114c865b32520805c16a990ef5f286a514413b05d5ecd6a23d", size = 470786, upload-time = "2026-05-20T14:01:42.064Z" },
{ url = "https://files.pythonhosted.org/packages/7a/e0/6c71401a25cac7000261304e866a2f2cc04dc74810d40e2f118aa4799495/greenlet-3.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c0141e37414c10164e702b8fb1473304221ad98f71600850c6ef7ff4880feba0", size = 1617518, upload-time = "2026-05-20T14:02:28.662Z" },
{ url = "https://files.pythonhosted.org/packages/41/26/c5c06643e8c0af9e7bf18e16cb51d0ab7625155f0392e1c9015d66d556cd/greenlet-3.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:50ae25a67bea74ea41fb14b960bc532df73eb713417b2d61892dced82fe8d3bc", size = 1681593, upload-time = "2026-05-20T13:14:39.417Z" },
{ url = "https://files.pythonhosted.org/packages/8a/bd/e11a108317485075e68af9d23039619b86b28130c3b50d227d42edece64b/greenlet-3.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:8a17c42330e261299766b75ac1ea32caa437a9453c8f65d16a13140db378ecd3", size = 239800, upload-time = "2026-05-20T13:09:30.128Z" },
{ url = "https://files.pythonhosted.org/packages/47/f8/8e8e8417b7bf28639a5a56356ef934d0375e1d0c70a57e04d7701e870ffe/greenlet-3.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:7b5f5fae05b8ac6d176a61b60c394a8cbdc2b5b91b81793066e68745cf165e54", size = 236862, upload-time = "2026-05-20T13:09:10.498Z" },
{ url = "https://files.pythonhosted.org/packages/90/12/41bf27fde4d3605d3773ae57751eda182b8be2f5398011c041173b1d9534/greenlet-3.5.1-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:ea8da1e900d758d078810d4255d8c6aa572181896a31ec79d779eb79c3adc9ad", size = 293637, upload-time = "2026-05-20T13:12:35.529Z" },
{ url = "https://files.pythonhosted.org/packages/44/44/ba14b23e9757707050c2f397d305bbcae62e5d7cad122f8b6baec5ae4a1f/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a19570c52a21420dcbc94e661994bc325c0b5b11304540fed514586da5dc8f2e", size = 650840, upload-time = "2026-05-20T14:00:11.079Z" },
{ url = "https://files.pythonhosted.org/packages/a8/37/5ddc2b686a6844f91abecef43411842426da2e1573f60b49ecf2547f4ae1/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3d955c89b75eeca4723d7cc14135f393cd47c32e2a6cb4a8e4c6e760a26b0986", size = 656416, upload-time = "2026-05-20T14:05:47.118Z" },
{ url = "https://files.pythonhosted.org/packages/8c/46/5987dcd1a2570ba84f3b187536b2ca3ae97613387e57f5cfa99df068fe5e/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea37d5a157eb9493820d3792ac4ece28619a394391d2b9f2f78057d396ff0f0f", size = 656607, upload-time = "2026-05-20T14:09:13.949Z" },
{ url = "https://files.pythonhosted.org/packages/e1/f0/d17510297c35a2992712f0bf84de3779749999f7d3d63aa1f09db7c62dbe/greenlet-3.5.1-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2daaaebd1a5aa88c49045b6baf9310b3263796bd88db713edf37cf53e7bb4e", size = 654397, upload-time = "2026-05-20T13:14:30.696Z" },
{ url = "https://files.pythonhosted.org/packages/2c/c1/6da0a9ddcc29d7e51ef14883fa3dc1e53b3f4ffba00582106c7bf55da1d8/greenlet-3.5.1-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:8d8a23250ea3ec7b36de8fa4b541e9e2db3ee82915cc060ab0631609ad8b28de", size = 488287, upload-time = "2026-05-20T14:01:43.143Z" },
{ url = "https://files.pythonhosted.org/packages/37/eb/147387705bb89092645b012586e7273cb5ed3c90ef7eaf3a69173eaf0209/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bfbd69cc349e43bf3a8ae1c85548ff0718efc887615c2db16c3833d7b0b072d", size = 1614469, upload-time = "2026-05-20T14:02:30.192Z" },
{ url = "https://files.pythonhosted.org/packages/a6/4e/37ee0da7732b7aa9896f17e15579a9df34b9fcb9dd494f0adfa749af6623/greenlet-3.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4378720dd888136c27215a0214d32a4d37c3852765d45bc37aad0623423cfd78", size = 1675115, upload-time = "2026-05-20T13:14:40.972Z" },
{ url = "https://files.pythonhosted.org/packages/57/f3/97dfcf4a6eb5077f8a672234216fb5923eb89f2cab7081cb10b2cf75b605/greenlet-3.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:45718441607f9325d948db98cbc691276059316d0358c188c246da4e1d4d23d2", size = 245246, upload-time = "2026-05-20T13:12:22.646Z" },
{ url = "https://files.pythonhosted.org/packages/5d/73/d7f72e34b582f694f4a9b248162db7b09cc458a259ba8f0c0bfa1a34ea7d/greenlet-3.5.1-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:2baee5ca02031757ffe8cc3d69f0cc0aec7065ce362622da74f32d3bcab1c541", size = 285575, upload-time = "2026-05-20T13:12:07.043Z" },
{ url = "https://files.pythonhosted.org/packages/df/59/fa9c6e87dc8ad27a95dabe2f29f372b733d05a8a67470f6c901ed9975655/greenlet-3.5.1-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b1ec3274918a81d3ea778b9e75b56b72b33f300edb6cf7f3a7fe1dae56683de", size = 656428, upload-time = "2026-05-20T14:00:12.556Z" },
{ url = "https://files.pythonhosted.org/packages/f6/f9/e753408871eaa61dfe35e619cfc67512b036fde99893685d50eea9e07146/greenlet-3.5.1-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:111e2390ffffc47d5840b01711dd7fac07d4c09283d0283e7f3264b14e284c64", size = 667064, upload-time = "2026-05-20T14:05:48.662Z" },
{ url = "https://files.pythonhosted.org/packages/dc/74/807a047255bf1e09303627c46dc043dca596b6958a354d904f32ab382005/greenlet-3.5.1-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:10a9a1c0bfbc93d41156ffcb90c75fbc05544054faf15dcc1fdf9765f8b607f0", size = 672962, upload-time = "2026-05-20T14:09:15.532Z" },
{ url = "https://files.pythonhosted.org/packages/96/27/5565b5b40389f1c7753003a07e21892fda8660926787036d5bc0308b8113/greenlet-3.5.1-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e630136e905fe5ff43e86945ae41220b6d1470956a39220e708110ac48d01ea5", size = 665697, upload-time = "2026-05-20T13:14:32.943Z" },
{ url = "https://files.pythonhosted.org/packages/76/32/19d4e13225193c29b13e308015223f7d75fd3d8623d49dd19040d2ce8ec1/greenlet-3.5.1-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:ef08c1567c78074b22d1a200183d52d04a14df447bf70bcbb6a3507a48e776fc", size = 476047, upload-time = "2026-05-20T14:01:44.39Z" },
{ url = "https://files.pythonhosted.org/packages/cf/82/e7de4178c0c2d1c9a5a3be3cc0b33e46a85b3ee4a77c071bf7ad8600e079/greenlet-3.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:975eac34b44a7077ca4d421348455b94f0f518246a7f14bc6d2fdcfe5b584368", size = 1621256, upload-time = "2026-05-20T14:02:31.91Z" },
{ url = "https://files.pythonhosted.org/packages/00/10/f2dddcf7dacac17dfc68691809589adad06135eb28930429cf58a6467a2f/greenlet-3.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9ab3c3a0b2ae6198e67c898dad5215a49f9ae0d0081b3c3ec59f333e39eeca26", size = 1685956, upload-time = "2026-05-20T13:14:42.55Z" },
{ url = "https://files.pythonhosted.org/packages/22/17/4a232b32133230ada52f70e9d7f5b65b0caef8772f01849bd8d149e7e4ca/greenlet-3.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:cbfc69be86e10dcfef5b1e6269d1d6926552aa89ee39e1de3353360c1b6989ab", size = 239802, upload-time = "2026-05-20T13:13:15.481Z" },
{ url = "https://files.pythonhosted.org/packages/c2/ae/4e623a7e6d4d2a5f4cb8e4c82de4169fc637942caae68d6e676b8a128ac5/greenlet-3.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:92fd6d44ac5e5a887c8a5dc4a8ba0ba908527c31c12f78c6bc7dcfe8aab279f6", size = 236853, upload-time = "2026-05-20T13:15:37.301Z" },
{ url = "https://files.pythonhosted.org/packages/7a/57/816d9cff29119da3505b3d6a5e14a8af89006ac36f47f891ff293ee05af1/greenlet-3.5.1-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:a6fdf2433a5441ef9a95464f7c3e674775da1c8c1177fff311cee1acad4626ed", size = 293877, upload-time = "2026-05-20T13:10:19.078Z" },
{ url = "https://files.pythonhosted.org/packages/23/a1/59b0a7c7d140ff1a75626680b9a9899b79a9176cab298b394968fb023295/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7546556f0d649f99f6a361098a55f761181bb2ea12ff150bb16d26092ad88244", size = 655333, upload-time = "2026-05-20T14:00:14.758Z" },
{ url = "https://files.pythonhosted.org/packages/72/1b/5efe127597625042218939d01855109f352779050768b670b52edcc16a6c/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5ee3ea898009fa898f85f9982255d35278c477bebe185beca249cab42d4526c", size = 659443, upload-time = "2026-05-20T14:05:50.159Z" },
{ url = "https://files.pythonhosted.org/packages/c9/9d/1dcdf7b95ab3cf8c7b6d7277c18a5e167312f2b362ddfcc5d5e6d8d84b43/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a57b0d05a0448eed231d59c0ceb287dde984551e54cbc51ac2d4865712838e9c", size = 659998, upload-time = "2026-05-20T14:09:16.912Z" },
{ url = "https://files.pythonhosted.org/packages/6c/6d/c404246ea4d22d097a7426d0efb5b781bd7eb67715f09e79001bd552ab18/greenlet-3.5.1-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a5c81f74d204d3edd136ebfd50dce53acbb776995d721a0fe801626cfc93b8cd", size = 658356, upload-time = "2026-05-20T13:14:35.091Z" },
{ url = "https://files.pythonhosted.org/packages/05/7e/c4959664fc231d587d66d8e81f2095e98056ba1954beafdcbe635e251052/greenlet-3.5.1-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:b0703c2cef53e01baec47f7a3868009913ad71ec678bbecb42a6f40895e4ce62", size = 494470, upload-time = "2026-05-20T14:01:45.611Z" },
{ url = "https://files.pythonhosted.org/packages/51/02/f8ee37fb6d2219329f350af241c27fcf12df57e723d11f6fc6d3bacdadaa/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:2c18ef16bf6d4dd410e4dd52996888ea1497be26892fe5bbc73580aba4287b8e", size = 1619216, upload-time = "2026-05-20T14:02:33.403Z" },
{ url = "https://files.pythonhosted.org/packages/93/c5/3dc9475ace2c7a3680da12372cddd7f1ac874eb410a1ac48d3e9dab83782/greenlet-3.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:17d86354f0ae6b61bf9be5148d0dd34e06c3cb7c602c671f79f29ac3b150e659", size = 1678427, upload-time = "2026-05-20T13:14:43.71Z" },
{ url = "https://files.pythonhosted.org/packages/df/4e/750c15c317a41ffb36f0bf40b933e3d744a7dede61889f74443ea69690cf/greenlet-3.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:e7516cf6ae6b8a582c2770a0caed47b8a48373ed732c33d69a72913ae6ac923e", size = 245225, upload-time = "2026-05-20T13:13:59.366Z" },
{ url = "https://files.pythonhosted.org/packages/4f/fd/d3baea2eeb7b617efd47e87ca06e2ec2c6118d303aa9e918e0ce16eadc10/greenlet-3.5.1-cp315-cp315t-win_arm64.whl", hash = "sha256:5028648bf2253ec4745add746129d3904121fa7fe871a76bed23c5720573ce0a", size = 239590, upload-time = "2026-05-20T13:13:37.382Z" },
]
[[package]]
name = "h11"
version = "0.16.0"
@@ -550,10 +617,12 @@ dependencies = [
{ name = "langchain-anthropic" },
{ name = "langfuse" },
{ name = "langgraph" },
{ name = "openai" },
{ name = "psycopg", extra = ["binary"] },
{ name = "pydantic" },
{ name = "pydantic-settings" },
{ name = "pyyaml" },
{ name = "sqlalchemy" },
{ name = "sqlglot" },
{ name = "uvicorn", extra = ["standard"] },
]
@@ -573,6 +642,7 @@ requires-dist = [
{ name = "langchain-anthropic" },
{ name = "langfuse" },
{ name = "langgraph" },
{ name = "openai" },
{ name = "psycopg", extras = ["binary"] },
{ name = "pydantic", specifier = ">=2.0" },
{ name = "pydantic-settings" },
@@ -580,11 +650,31 @@ requires-dist = [
{ name = "pytest-asyncio", marker = "extra == 'dev'" },
{ name = "pyyaml" },
{ name = "ruff", marker = "extra == 'dev'" },
{ name = "sqlalchemy", specifier = ">=2.0" },
{ name = "sqlglot" },
{ name = "uvicorn", extras = ["standard"] },
]
provides-extras = ["dev"]
[[package]]
name = "openai"
version = "2.40.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "anyio" },
{ name = "distro" },
{ name = "httpx" },
{ name = "jiter" },
{ name = "pydantic" },
{ name = "sniffio" },
{ name = "tqdm" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f9/9f/136562ec6c3b1a50fe06eb0bb34ed21f0d7426ec0140e5cc43ac785b69a5/openai-2.40.0.tar.gz", hash = "sha256:9a756f91f274a24ad6026cbcb2042fd356c8d4a10e8f347b08d34465e585f7a2", size = 781177, upload-time = "2026-06-01T21:48:23.878Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f6/46/180e14be801a75bc13f234cb1b594b232adeb9c84e60a9ab1832e8333591/openai-2.40.0-py3-none-any.whl", hash = "sha256:2b205637ff214477f9ce9ab035e9f494db0e3fa8f1e599008953735fbf6ff1ff", size = 1350935, upload-time = "2026-06-01T21:48:21.462Z" },
]
[[package]]
name = "opentelemetry-api"
version = "1.42.1"
@@ -1107,6 +1197,47 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235, upload-time = "2024-02-25T23:20:01.196Z" },
]
[[package]]
name = "sqlalchemy"
version = "2.0.50"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "greenlet", marker = "platform_machine == 'AMD64' or platform_machine == 'WIN32' or platform_machine == 'aarch64' or platform_machine == 'amd64' or platform_machine == 'ppc64le' or platform_machine == 'win32' or platform_machine == 'x86_64'" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/57/da/6fbf010c8ebb347679d0d100b22fe9ba5e13fd04046c5df7280d2f0bf706/sqlalchemy-2.0.50.tar.gz", hash = "sha256:af5607d11ef90fd6a5c0549fe0045dce1663d427426bcfb506dcb5346a85a3b9", size = 9907424, upload-time = "2026-05-24T19:20:04.018Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/be/b0/a9d19b43f38f878b1278bca5b00b909f7540d41494396dd2561f9ad0956d/sqlalchemy-2.0.50-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:23ae23d8b9d344d30d0a92f06d45825024a5790f1c1dd4cf452636a50d3e58cb", size = 2159807, upload-time = "2026-05-24T19:27:53.086Z" },
{ url = "https://files.pythonhosted.org/packages/f5/2c/191dd58a248fd2cfd4780fa82c375c505e4ad98c8b522fa69ec492130d77/sqlalchemy-2.0.50-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:47b71b933e7b4ebad407c8fdfd70d2c4f08b78b3238bb30eebdd6eb32ca51b89", size = 3343358, upload-time = "2026-05-24T20:09:29.279Z" },
{ url = "https://files.pythonhosted.org/packages/8a/2b/514fce8a7df81cf5bad7ff7865de7ac0c5776a38cc043475c4703eb7fe8b/sqlalchemy-2.0.50-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:110fdac56ace278949f00de805edacbd6141e382d992f9ba28238b3a0827a600", size = 3357994, upload-time = "2026-05-24T20:17:13.495Z" },
{ url = "https://files.pythonhosted.org/packages/35/a6/a0e283f5494f92b0d77e319ff77e437b1ffe4a051ba67c81d53234825475/sqlalchemy-2.0.50-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0f5e4ac70e9e757f6b3e87c0491ff034442ecd8dfd36d041a50564c322dafc0e", size = 3289399, upload-time = "2026-05-24T20:09:32.239Z" },
{ url = "https://files.pythonhosted.org/packages/b7/96/1b07325ba71752d6a028b77d07bed1483ad545f794e8b1dc89b3ba3b3c68/sqlalchemy-2.0.50-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:724f3dcbe53dd0151e3cb5e7ec4ba4c620bede579caacd16275dc35ce06e8615", size = 3321216, upload-time = "2026-05-24T20:17:15.581Z" },
{ url = "https://files.pythonhosted.org/packages/ed/8e/bad6ed253e8a99edfc99af02f7173ec48a1d3ed1b9b35a1b8bc1700900cc/sqlalchemy-2.0.50-cp312-cp312-win32.whl", hash = "sha256:1208050441471d003b7c8cb4054fb084f185cf35ac3f0ea270803865bca9939a", size = 2119194, upload-time = "2026-05-24T19:50:04.943Z" },
{ url = "https://files.pythonhosted.org/packages/b6/2d/314a6690dda4b9cfc571eab1a63cf6fe6e1470aa3759ccda6aa016ee0f5a/sqlalchemy-2.0.50-cp312-cp312-win_amd64.whl", hash = "sha256:9d1af51558029a156a70986b7df88f042b3d158d7c8d8fb5072912d4b32d89c7", size = 2146186, upload-time = "2026-05-24T19:50:06.74Z" },
{ url = "https://files.pythonhosted.org/packages/0b/c4/c42356b527296e9862f67990efce31ef78b4cf69cd3f80873a528a060320/sqlalchemy-2.0.50-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:06a9210bdc5f4298cff0781087e2ff45683922252dacc452846373a58761f093", size = 2156697, upload-time = "2026-05-24T19:27:54.764Z" },
{ url = "https://files.pythonhosted.org/packages/60/a1/b1a70e3c4365ac7fe9e347f3710f19b562c866fb96d45e3c891588789a7b/sqlalchemy-2.0.50-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8b53784972ade4f8174b9aa661f31a06f8a936d2cfdd602913ff3c6dd40ae873", size = 3284260, upload-time = "2026-05-24T20:09:34.195Z" },
{ url = "https://files.pythonhosted.org/packages/3f/4a/f3ac3caa19f263d57b0a47f8c91bbf56583dc2d3fc63acfbf644abb24fe0/sqlalchemy-2.0.50-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:31648fa14460537e768a7303b078e4344d208e0d23e06867c1f376a227ed82db", size = 3302280, upload-time = "2026-05-24T20:17:17.825Z" },
{ url = "https://files.pythonhosted.org/packages/66/55/ccada3e3d62254587819749a0bc69f41173eb48a6e385d10e66d32a9c88e/sqlalchemy-2.0.50-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:03f4323c980ad0e918cc9e5369b015f759f4e534db5bbaf4dc36832c10d05064", size = 3231580, upload-time = "2026-05-24T20:09:36.406Z" },
{ url = "https://files.pythonhosted.org/packages/05/f6/6809349130a2de0e109e7f00fd7d431da9565b9b2868b32ee684754f672b/sqlalchemy-2.0.50-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2b9dcc43afef8ac157cd92fce96985d6b8b0cfbd3df4d666f66b4d55a75d202f", size = 3269375, upload-time = "2026-05-24T20:17:20.34Z" },
{ url = "https://files.pythonhosted.org/packages/48/84/278a811ef4e07be9c89dc5cdd7be833268509a66a68c4897cf585e67428f/sqlalchemy-2.0.50-cp313-cp313-win32.whl", hash = "sha256:60922d6599065ddca2c6f376b9aa2f41a6b85a271725e0909490bbc50b1998a5", size = 2117229, upload-time = "2026-05-24T19:50:08.215Z" },
{ url = "https://files.pythonhosted.org/packages/f6/1c/067cc6187ed32d2ec222fe6d2643acc1659a6d0659f8a7cbc5ad3ae83280/sqlalchemy-2.0.50-cp313-cp313-win_amd64.whl", hash = "sha256:287086e67275a212c4582d166a6fb03a65ccc5551d80866270ce0dd9f34eccd3", size = 2143126, upload-time = "2026-05-24T19:50:09.691Z" },
{ url = "https://files.pythonhosted.org/packages/df/32/10ac51b4be7cdecd7e93d069251c86dfbf70b7adbd7c67b48ccea6c49e1c/sqlalchemy-2.0.50-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c966932507a4d7d0a37314927dbfcd89720e3f37d2a1e3352e7ae7939fa8e8a0", size = 2158519, upload-time = "2026-05-24T19:27:56.472Z" },
{ url = "https://files.pythonhosted.org/packages/5a/76/e703d2f7681d7d66c4c891af3f07c7ccf4c76ad7f18351de035b5eda007a/sqlalchemy-2.0.50-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:faffef4bcc20a1892e65e155293d99d60855bbbc79250ab712819cfd56a8e6bb", size = 3282063, upload-time = "2026-05-24T20:09:38.57Z" },
{ url = "https://files.pythonhosted.org/packages/31/26/ef168b184a25701f9995e8fb7e503fafd7a99c1c77cda1bc1a26ea2ed486/sqlalchemy-2.0.50-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c206aec519a2e7bd08abbfb33436e325fd22c632d9c21a9047e376ce241646e", size = 3287069, upload-time = "2026-05-24T20:17:21.942Z" },
{ url = "https://files.pythonhosted.org/packages/c2/15/765acc2bc693bccc43ca4a95d5b69750da8aaf6db1b5c616536e087f8920/sqlalchemy-2.0.50-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:bef4ac756363227ef6402a75fee025a4bc690f92328e825868939b3b3a446a6d", size = 3230453, upload-time = "2026-05-24T20:09:40.398Z" },
{ url = "https://files.pythonhosted.org/packages/63/61/08e03c3adbf5db0087a0b6816746fec8f3032fb2f7fc899a9bb9b2a48ce4/sqlalchemy-2.0.50-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:96fbee6b19c19cd1556c8bf9419447cf2ec149ffcab7ab64348c23e54ef8547f", size = 3252413, upload-time = "2026-05-24T20:17:24.067Z" },
{ url = "https://files.pythonhosted.org/packages/03/0c/370a1f2db38436c615e10134c8a37de3688e74084792380695f3f5083860/sqlalchemy-2.0.50-cp314-cp314-win32.whl", hash = "sha256:8f00e3eb43ba30eb1b238ee03a8a62309486d1321eda3328bb611e0340033ad8", size = 2120063, upload-time = "2026-05-24T19:50:11.08Z" },
{ url = "https://files.pythonhosted.org/packages/7f/a0/fe92bb9817863bc13ba093bda931979a26cc2ca69f8e8f26d07add3d7c6f/sqlalchemy-2.0.50-cp314-cp314-win_amd64.whl", hash = "sha256:15708c613cd5005b7dffe1f66ee6a63ee8f5e46799f71c70ebad74178c676a39", size = 2145830, upload-time = "2026-05-24T19:50:12.452Z" },
{ url = "https://files.pythonhosted.org/packages/cc/ff/e5640a98a0b2f491eb8fde10fb6c773621a2e44340de231fafcc9370f4a9/sqlalchemy-2.0.50-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3699dac4be410e97049a1658e9480da9cde956594aa0f3aebc60b88f21c5ba70", size = 2178435, upload-time = "2026-05-24T19:42:58.889Z" },
{ url = "https://files.pythonhosted.org/packages/b7/85/337116e186f1236375b5fb70c21cfac98e8e8ab0d3a47be838dc47a59e08/sqlalchemy-2.0.50-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f96233858e3df43932ac11589e22520da6e8aeb624b03fedfeebb0e8ea213086", size = 3566059, upload-time = "2026-05-24T20:01:20.848Z" },
{ url = "https://files.pythonhosted.org/packages/96/34/bb0e190e161c3c2c24314a65add57218be14a4a9486886b7f5047c1ff7c8/sqlalchemy-2.0.50-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c4e70c46fad30c3bcc6a4708bc0130a3173e11a5b25f0ea4a9d8911b450f1f52", size = 3535366, upload-time = "2026-05-24T20:03:56.768Z" },
{ url = "https://files.pythonhosted.org/packages/df/5a/a7f759f97e4fd499c5d4e4488c760d5a7fbecf3028b465a04274fcd52384/sqlalchemy-2.0.50-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1918a3cf564d16d95bca7301005f41ab2ad50b07cd3b9da50d3ed986db148d6a", size = 3474879, upload-time = "2026-05-24T20:01:23.058Z" },
{ url = "https://files.pythonhosted.org/packages/9d/d9/2907ea38eb60687d297bf9c39e5ee58053c87b57fe8a9cae97090cecbf10/sqlalchemy-2.0.50-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b00098cdbdbd38c7be3d568b0c9c3122b8c0ec62b911b57cd5e6e0254d60a76d", size = 3486117, upload-time = "2026-05-24T20:03:59.052Z" },
{ url = "https://files.pythonhosted.org/packages/f2/e3/5aa06f167559f8c0bdae487e297d23ba548150ab016a3418265d617a4985/sqlalchemy-2.0.50-cp314-cp314t-win32.whl", hash = "sha256:1fbd55a969d7ac44a98e3dec75016074f809fa08f871585ace58dde110d1bf3e", size = 2150823, upload-time = "2026-05-24T20:08:58.644Z" },
{ url = "https://files.pythonhosted.org/packages/65/9b/112fb8f977582d7489d036e409e3723948bcf5320b3ac465f3c481bbe8f9/sqlalchemy-2.0.50-cp314-cp314t-win_amd64.whl", hash = "sha256:c5c3cdb753a9004183e1ccb634b41611654c989e61bc68617ce878e46d6f1e51", size = 2185794, upload-time = "2026-05-24T20:09:00.319Z" },
{ url = "https://files.pythonhosted.org/packages/d0/10/f7220e9b784d295d241c86ed99aeb537f92afcd469a64861f2717e9bb077/sqlalchemy-2.0.50-py3-none-any.whl", hash = "sha256:92064363517a3ff8212b5a93b8c62876579d8dfd1ca5b561335f30152d884fa9", size = 1943861, upload-time = "2026-05-24T19:59:01.119Z" },
]
[[package]]
name = "sqlglot"
version = "30.8.0"
@@ -1138,6 +1269,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" },
]
[[package]]
name = "tqdm"
version = "4.67.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/09/a9/6ba95a270c6f1fbcd8dac228323f2777d886cb206987444e4bce66338dd4/tqdm-4.67.3.tar.gz", hash = "sha256:7d825f03f89244ef73f1d4ce193cb1774a8179fd96f31d7e1dcde62092b960bb", size = 169598, upload-time = "2026-02-03T17:35:53.048Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/16/e1/3079a9ff9b8e11b846c6ac5c8b5bfb7ff225eee721825310c91b3b50304f/tqdm-4.67.3-py3-none-any.whl", hash = "sha256:ee1e4c0e59148062281c49d80b25b67771a127c85fc9676d3be5f243206826bf", size = 78374, upload-time = "2026-02-03T17:35:50.982Z" },
]
[[package]]
name = "typing-extensions"
version = "4.15.0"