scaffold: kind+Tilt cluster, api/ui/docs stubs, green at nvi.local.ar

This commit is contained in:
2026-06-03 00:33:51 -03:00
commit 131f4d9b86
39 changed files with 3571 additions and 0 deletions

15
.env.example Normal file
View File

@@ -0,0 +1,15 @@
# Copy to .env and fill in. Used by docker-compose; k8s pulls from the
# nvi-config ConfigMap (ctrl/k8s/base/configmap.yaml).
# Warehouse
DATABASE_URL=postgresql://nvi:nvi@postgres:5432/nvi
# LLM
ANTHROPIC_API_KEY=
ANTHROPIC_MODEL=claude-sonnet-4-6
# Langfuse — set LANGFUSE_HOST to your lng WireGuard endpoint; create an
# "nvi" project in the lng UI and paste its keys here.
LANGFUSE_HOST=http://lng.local.ar:3000
LANGFUSE_PUBLIC_KEY=
LANGFUSE_SECRET_KEY=

15
.gitignore vendored Normal file
View File

@@ -0,0 +1,15 @@
def
.data
.env
.venv
__pycache__
*.pyc
.pytest_cache
node_modules
dist
.DS_Store
.vscode
.idea
# soleprint-ui framework is propagated by spr; not tracked in nvi.
ui/framework

50
Makefile Normal file
View File

