commit 131f4d9b8636585f3f38c53f5564388d2225a9b0 Author: buenosairesam Date: Wed Jun 3 00:33:51 2026 -0300 scaffold: kind+Tilt cluster, api/ui/docs stubs, green at nvi.local.ar diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1a557a8 --- /dev/null +++ b/.env.example @@ -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= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1d5abe9 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8d238f5 --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..9871959 --- /dev/null +++ b/README.md @@ -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`. diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/api/config.py b/api/config.py new file mode 100644 index 0000000..aba53d1 --- /dev/null +++ b/api/config.py @@ -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() diff --git a/api/main.py b/api/main.py new file mode 100644 index 0000000..bb7118f --- /dev/null +++ b/api/main.py @@ -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") diff --git a/ctrl/Dockerfile.api b/ctrl/Dockerfile.api new file mode 100644 index 0000000..9eb9948 --- /dev/null +++ b/ctrl/Dockerfile.api @@ -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"] diff --git a/ctrl/Dockerfile.docs b/ctrl/Dockerfile.docs new file mode 100644 index 0000000..fb97550 --- /dev/null +++ b/ctrl/Dockerfile.docs @@ -0,0 +1,3 @@ +FROM nginx:alpine +COPY docs/ /usr/share/nginx/html/ +COPY ctrl/nginx.conf /etc/nginx/conf.d/default.conf diff --git a/ctrl/Dockerfile.ui b/ctrl/Dockerfile.ui new file mode 100644 index 0000000..5484bcb --- /dev/null +++ b/ctrl/Dockerfile.ui @@ -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 diff --git a/ctrl/Tiltfile b/ctrl/Tiltfile new file mode 100644 index 0000000..305d331 --- /dev/null +++ b/ctrl/Tiltfile @@ -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', +) diff --git a/ctrl/docker-compose.yml b/ctrl/docker-compose.yml new file mode 100644 index 0000000..d1625c2 --- /dev/null +++ b/ctrl/docker-compose.yml @@ -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: diff --git a/ctrl/k8s/base/Caddyfile b/ctrl/k8s/base/Caddyfile new file mode 100644 index 0000000..eaacbd2 --- /dev/null +++ b/ctrl/k8s/base/Caddyfile @@ -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. diff --git a/ctrl/k8s/base/api.yaml b/ctrl/k8s/base/api.yaml new file mode 100644 index 0000000..d3dc661 --- /dev/null +++ b/ctrl/k8s/base/api.yaml @@ -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 diff --git a/ctrl/k8s/base/configmap.yaml b/ctrl/k8s/base/configmap.yaml new file mode 100644 index 0000000..79a6591 --- /dev/null +++ b/ctrl/k8s/base/configmap.yaml @@ -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" diff --git a/ctrl/k8s/base/docs.yaml b/ctrl/k8s/base/docs.yaml new file mode 100644 index 0000000..d16c82a --- /dev/null +++ b/ctrl/k8s/base/docs.yaml @@ -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 diff --git a/ctrl/k8s/base/gateway.yaml b/ctrl/k8s/base/gateway.yaml new file mode 100644 index 0000000..8af7752 --- /dev/null +++ b/ctrl/k8s/base/gateway.yaml @@ -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 diff --git a/ctrl/k8s/base/kustomization.yaml b/ctrl/k8s/base/kustomization.yaml new file mode 100644 index 0000000..b8db436 --- /dev/null +++ b/ctrl/k8s/base/kustomization.yaml @@ -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 diff --git a/ctrl/k8s/base/namespace.yaml b/ctrl/k8s/base/namespace.yaml new file mode 100644 index 0000000..38b7deb --- /dev/null +++ b/ctrl/k8s/base/namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: nvi diff --git a/ctrl/k8s/base/postgres.yaml b/ctrl/k8s/base/postgres.yaml new file mode 100644 index 0000000..446c9ae --- /dev/null +++ b/ctrl/k8s/base/postgres.yaml @@ -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 diff --git a/ctrl/k8s/base/ui.yaml b/ctrl/k8s/base/ui.yaml new file mode 100644 index 0000000..dc5cedf --- /dev/null +++ b/ctrl/k8s/base/ui.yaml @@ -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 diff --git a/ctrl/k8s/kind-config.yaml b/ctrl/k8s/kind-config.yaml new file mode 100644 index 0000000..ddeaf44 --- /dev/null +++ b/ctrl/k8s/kind-config.yaml @@ -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 diff --git a/ctrl/k8s/overlays/dev/kustomization.yaml b/ctrl/k8s/overlays/dev/kustomization.yaml new file mode 100644 index 0000000..41e87c3 --- /dev/null +++ b/ctrl/k8s/overlays/dev/kustomization.yaml @@ -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 diff --git a/ctrl/kind-config.sh b/ctrl/kind-config.sh new file mode 100755 index 0000000..13feb1b --- /dev/null +++ b/ctrl/kind-config.sh @@ -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 diff --git a/ctrl/nginx.conf b/ctrl/nginx.conf new file mode 100644 index 0000000..cc24e66 --- /dev/null +++ b/ctrl/nginx.conf @@ -0,0 +1,9 @@ +server { + listen 80; + + location / { + root /usr/share/nginx/html; + index index.html; + try_files $uri $uri/ /index.html; + } +} diff --git a/docs/graphs/README.md b/docs/graphs/README.md new file mode 100644 index 0000000..52c8ef3 --- /dev/null +++ b/docs/graphs/README.md @@ -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 ``. + +Convention follows eth/unt/mpr: dark background, JetBrains Mono labels, +accent blue for cluster titles, dim grey for edges. diff --git a/docs/graphs/architecture.dot b/docs/graphs/architecture.dot new file mode 100644 index 0000000..f2c3e44 --- /dev/null +++ b/docs/graphs/architecture.dot @@ -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]; +} diff --git a/docs/graphs/architecture.svg b/docs/graphs/architecture.svg new file mode 100644 index 0000000..1b8f49f --- /dev/null +++ b/docs/graphs/architecture.svg @@ -0,0 +1,234 @@ + + + + + + +nvi + + +cluster_l3 + +L3 — Plan + + +cluster_l2 + +L2 — Analyses (mini-agents) + + +cluster_l1 + +L1 — Tools (deterministic) + + + +user + +user question + + + +plan + +planner +(1 LLM call) + + + +user->plan + + + + + +compare + +compare_periods +(CoT) + + + +plan->compare + + + + + +drill + +drill_down +(ReAct) + + + +plan->drill + + + + + +outl + +find_outliers +(CoT) + + + +plan->outl + + + + + +corr + +correlate +(plan-and-execute) + + + +plan->corr + + + + + +langfuse + +Langfuse (lng, WG) + + + +plan->langfuse + + +spans + + + +t2s + +text_to_sql + + + +compare->t2s + + + + + +compare->langfuse + + + + + +synth + +synthesize +findings → answer + + + +compare->synth + + + + + +drill->t2s + + + + + +schema + +retrieve_schema + + + +drill->schema + + + + + +drill->synth + + + + + +outl->t2s + + + + + +outl->synth + + + + + +corr->t2s + + + + + +py + +python_sandbox + + + +corr->py + + + + + +corr->synth + + + + + +exec + +execute_sql + + + +t2s->exec + + + + + +warehouse + +Postgres +BIRD financial + + + +exec->warehouse + + + + + +exec->langfuse + + + + + diff --git a/docs/index.html b/docs/index.html new file mode 100644 index 0000000..b8847a7 --- /dev/null +++ b/docs/index.html @@ -0,0 +1,435 @@ + + + + + +nvi — analytics agent + + + + +
+

NVI

+ analytics agent — BIRD financial + +
+ +
+ + + + +
+ +
+

Overview

+

A text-to-SQL analytics agent over a real warehouse — structured so the agent layer is legible, not buried in a framework.

+
+

+ nvi answers business questions over the BIRD financial + warehouse (PKDD'99 Czech bank: accounts, transactions, loans, cards, + clients, districts, orders, dispositions). It does this by composing + a small set of Analyses — each its own mini-agent — orchestrated + through a langgraph wiring layer with full Langfuse observability. +

+

+ The shape mirrors the JD's three building blocks + (text-to-SQL, reasoning agents, planners) as three distinct + layers rather than one omnibus prompt. That gives every part a name + and a tight contract, and lets each Analysis pick the simplest + reasoning pattern (CoT, ReAct, plan-and-execute) for what it does + instead of forcing one over everything. +

+
+
+ +
+

Architecture

+

Three layers — Plan composes Analyses; Analyses are mini-agents; Tools are deterministic.

+ +
+
Plan (L3)
+
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.
+
Analyses (L2)
+
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.
+
Tools (L1)
+
Deterministic primitives — text_to_sql, execute_sql, retrieve_schema, retrieve_metric_definition, python_sandbox. No LLM reasoning inside. Called from inside Analyses.
+
+
+ +
+

Plan layer

+

L3 — one LLM call that decides which Analyses to run.

+
+

+ The planner gets the user question, the metric catalog, a schema + overview, and the catalog of Analyses. It returns a structured plan: + an ordered list of Analysis invocations with arguments and an intended + interpretation. It does not call tools or query data itself — it's a + composition step. +

+

To be expanded as the planner is built out.

+
+
+ +
+

Analyses layer

+

L2 — each Analysis is its own mini-agent.

+
+

+ Analyses are the agent layer. Each one wraps a small piece of analytical + intent (compare two periods, drill down by dimension, + find outliers) and decides how to satisfy it — sometimes one + LLM call is enough, sometimes a ReAct loop over Tool results is the + right fit. The Analysis base class only enforces a uniform external + contract (run(state) → Finding); the internal pattern is + a per-Analysis decision. +

+

Per-Analysis pattern table coming as each Analysis is implemented.

+
+
+ +
+

Tools layer

+

L1 — deterministic primitives.

+
+

+ Tools have no LLM reasoning inside them. text_to_sql is + the one exception that uses an LLM, but tightly: schema-RAG context, + sqlglot validation, retry on parse/runtime errors. execute_sql + runs against Postgres read-only with a row cap and timeout. + retrieve_schema and retrieve_metric_definition + power the semantic layer. python_sandbox handles stats + work that's awkward in SQL. +

+

Per-Tool documentation coming as each Tool is implemented.

+
+
+ +
+

Runtime

+

langgraph wires the graph; nvi's domain code stays legible on its own.

+
+

+ The orchestration is built on langgraph — 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. +

+

+ 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 are, and that's where the interesting reading is. +

+

+ Concretely: api/runtime/ contains the langgraph builder + and event tap. api/plan/, api/analyses/, and + api/tools/ contain the domain code — and those files + don't expose langgraph types in their public interfaces. +

+
+
+ +
+

Run it locally

+

Prerequisites: lng Langfuse cluster reachable, soleprint-ui framework propagated by spr, .env with Anthropic + Langfuse keys.

+
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
+
+

+ *.local.ar resolves via dnsmasq; the system Caddy at + ~/wdir/ppl/local/Caddyfile proxies nvi.local.ar + to the kind NodePort. Reload after first install: sudo systemctl reload caddy. +

+
+
+ +
+

Architecture decisions

+
+
    +
  • Three layers, not one omnibus prompt. 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.
  • +
  • langgraph for orchestration, nvi for domain. Framework owns graph wiring and streaming; nvi's plan/, analyses/, tools/ own the interesting code and are readable without reading langgraph.
  • +
  • BIRD financial as the warehouse. Fits Nivii's stated Finance vertical; ships with golden SQL pairs so the eval set is free and verifiable.
  • +
  • Langfuse hosted independently by the lng project; reached over WireGuard. Same env var works in dev and any future deploy.
  • +
  • Postgres dedicated to nvi. Persisted via .data/postgres hostPath mount so the BIRD load survives cluster restarts.
  • +
+
+
+ +
+

Evals

+

BIRD financial dev set, run by evals/run_evals.py, results pushed to Langfuse tagged eval.

+
+

To be populated as the agent stack lands.

+
+
+ +
+
+ + + + + diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..e075eb4 --- /dev/null +++ b/pyproject.toml @@ -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" diff --git a/ui/app/index.html b/ui/app/index.html new file mode 100644 index 0000000..6a3bfd2 --- /dev/null +++ b/ui/app/index.html @@ -0,0 +1,14 @@ + + + + + + nvi — analytics agent + + + + +
+ + + diff --git a/ui/app/package.json b/ui/app/package.json new file mode 100644 index 0000000..8aa1588 --- /dev/null +++ b/ui/app/package.json @@ -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" + } +} diff --git a/ui/app/src/App.vue b/ui/app/src/App.vue new file mode 100644 index 0000000..31e6ce2 --- /dev/null +++ b/ui/app/src/App.vue @@ -0,0 +1,114 @@ + + + + + diff --git a/ui/app/src/main.ts b/ui/app/src/main.ts new file mode 100644 index 0000000..da5e9e4 --- /dev/null +++ b/ui/app/src/main.ts @@ -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') diff --git a/ui/app/src/pages/Ask.vue b/ui/app/src/pages/Ask.vue new file mode 100644 index 0000000..8b9abe0 --- /dev/null +++ b/ui/app/src/pages/Ask.vue @@ -0,0 +1,80 @@ + + +