scaffold: kind+Tilt cluster, api/ui/docs stubs, green at nvi.local.ar
This commit is contained in:
15
.env.example
Normal file
15
.env.example
Normal 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
15
.gitignore
vendored
Normal 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
50
Makefile
Normal 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
61
README.md
Normal 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
0
api/__init__.py
Normal file
21
api/config.py
Normal file
21
api/config.py
Normal 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
108
api/main.py
Normal 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
12
ctrl/Dockerfile.api
Normal 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
3
ctrl/Dockerfile.docs
Normal 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
22
ctrl/Dockerfile.ui
Normal 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
80
ctrl/Tiltfile
Normal 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
56
ctrl/docker-compose.yml
Normal 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
22
ctrl/k8s/base/Caddyfile
Normal 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
47
ctrl/k8s/base/api.yaml
Normal 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
|
||||
19
ctrl/k8s/base/configmap.yaml
Normal file
19
ctrl/k8s/base/configmap.yaml
Normal 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
44
ctrl/k8s/base/docs.yaml
Normal 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
|
||||
53
ctrl/k8s/base/gateway.yaml
Normal file
53
ctrl/k8s/base/gateway.yaml
Normal 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
|
||||
23
ctrl/k8s/base/kustomization.yaml
Normal file
23
ctrl/k8s/base/kustomization.yaml
Normal 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
|
||||
4
ctrl/k8s/base/namespace.yaml
Normal file
4
ctrl/k8s/base/namespace.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: nvi
|
||||
60
ctrl/k8s/base/postgres.yaml
Normal file
60
ctrl/k8s/base/postgres.yaml
Normal 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
44
ctrl/k8s/base/ui.yaml
Normal 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
18
ctrl/k8s/kind-config.yaml
Normal 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
|
||||
19
ctrl/k8s/overlays/dev/kustomization.yaml
Normal file
19
ctrl/k8s/overlays/dev/kustomization.yaml
Normal 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
14
ctrl/kind-config.sh
Executable 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
9
ctrl/nginx.conf
Normal 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
7
docs/graphs/README.md
Normal 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.
|
||||
65
docs/graphs/architecture.dot
Normal file
65
docs/graphs/architecture.dot
Normal 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];
|
||||
}
|
||||
234
docs/graphs/architecture.svg
Normal file
234
docs/graphs/architecture.svg
Normal 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-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->plan -->
|
||||
<g id="edge1" class="edge">
|
||||
<title>user->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->compare -->
|
||||
<g id="edge2" class="edge">
|
||||
<title>plan->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->drill -->
|
||||
<g id="edge3" class="edge">
|
||||
<title>plan->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->outl -->
|
||||
<g id="edge4" class="edge">
|
||||
<title>plan->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-and-execute)</text>
|
||||
</g>
|
||||
<!-- plan->corr -->
|
||||
<g id="edge5" class="edge">
|
||||
<title>plan->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->langfuse -->
|
||||
<g id="edge18" class="edge">
|
||||
<title>plan->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->t2s -->
|
||||
<g id="edge6" class="edge">
|
||||
<title>compare->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->langfuse -->
|
||||
<g id="edge19" class="edge">
|
||||
<title>compare->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->synth -->
|
||||
<g id="edge14" class="edge">
|
||||
<title>compare->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->t2s -->
|
||||
<g id="edge7" class="edge">
|
||||
<title>drill->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->schema -->
|
||||
<g id="edge10" class="edge">
|
||||
<title>drill->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->synth -->
|
||||
<g id="edge15" class="edge">
|
||||
<title>drill->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->t2s -->
|
||||
<g id="edge8" class="edge">
|
||||
<title>outl->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->synth -->
|
||||
<g id="edge16" class="edge">
|
||||
<title>outl->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->t2s -->
|
||||
<g id="edge9" class="edge">
|
||||
<title>corr->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->py -->
|
||||
<g id="edge11" class="edge">
|
||||
<title>corr->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->synth -->
|
||||
<g id="edge17" class="edge">
|
||||
<title>corr->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->exec -->
|
||||
<g id="edge12" class="edge">
|
||||
<title>t2s->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->warehouse -->
|
||||
<g id="edge13" class="edge">
|
||||
<title>exec->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->langfuse -->
|
||||
<g id="edge20" class="edge">
|
||||
<title>exec->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
435
docs/index.html
Normal 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
35
pyproject.toml
Normal 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
14
ui/app/index.html
Normal 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
23
ui/app/package.json
Normal 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
114
ui/app/src/App.vue
Normal 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
16
ui/app/src/main.ts
Normal 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
80
ui/app/src/pages/Ask.vue
Normal 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>
|
||||
47
ui/app/src/pages/RunInspector.vue
Normal file
47
ui/app/src/pages/RunInspector.vue
Normal 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
17
ui/app/tsconfig.json
Normal 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
17
ui/app/vite.config.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user