@@ -0,0 +1,50 @@
.PHONY: kind tilt-up tilt-down seed evals \
compose-up compose-down compose-clean compose-seed compose-evals \
docs-graphs
COMPOSE := docker compose -f ctrl/docker-compose.yml
KCTX := --context kind-nvi
KNS := -n nvi
# ── kind + Tilt (primary path) ─────────────────────────────
kind:
bash ctrl/kind-config.sh
tilt-up:
cd ctrl && tilt up $(KCTX)
tilt-down:
cd ctrl && tilt down $(KCTX)
seed:
kubectl $(KCTX) $(KNS) exec deploy/api -- uv run python -m seed.load_bird
evals:
kubectl $(KCTX) $(KNS) exec deploy/api -- uv run python -m evals.run_evals
# ── docker-compose (alternative path) ──────────────────────
compose-up:
$(COMPOSE) up -d --build
compose-down:
$(COMPOSE) down
compose-clean:
$(COMPOSE) down -v
compose-seed:
$(COMPOSE) exec api uv run python -m seed.load_bird
compose-evals:
$(COMPOSE) exec api uv run python -m evals.run_evals
# ── docs ───────────────────────────────────────────────────
docs-graphs:
@for f in docs/graphs/*.dot; do \
out=$${f%.dot}.svg; \
echo " graphviz $$f → $$out"; \
dot -Tsvg $$f -o $$out; \
done

61
README.md Normal file
View File

@@ -0,0 +1,61 @@
# nvi
Text-to-SQL analytics agent over the BIRD `financial` warehouse (PKDD'99 Czech bank).
Three-layer Plan / Analyses / Tools architecture, purpose-built runtime, soleprint-ui frontend, Langfuse observability via the sibling `lng` cluster.
## Quick start
Prerequisites: the `lng` Langfuse cluster reachable from this host, and the
soleprint-ui framework propagated into `ui/framework` (handled by `spr`).
Set `LANGFUSE_HOST` and the Langfuse keys in `.env` (see `.env.example`).
Two equivalent paths — pick one.
### Primary: kind + Tilt
```bash
make kind # create kind-nvi cluster
make tilt-up # build images, bring up postgres, api, ui, docs, gateway
make seed # load BIRD financial into postgres (runs inside the api pod)
```
`*.local.ar` resolves via dnsmasq; the system Caddy at
`~/wdir/ppl/local/Caddyfile` proxies `nvi.local.ar` to the kind NodePort
(`localhost:8060`). Reload after first install: `sudo systemctl reload caddy`.
Then open `http://nvi.local.ar`.
### Alternative: docker-compose
For when you don't want a k8s cluster running.
```bash
make compose-up # build + start (postgres, api, ui, docs)
make compose-seed # load BIRD financial (runs inside the api container)
```
Then open `http://localhost:8060` (ui) or `http://localhost:8000` (api).
### IDE setup (optional)
The app runs entirely in containers; `uv sync` on the host is only useful for
editor/LSP support. Run `uv sync` yourself if you want a local `.venv`.
## Layout
```
api/ FastAPI: Plan, Analyses, Tools, runtime
ui/app/ Vue 3 + soleprint-ui frontend
ui/framework/ soleprint-ui (managed by spr; gitignored)
docs/ hand-built HTML + Graphviz SVGs, served via nginx
seed/ BIRD financial loader
evals/ BIRD golden-set runner
ctrl/ Tiltfile, kind config, k8s manifests, Dockerfiles
def/ external reference notes (gitignored)
```
## Why this shape
See `docs/` (served at `http://docs.nvi.local.ar:8060` once Tilt is up) and the
approved plan at
`~/.claude/plans/context-ref-file-home-mariano-wdir-nvi-floating-dream.md`.

0
api/__init__.py Normal file
View File

21
api/config.py Normal file
View File

@@ -0,0 +1,21 @@
from functools import lru_cache
from pydantic_settings import BaseSettings
class Settings(BaseSettings):
database_url: str = "postgresql://nvi:nvi@postgres:5432/nvi"
anthropic_api_key: str = ""
anthropic_model: str = "claude-sonnet-4-6"
langfuse_host: str = "http://lng.local.ar:3000"
langfuse_public_key: str = ""
langfuse_secret_key: str = ""
model_config = {"env_prefix": "", "case_sensitive": False}
@lru_cache
def get_settings() -> Settings:
return Settings()

108
api/main.py Normal file
View File

@@ -0,0 +1,108 @@
"""FastAPI entry point for nvi.
Endpoints:
GET /healthz — liveness / readiness probe
POST /ask — submit a question, returns run_id
GET /runs/{run_id}/stream — SSE stream of node-state events
GET /runs/{run_id} — final state snapshot
Stubs only at this point — the Plan/Analyses/Tools/runtime are next.
"""
import asyncio
import logging
import uuid
from contextlib import asynccontextmanager
from datetime import datetime, timezone
from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import StreamingResponse
from pydantic import BaseModel
logger = logging.getLogger("nvi")
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s")
# ── In-memory run store (single-process; replace with redis if we ever scale) ──
runs: dict[str, dict] = {}
RUN_TTL_SECONDS = 3600
RUN_CLEANUP_INTERVAL = 300
async def _cleanup_runs():
while True:
await asyncio.sleep(RUN_CLEANUP_INTERVAL)
now = datetime.now(timezone.utc).timestamp()
expired = [
rid for rid, r in runs.items()
if r["status"] in ("completed", "error")
and now - r.get("created_at", now) > RUN_TTL_SECONDS
]
for rid in expired:
del runs[rid]
@asynccontextmanager
async def lifespan(_app: FastAPI):
task = asyncio.create_task(_cleanup_runs())
yield
task.cancel()
app = FastAPI(title="nvi", lifespan=lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
@app.get("/healthz")
async def healthz():
return {"status": "ok", "runs_in_memory": len(runs)}
# ── Ask ──
class AskRequest(BaseModel):
question: str
@app.post("/ask")
async def ask(req: AskRequest):
run_id = uuid.uuid4().hex[:8]
now = datetime.now(timezone.utc)
runs[run_id] = {
"status": "pending",
"question": req.question,
"created_at": now.timestamp(),
"events": [],
}
# Real runtime invocation lands here once it exists.
logger.info("ask_received run_id=%s q=%r", run_id, req.question)
return {"run_id": run_id, "status": "pending"}
@app.get("/runs/{run_id}")
async def get_run(run_id: str):
if run_id not in runs:
raise HTTPException(404, detail=f"Run {run_id} not found")
return runs[run_id]
@app.get("/runs/{run_id}/stream")
async def stream_run(run_id: str):
if run_id not in runs:
raise HTTPException(404, detail=f"Run {run_id} not found")
async def event_stream():
# Placeholder: real implementation will tail the runtime's event queue.
yield f"event: hello\ndata: {{\"run_id\": \"{run_id}\"}}\n\n"
await asyncio.sleep(0.1)
yield "event: end\ndata: {}\n\n"
return StreamingResponse(event_stream(), media_type="text/event-stream")

12
ctrl/Dockerfile.api Normal file
View File

@@ -0,0 +1,12 @@
FROM python:3.13-slim
WORKDIR /app
RUN pip install --no-cache-dir uv
COPY pyproject.toml uv.lock* ./
RUN uv sync --no-dev --no-install-project --frozen 2>/dev/null || uv sync --no-dev --no-install-project
COPY api/ api/
CMD ["uv", "run", "uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]

3
ctrl/Dockerfile.docs Normal file
View File

@@ -0,0 +1,3 @@
FROM nginx:alpine
COPY docs/ /usr/share/nginx/html/
COPY ctrl/nginx.conf /etc/nginx/conf.d/default.conf

22
ctrl/Dockerfile.ui Normal file
View File

@@ -0,0 +1,22 @@
FROM node:22-slim AS build
# Pin pnpm to v9 — v10 treats "ignored build scripts" (esbuild, vue-demi) as
# fatal under CI=true. Allowlisting them belongs in spr's framework
# package.json; until that lands, v9's lenient behavior keeps us building.
RUN corepack enable && corepack prepare pnpm@9 --activate
WORKDIR /ui
COPY ui/framework/ framework/
COPY ui/app/ app/
ENV CI=true
WORKDIR /ui/framework
RUN pnpm install
WORKDIR /ui/app
RUN pnpm install && pnpm run build
FROM nginx:alpine
COPY --from=build /ui/app/dist /usr/share/nginx/html
COPY ctrl/nginx.conf /etc/nginx/conf.d/default.conf

80
ctrl/Tiltfile Normal file
View File

@@ -0,0 +1,80 @@
# NVI — Tilt development environment
# Usage: cd ctrl && tilt up
# Cluster: kind (name: nvi — create via ./kind-config.sh)
# Entry points (via system Caddy + dnsmasq; kind NodePort is 30060→host 8060):
# http://nvi.local.ar — UI
# http://api.nvi.local.ar — API
# http://docs.nvi.local.ar — Docs
allow_k8s_contexts('kind-nvi')
# Hard guard: Tilt snapshots the kubectl context at startup (before parsing
# this file), so we can't switch it from here. Prefer `tilt up --context
# kind-nvi` to bypass the shell's global context entirely.
if k8s_context() != 'kind-nvi':
fail("Wrong kubectl context: '%s'. Run with: tilt up --context kind-nvi" % k8s_context())
local('kubectl --context kind-nvi create namespace nvi --dry-run=client -o yaml | kubectl --context kind-nvi apply -f -')
k8s_yaml(kustomize('k8s/overlays/dev'))
# --- Images ---
# FastAPI — Plan / Analyses / Tools / runtime
docker_build(
'nvi-api',
context='..',
dockerfile='Dockerfile.api',
ignore=['.git', 'def', 'ui', '.claude', 'tests', '.venv', 'node_modules', '.data', 'docs'],
live_update=[
sync('../api', '/app/api'),
],
)
# Vue UI — context is project root so the framework symlink resolves
docker_build(
'nvi-ui',
context='..',
dockerfile='Dockerfile.ui',
live_update=[
sync('../ui/app/src', '/ui/app/src'),
sync('../ui/app/index.html', '/ui/app/index.html'),
sync('../ui/app/vite.config.ts', '/ui/app/vite.config.ts'),
sync('../ui/framework/src', '/ui/framework/src'),
],
)
# Docs — nginx serving pre-rendered HTML + Graphviz SVGs
docker_build(
'nvi-docs',
context='..',
dockerfile='Dockerfile.docs',
live_update=[
sync('../docs', '/usr/share/nginx/html'),
],
)
# --- Resources ---
k8s_resource('postgres')
k8s_resource('api', resource_deps=['postgres'])
k8s_resource('ui', resource_deps=['api'])
k8s_resource('docs')
k8s_resource('gateway', resource_deps=['ui', 'api', 'docs'])
# Hot-reload gateway Caddy on Caddyfile edit. configMapGenerator uses
# disableNameSuffixHash so the Deployment template doesn't change → kustomize
# won't roll the pod on its own. This local_resource closes the loop.
local_resource(
'gateway-reload',
cmd='kubectl --context kind-nvi -n nvi rollout restart deployment/gateway',
deps=['k8s/base/Caddyfile'],
resource_deps=['gateway'],
auto_init=False,
)
# Group infra objects
k8s_resource(
objects=['nvi:namespace', 'nvi-config:configmap', 'gateway-config:configmap'],
new_name='infra',
)

56
ctrl/docker-compose.yml Normal file
View File

@@ -0,0 +1,56 @@
# Fallback local dev (no kind). Prefer `tilt up` for the real flow.
# Usage from repo root: docker compose -f ctrl/docker-compose.yml up -d
x-common-env: &common-env
DATABASE_URL: postgresql://nvi:nvi@postgres:5432/nvi
LANGFUSE_HOST: ${LANGFUSE_HOST:-http://host.docker.internal:3000}
LANGFUSE_PUBLIC_KEY: ${LANGFUSE_PUBLIC_KEY:-}
LANGFUSE_SECRET_KEY: ${LANGFUSE_SECRET_KEY:-}
ANTHROPIC_API_KEY: ${ANTHROPIC_API_KEY:-}
ANTHROPIC_MODEL: ${ANTHROPIC_MODEL:-claude-sonnet-4-6}
services:
postgres:
image: postgres:16-alpine
environment:
POSTGRES_USER: nvi
POSTGRES_PASSWORD: nvi
POSTGRES_DB: nvi
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U nvi -d nvi"]
interval: 5s
retries: 10
api:
build:
context: ..
dockerfile: ctrl/Dockerfile.api
environment: *common-env
ports:
- "8000:8000"
depends_on:
postgres:
condition: service_healthy
ui:
build:
context: ..
dockerfile: ctrl/Dockerfile.ui
ports:
- "8060:80"
depends_on:
- api
docs:
build:
context: ..
dockerfile: ctrl/Dockerfile.docs
ports:
- "8061:80"
volumes:
pgdata:

22
ctrl/k8s/base/Caddyfile Normal file
View File

@@ -0,0 +1,22 @@
{
auto_https off
admin off
}
# Routes by Host header from the system Caddy (which proxies *.nvi.local.ar
# from the host to this gateway via the kind NodePort).
nvi.local.ar:80 {
reverse_proxy ui:80
}
api.nvi.local.ar:80 {
reverse_proxy api:8000
}
docs.nvi.local.ar:80 {
reverse_proxy docs:80
}
# AWS public hostname later — VPS Caddy will reverse-proxy nivii.mcrn.ar
# over WireGuard to this gateway. Add matching blocks when that's wired up.

47
ctrl/k8s/base/api.yaml Normal file
View File

@@ -0,0 +1,47 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
namespace: nvi
spec:
replicas: 1
selector:
matchLabels:
app: api
template:
metadata:
labels:
app: api
spec:
containers:
- name: api
image: nvi-api
ports:
- containerPort: 8000
envFrom:
- configMapRef:
name: nvi-config
readinessProbe:
httpGet:
path: /healthz
port: 8000
initialDelaySeconds: 5
periodSeconds: 10
resources:
requests:
memory: 256Mi
cpu: 200m
limits:
memory: 1Gi
---
apiVersion: v1
kind: Service
metadata:
name: api
namespace: nvi
spec:
selector:
app: api
ports:
- port: 8000
targetPort: 8000

View File

@@ -0,0 +1,19 @@
apiVersion: v1
kind: ConfigMap
metadata:
name: nvi-config
namespace: nvi
data:
# Warehouse (in-cluster postgres)
DATABASE_URL: "postgresql://nvi:nvi@postgres:5432/nvi"
# Langfuse (lng cluster, reached over WireGuard from the host).
# Override LANGFUSE_HOST to your lng WireGuard endpoint; keys come from
# the "nvi" project created inside the lng Langfuse UI.
LANGFUSE_HOST: "http://lng.local.ar:3000"
LANGFUSE_PUBLIC_KEY: ""
LANGFUSE_SECRET_KEY: ""
# LLM
ANTHROPIC_API_KEY: ""
ANTHROPIC_MODEL: "claude-sonnet-4-6"

44
ctrl/k8s/base/docs.yaml Normal file
View File

@@ -0,0 +1,44 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: docs
namespace: nvi
spec:
replicas: 1
selector:
matchLabels:
app: docs
template:
metadata:
labels:
app: docs
spec:
containers:
- name: docs
image: nvi-docs
ports:
- containerPort: 80
readinessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 2
periodSeconds: 10
resources:
requests:
memory: 32Mi
cpu: 50m
limits:
memory: 128Mi
---
apiVersion: v1
kind: Service
metadata:
name: docs
namespace: nvi
spec:
selector:
app: docs
ports:
- port: 80
targetPort: 80

View File

@@ -0,0 +1,53 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: gateway
namespace: nvi
spec:
replicas: 1
selector:
matchLabels:
app: gateway
template:
metadata:
labels:
app: gateway
spec:
containers:
- name: caddy
image: caddy:2-alpine
ports:
- containerPort: 80
name: http
volumeMounts:
- name: config
mountPath: /etc/caddy
readOnly: true
readinessProbe:
tcpSocket:
port: 80
initialDelaySeconds: 2
periodSeconds: 10
resources:
requests:
memory: 32Mi
cpu: 50m
limits:
memory: 128Mi
volumes:
- name: config
configMap:
name: gateway-config
---
apiVersion: v1
kind: Service
metadata:
name: gateway
namespace: nvi
spec:
selector:
app: gateway
ports:
- name: http
port: 80
targetPort: 80

View File

@@ -0,0 +1,23 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: nvi
resources:
- namespace.yaml
- configmap.yaml
- postgres.yaml
- api.yaml
- ui.yaml
- docs.yaml
- gateway.yaml
# Hash suffix disabled so the name stays static — lets Tilt group it under the
# 'infra' resource. Pod doesn't auto-restart on Caddyfile edit; the Tiltfile's
# 'gateway-reload' local_resource closes that loop.
configMapGenerator:
- name: gateway-config
files:
- Caddyfile
options:
disableNameSuffixHash: true

View File

@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: nvi

View File

@@ -0,0 +1,60 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: postgres
namespace: nvi
spec:
replicas: 1
selector:
matchLabels:
app: postgres
template:
metadata:
labels:
app: postgres
spec:
containers:
- name: postgres
image: postgres:16-alpine
ports:
- containerPort: 5432
env:
- name: POSTGRES_USER
value: "nvi"
- name: POSTGRES_PASSWORD
value: "nvi"
- name: POSTGRES_DB
value: "nvi"
- name: PGDATA
value: "/var/lib/postgresql/data/pgdata"
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
readinessProbe:
exec:
command: ["pg_isready", "-U", "nvi", "-d", "nvi"]
initialDelaySeconds: 5
periodSeconds: 5
resources:
requests:
memory: 256Mi
cpu: 100m
limits:
memory: 1Gi
volumes:
- name: data
hostPath:
path: /data/postgres
type: DirectoryOrCreate
---
apiVersion: v1
kind: Service
metadata:
name: postgres
namespace: nvi
spec:
selector:
app: postgres
ports:
- port: 5432
targetPort: 5432

44
ctrl/k8s/base/ui.yaml Normal file
View File

@@ -0,0 +1,44 @@
apiVersion: apps/v1
kind: Deployment
metadata:
name: ui
namespace: nvi
spec:
replicas: 1
selector:
matchLabels:
app: ui
template:
metadata:
labels:
app: ui
spec:
containers:
- name: ui
image: nvi-ui
ports:
- containerPort: 80
readinessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 2
periodSeconds: 10
resources:
requests:
memory: 64Mi
cpu: 50m
limits:
memory: 128Mi
---
apiVersion: v1
kind: Service
metadata:
name: ui
namespace: nvi
spec:
selector:
app: ui
ports:
- port: 80
targetPort: 80

18
ctrl/k8s/kind-config.yaml Normal file
View File

@@ -0,0 +1,18 @@
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
name: nvi
nodes:
- role: control-plane
extraPortMappings:
# In-cluster Caddy gateway. Routes by Host header:
# nvi.local.ar → ui
# api.nvi.local.ar → api
# docs.nvi.local.ar → docs
- containerPort: 30060
hostPort: 8060
listenAddress: "0.0.0.0"
protocol: TCP
extraMounts:
# Postgres data persists across cluster recreations.
- hostPath: /home/mariano/wdir/nvi/.data
containerPath: /data

View File

@@ -0,0 +1,19 @@
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- ../../base
patches:
# In-cluster Caddy gateway owns the single NodePort. Routes by Host header
# to ui / api / docs via service DNS inside the cluster.
- target:
kind: Service
name: gateway
patch: |
- op: replace
path: /spec/type
value: NodePort
- op: add
path: /spec/ports/0/nodePort
value: 30060

14
ctrl/kind-config.sh Executable file
View File

@@ -0,0 +1,14 @@
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$0")"
mkdir -p ../.data/postgres
if kind get clusters 2>/dev/null | grep -qx nvi; then
echo "kind cluster 'nvi' already exists"
else
echo "creating kind cluster 'nvi'..."
kind create cluster --config k8s/kind-config.yaml
fi
kubectl config use-context kind-nvi

9
ctrl/nginx.conf Normal file
View File

@@ -0,0 +1,9 @@
server {
listen 80;
location / {
root /usr/share/nginx/html;
index index.html;
try_files $uri $uri/ /index.html;
}
}

7
docs/graphs/README.md Normal file
View File

@@ -0,0 +1,7 @@
# Graphviz sources
Each `.dot` file in this directory is rendered to `.svg` by the docs build
(`make docs-graphs`). The HTML references the SVGs via `<object>`.
Convention follows eth/unt/mpr: dark background, JetBrains Mono labels,
accent blue for cluster titles, dim grey for edges.

View File

@@ -0,0 +1,65 @@
digraph nvi {
rankdir=TB;
bgcolor="#161b22";
fontname="JetBrains Mono";
fontcolor="#c9d1d9";
node [
shape=box,
style="rounded,filled",
fillcolor="#21262d",
color="#30363d",
fontname="JetBrains Mono",
fontcolor="#c9d1d9",
fontsize=11,
];
edge [color="#8b949e", fontname="JetBrains Mono", fontcolor="#8b949e", fontsize=10];
user [label="user question", fillcolor="#1f6feb", fontcolor="#ffffff"];
subgraph cluster_l3 {
label="L3 — Plan";
color="#30363d";
fontcolor="#58a6ff";
fontsize=11;
plan [label="planner\n(1 LLM call)"];
}
subgraph cluster_l2 {
label="L2 — Analyses (mini-agents)";
color="#30363d";
fontcolor="#58a6ff";
fontsize=11;
compare [label="compare_periods\n(CoT)"];
drill [label="drill_down\n(ReAct)"];
outl [label="find_outliers\n(CoT)"];
corr [label="correlate\n(plan-and-execute)"];
}
subgraph cluster_l1 {
label="L1 — Tools (deterministic)";
color="#30363d";
fontcolor="#58a6ff";
fontsize=11;
t2s [label="text_to_sql"];
exec [label="execute_sql"];
schema [label="retrieve_schema"];
py [label="python_sandbox"];
}
warehouse [label="Postgres\nBIRD financial", fillcolor="#388bfd33"];
langfuse [label="Langfuse (lng, WG)", fillcolor="#bf4b8a33", fontcolor="#ffffff"];
synth [label="synthesize\nfindings → answer"];
user -> plan;
plan -> compare; plan -> drill; plan -> outl; plan -> corr;
compare -> t2s; drill -> t2s; outl -> t2s; corr -> t2s;
drill -> schema [style=dashed];
corr -> py [style=dashed];
t2s -> exec;
exec -> warehouse;
compare -> synth; drill -> synth; outl -> synth; corr -> synth;
plan -> langfuse [style=dotted, label="spans"];
compare -> langfuse [style=dotted];
exec -> langfuse [style=dotted];
}

View File

@@ -0,0 +1,234 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN"
"http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<!-- Generated by graphviz version 14.1.2 (0)
-->
<!-- Title: nvi Pages: 1 -->
<svg width="562pt" height="487pt"
viewBox="0.00 0.00 562.00 487.00" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="graph0" class="graph" transform="scale(1 1) rotate(0) translate(4 482.5)">
<title>nvi</title>
<polygon fill="#161b22" stroke="none" points="-4,4 -4,-482.5 558.25,-482.5 558.25,4 -4,4"/>
<g id="clust1" class="cluster">
<title>cluster_l3</title>
<polygon fill="#161b22" stroke="#30363d" points="258,-336.25 258,-415.5 348,-415.5 348,-336.25 258,-336.25"/>
<text xml:space="preserve" text-anchor="middle" x="303" y="-401.05" font-family="JetBrains Mono" font-size="11.00" fill="#58a6ff">L3 — Plan</text>
</g>
<g id="clust2" class="cluster">
<title>cluster_l2</title>
<polygon fill="#161b22" stroke="#30363d" points="52,-238 52,-317.25 490,-317.25 490,-238 52,-238"/>
<text xml:space="preserve" text-anchor="middle" x="271" y="-302.8" font-family="JetBrains Mono" font-size="11.00" fill="#58a6ff">L2 — Analyses (mini&#45;agents)</text>
</g>
<g id="clust3" class="cluster">
<title>cluster_l1</title>
<polygon fill="#161b22" stroke="#30363d" points="8,-68.5 8,-219 334,-219 334,-68.5 8,-68.5"/>
<text xml:space="preserve" text-anchor="middle" x="171" y="-204.55" font-family="JetBrains Mono" font-size="11.00" fill="#58a6ff">L1 — Tools (deterministic)</text>
</g>
<!-- user -->
<g id="node1" class="node">
<title>user</title>
<path fill="#1f6feb" stroke="#30363d" d="M334.25,-478.5C334.25,-478.5 271.75,-478.5 271.75,-478.5 265.75,-478.5 259.75,-472.5 259.75,-466.5 259.75,-466.5 259.75,-454.5 259.75,-454.5 259.75,-448.5 265.75,-442.5 271.75,-442.5 271.75,-442.5 334.25,-442.5 334.25,-442.5 340.25,-442.5 346.25,-448.5 346.25,-454.5 346.25,-454.5 346.25,-466.5 346.25,-466.5 346.25,-472.5 340.25,-478.5 334.25,-478.5"/>
<text xml:space="preserve" text-anchor="middle" x="303" y="-457.93" font-family="JetBrains Mono" font-size="11.00" fill="#ffffff">user question</text>
</g>
<!-- plan -->
<g id="node2" class="node">
<title>plan</title>
<path fill="#21262d" stroke="#30363d" d="M327.5,-383.75C327.5,-383.75 278.5,-383.75 278.5,-383.75 272.5,-383.75 266.5,-377.75 266.5,-371.75 266.5,-371.75 266.5,-356.25 266.5,-356.25 266.5,-350.25 272.5,-344.25 278.5,-344.25 278.5,-344.25 327.5,-344.25 327.5,-344.25 333.5,-344.25 339.5,-350.25 339.5,-356.25 339.5,-356.25 339.5,-371.75 339.5,-371.75 339.5,-377.75 333.5,-383.75 327.5,-383.75"/>
<text xml:space="preserve" text-anchor="middle" x="303" y="-369.3" font-family="JetBrains Mono" font-size="11.00" fill="#c9d1d9">planner</text>
<text xml:space="preserve" text-anchor="middle" x="303" y="-353.55" font-family="JetBrains Mono" font-size="11.00" fill="#c9d1d9">(1 LLM call)</text>
</g>
<!-- user&#45;&gt;plan -->
<g id="edge1" class="edge">
<title>user&#45;&gt;plan</title>
<path fill="none" stroke="#8b949e" d="M303,-442.17C303,-429.09 303,-410.82 303,-395.34"/>
<polygon fill="#8b949e" stroke="#8b949e" points="306.5,-395.69 303,-385.69 299.5,-395.69 306.5,-395.69"/>
</g>
<!-- compare -->
<g id="node3" class="node">
<title>compare</title>
<path fill="#21262d" stroke="#30363d" d="M470,-285.5C470,-285.5 388,-285.5 388,-285.5 382,-285.5 376,-279.5 376,-273.5 376,-273.5 376,-258 376,-258 376,-252 382,-246 388,-246 388,-246 470,-246 470,-246 476,-246 482,-252 482,-258 482,-258 482,-273.5 482,-273.5 482,-279.5 476,-285.5 470,-285.5"/>
<text xml:space="preserve" text-anchor="middle" x="429" y="-271.05" font-family="JetBrains Mono" font-size="11.00" fill="#c9d1d9">compare_periods</text>
<text xml:space="preserve" text-anchor="middle" x="429" y="-255.3" font-family="JetBrains Mono" font-size="11.00" fill="#c9d1d9">(CoT)</text>
</g>
<!-- plan&#45;&gt;compare -->
<g id="edge2" class="edge">
<title>plan&#45;&gt;compare</title>
<path fill="none" stroke="#8b949e" d="M330.68,-343.78C342.05,-335.74 355.28,-326.2 367,-317.25 377.03,-309.59 387.76,-301 397.43,-293.11"/>
<polygon fill="#8b949e" stroke="#8b949e" points="399.64,-295.82 405.14,-286.77 395.19,-290.41 399.64,-295.82"/>
</g>
<!-- drill -->
<g id="node4" class="node">
<title>drill</title>
<path fill="#21262d" stroke="#30363d" d="M118.38,-285.5C118.38,-285.5 71.62,-285.5 71.62,-285.5 65.62,-285.5 59.62,-279.5 59.62,-273.5 59.62,-273.5 59.62,-258 59.62,-258 59.62,-252 65.62,-246 71.62,-246 71.62,-246 118.38,-246 118.38,-246 124.38,-246 130.38,-252 130.38,-258 130.38,-258 130.38,-273.5 130.38,-273.5 130.38,-279.5 124.38,-285.5 118.38,-285.5"/>
<text xml:space="preserve" text-anchor="middle" x="95" y="-271.05" font-family="JetBrains Mono" font-size="11.00" fill="#c9d1d9">drill_down</text>
<text xml:space="preserve" text-anchor="middle" x="95" y="-255.3" font-family="JetBrains Mono" font-size="11.00" fill="#c9d1d9">(ReAct)</text>
</g>
<!-- plan&#45;&gt;drill -->
<g id="edge3" class="edge">
<title>plan&#45;&gt;drill</title>
<path fill="none" stroke="#8b949e" d="M266.28,-358.73C231.58,-353.36 179.14,-341.69 140,-317.25 130.49,-311.31 121.82,-302.78 114.68,-294.41"/>
<polygon fill="#8b949e" stroke="#8b949e" points="117.49,-292.33 108.51,-286.71 112.03,-296.71 117.49,-292.33"/>
</g>
<!-- outl -->
<g id="node5" class="node">
<title>outl</title>
<path fill="#21262d" stroke="#30363d" d="M217.25,-285.5C217.25,-285.5 160.75,-285.5 160.75,-285.5 154.75,-285.5 148.75,-279.5 148.75,-273.5 148.75,-273.5 148.75,-258 148.75,-258 148.75,-252 154.75,-246 160.75,-246 160.75,-246 217.25,-246 217.25,-246 223.25,-246 229.25,-252 229.25,-258 229.25,-258 229.25,-273.5 229.25,-273.5 229.25,-279.5 223.25,-285.5 217.25,-285.5"/>
<text xml:space="preserve" text-anchor="middle" x="189" y="-271.05" font-family="JetBrains Mono" font-size="11.00" fill="#c9d1d9">find_outliers</text>
<text xml:space="preserve" text-anchor="middle" x="189" y="-255.3" font-family="JetBrains Mono" font-size="11.00" fill="#c9d1d9">(CoT)</text>
</g>
<!-- plan&#45;&gt;outl -->
<g id="edge4" class="edge">
<title>plan&#45;&gt;outl</title>
<path fill="none" stroke="#8b949e" d="M274.18,-343.95C262.89,-336.09 250.02,-326.64 239,-317.25 230.58,-310.08 221.94,-301.75 214.25,-293.94"/>
<polygon fill="#8b949e" stroke="#8b949e" points="216.86,-291.6 207.39,-286.84 211.82,-296.46 216.86,-291.6"/>
</g>
<!-- corr -->
<g id="node6" class="node">
<title>corr</title>
<path fill="#21262d" stroke="#30363d" d="M346.25,-285.5C346.25,-285.5 259.75,-285.5 259.75,-285.5 253.75,-285.5 247.75,-279.5 247.75,-273.5 247.75,-273.5 247.75,-258 247.75,-258 247.75,-252 253.75,-246 259.75,-246 259.75,-246 346.25,-246 346.25,-246 352.25,-246 358.25,-252 358.25,-258 358.25,-258 358.25,-273.5 358.25,-273.5 358.25,-279.5 352.25,-285.5 346.25,-285.5"/>
<text xml:space="preserve" text-anchor="middle" x="303" y="-271.05" font-family="JetBrains Mono" font-size="11.00" fill="#c9d1d9">correlate</text>
<text xml:space="preserve" text-anchor="middle" x="303" y="-255.3" font-family="JetBrains Mono" font-size="11.00" fill="#c9d1d9">(plan&#45;and&#45;execute)</text>
</g>
<!-- plan&#45;&gt;corr -->
<g id="edge5" class="edge">
<title>plan&#45;&gt;corr</title>
<path fill="none" stroke="#8b949e" d="M303,-343.98C303,-330.54 303,-312.23 303,-296.82"/>
<polygon fill="#8b949e" stroke="#8b949e" points="306.5,-297.24 303,-287.24 299.5,-297.24 306.5,-297.24"/>
</g>
<!-- langfuse -->
<g id="node12" class="node">
<title>langfuse</title>
<path fill="#bf4b8a" fill-opacity="0.200000" stroke="#30363d" d="M526.38,-37.75C526.38,-37.75 437.62,-37.75 437.62,-37.75 431.62,-37.75 425.62,-31.75 425.62,-25.75 425.62,-25.75 425.62,-13.75 425.62,-13.75 425.62,-7.75 431.62,-1.75 437.62,-1.75 437.62,-1.75 526.38,-1.75 526.38,-1.75 532.38,-1.75 538.38,-7.75 538.38,-13.75 538.38,-13.75 538.38,-25.75 538.38,-25.75 538.38,-31.75 532.38,-37.75 526.38,-37.75"/>
<text xml:space="preserve" text-anchor="middle" x="482" y="-17.18" font-family="JetBrains Mono" font-size="11.00" fill="#ffffff">Langfuse (lng, WG)</text>
</g>
<!-- plan&#45;&gt;langfuse -->
<g id="edge18" class="edge">
<title>plan&#45;&gt;langfuse</title>
<path fill="none" stroke="#8b949e" stroke-dasharray="1,5" d="M339.9,-363.21C403.73,-361.43 528,-347.58 528,-266.75 528,-266.75 528,-266.75 528,-93.5 528,-76.02 518.39,-59.32 507.96,-46.34"/>
<polygon fill="#8b949e" stroke="#8b949e" points="510.9,-44.39 501.7,-39.13 505.61,-48.97 510.9,-44.39"/>
<text xml:space="preserve" text-anchor="middle" x="541.12" y="-166.88" font-family="JetBrains Mono" font-size="10.00" fill="#8b949e">spans</text>
</g>
<!-- t2s -->
<g id="node7" class="node">
<title>t2s</title>
<path fill="#21262d" stroke="#30363d" d="M193.75,-187.25C193.75,-187.25 146.25,-187.25 146.25,-187.25 140.25,-187.25 134.25,-181.25 134.25,-175.25 134.25,-175.25 134.25,-163.25 134.25,-163.25 134.25,-157.25 140.25,-151.25 146.25,-151.25 146.25,-151.25 193.75,-151.25 193.75,-151.25 199.75,-151.25 205.75,-157.25 205.75,-163.25 205.75,-163.25 205.75,-175.25 205.75,-175.25 205.75,-181.25 199.75,-187.25 193.75,-187.25"/>
<text xml:space="preserve" text-anchor="middle" x="170" y="-166.68" font-family="JetBrains Mono" font-size="11.00" fill="#c9d1d9">text_to_sql</text>
</g>
<!-- compare&#45;&gt;t2s -->
<g id="edge6" class="edge">
<title>compare&#45;&gt;t2s</title>
<path fill="none" stroke="#8b949e" d="M388.9,-245.51C381.73,-242.63 374.24,-239.97 367,-238 334.16,-229.06 245.27,-234.56 215,-219 204.66,-213.68 195.48,-204.98 188.15,-196.39"/>
<polygon fill="#8b949e" stroke="#8b949e" points="191.1,-194.48 182.15,-188.81 185.61,-198.82 191.1,-194.48"/>
</g>
<!-- compare&#45;&gt;langfuse -->
<g id="edge19" class="edge">
<title>compare&#45;&gt;langfuse</title>
<path fill="none" stroke="#8b949e" stroke-dasharray="1,5" d="M446.82,-245.68C453,-237.96 459.3,-228.61 463,-219 484.95,-161.95 485.65,-89.15 483.97,-49.56"/>
<polygon fill="#8b949e" stroke="#8b949e" points="487.47,-49.48 483.46,-39.68 480.48,-49.84 487.47,-49.48"/>
</g>
<!-- synth -->
<g id="node13" class="node">
<title>synth</title>
<path fill="#21262d" stroke="#30363d" d="M442.25,-189C442.25,-189 355.75,-189 355.75,-189 349.75,-189 343.75,-183 343.75,-177 343.75,-177 343.75,-161.5 343.75,-161.5 343.75,-155.5 349.75,-149.5 355.75,-149.5 355.75,-149.5 442.25,-149.5 442.25,-149.5 448.25,-149.5 454.25,-155.5 454.25,-161.5 454.25,-161.5 454.25,-177 454.25,-177 454.25,-183 448.25,-189 442.25,-189"/>
<text xml:space="preserve" text-anchor="middle" x="399" y="-174.55" font-family="JetBrains Mono" font-size="11.00" fill="#c9d1d9">synthesize</text>
<text xml:space="preserve" text-anchor="middle" x="399" y="-158.8" font-family="JetBrains Mono" font-size="11.00" fill="#c9d1d9">findings → answer</text>
</g>
<!-- compare&#45;&gt;synth -->
<g id="edge14" class="edge">
<title>compare&#45;&gt;synth</title>
<path fill="none" stroke="#8b949e" d="M422.93,-245.63C418.78,-232.56 413.2,-214.99 408.47,-200.09"/>
<polygon fill="#8b949e" stroke="#8b949e" points="411.88,-199.26 405.52,-190.79 405.21,-201.38 411.88,-199.26"/>
</g>
<!-- drill&#45;&gt;t2s -->
<g id="edge7" class="edge">
<title>drill&#45;&gt;t2s</title>
<path fill="none" stroke="#8b949e" d="M110.18,-245.63C121.48,-231.38 137.05,-211.77 149.47,-196.12"/>
<polygon fill="#8b949e" stroke="#8b949e" points="151.94,-198.63 155.42,-188.62 146.46,-194.28 151.94,-198.63"/>
</g>
<!-- schema -->
<g id="node9" class="node">
<title>schema</title>
<path fill="#21262d" stroke="#30363d" d="M104,-187.25C104,-187.25 28,-187.25 28,-187.25 22,-187.25 16,-181.25 16,-175.25 16,-175.25 16,-163.25 16,-163.25 16,-157.25 22,-151.25 28,-151.25 28,-151.25 104,-151.25 104,-151.25 110,-151.25 116,-157.25 116,-163.25 116,-163.25 116,-175.25 116,-175.25 116,-181.25 110,-187.25 104,-187.25"/>
<text xml:space="preserve" text-anchor="middle" x="66" y="-166.68" font-family="JetBrains Mono" font-size="11.00" fill="#c9d1d9">retrieve_schema</text>
</g>
<!-- drill&#45;&gt;schema -->
<g id="edge10" class="edge">
<title>drill&#45;&gt;schema</title>
<path fill="none" stroke="#8b949e" stroke-dasharray="5,2" d="M89.13,-245.63C84.96,-232.05 79.3,-213.59 74.62,-198.34"/>
<polygon fill="#8b949e" stroke="#8b949e" points="78,-197.42 71.72,-188.89 71.31,-199.47 78,-197.42"/>
</g>
<!-- drill&#45;&gt;synth -->
<g id="edge15" class="edge">
<title>drill&#45;&gt;synth</title>
<path fill="none" stroke="#8b949e" d="M122.66,-245.77C128.2,-242.71 134.13,-239.92 140,-238 182.01,-224.26 297.03,-235.6 338,-219 350.41,-213.97 362.32,-205.57 372.28,-197.14"/>
<polygon fill="#8b949e" stroke="#8b949e" points="374.58,-199.78 379.7,-190.5 369.91,-194.56 374.58,-199.78"/>
</g>
<!-- outl&#45;&gt;t2s -->
<g id="edge8" class="edge">
<title>outl&#45;&gt;t2s</title>
<path fill="none" stroke="#8b949e" d="M185.16,-245.63C182.42,-232.05 178.71,-213.59 175.65,-198.34"/>
<polygon fill="#8b949e" stroke="#8b949e" points="179.16,-198.04 173.75,-188.92 172.29,-199.42 179.16,-198.04"/>
</g>
<!-- outl&#45;&gt;synth -->
<g id="edge16" class="edge">
<title>outl&#45;&gt;synth</title>
<path fill="none" stroke="#8b949e" d="M221.44,-245.59C227.16,-242.74 233.15,-240.07 239,-238 281.23,-223.04 297.31,-237.76 338,-219 349.97,-213.48 361.65,-205.12 371.53,-196.88"/>
<polygon fill="#8b949e" stroke="#8b949e" points="373.69,-199.63 378.91,-190.42 369.08,-194.37 373.69,-199.63"/>
</g>
<!-- corr&#45;&gt;t2s -->
<g id="edge9" class="edge">
<title>corr&#45;&gt;t2s</title>
<path fill="none" stroke="#8b949e" d="M259.14,-245.69C244.45,-238.35 228.41,-229.24 215,-219 206.36,-212.4 197.96,-204.03 190.82,-196.1"/>
<polygon fill="#8b949e" stroke="#8b949e" points="193.51,-193.86 184.32,-188.59 188.22,-198.44 193.51,-193.86"/>
</g>
<!-- py -->
<g id="node10" class="node">
<title>py</title>
<path fill="#21262d" stroke="#30363d" d="M313.75,-187.25C313.75,-187.25 236.25,-187.25 236.25,-187.25 230.25,-187.25 224.25,-181.25 224.25,-175.25 224.25,-175.25 224.25,-163.25 224.25,-163.25 224.25,-157.25 230.25,-151.25 236.25,-151.25 236.25,-151.25 313.75,-151.25 313.75,-151.25 319.75,-151.25 325.75,-157.25 325.75,-163.25 325.75,-163.25 325.75,-175.25 325.75,-175.25 325.75,-181.25 319.75,-187.25 313.75,-187.25"/>
<text xml:space="preserve" text-anchor="middle" x="275" y="-166.68" font-family="JetBrains Mono" font-size="11.00" fill="#c9d1d9">python_sandbox</text>
</g>
<!-- corr&#45;&gt;py -->
<g id="edge11" class="edge">
<title>corr&#45;&gt;py</title>
<path fill="none" stroke="#8b949e" stroke-dasharray="5,2" d="M297.33,-245.63C293.31,-232.05 287.84,-213.59 283.32,-198.34"/>
<polygon fill="#8b949e" stroke="#8b949e" points="286.72,-197.48 280.52,-188.89 280.01,-199.47 286.72,-197.48"/>
</g>
<!-- corr&#45;&gt;synth -->
<g id="edge17" class="edge">
<title>corr&#45;&gt;synth</title>
<path fill="none" stroke="#8b949e" d="M322.43,-245.63C336.61,-231.66 356.03,-212.55 371.76,-197.07"/>
<polygon fill="#8b949e" stroke="#8b949e" points="373.85,-199.92 378.52,-190.41 368.94,-194.93 373.85,-199.92"/>
</g>
<!-- exec -->
<g id="node8" class="node">
<title>exec</title>
<path fill="#21262d" stroke="#30363d" d="M254.62,-112.5C254.62,-112.5 203.38,-112.5 203.38,-112.5 197.38,-112.5 191.38,-106.5 191.38,-100.5 191.38,-100.5 191.38,-88.5 191.38,-88.5 191.38,-82.5 197.38,-76.5 203.38,-76.5 203.38,-76.5 254.62,-76.5 254.62,-76.5 260.62,-76.5 266.62,-82.5 266.62,-88.5 266.62,-88.5 266.62,-100.5 266.62,-100.5 266.62,-106.5 260.62,-112.5 254.62,-112.5"/>
<text xml:space="preserve" text-anchor="middle" x="229" y="-91.92" font-family="JetBrains Mono" font-size="11.00" fill="#c9d1d9">execute_sql</text>
</g>
<!-- t2s&#45;&gt;exec -->
<g id="edge12" class="edge">
<title>t2s&#45;&gt;exec</title>
<path fill="none" stroke="#8b949e" d="M183.98,-151.01C191.06,-142.28 199.8,-131.51 207.69,-121.78"/>
<polygon fill="#8b949e" stroke="#8b949e" points="210.38,-124.02 213.96,-114.04 204.94,-119.61 210.38,-124.02"/>
</g>
<!-- warehouse -->
<g id="node11" class="node">
<title>warehouse</title>
<path fill="#388bfd" fill-opacity="0.200000" stroke="#30363d" d="M261.38,-39.5C261.38,-39.5 196.62,-39.5 196.62,-39.5 190.62,-39.5 184.62,-33.5 184.62,-27.5 184.62,-27.5 184.62,-12 184.62,-12 184.62,-6 190.62,0 196.62,0 196.62,0 261.38,0 261.38,0 267.38,0 273.38,-6 273.38,-12 273.38,-12 273.38,-27.5 273.38,-27.5 273.38,-33.5 267.38,-39.5 261.38,-39.5"/>
<text xml:space="preserve" text-anchor="middle" x="229" y="-25.05" font-family="JetBrains Mono" font-size="11.00" fill="#c9d1d9">Postgres</text>
<text xml:space="preserve" text-anchor="middle" x="229" y="-9.3" font-family="JetBrains Mono" font-size="11.00" fill="#c9d1d9">BIRD financial</text>
</g>
<!-- exec&#45;&gt;warehouse -->
<g id="edge13" class="edge">
<title>exec&#45;&gt;warehouse</title>
<path fill="none" stroke="#8b949e" d="M229,-76.26C229,-68.75 229,-59.72 229,-51.14"/>
<polygon fill="#8b949e" stroke="#8b949e" points="232.5,-51.43 229,-41.43 225.5,-51.43 232.5,-51.43"/>
</g>
<!-- exec&#45;&gt;langfuse -->
<g id="edge20" class="edge">
<title>exec&#45;&gt;langfuse</title>
<path fill="none" stroke="#8b949e" stroke-dasharray="1,5" d="M266.88,-82.61C305.88,-71.39 367.48,-53.68 414.78,-40.08"/>
<polygon fill="#8b949e" stroke="#8b949e" points="415.46,-43.52 424.11,-37.4 413.53,-36.8 415.46,-43.52"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 17 KiB

435
docs/index.html Normal file
View File

@@ -0,0 +1,435 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>nvi — analytics agent</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap');
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #0a0e17;
color: #e8eaf0;
font-family: 'Inter', sans-serif;
line-height: 1.6;
height: 100vh;
overflow: hidden;
display: flex;
flex-direction: column;
}
header {
padding: 16px 24px;
border-bottom: 1px solid #1e2a4a;
display: flex;
align-items: baseline;
gap: 16px;
flex-shrink: 0;
}
header h1 {
font-family: 'JetBrains Mono', monospace;
font-size: 22px;
font-weight: 600;
letter-spacing: 3px;
color: #0066ff;
}
header .subtitle {
font-size: 13px;
color: #4a5568;
letter-spacing: 1px;
text-transform: uppercase;
}
.layout {
display: flex;
flex: 1;
min-height: 0;
}
nav {
display: flex;
flex-direction: column;
width: 200px;
flex-shrink: 0;
background: #121829;
border-right: 1px solid #1e2a4a;
padding: 8px 0;
overflow-y: auto;
}
nav a {
padding: 10px 20px;
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
color: #8892a8;
text-decoration: none;
border-left: 2px solid transparent;
transition: all 0.15s;
cursor: pointer;
}
nav a:hover { color: #e8eaf0; background: #1a2340; }
nav a.active { color: #0066ff; border-left-color: #0066ff; background: #0d1a33; }
main {
flex: 1;
overflow: auto;
padding: 32px 48px;
}
.section {
display: none;
animation: fadeIn 0.2s ease;
}
.section.active { display: block; }
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.section h2 {
font-family: 'JetBrains Mono', monospace;
font-size: 15px;
font-weight: 500;
color: #8892a8;
margin-bottom: 8px;
letter-spacing: 1px;
text-transform: uppercase;
}
.section > p.lede {
font-size: 13px;
color: #4a5568;
margin-bottom: 24px;
max-width: 800px;
}
.prose { max-width: 820px; }
.prose p {
font-size: 14px;
color: #b4bccf;
margin-bottom: 14px;
line-height: 1.7;
}
.prose p b { color: #e8eaf0; font-weight: 600; }
.prose ul {
margin: 8px 0 16px 20px;
font-size: 14px;
color: #b4bccf;
line-height: 1.7;
}
.prose ul li { margin-bottom: 8px; }
.prose h3 {
font-family: 'JetBrains Mono', monospace;
font-size: 13px;
font-weight: 500;
color: #e8eaf0;
letter-spacing: 1px;
margin: 32px 0 10px;
text-transform: uppercase;
}
.prose code, pre code {
font-family: 'JetBrains Mono', monospace;
font-size: 12px;
color: #7ab0ff;
background: #121829;
padding: 1px 5px;
border-radius: 3px;
}
pre {
background: #121829;
border: 1px solid #1e2a4a;
padding: 16px;
overflow: auto;
margin-bottom: 16px;
}
pre code {
background: none;
padding: 0;
}
.diagram {
display: block;
max-width: 100%;
margin: 16px 0;
background: #121829;
border: 1px solid #1e2a4a;
padding: 24px;
}
dl {
display: grid;
grid-template-columns: max-content 1fr;
gap: 10px 24px;
margin: 16px 0;
max-width: 820px;
}
dt {
font-family: 'JetBrains Mono', monospace;
color: #0066ff;
font-size: 13px;
padding-top: 2px;
}
dd {
font-size: 14px;
color: #b4bccf;
line-height: 1.6;
}
.menu-toggle {
display: none;
background: transparent;
border: 1px solid #1e2a4a;
color: #8892a8;
padding: 6px 10px;
font-size: 14px;
cursor: pointer;
line-height: 1;
margin-left: auto;
}
.menu-toggle:hover { background: #1a2340; }
.nav-backdrop {
display: none;
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 10;
}
.layout.nav-open .nav-backdrop { display: block; }
@media (max-width: 720px) {
header { padding: 10px 12px; gap: 8px; }
header h1 { font-size: 16px; letter-spacing: 1px; }
header .subtitle { display: none; }
.menu-toggle { display: inline-block; }
.layout { position: relative; }
nav {
position: absolute;
left: 0; top: 0; bottom: 0;
width: 200px;
z-index: 20;
transform: translateX(-100%);
transition: transform 0.2s ease;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.5);
}
.layout.nav-open nav { transform: translateX(0); }
main { padding: 16px; }
.section h2 { font-size: 13px; }
.prose p, .prose ul { font-size: 13px; }
}
</style>
</head>
<body>
<header>
<h1>NVI</h1>
<span class="subtitle">analytics agent — BIRD financial</span>
<button class="menu-toggle" onclick="toggleNav()" aria-label="Toggle navigation"></button>
</header>
<div class="layout">
<div class="nav-backdrop" onclick="toggleNav()"></div>
<nav>
<a class="active" onclick="show('overview')">Overview</a>
<a onclick="show('architecture')">Architecture</a>
<a onclick="show('plan')">Plan</a>
<a onclick="show('analyses')">Analyses</a>
<a onclick="show('tools')">Tools</a>
<a onclick="show('runtime')">Runtime</a>
<a onclick="show('run')">Run it</a>
<a onclick="show('decisions')">Decisions</a>
<a onclick="show('evals')">Evals</a>
</nav>
<main>
<section id="overview" class="section active">
<h2>Overview</h2>
<p class="lede">A text-to-SQL analytics agent over a real warehouse — structured so the agent layer is legible, not buried in a framework.</p>
<div class="prose">
<p>
nvi answers business questions over the BIRD <code>financial</code>
warehouse (PKDD'99 Czech bank: accounts, transactions, loans, cards,
clients, districts, orders, dispositions). It does this by composing
a small set of <b>Analyses</b> — each its own mini-agent — orchestrated
through a langgraph wiring layer with full Langfuse observability.
</p>
<p>
The shape mirrors the JD's three building blocks
(<i>text-to-SQL, reasoning agents, planners</i>) as three distinct
layers rather than one omnibus prompt. That gives every part a name
and a tight contract, and lets each Analysis pick the simplest
reasoning pattern (CoT, ReAct, plan-and-execute) for what it does
instead of forcing one over everything.
</p>
</div>
</section>
<section id="architecture" class="section">
<h2>Architecture</h2>
<p class="lede">Three layers — Plan composes Analyses; Analyses are mini-agents; Tools are deterministic.</p>
<object data="graphs/architecture.svg" type="image/svg+xml" class="diagram"></object>
<dl>
<dt>Plan (L3)</dt>
<dd>One LLM call that turns a question into an ordered set of Analyses with intended interpretation. Not agentic by itself — produces a structured artifact the runtime executes.</dd>
<dt>Analyses (L2)</dt>
<dd>Each Analysis is a self-contained mini-agent. It picks the simplest reasoning pattern it needs — CoT for one-shot interpretive moves, ReAct for ones that iterate over Tool results, plan-and-execute for compound ones. The Analysis owns its loop.</dd>
<dt>Tools (L1)</dt>
<dd>Deterministic primitives — <code>text_to_sql</code>, <code>execute_sql</code>, <code>retrieve_schema</code>, <code>retrieve_metric_definition</code>, <code>python_sandbox</code>. No LLM reasoning inside. Called from inside Analyses.</dd>
</dl>
</section>
<section id="plan" class="section">
<h2>Plan layer</h2>
<p class="lede">L3 — one LLM call that decides which Analyses to run.</p>
<div class="prose">
<p>
The planner gets the user question, the metric catalog, a schema
overview, and the catalog of Analyses. It returns a structured plan:
an ordered list of Analysis invocations with arguments and an intended
interpretation. It does not call tools or query data itself — it's a
composition step.
</p>
<p><i>To be expanded as the planner is built out.</i></p>
</div>
</section>
<section id="analyses" class="section">
<h2>Analyses layer</h2>
<p class="lede">L2 — each Analysis is its own mini-agent.</p>
<div class="prose">
<p>
Analyses are the agent layer. Each one wraps a small piece of analytical
intent (<i>compare two periods</i>, <i>drill down by dimension</i>,
<i>find outliers</i>) and decides how to satisfy it — sometimes one
LLM call is enough, sometimes a ReAct loop over Tool results is the
right fit. The Analysis base class only enforces a uniform external
contract (<code>run(state) → Finding</code>); the internal pattern is
a per-Analysis decision.
</p>
<p><i>Per-Analysis pattern table coming as each Analysis is implemented.</i></p>
</div>
</section>
<section id="tools" class="section">
<h2>Tools layer</h2>
<p class="lede">L1 — deterministic primitives.</p>
<div class="prose">
<p>
Tools have no LLM reasoning inside them. <code>text_to_sql</code> is
the one exception that uses an LLM, but tightly: schema-RAG context,
sqlglot validation, retry on parse/runtime errors. <code>execute_sql</code>
runs against Postgres read-only with a row cap and timeout.
<code>retrieve_schema</code> and <code>retrieve_metric_definition</code>
power the semantic layer. <code>python_sandbox</code> handles stats
work that's awkward in SQL.
</p>
<p><i>Per-Tool documentation coming as each Tool is implemented.</i></p>
</div>
</section>
<section id="runtime" class="section">
<h2>Runtime</h2>
<p class="lede">langgraph wires the graph; nvi's domain code stays legible on its own.</p>
<div class="prose">
<p>
The orchestration is built on <b>langgraph</b> — it's the right tool
for the graph wiring, parallel execution, and streaming state we need,
and reaching for a custom DAG runner here would be reinventing the
wheel.
</p>
<p>
The intent is the opposite, though: nvi's domain code (Plan, Analyses,
Tools) presents a clean surface that's readable on its own terms.
Understanding what nvi does should not require first learning
langgraph's API. The framework wires nodes; nvi's code defines what
the nodes <i>are</i>, and that's where the interesting reading is.
</p>
<p>
Concretely: <code>api/runtime/</code> contains the langgraph builder
and event tap. <code>api/plan/</code>, <code>api/analyses/</code>, and
<code>api/tools/</code> contain the domain code — and those files
don't expose langgraph types in their public interfaces.
</p>
</div>
</section>
<section id="run" class="section">
<h2>Run it locally</h2>
<p class="lede">Prerequisites: lng Langfuse cluster reachable, soleprint-ui framework propagated by spr, <code>.env</code> with Anthropic + Langfuse keys.</p>
<pre><code>make install # uv sync
make kind # create kind-nvi cluster
make tilt-up # bring up postgres, api, ui, docs, gateway
make seed # load BIRD financial into postgres
open http://nvi.local.ar</code></pre>
<div class="prose">
<p>
<code>*.local.ar</code> resolves via dnsmasq; the system Caddy at
<code>~/wdir/ppl/local/Caddyfile</code> proxies <code>nvi.local.ar</code>
to the kind NodePort. Reload after first install: <code>sudo systemctl reload caddy</code>.
</p>
</div>
</section>
<section id="decisions" class="section">
<h2>Architecture decisions</h2>
<div class="prose">
<ul>
<li><b>Three layers, not one omnibus prompt.</b> Plan composes Analyses; each Analysis picks its own reasoning pattern (CoT / ReAct / plan-and-execute); Tools are deterministic. Borrowing elements from agent frameworks without committing the whole system to one.</li>
<li><b>langgraph for orchestration, nvi for domain.</b> Framework owns graph wiring and streaming; nvi's <code>plan/</code>, <code>analyses/</code>, <code>tools/</code> own the interesting code and are readable without reading langgraph.</li>
<li><b>BIRD <code>financial</code> as the warehouse.</b> Fits Nivii's stated Finance vertical; ships with golden SQL pairs so the eval set is free and verifiable.</li>
<li><b>Langfuse hosted independently</b> by the <code>lng</code> project; reached over WireGuard. Same env var works in dev and any future deploy.</li>
<li><b>Postgres dedicated to nvi.</b> Persisted via <code>.data/postgres</code> hostPath mount so the BIRD load survives cluster restarts.</li>
</ul>
</div>
</section>
<section id="evals" class="section">
<h2>Evals</h2>
<p class="lede">BIRD <code>financial</code> dev set, run by <code>evals/run_evals.py</code>, results pushed to Langfuse tagged <code>eval</code>.</p>
<div class="prose">
<p><i>To be populated as the agent stack lands.</i></p>
</div>
</section>
</main>
</div>
<script>
function show(id) {
document.querySelectorAll('nav a').forEach(a => a.classList.remove('active'));
document.querySelectorAll('.section').forEach(s => s.classList.remove('active'));
event.target.classList.add('active');
document.getElementById(id).classList.add('active');
if (window.innerWidth <= 720) document.querySelector('.layout').classList.remove('nav-open');
}
function toggleNav() {
document.querySelector('.layout').classList.toggle('nav-open');
}
</script>
</body>
</html>

35
pyproject.toml Normal file
View File

@@ -0,0 +1,35 @@
[project]
name = "nvi"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [
"fastapi",
"uvicorn[standard]",
"pydantic>=2.0",
"pydantic-settings",
"anthropic",
"langgraph",
"langchain-anthropic",
"langfuse",
"psycopg[binary]",
"sqlglot",
"httpx",
"pyyaml",
]
[project.optional-dependencies]
dev = [
"pytest",
"pytest-asyncio",
"ruff",
]
[tool.ruff]
line-length = 100
target-version = "py312"
[tool.ruff.lint]
select = ["E", "F", "I"]
[tool.pytest.ini_options]
asyncio_mode = "auto"

14
ui/app/index.html Normal file
View File

@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>nvi — analytics agent</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

23
ui/app/package.json Normal file
View File

@@ -0,0 +1,23 @@
{
"name": "nvi-ui",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@vue-flow/core": "^1.48.2",
"soleprint-ui": "link:../framework",
"uplot": "^1.6.32",
"vue": "^3.5",
"vue-router": "^4.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5",
"typescript": "^5.7",
"vite": "^6.0"
}
}

114
ui/app/src/App.vue Normal file
View File

@@ -0,0 +1,114 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute } from 'vue-router'
const route = useRoute()
// Docs live in a separate service. Resolve the URL from the current host so
// the same build works in dev (per-project subdomain) and in either AWS
// hosting mode (per-project or aggregated under docs.mcrn.ar/nivii).
const docsUrl = computed(() => {
const { protocol, hostname } = window.location
if (hostname.endsWith('.local.ar')) {
// dev: docs.nvi.local.ar (served by in-cluster Caddy)
return `${protocol}//docs.${hostname}`
}
if (hostname === 'nivii.mcrn.ar') {
// aws: aggregated docs site, nvi's entry is /nivii
return 'https://docs.mcrn.ar/nivii'
}
if (hostname === 'docs.nivii.mcrn.ar') {
// aws redundancy: per-project docs subdomain
return 'https://docs.nivii.mcrn.ar'
}
return '/docs/'
})
</script>
<template>
<div class="app">
<header class="app-header">
<div class="app-title">
<h1>NVI</h1>
<span class="app-subtitle">analytics agent</span>
</div>
<nav class="app-nav">
<router-link to="/" :class="{ active: route.path === '/' }">Ask</router-link>
<a :href="docsUrl" class="docs-link" target="_blank">Docs</a>
</nav>
</header>
<main class="app-main">
<router-view />
</main>
</div>
</template>
<style scoped>
.app {
display: flex;
flex-direction: column;
height: 100vh;
}
.app-header {
display: flex;
align-items: center;
gap: 24px;
padding: 12px 24px;
background: var(--surface-1);
border-bottom: var(--panel-border);
}
.app-title h1 {
font-family: var(--font-mono);
font-size: 18px;
font-weight: 600;
letter-spacing: 2px;
color: var(--accent);
}
.app-subtitle {
font-size: 11px;
color: var(--text-dim);
text-transform: uppercase;
letter-spacing: 1px;
}
.app-nav {
display: flex;
gap: 4px;
margin-left: auto;
}
.app-nav a {
padding: 6px 16px;
font-size: 13px;
font-family: var(--font-mono);
color: var(--text-secondary);
text-decoration: none;
border: var(--panel-border);
transition: all 0.15s;
}
.app-nav a:hover {
background: var(--surface-2);
color: var(--text-primary);
}
.app-nav a.active {
background: var(--accent-dim);
color: var(--text-primary);
border-color: var(--accent);
}
.app-nav .docs-link {
color: var(--text-dim);
font-size: 11px;
}
.app-main {
flex: 1;
overflow: auto;
padding: 24px;
}
</style>

16
ui/app/src/main.ts Normal file
View File

@@ -0,0 +1,16 @@
import { createApp } from 'vue'
import { createRouter, createWebHistory } from 'vue-router'
import 'soleprint-ui/src/tokens.css'
import App from './App.vue'
import Ask from './pages/Ask.vue'
import RunInspector from './pages/RunInspector.vue'
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', component: Ask },
{ path: '/runs/:id', component: RunInspector, props: true },
],
})
createApp(App).use(router).mount('#app')

80
ui/app/src/pages/Ask.vue Normal file
View File

@@ -0,0 +1,80 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useRouter } from 'vue-router'
const question = ref('Which districts had the sharpest rise in loan defaults?')
const submitting = ref(false)
const error = ref<string | null>(null)
const router = useRouter()
async function submit() {
if (!question.value.trim()) return
submitting.value = true
error.value = null
try {
const res = await fetch('/ask', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question: question.value }),
})
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`)
const { run_id } = await res.json()
router.push(`/runs/${run_id}`)
} catch (e: any) {
error.value = e.message ?? String(e)
} finally {
submitting.value = false
}
}
</script>
<template>
<div class="ask">
<h2>Ask a question</h2>
<textarea v-model="question" rows="3" :disabled="submitting" />
<div class="actions">
<button @click="submit" :disabled="submitting || !question.trim()">
{{ submitting ? 'Submitting…' : 'Ask' }}
</button>
</div>
<p v-if="error" class="error">{{ error }}</p>
</div>
</template>
<style scoped>
.ask {
max-width: 720px;
display: flex;
flex-direction: column;
gap: 12px;
}
textarea {
background: var(--surface-2);
color: var(--text-primary);
border: var(--panel-border);
padding: 12px;
font-family: var(--font-mono);
font-size: 14px;
}
.actions {
display: flex;
justify-content: flex-end;
}
button {
padding: 8px 20px;
font-family: var(--font-mono);
background: var(--accent-dim);
color: var(--text-primary);
border: var(--panel-border);
cursor: pointer;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.error {
color: var(--status-error, #f55);
font-family: var(--font-mono);
font-size: 12px;
}
</style>

View File

@@ -0,0 +1,47 @@
<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>

17
ui/app/tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"jsx": "preserve",
"sourceMap": true,
"resolveJsonModule": true,
"isolatedModules": true,
"esModuleInterop": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"types": ["vite/client"]
},
"include": ["src/**/*", "src/**/*.vue"]
}

17
ui/app/vite.config.ts Normal file
View File

@@ -0,0 +1,17 @@
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
server: {
port: 5173,
proxy: {
'/ask': 'http://localhost:8000',
'/runs': {
target: 'http://localhost:8000',
changeOrigin: true,
},
'/healthz': 'http://localhost:8000',
},
},
})

1648
uv.lock generated Normal file

File diff suppressed because it is too large Load Diff