verbose live UI + tool-level SSE events + Groq default + regression tests
This commit is contained in:
15
.dockerignore
Normal file
15
.dockerignore
Normal 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
|
||||||
@@ -4,6 +4,9 @@
|
|||||||
# Warehouse
|
# Warehouse
|
||||||
DATABASE_URL=postgresql://nvi:nvi@postgres:5432/nvi
|
DATABASE_URL=postgresql://nvi:nvi@postgres:5432/nvi
|
||||||
|
|
||||||
|
# Active dataset (selects api/datasets/<name>/ + Postgres schema + sqlite file).
|
||||||
|
NVI_DATASET=financial
|
||||||
|
|
||||||
# LLM
|
# LLM
|
||||||
ANTHROPIC_API_KEY=
|
ANTHROPIC_API_KEY=
|
||||||
ANTHROPIC_MODEL=claude-sonnet-4-6
|
ANTHROPIC_MODEL=claude-sonnet-4-6
|
||||||
|
|||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -13,3 +13,8 @@ dist
|
|||||||
|
|
||||||
# soleprint-ui framework is propagated by spr; not tracked in nvi.
|
# soleprint-ui framework is propagated by spr; not tracked in nvi.
|
||||||
ui/framework
|
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
|
||||||
|
|||||||
28
Makefile
28
Makefile
@@ -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 \
|
compose-up compose-down compose-clean compose-seed compose-evals \
|
||||||
docs-graphs
|
docs-graphs
|
||||||
|
|
||||||
@@ -17,11 +17,29 @@ tilt-up:
|
|||||||
tilt-down:
|
tilt-down:
|
||||||
cd ctrl && tilt down $(KCTX)
|
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
|
kubectl $(KCTX) $(KNS) exec deploy/api -- uv run python -m seed.load_bird
|
||||||
|
|
||||||
evals:
|
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) ──────────────────────
|
# ── docker-compose (alternative path) ──────────────────────
|
||||||
|
|
||||||
@@ -34,11 +52,11 @@ compose-down:
|
|||||||
compose-clean:
|
compose-clean:
|
||||||
$(COMPOSE) down -v
|
$(COMPOSE) down -v
|
||||||
|
|
||||||
compose-seed:
|
compose-seed: seed-fetch
|
||||||
$(COMPOSE) exec api uv run python -m seed.load_bird
|
$(COMPOSE) exec api uv run python -m seed.load_bird
|
||||||
|
|
||||||
compose-evals:
|
compose-evals:
|
||||||
$(COMPOSE) exec api uv run python -m evals.run_evals
|
$(COMPOSE) exec api uv run python -m api.evals.run_evals
|
||||||
|
|
||||||
# ── docs ───────────────────────────────────────────────────
|
# ── docs ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|||||||
0
api/analyses/__init__.py
Normal file
0
api/analyses/__init__.py
Normal file
27
api/analyses/base.py
Normal file
27
api/analyses/base.py
Normal 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
|
||||||
141
api/analyses/compare_periods.py
Normal file
141
api/analyses/compare_periods.py
Normal 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
|
||||||
81
api/analyses/direct_answer.py
Normal file
81
api/analyses/direct_answer.py
Normal 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
30
api/analyses/registry.py
Normal 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
17
api/analyses/types.py
Normal 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)
|
||||||
@@ -6,10 +6,26 @@ from pydantic_settings import BaseSettings
|
|||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
database_url: str = "postgresql://nvi:nvi@postgres:5432/nvi"
|
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_api_key: str = ""
|
||||||
anthropic_model: str = "claude-sonnet-4-6"
|
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_public_key: str = ""
|
||||||
langfuse_secret_key: str = ""
|
langfuse_secret_key: str = ""
|
||||||
|
|
||||||
|
|||||||
23
api/datasets/README.md
Normal file
23
api/datasets/README.md
Normal 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
40
api/datasets/__init__.py
Normal 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 {}
|
||||||
50
api/datasets/financial/metrics.yaml
Normal file
50
api/datasets/financial/metrics.yaml
Normal 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
|
||||||
93
api/datasets/financial/schema_docs.yaml
Normal file
93
api/datasets/financial/schema_docs.yaml
Normal 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
0
api/evals/__init__.py
Normal file
11
api/evals/run_evals.py
Normal file
11
api/evals/run_evals.py
Normal 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
87
api/langfuse_client.py
Normal 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
91
api/llm.py
Normal 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)
|
||||||
52
api/main.py
52
api/main.py
@@ -1,14 +1,14 @@
|
|||||||
"""FastAPI entry point for nvi.
|
"""FastAPI entry point for nvi.
|
||||||
|
|
||||||
Endpoints:
|
Endpoints:
|
||||||
GET /healthz — liveness / readiness probe
|
GET /healthz — liveness probe
|
||||||
POST /ask — submit a question, returns run_id
|
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
|
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 asyncio
|
||||||
import logging
|
import logging
|
||||||
import uuid
|
import uuid
|
||||||
@@ -20,12 +20,15 @@ from fastapi.middleware.cors import CORSMiddleware
|
|||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from api.runtime import events
|
||||||
|
from api.runtime.runner import run as run_graph
|
||||||
|
|
||||||
logger = logging.getLogger("nvi")
|
logger = logging.getLogger("nvi")
|
||||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
|
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] = {}
|
runs: dict[str, dict] = {}
|
||||||
RUN_TTL_SECONDS = 3600
|
RUN_TTL_SECONDS = 3600
|
||||||
RUN_CLEANUP_INTERVAL = 300
|
RUN_CLEANUP_INTERVAL = 300
|
||||||
@@ -52,7 +55,6 @@ async def lifespan(_app: FastAPI):
|
|||||||
|
|
||||||
|
|
||||||
app = FastAPI(title="nvi", lifespan=lifespan)
|
app = FastAPI(title="nvi", lifespan=lifespan)
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
CORSMiddleware,
|
CORSMiddleware,
|
||||||
allow_origins=["*"],
|
allow_origins=["*"],
|
||||||
@@ -66,8 +68,6 @@ async def healthz():
|
|||||||
return {"status": "ok", "runs_in_memory": len(runs)}
|
return {"status": "ok", "runs_in_memory": len(runs)}
|
||||||
|
|
||||||
|
|
||||||
# ── Ask ──
|
|
||||||
|
|
||||||
class AskRequest(BaseModel):
|
class AskRequest(BaseModel):
|
||||||
question: str
|
question: str
|
||||||
|
|
||||||
@@ -77,14 +77,31 @@ async def ask(req: AskRequest):
|
|||||||
run_id = uuid.uuid4().hex[:8]
|
run_id = uuid.uuid4().hex[:8]
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
runs[run_id] = {
|
runs[run_id] = {
|
||||||
"status": "pending",
|
"status": "running",
|
||||||
"question": req.question,
|
"question": req.question,
|
||||||
"created_at": now.timestamp(),
|
"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)
|
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}")
|
@app.get("/runs/{run_id}")
|
||||||
@@ -98,11 +115,4 @@ async def get_run(run_id: str):
|
|||||||
async def stream_run(run_id: str):
|
async def stream_run(run_id: str):
|
||||||
if run_id not in runs:
|
if run_id not in runs:
|
||||||
raise HTTPException(404, detail=f"Run {run_id} not found")
|
raise HTTPException(404, detail=f"Run {run_id} not found")
|
||||||
|
return StreamingResponse(events.stream(run_id), media_type="text/event-stream")
|
||||||
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")
|
|
||||||
|
|||||||
0
api/plan/__init__.py
Normal file
0
api/plan/__init__.py
Normal file
52
api/plan/planner.py
Normal file
52
api/plan/planner.py
Normal 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
37
api/plan/types.py
Normal 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
31
api/prompts/__init__.py
Normal 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)
|
||||||
5
api/prompts/compare_periods.interpret.txt
Normal file
5
api/prompts/compare_periods.interpret.txt
Normal 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.
|
||||||
7
api/prompts/compare_periods.interpret.user.txt
Normal file
7
api/prompts/compare_periods.interpret.user.txt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
Question: {question}
|
||||||
|
|
||||||
|
Period A ({label_a}):
|
||||||
|
{rows_a}
|
||||||
|
|
||||||
|
Period B ({label_b}):
|
||||||
|
{rows_b}
|
||||||
16
api/prompts/compare_periods.pair.txt
Normal file
16
api/prompts/compare_periods.pair.txt
Normal 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.
|
||||||
10
api/prompts/compare_periods.pair.user.txt
Normal file
10
api/prompts/compare_periods.pair.user.txt
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
Question: {question}
|
||||||
|
|
||||||
|
Period A: {period_a}
|
||||||
|
Period B: {period_b}
|
||||||
|
|
||||||
|
Schema:
|
||||||
|
{schema_block}
|
||||||
|
|
||||||
|
Metrics:
|
||||||
|
{metrics_block}
|
||||||
5
api/prompts/direct_answer.interpret.txt
Normal file
5
api/prompts/direct_answer.interpret.txt
Normal 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.
|
||||||
7
api/prompts/direct_answer.interpret.user.txt
Normal file
7
api/prompts/direct_answer.interpret.user.txt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
Question: {question}
|
||||||
|
|
||||||
|
SQL:
|
||||||
|
{sql}
|
||||||
|
|
||||||
|
Rows (up to 20):
|
||||||
|
{rows}
|
||||||
17
api/prompts/planner.system.txt
Normal file
17
api/prompts/planner.system.txt
Normal 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.
|
||||||
9
api/prompts/planner.user.txt
Normal file
9
api/prompts/planner.user.txt
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
Question: {question}
|
||||||
|
|
||||||
|
Warehouse tables: {table_names}
|
||||||
|
|
||||||
|
Metric catalog:
|
||||||
|
{metrics_block}
|
||||||
|
|
||||||
|
Analysis catalog:
|
||||||
|
{catalog}
|
||||||
7
api/prompts/synthesize.system.txt
Normal file
7
api/prompts/synthesize.system.txt
Normal 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").
|
||||||
4
api/prompts/synthesize.user.txt
Normal file
4
api/prompts/synthesize.user.txt
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
Question: {question}
|
||||||
|
|
||||||
|
Findings:
|
||||||
|
{findings_block}
|
||||||
15
api/prompts/text_to_sql.system.txt
Normal file
15
api/prompts/text_to_sql.system.txt
Normal 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.
|
||||||
10
api/prompts/text_to_sql.user.txt
Normal file
10
api/prompts/text_to_sql.user.txt
Normal 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
0
api/runtime/__init__.py
Normal file
12
api/runtime/context.py
Normal file
12
api/runtime/context.py
Normal 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
133
api/runtime/events.py
Normal 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
139
api/runtime/runner.py
Normal 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
20
api/runtime/state.py
Normal 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
0
api/tools/__init__.py
Normal file
39
api/tools/db.py
Normal file
39
api/tools/db.py
Normal 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
55
api/tools/execute_sql.py
Normal 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
57
api/tools/schema.py
Normal 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
70
api/tools/text_to_sql.py
Normal 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
128
api/tools/types.py
Normal 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
|
||||||
@@ -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
|
RUN uv sync --no-dev --no-install-project --frozen 2>/dev/null || uv sync --no-dev --no-install-project
|
||||||
|
|
||||||
COPY api/ api/
|
COPY api/ api/
|
||||||
|
COPY ctrl/seed/ seed/
|
||||||
|
|
||||||
CMD ["uv", "run", "uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
CMD ["uv", "run", "uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||||
|
|||||||
@@ -25,23 +25,23 @@ docker_build(
|
|||||||
'nvi-api',
|
'nvi-api',
|
||||||
context='..',
|
context='..',
|
||||||
dockerfile='Dockerfile.api',
|
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=[
|
live_update=[
|
||||||
sync('../api', '/app/api'),
|
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(
|
docker_build(
|
||||||
'nvi-ui',
|
'nvi-ui',
|
||||||
context='..',
|
context='..',
|
||||||
dockerfile='Dockerfile.ui',
|
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
|
# Docs — nginx serving pre-rendered HTML + Graphviz SVGs
|
||||||
|
|||||||
@@ -32,6 +32,11 @@ services:
|
|||||||
environment: *common-env
|
environment: *common-env
|
||||||
ports:
|
ports:
|
||||||
- "8000:8000"
|
- "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:
|
depends_on:
|
||||||
postgres:
|
postgres:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
|||||||
@@ -7,13 +7,19 @@ data:
|
|||||||
# Warehouse (in-cluster postgres)
|
# Warehouse (in-cluster postgres)
|
||||||
DATABASE_URL: "postgresql://nvi:nvi@postgres:5432/nvi"
|
DATABASE_URL: "postgresql://nvi:nvi@postgres:5432/nvi"
|
||||||
|
|
||||||
# Langfuse (lng cluster, reached over WireGuard from the host).
|
# Active dataset. Selects api/datasets/<name>/, the Postgres schema, and
|
||||||
# Override LANGFUSE_HOST to your lng WireGuard endpoint; keys come from
|
# ctrl/seed/data/<name>.sqlite. Restart the api to switch.
|
||||||
# the "nvi" project created inside the lng Langfuse UI.
|
NVI_DATASET: "financial"
|
||||||
LANGFUSE_HOST: "http://lng.local.ar:3000"
|
|
||||||
LANGFUSE_PUBLIC_KEY: ""
|
# LLM. Supported providers: groq, anthropic, openai.
|
||||||
LANGFUSE_SECRET_KEY: ""
|
LLM_PROVIDER: "groq"
|
||||||
|
GROQ_API_KEY: "REDACTED_GROQ_API_KEY"
|
||||||
|
GROQ_MODEL: "llama-3.3-70b-versatile"
|
||||||
|
|
||||||
# LLM
|
|
||||||
ANTHROPIC_API_KEY: ""
|
ANTHROPIC_API_KEY: ""
|
||||||
ANTHROPIC_MODEL: "claude-sonnet-4-6"
|
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
0
ctrl/seed/__init__.py
Normal file
44
ctrl/seed/download.sh
Executable file
44
ctrl/seed/download.sh
Executable 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
147
ctrl/seed/load_bird.py
Normal 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()
|
||||||
@@ -8,9 +8,11 @@ dependencies = [
|
|||||||
"pydantic>=2.0",
|
"pydantic>=2.0",
|
||||||
"pydantic-settings",
|
"pydantic-settings",
|
||||||
"anthropic",
|
"anthropic",
|
||||||
|
"openai",
|
||||||
"langgraph",
|
"langgraph",
|
||||||
"langchain-anthropic",
|
"langchain-anthropic",
|
||||||
"langfuse",
|
"langfuse",
|
||||||
|
"sqlalchemy>=2.0",
|
||||||
"psycopg[binary]",
|
"psycopg[binary]",
|
||||||
"sqlglot",
|
"sqlglot",
|
||||||
"httpx",
|
"httpx",
|
||||||
|
|||||||
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
60
tests/test_events.py
Normal file
60
tests/test_events.py
Normal 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
42
tests/test_imports.py
Normal 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)
|
||||||
41
tests/test_langfuse_client.py
Normal file
41
tests/test_langfuse_client.py
Normal 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
15
tests/test_llm.py
Normal 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
31
tests/test_plan_types.py
Normal 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
30
tests/test_prompts.py
Normal 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
|
||||||
46
tests/test_yaml_datasets.py
Normal file
46
tests/test_yaml_datasets.py
Normal 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"]
|
||||||
267
ui/app/src/composables/useRunStream.ts
Normal file
267
ui/app/src/composables/useRunStream.ts
Normal 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
21
ui/app/src/config.ts
Normal 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
|
||||||
|
}
|
||||||
@@ -3,13 +3,12 @@ import { createRouter, createWebHistory } from 'vue-router'
|
|||||||
import 'soleprint-ui/src/tokens.css'
|
import 'soleprint-ui/src/tokens.css'
|
||||||
import App from './App.vue'
|
import App from './App.vue'
|
||||||
import Ask from './pages/Ask.vue'
|
import Ask from './pages/Ask.vue'
|
||||||
import RunInspector from './pages/RunInspector.vue'
|
|
||||||
|
|
||||||
const router = createRouter({
|
const router = createRouter({
|
||||||
history: createWebHistory(),
|
history: createWebHistory(),
|
||||||
routes: [
|
routes: [
|
||||||
{ path: '/', component: Ask },
|
{ path: '/', component: Ask },
|
||||||
{ path: '/runs/:id', component: RunInspector, props: true },
|
{ path: '/:catchAll(.*)', redirect: '/' },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,80 +1,477 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { nextTick, ref, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { Panel } from 'soleprint-ui'
|
||||||
|
import { useRunStream } from '../composables/useRunStream'
|
||||||
|
|
||||||
const question = ref('Which districts had the sharpest rise in loan defaults?')
|
const question = ref('How many loans are currently in debt (status D)?')
|
||||||
const submitting = ref(false)
|
const examples = [
|
||||||
const error = ref<string | null>(null)
|
'How many loans are currently in debt (status D)?',
|
||||||
const router = useRouter()
|
'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() {
|
async function submit() {
|
||||||
if (!question.value.trim()) return
|
if (!question.value.trim() || isRunning.value) return
|
||||||
submitting.value = true
|
await ask(question.value)
|
||||||
error.value = null
|
}
|
||||||
try {
|
|
||||||
const res = await fetch('/ask', {
|
function pick(q: string) {
|
||||||
method: 'POST',
|
question.value = q
|
||||||
headers: { 'Content-Type': 'application/json' },
|
}
|
||||||
body: JSON.stringify({ question: question.value }),
|
|
||||||
})
|
function statusOf(s: string): 'idle' | 'processing' | 'live' | 'error' {
|
||||||
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
|
if (s === 'streaming' || s === 'connecting') return 'processing'
|
||||||
const { run_id } = await res.json()
|
if (s === 'done') return 'live'
|
||||||
router.push(`/runs/${run_id}`)
|
if (s === 'error') return 'error'
|
||||||
} catch (e: any) {
|
return 'idle'
|
||||||
error.value = e.message ?? String(e)
|
|
||||||
} finally {
|
|
||||||
submitting.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="ask">
|
<div class="ask-layout">
|
||||||
<h2>Ask a question</h2>
|
<!-- Left / top: ask + answer (the user's surface) -->
|
||||||
<textarea v-model="question" rows="3" :disabled="submitting" />
|
<div class="ask-pane">
|
||||||
<div class="actions">
|
<Panel title="Ask" :status="statusOf(status)">
|
||||||
<button @click="submit" :disabled="submitting || !question.trim()">
|
<div class="ask-form">
|
||||||
{{ submitting ? 'Submitting…' : 'Ask' }}
|
<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>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.ask {
|
.ask-layout {
|
||||||
max-width: 720px;
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 16px;
|
||||||
|
height: calc(100vh - 80px);
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ask-pane,
|
||||||
|
.trace-pane {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
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 {
|
textarea {
|
||||||
|
width: 100%;
|
||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
border: var(--panel-border);
|
border: var(--panel-border);
|
||||||
padding: 12px;
|
padding: 10px;
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 14px;
|
font-size: 13px;
|
||||||
|
resize: vertical;
|
||||||
}
|
}
|
||||||
.actions {
|
|
||||||
|
.ask-actions {
|
||||||
display: flex;
|
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);
|
font-family: var(--font-mono);
|
||||||
background: var(--accent-dim);
|
font-size: 11px;
|
||||||
color: var(--text-primary);
|
color: var(--text-dim);
|
||||||
border: var(--panel-border);
|
}
|
||||||
|
|
||||||
|
.run-btn {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 6px 18px;
|
||||||
|
font-family: var(--font-mono);
|
||||||
|
font-size: 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
button:disabled {
|
.run-btn:hover { background: var(--accent-dim); }
|
||||||
opacity: 0.5;
|
.run-btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||||||
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);
|
color: var(--status-error, #f55);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 12px;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -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
143
uv.lock
generated
@@ -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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "h11"
|
name = "h11"
|
||||||
version = "0.16.0"
|
version = "0.16.0"
|
||||||
@@ -550,10 +617,12 @@ dependencies = [
|
|||||||
{ name = "langchain-anthropic" },
|
{ name = "langchain-anthropic" },
|
||||||
{ name = "langfuse" },
|
{ name = "langfuse" },
|
||||||
{ name = "langgraph" },
|
{ name = "langgraph" },
|
||||||
|
{ name = "openai" },
|
||||||
{ name = "psycopg", extra = ["binary"] },
|
{ name = "psycopg", extra = ["binary"] },
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
{ name = "pydantic-settings" },
|
{ name = "pydantic-settings" },
|
||||||
{ name = "pyyaml" },
|
{ name = "pyyaml" },
|
||||||
|
{ name = "sqlalchemy" },
|
||||||
{ name = "sqlglot" },
|
{ name = "sqlglot" },
|
||||||
{ name = "uvicorn", extra = ["standard"] },
|
{ name = "uvicorn", extra = ["standard"] },
|
||||||
]
|
]
|
||||||
@@ -573,6 +642,7 @@ requires-dist = [
|
|||||||
{ name = "langchain-anthropic" },
|
{ name = "langchain-anthropic" },
|
||||||
{ name = "langfuse" },
|
{ name = "langfuse" },
|
||||||
{ name = "langgraph" },
|
{ name = "langgraph" },
|
||||||
|
{ name = "openai" },
|
||||||
{ name = "psycopg", extras = ["binary"] },
|
{ name = "psycopg", extras = ["binary"] },
|
||||||
{ name = "pydantic", specifier = ">=2.0" },
|
{ name = "pydantic", specifier = ">=2.0" },
|
||||||
{ name = "pydantic-settings" },
|
{ name = "pydantic-settings" },
|
||||||
@@ -580,11 +650,31 @@ requires-dist = [
|
|||||||
{ name = "pytest-asyncio", marker = "extra == 'dev'" },
|
{ name = "pytest-asyncio", marker = "extra == 'dev'" },
|
||||||
{ name = "pyyaml" },
|
{ name = "pyyaml" },
|
||||||
{ name = "ruff", marker = "extra == 'dev'" },
|
{ name = "ruff", marker = "extra == 'dev'" },
|
||||||
|
{ name = "sqlalchemy", specifier = ">=2.0" },
|
||||||
{ name = "sqlglot" },
|
{ name = "sqlglot" },
|
||||||
{ name = "uvicorn", extras = ["standard"] },
|
{ name = "uvicorn", extras = ["standard"] },
|
||||||
]
|
]
|
||||||
provides-extras = ["dev"]
|
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]]
|
[[package]]
|
||||||
name = "opentelemetry-api"
|
name = "opentelemetry-api"
|
||||||
version = "1.42.1"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "sqlglot"
|
name = "sqlglot"
|
||||||
version = "30.8.0"
|
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" },
|
{ 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]]
|
[[package]]
|
||||||
name = "typing-extensions"
|
name = "typing-extensions"
|
||||||
version = "4.15.0"
|
version = "4.15.0"
|
||||||
|
|||||||
Reference in New Issue
Block a user