From ca626f14649975d82af3fbe0b143487707678e02 Mon Sep 17 00:00:00 2001 From: Patrick Werle Date: Sun, 4 Oct 2026 19:08:58 +0200 Subject: [PATCH 1/5] feat(deploy): target Google Cloud Run instead of a Hugging Face Space Hugging Face now requires PRO for Docker Spaces, so the free option is gone. The container is host-neutral (deploy/Dockerfile, deploy/start.sh) and deploy/cloud-run.sh builds HEAD for linux/amd64, stores the Nebius key from .env in Secret Manager without printing it, and deploys one scale-to-zero service capped at one instance. Co-Authored-By: Claude Opus 5.5 --- .dockerignore | 2 +- deploy/{hf-space => }/Dockerfile | 10 ++--- deploy/cloud-run.sh | 64 ++++++++++++++++++++++++++++++++ deploy/hf-space/README.md | 22 ----------- deploy/hf-space/push.sh | 28 -------------- deploy/{hf-space => }/start.sh | 2 +- 6 files changed, 71 insertions(+), 57 deletions(-) rename deploy/{hf-space => }/Dockerfile (87%) create mode 100755 deploy/cloud-run.sh delete mode 100644 deploy/hf-space/README.md delete mode 100755 deploy/hf-space/push.sh rename deploy/{hf-space => }/start.sh (91%) diff --git a/.dockerignore b/.dockerignore index 30dddb1..bb3ed72 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,4 +1,4 @@ -# Build context for deploy/hf-space/Dockerfile. Never ship secrets or local state into an image. +# Build context for deploy/Dockerfile. Never ship secrets or local state into an image. .env .env.* !.env.example diff --git a/deploy/hf-space/Dockerfile b/deploy/Dockerfile similarity index 87% rename from deploy/hf-space/Dockerfile rename to deploy/Dockerfile index af0bd70..106fd4e 100644 --- a/deploy/hf-space/Dockerfile +++ b/deploy/Dockerfile @@ -1,7 +1,7 @@ # syntax=docker/dockerfile:1.7 -# Hugging Face Space image: the Next.js app and the KoSIT verifier in one container. -# The verifier listens on localhost only; the Space exposes nothing but the app port (7860). -# Verifier pins must match services/verifier/Dockerfile (deploy/hf-space/push.sh checks this). +# Demo image: the Next.js app and the KoSIT verifier in one container. +# The verifier listens on localhost only; the host exposes nothing but the app port (7860). +# Verifier pins must match services/verifier/Dockerfile (deploy/cloud-run.sh checks this). ARG KOSIT_VALIDATOR_VERSION=1.6.3 ARG XRECHNUNG_CONFIG_VERSION=xrechnung-3.0.2-validator-configuration-2026-08-31 @@ -46,8 +46,8 @@ COPY --from=verifier /build/lib/validator.jar /opt/verifier/lib/validator.jar COPY --from=verifier /build/classes /opt/verifier/classes COPY --from=verifier /build/config /opt/xrechnung-config COPY --from=web /repo/apps/web/.next/standalone /app -COPY --chmod=755 deploy/hf-space/start.sh /usr/local/bin/start-verdict -# Spaces run the container as uid 1000. +COPY --chmod=755 deploy/start.sh /usr/local/bin/start-verdict +# Unprivileged user; no host needs root here. USER 1000 WORKDIR /app/apps/web EXPOSE 7860 diff --git a/deploy/cloud-run.sh b/deploy/cloud-run.sh new file mode 100755 index 0000000..7019397 --- /dev/null +++ b/deploy/cloud-run.sh @@ -0,0 +1,64 @@ +#!/usr/bin/env bash +# Deploy the committed tree (HEAD) to Google Cloud Run as one service. +# Needs `gcloud auth login` and a project with billing enabled (the demo stays inside the +# free tier: request-based billing, scale to zero, at most one instance). +# deploy/cloud-run.sh build, push, deploy +# deploy/cloud-run.sh --secret (re)store NEBIUS_API_KEY from .env in Secret Manager first +set -euo pipefail + +PROJECT="${GCP_PROJECT:-$(gcloud config get-value project 2>/dev/null)}" +REGION="${GCP_REGION:-europe-west1}" +SERVICE=verdict +REPO=verdict +[ -n "$PROJECT" ] || { echo "No project: gcloud config set project " >&2; exit 1; } + +ROOT="$(git rev-parse --show-toplevel)" +cd "$ROOT" + +# The demo Dockerfile duplicates the verifier pins; refuse to ship if they drifted. +pins() { grep -oE 'sha256:[0-9a-f]{64}|(VERSION|CONFIG_VERSION)=[^ ]+' "$1" | sort; } +if ! diff <(pins services/verifier/Dockerfile) <(pins deploy/Dockerfile) >/dev/null; then + echo "Verifier pins differ between services/verifier/Dockerfile and deploy/Dockerfile" >&2 + exit 1 +fi + +gcloud services enable run.googleapis.com artifactregistry.googleapis.com \ + secretmanager.googleapis.com --project "$PROJECT" --quiet + +if [ "${1:-}" = "--secret" ]; then + # The key goes from .env straight into Secret Manager; it is never echoed or logged. + key="$(grep -E '^NEBIUS_API_KEY=' .env | head -1 | cut -d= -f2-)" + [ -n "$key" ] || { echo "NEBIUS_API_KEY is empty in .env" >&2; exit 1; } + if gcloud secrets describe nebius-api-key --project "$PROJECT" >/dev/null 2>&1; then + printf '%s' "$key" | gcloud secrets versions add nebius-api-key --data-file=- --project "$PROJECT" >/dev/null + else + printf '%s' "$key" | gcloud secrets create nebius-api-key --data-file=- \ + --replication-policy=automatic --project "$PROJECT" >/dev/null + fi + unset key + sa="$(gcloud projects describe "$PROJECT" --format='value(projectNumber)')-compute@developer.gserviceaccount.com" + gcloud secrets add-iam-policy-binding nebius-api-key --project "$PROJECT" \ + --member "serviceAccount:$sa" --role roles/secretmanager.secretAccessor >/dev/null + echo "Secret nebius-api-key stored." +fi + +gcloud artifacts repositories describe "$REPO" --location "$REGION" --project "$PROJECT" >/dev/null 2>&1 \ + || gcloud artifacts repositories create "$REPO" --repository-format docker \ + --location "$REGION" --project "$PROJECT" --quiet +gcloud auth configure-docker "$REGION-docker.pkg.dev" --quiet >/dev/null + +# Build from HEAD only, so uncommitted changes and local files never reach the image. +STAGE="$(mktemp -d)" +trap 'rm -rf "$STAGE"' EXIT +git archive HEAD | tar -x -C "$STAGE" +IMAGE="$REGION-docker.pkg.dev/$PROJECT/$REPO/$SERVICE:$(git rev-parse --short HEAD)" +docker buildx build --platform linux/amd64 -f "$STAGE/deploy/Dockerfile" -t "$IMAGE" --push "$STAGE" + +# max-instances 1: the rate limit and the daily model budget live in memory, so a second +# instance would double both. It also caps what a traffic spike can cost. +gcloud run deploy "$SERVICE" --image "$IMAGE" --region "$REGION" --project "$PROJECT" \ + --port 7860 --cpu 1 --memory 2Gi --cpu-boost \ + --min-instances 0 --max-instances 1 --concurrency 20 --timeout 300 \ + --set-secrets NEBIUS_API_KEY=nebius-api-key:latest \ + --allow-unauthenticated --quiet +gcloud run services describe "$SERVICE" --region "$REGION" --project "$PROJECT" --format 'value(status.url)' diff --git a/deploy/hf-space/README.md b/deploy/hf-space/README.md deleted file mode 100644 index fa10d16..0000000 --- a/deploy/hf-space/README.md +++ /dev/null @@ -1,22 +0,0 @@ ---- -title: Verdict -emoji: 🧾 -colorFrom: indigo -colorTo: green -sdk: docker -app_port: 7860 -pinned: false -license: apache-2.0 -short_description: PDF invoice to valid XRechnung. Nemotron proposes, KoSIT decides. ---- - -# Verdict - -Verdict turns a German PDF invoice into an XRechnung (EN 16931, CII). NVIDIA Nemotron -models on Nebius Token Factory extract and repair; the official KoSIT validator decides. -A file is only stamped "verified" when the validator accepts it. - -This Space runs the web app and the validator in one container. Use the gallery to replay -recorded runs at no cost, or upload a synthetic invoice for a live run. - -Source: https://github.com/OnePieceMonkey/Verdict diff --git a/deploy/hf-space/push.sh b/deploy/hf-space/push.sh deleted file mode 100755 index 16112fd..0000000 --- a/deploy/hf-space/push.sh +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env bash -# Publish the committed tree (HEAD) to the Hugging Face Space. -# Needs `hf auth login` once. Uncommitted changes are never uploaded. -# deploy/hf-space/push.sh [owner/space] (default: Tschoko86/verdict) -set -euo pipefail - -SPACE="${1:-Tschoko86/verdict}" -ROOT="$(git rev-parse --show-toplevel)" -cd "$ROOT" - -# The Space Dockerfile duplicates the verifier pins; refuse to ship if they drifted. -pins() { grep -oE 'sha256:[0-9a-f]{64}|(VERSION|CONFIG_VERSION)=[^ ]+' "$1" | sort; } -if ! diff <(pins services/verifier/Dockerfile) <(pins deploy/hf-space/Dockerfile) >/dev/null; then - echo "Verifier pins differ between services/verifier/Dockerfile and deploy/hf-space/Dockerfile" >&2 - exit 1 -fi - -STAGE="$(mktemp -d)" -trap 'rm -rf "$STAGE"' EXIT -git archive HEAD | tar -x -C "$STAGE" -rm -rf "$STAGE/.planning" "$STAGE/.claude" "$STAGE/.github" "$STAGE/.githooks" -cp deploy/hf-space/Dockerfile "$STAGE/Dockerfile" -cp deploy/hf-space/README.md "$STAGE/README.md" - -hf repos create "$SPACE" --type space --sdk docker --private --exist-ok -hf upload "$SPACE" "$STAGE" . --repo-type space --delete "*" \ - --commit-message "Deploy $(git rev-parse --short HEAD)" -echo "Space: https://huggingface.co/spaces/$SPACE" diff --git a/deploy/hf-space/start.sh b/deploy/start.sh similarity index 91% rename from deploy/hf-space/start.sh rename to deploy/start.sh index cbc4f1a..cab0d50 100755 --- a/deploy/hf-space/start.sh +++ b/deploy/start.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # Start the verifier on localhost, wait until it is healthy, then run the app. -# If either process exits, the container exits and the Space restarts it. +# If either process exits, the container exits and the host restarts it. set -euo pipefail # The secret only guards localhost traffic inside this container, so a fresh one per boot is enough. From 129f7ea09617ef5b4e868b96be7166b863bd4667 Mon Sep 17 00:00:00 2001 From: Patrick Werle Date: Sun, 4 Oct 2026 21:05:10 +0200 Subject: [PATCH 2/5] chore(deploy): cap image storage and record the Cloud Run decision Artifact Registry keeps only the two newest images, so storage stays in the free tier. OPS-05 now targets Cloud Run; account setup is pending. Co-Authored-By: Claude Opus 5.5 --- .planning/REQUIREMENTS.md | 2 +- .planning/ROADMAP.md | 4 ++-- deploy/cloud-run.sh | 11 +++++++++++ 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index c4e4748..5c126ae 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -10,7 +10,7 @@ - [ ] **OPS-02** *(02.10.: Hook + CI-Job angelegt, GitHub-Lauf und Lizenz-Erkennung stehen aus)*: gitleaks in CI und pre-commit; Apache-2.0-LICENSE im Repo-Root, auf GitHub im About-Bereich erkannt - [x] **OPS-03**: Credits eingelöst (Event-Code, Builder Program, Tavily); Smoke-Test ruft je ein Modell für Extraktion und Reparatur über Token Factory auf - [x] **OPS-04**: Zentraler LLM-Client zählt Tokens und Kosten je Aufruf und bricht bei Überschreitung von `DAILY_BUDGET_USD` ab -- [ ] **OPS-05** *(02.10.: `docker compose up verifier` läuft lokal; Koyeb offen, Verifier braucht ~570 MB RAM)*: Deployment `web` (öffentlich) und `verifier` (intern oder Secret-geschützt) auf Koyeb; `docker compose up` startet den Verifier lokal +- [ ] **OPS-05** *(04.10.: Ziel geändert auf Google Cloud Run Free Tier, weil Patrick 0 € will; Koyeb ~8 $/Monat, Hugging Face Docker-Spaces nur mit PRO. Ein Container mit App + Verifier (`deploy/Dockerfile`), Verifier nur auf localhost; `deploy/cloud-run.sh` fertig, PR #5. Offen: GCP-Projekt + Billing + Login durch Patrick, dann erster Deploy)*: Deployment `web` (öffentlich) und `verifier` (intern oder Secret-geschützt); `docker compose up` startet den Verifier lokal ### Verifier (VER) - [x] **VER-01**: `POST /v1/validate/xrechnung` liefert für jede valide Testsuite-Datei `valid: true` und für mutierte Dateien Fehler mit Regel-ID, Meldung und Fundstelle diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 4192b44..3bfb1d9 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -40,7 +40,7 @@ Fünf Phasen vom 17.09. bis 28.10.2026, ~50 h Gesamtaufwand. Die riskantesten Te - Verifier: KoSIT 1.6.3 + Konfiguration 2026-08-31 in Docker, ohne lokales Java. `pnpm verifier:check`: 41/41 CII-Instanzen der Testsuite valide, 4/4 Mutationen mit erwarteter Regel-ID abgelehnt. JDK-`HttpServer` statt Javalin (keine Klassenkonflikte mit dem KoSIT-Fat-Jar). - Befund: 2 Testsuite-Instanzen (04.05a, 02.01a-cvd) sind ACCEPTABLE trotz Codelisten-Meldungen der Stufe `error`. „Valid“ folgt deshalb ausschließlich der `acceptRecommendation`, nie der Fehlerzählung. - Cut-Gate ZUGFeRD: ohne Spike verstrichen → ZUGFeRD aus Phase 1 genommen, in Phase 4 verankert (VER-06 + CORE-05, nach Kill-Gate). -- Offen in Phase 1: Koyeb-Deployment (OPS-05), erster CI-Lauf auf GitHub (OPS-01/02). +- Offen in Phase 1: Deployment (OPS-05, seit 04.10. Google Cloud Run statt Koyeb), erster CI-Lauf auf GitHub (OPS-01/02). ### Phase 2: Korpus & Ground Truth **Goal**: Es gibt einen reproduzierbaren, gemessenen Maßstab, bevor irgendein Modell Rechnungen verarbeitet. @@ -98,7 +98,7 @@ Fünf Phasen vom 17.09. bis 28.10.2026, ~50 h Gesamtaufwand. Die riskantesten Te - Next.js 16: `POST /api/run` (SSE, Limits, Rate-Limit, Tagesbudget, nichts gespeichert), `GET /api/budget`. Galerie: 7 aufgezeichnete echte Läufe (`pnpm --filter @verdict/web gallery:build`), funktioniert ohne Budget. - Unabhängiger Finish-Reviewer: Runde 1 „fix" (8 Punkte), Runde 2 „fix" (3 Regressionen), Runde 3 „ship". Dabei gefunden und im Kern behoben: Zitate mit wörtlichem `\n` wurden fälschlich abgelehnt; KoSIT-Ablehnungen wie BR-DE-1 werden jetzt zur gezielten Rückfrage. - Produktions-Build und `next start` mit echter PDF geprüft. -- Offen: Koyeb-Deployment (OPS-05), Galerie-Lauf mit echter Ablehnung fürs Video (Reviewer N2), ZUGFeRD (VER-06/CORE-05, nach Kill-Gate erlaubt), README/Video (Phase 5). +- Offen: Deployment auf Google Cloud Run (OPS-05, Skript fertig in PR #5, Konto-Einrichtung durch Patrick am 05.10.), Galerie-Lauf mit echter Ablehnung fürs Video (Reviewer N2), ZUGFeRD (VER-06/CORE-05, nach Kill-Gate erlaubt), README/Video (Phase 5). ### Phase 5: Einreichung **Goal**: Vollständige, regelkonforme Einreichung mit belastbaren Zahlen, zwei Tage vor Deadline. diff --git a/deploy/cloud-run.sh b/deploy/cloud-run.sh index 7019397..b003af2 100755 --- a/deploy/cloud-run.sh +++ b/deploy/cloud-run.sh @@ -45,6 +45,17 @@ fi gcloud artifacts repositories describe "$REPO" --location "$REGION" --project "$PROJECT" >/dev/null 2>&1 \ || gcloud artifacts repositories create "$REPO" --repository-format docker \ --location "$REGION" --project "$PROJECT" --quiet +# Keep only the two newest images so storage stays inside the 0.5 GB free tier. +policy="$(mktemp)" +cat > "$policy" <<'JSON' +[ + {"name": "keep-newest-2", "action": {"type": "Keep"}, "mostRecentVersions": {"keepCount": 2}}, + {"name": "delete-older", "action": {"type": "Delete"}, "condition": {"tagState": "any"}} +] +JSON +gcloud artifacts repositories set-cleanup-policies "$REPO" --location "$REGION" \ + --project "$PROJECT" --policy "$policy" --no-dry-run --quiet >/dev/null +rm -f "$policy" gcloud auth configure-docker "$REGION-docker.pkg.dev" --quiet >/dev/null # Build from HEAD only, so uncommitted changes and local files never reach the image. From 459b69a92386e1688d2780121ab7e70cbaaa8291 Mon Sep 17 00:00:00 2001 From: Patrick Werle Date: Sun, 4 Oct 2026 21:05:21 +0200 Subject: [PATCH 3/5] docs: hosting is Google Cloud Run, not Koyeb Co-Authored-By: Claude Opus 5.5 --- .claude/CLAUDE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index c1f3cb5..e1586f1 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -12,7 +12,7 @@ Maßgebliche Dokumente: `docs/PRD.md`, `docs/SRS.md`, `.planning/ROADMAP.md`, `. - `services/verifier`: Java 21, JDK-`HttpServer` (kein Javalin, um Klassenkonflikte mit dem KoSIT-Fat-Jar zu vermeiden), KoSIT Validator 1.6.3 + validator-configuration-xrechnung 2026-08-31 (im Dockerfile mit SHA-256 gepinnt), Build nur in Docker - `eval/`: Korpus-Builder (Playwright für PDF-Rendering), Mutationen, Benchmark-Harness - LLM: Nebius Token Factory, OpenAI-kompatibel, `https://api.tokenfactory.nebius.com/v1` (verifiziert 02.10.). Nemotron gibt es dort nur als Textmodell, kein Nano Omni. Preise gepinnt in `packages/core/src/llm/pricing.ts`. Tavily -- Tests: vitest (TS), JUnit (Java). Hosting: Koyeb (web + verifier, verifier nur intern) +- Tests: vitest (TS), JUnit (Java). Hosting: Google Cloud Run Free Tier, ein Container mit web + verifier (verifier nur auf localhost), `deploy/cloud-run.sh` (seit 04.10., vorher Koyeb geplant) ## Architektur PDF → Rasterisierung + Textlayer → EXTRACT (Nano Omni, JSON mit Evidenz) → EVIDENCE_CHECK → NORMALIZE → DERIVE (Beträge, USt-Aufschlüsselung) → BUILD_CII → VALIDATE (KoSIT) → bei Fehlern REPAIR (Ultra, JSON Patch mit Evidenz) → max. 3 Iterationen → NEEDS_INPUT oder OUTPUT (XRechnung XML, ZUGFeRD PDF/A-3, Audit-Log). Details: `docs/SRS.md`. From 171db76bee85cc96c12004fc91ee91e1a841281e Mon Sep 17 00:00:00 2001 From: Patrick Werle Date: Mon, 5 Oct 2026 10:07:59 +0200 Subject: [PATCH 4/5] docs: OPS-05 done, demo live on Google Cloud Run Co-Authored-By: Claude Opus 5.5 --- .planning/REQUIREMENTS.md | 2 +- .planning/ROADMAP.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index 5c126ae..8af07e4 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -10,7 +10,7 @@ - [ ] **OPS-02** *(02.10.: Hook + CI-Job angelegt, GitHub-Lauf und Lizenz-Erkennung stehen aus)*: gitleaks in CI und pre-commit; Apache-2.0-LICENSE im Repo-Root, auf GitHub im About-Bereich erkannt - [x] **OPS-03**: Credits eingelöst (Event-Code, Builder Program, Tavily); Smoke-Test ruft je ein Modell für Extraktion und Reparatur über Token Factory auf - [x] **OPS-04**: Zentraler LLM-Client zählt Tokens und Kosten je Aufruf und bricht bei Überschreitung von `DAILY_BUDGET_USD` ab -- [ ] **OPS-05** *(04.10.: Ziel geändert auf Google Cloud Run Free Tier, weil Patrick 0 € will; Koyeb ~8 $/Monat, Hugging Face Docker-Spaces nur mit PRO. Ein Container mit App + Verifier (`deploy/Dockerfile`), Verifier nur auf localhost; `deploy/cloud-run.sh` fertig, PR #5. Offen: GCP-Projekt + Billing + Login durch Patrick, dann erster Deploy)*: Deployment `web` (öffentlich) und `verifier` (intern oder Secret-geschützt); `docker compose up` startet den Verifier lokal +- [x] **OPS-05** *(05.10.: live auf Google Cloud Run, https://verdict-678224659019.europe-west1.run.app — ein Container mit App + Verifier, Verifier nur auf localhost; `deploy/cloud-run.sh`. Koyeb verworfen (Kosten), Hugging Face Docker-Spaces nur mit PRO)*: Deployment `web` (öffentlich) und `verifier` (intern oder Secret-geschützt); `docker compose up` startet den Verifier lokal ### Verifier (VER) - [x] **VER-01**: `POST /v1/validate/xrechnung` liefert für jede valide Testsuite-Datei `valid: true` und für mutierte Dateien Fehler mit Regel-ID, Meldung und Fundstelle diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index 3bfb1d9..81036e0 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -40,7 +40,7 @@ Fünf Phasen vom 17.09. bis 28.10.2026, ~50 h Gesamtaufwand. Die riskantesten Te - Verifier: KoSIT 1.6.3 + Konfiguration 2026-08-31 in Docker, ohne lokales Java. `pnpm verifier:check`: 41/41 CII-Instanzen der Testsuite valide, 4/4 Mutationen mit erwarteter Regel-ID abgelehnt. JDK-`HttpServer` statt Javalin (keine Klassenkonflikte mit dem KoSIT-Fat-Jar). - Befund: 2 Testsuite-Instanzen (04.05a, 02.01a-cvd) sind ACCEPTABLE trotz Codelisten-Meldungen der Stufe `error`. „Valid“ folgt deshalb ausschließlich der `acceptRecommendation`, nie der Fehlerzählung. - Cut-Gate ZUGFeRD: ohne Spike verstrichen → ZUGFeRD aus Phase 1 genommen, in Phase 4 verankert (VER-06 + CORE-05, nach Kill-Gate). -- Offen in Phase 1: Deployment (OPS-05, seit 04.10. Google Cloud Run statt Koyeb), erster CI-Lauf auf GitHub (OPS-01/02). +- Offen in Phase 1: Deployment (OPS-05, erledigt 05.10. auf Google Cloud Run), erster CI-Lauf auf GitHub (OPS-01/02). ### Phase 2: Korpus & Ground Truth **Goal**: Es gibt einen reproduzierbaren, gemessenen Maßstab, bevor irgendein Modell Rechnungen verarbeitet. @@ -98,7 +98,7 @@ Fünf Phasen vom 17.09. bis 28.10.2026, ~50 h Gesamtaufwand. Die riskantesten Te - Next.js 16: `POST /api/run` (SSE, Limits, Rate-Limit, Tagesbudget, nichts gespeichert), `GET /api/budget`. Galerie: 7 aufgezeichnete echte Läufe (`pnpm --filter @verdict/web gallery:build`), funktioniert ohne Budget. - Unabhängiger Finish-Reviewer: Runde 1 „fix" (8 Punkte), Runde 2 „fix" (3 Regressionen), Runde 3 „ship". Dabei gefunden und im Kern behoben: Zitate mit wörtlichem `\n` wurden fälschlich abgelehnt; KoSIT-Ablehnungen wie BR-DE-1 werden jetzt zur gezielten Rückfrage. - Produktions-Build und `next start` mit echter PDF geprüft. -- Offen: Deployment auf Google Cloud Run (OPS-05, Skript fertig in PR #5, Konto-Einrichtung durch Patrick am 05.10.), Galerie-Lauf mit echter Ablehnung fürs Video (Reviewer N2), ZUGFeRD (VER-06/CORE-05, nach Kill-Gate erlaubt), README/Video (Phase 5). +- Deployment erledigt 05.10. (OPS-05, Google Cloud Run). Offen: Galerie-Lauf mit echter Ablehnung fürs Video (Reviewer N2), ZUGFeRD (VER-06/CORE-05, nach Kill-Gate erlaubt), README/Video (Phase 5). ### Phase 5: Einreichung **Goal**: Vollständige, regelkonforme Einreichung mit belastbaren Zahlen, zwei Tage vor Deadline. From a05d1201701ce5817fc58d6636aa88f879413fee Mon Sep 17 00:00:00 2001 From: Patrick Werle Date: Mon, 5 Oct 2026 10:20:11 +0200 Subject: [PATCH 5/5] feat(explain): explain validator rejections in plain English (EXP-01, EXP-02) A local table covers 29 rules (EN/DE, written from the XRechnung 3.0.2 rule texts). Rules outside it are explained at run time by Nemotron Super with optional Tavily sources, cached per rule ID and capped at three calls per run; any failure leaves the rule unexplained instead of failing the run. Nano was tried first and confused the payee with the buyer. The UI keeps the validator's own wording under every explanation. The deploy script also stores and mounts an optional Tavily key. Co-Authored-By: Claude Opus 5.5 --- .env.example | 2 +- .planning/REQUIREMENTS.md | 4 +- apps/web/components/rule-violations.tsx | 63 +++++++ apps/web/components/verdict-app.tsx | 22 +-- apps/web/lib/engine.ts | 14 +- apps/web/lib/run-core.ts | 6 + apps/web/lib/run-protocol.ts | 4 +- apps/web/lib/run-stream.ts | 9 +- apps/web/scripts/build-gallery.ts | 10 +- deploy/cloud-run.sh | 35 ++-- packages/core/package.json | 3 +- .../core/src/explain/explain-rule.test.ts | 88 +++++++++ packages/core/src/explain/explain-rule.ts | 129 ++++++++++++++ .../core/src/explain/rule-explanations.ts | 168 ++++++++++++++++++ packages/core/src/index.ts | 2 + 15 files changed, 517 insertions(+), 42 deletions(-) create mode 100644 apps/web/components/rule-violations.tsx create mode 100644 packages/core/src/explain/explain-rule.test.ts create mode 100644 packages/core/src/explain/explain-rule.ts create mode 100644 packages/core/src/explain/rule-explanations.ts diff --git a/.env.example b/.env.example index 4351ced..bb73339 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,7 @@ NEBIUS_API_KEY= NEBIUS_BASE_URL=https://api.tokenfactory.nebius.com/v1 MODEL_EXTRACT=nvidia/nemotron-3-super-120b-a12b MODEL_REPAIR=nvidia/Nemotron-3-Ultra-550b-a55b -MODEL_EXPLAIN=nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B +MODEL_EXPLAIN=nvidia/nemotron-3-super-120b-a12b # Tavily TAVILY_API_KEY= diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index 8af07e4..ba0dd35 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -54,8 +54,8 @@ - [x] **AUD-03**: `pnpm audit:verify` erkennt jede Manipulation an Events oder Ausgabedateien (Tests mit manipulierten Logs) ### Erklärung (EXP) -- [ ] **EXP-01**: Lokale Erklärungstabelle für die im Korpus auftretenden Regel-IDs (EN/DE) -- [ ] **EXP-02**: Fehlt ein Eintrag, erzeugt Nano/Super eine Erklärung; bei Bedarf Tavily-Aufruf mit angezeigter Quelle; Cache pro Regel-ID +- [x] **EXP-01** *(05.10.: 29 Regeln EN/DE in `packages/core/src/explain/rule-explanations.ts`, aus den Regeltexten der XRechnung-3.0.2-Konfiguration; UI zeigt Erklärung, Abhilfe und den Originaltext des Validators)*: Lokale Erklärungstabelle für die im Korpus auftretenden Regel-IDs (EN/DE) +- [x] **EXP-02** *(05.10.: `createExplainer`, Modell Nemotron Super — Nano hat im Test „Payee“ mit dem Käufer verwechselt; Cache pro Regel-ID, höchstens 3 Aufrufe pro Lauf, Fehler lassen die Regel nur unerklärt. Tavily optional, Server-Schlüssel fehlt noch)*: Fehlt ein Eintrag, erzeugt Nano/Super eine Erklärung; bei Bedarf Tavily-Aufruf mit angezeigter Quelle; Cache pro Regel-ID ### Web-UI (UI) - [x] **UI-01**: Upload mit Limits laut NFR-04 und klaren Fehlermeldungen diff --git a/apps/web/components/rule-violations.tsx b/apps/web/components/rule-violations.tsx new file mode 100644 index 0000000..21e1c0e --- /dev/null +++ b/apps/web/components/rule-violations.tsx @@ -0,0 +1,63 @@ +"use client"; +import { RULE_EXPLANATIONS } from "@verdict/core/rule-explanations"; +import { RULE_FIELDS } from "@verdict/core/rule-fields"; +import { labelFor } from "@/lib/field-groups.ts"; +import type { RunResultView } from "@/lib/run-protocol.ts"; + +const shortModel = (id: string) => id.split("/").pop()?.replace(/^NVIDIA-/, "") ?? id; + +/** + * The validator's rejections, each explained in plain English (EXP-01 table, or EXP-02 generated + * by Nemotron with the web sources it was given). The validator's own wording stays underneath as + * the evidence the explanation has to answer to. + */ +export function RuleViolations({ result }: { result: RunResultView }) { + if (!result.verifier || result.verifier.valid) return null; + const errors = result.verifier.errors.filter((e) => e.severity === "error"); + if (errors.length === 0) return null; + + return ( +
    + {errors.map((e) => { + const field = RULE_FIELDS[e.ruleId]; + const table = RULE_EXPLANATIONS[e.ruleId]; + const generated = result.explanations?.find((x) => x.ruleId === e.ruleId); + const text = field + ? `${labelFor(field).label} is required but not on the document. Asked above.` + : (table?.en ?? generated?.text ?? "Rule violation reported by the KoSIT validator."); + // For a missing fact the headline says what is asked; the table then says why the rule exists. + const fix = field ? table?.en : (table?.fix ?? generated?.fix); + return ( +
  • +
    + {e.ruleId} + {text} +
    + {fix &&

    {fix}

    } + {!table && !field && generated && ( +

    + Explained by {shortModel(generated.model)} + {generated.sources.length > 0 && ( + <> + {" "}from{" "} + {generated.sources.map((s, i) => ( + + {i > 0 && ", "} + + {new URL(s.url).hostname.replace(/^www\./, "")} + + + ))} + + )} + . Check it against the validator's wording below. +

    + )} + {/* The validator's own wording stays as evidence, without its duplicated rule prefix. */} +

    {e.message.replace(/^\[[^\]]+\]\s*-?\s*/, "")}

    +
  • + ); + })} +
+ ); +} diff --git a/apps/web/components/verdict-app.tsx b/apps/web/components/verdict-app.tsx index 0a302eb..b6569b3 100644 --- a/apps/web/components/verdict-app.tsx +++ b/apps/web/components/verdict-app.tsx @@ -1,8 +1,6 @@ "use client"; import { useEffect, useMemo, useState } from "react"; import type { Provenance } from "@verdict/core"; -import { RULE_FIELDS } from "@verdict/core/rule-fields"; -import { labelFor } from "@/lib/field-groups.ts"; import { ArrowLeft, Download } from "lucide-react"; import { useRun } from "@/lib/use-run.ts"; import { euro, FactsPanel, VoidedList, type FocusTarget } from "./facts-panel.tsx"; @@ -10,6 +8,7 @@ import { Intake } from "./intake.tsx"; import { NeedsInput } from "./needs-input.tsx"; import { PdfSheet, type QuoteMark } from "./pdf-sheet.tsx"; import { RoutingSlip } from "./routing-slip.tsx"; +import { RuleViolations } from "./rule-violations.tsx"; import { VerdictStamp, VoidMark } from "./verdict-stamp.tsx"; interface Budget { @@ -213,24 +212,7 @@ export function VerdictApp() { )} {result && } - {result?.verifier && !result.verifier.valid && ( -
    - {result.verifier.errors - .filter((e) => e.severity === "error") - .map((e) => ( -
  • -
    - {e.ruleId} - {RULE_FIELDS[e.ruleId] - ? `${labelFor(RULE_FIELDS[e.ruleId]!).label} is required but not on the document. Asked above.` - : "Rule violation reported by the KoSIT validator."} -
    - {/* The validator's own wording stays as evidence, without its duplicated rule prefix. */} -

    {e.message.replace(/^\[[^\]]+\]\s*-?\s*/, "")}

    -
  • - ))} -
- )} + {result && } {result ? ( ) : ( diff --git a/apps/web/lib/engine.ts b/apps/web/lib/engine.ts index e004be9..9d7f881 100644 --- a/apps/web/lib/engine.ts +++ b/apps/web/lib/engine.ts @@ -1,5 +1,5 @@ import "server-only"; -import { TokenFactoryClient } from "@verdict/core"; +import { createExplainer, tavilySearch, TokenFactoryClient, type Explainer } from "@verdict/core"; /** One client per server process, so the daily budget ledger is shared by all requests. */ let client: TokenFactoryClient | undefined; @@ -14,3 +14,15 @@ export const MODELS = { }; export const DAILY_BUDGET_USD = process.env.DAILY_BUDGET_USD || "2.00"; + +/** EXP-02: one explainer per process, so its per-rule cache is shared by all requests. */ +let explainer: Explainer | undefined; +export function engineExplainer(): Explainer { + const tavilyKey = process.env.TAVILY_API_KEY; + explainer ??= createExplainer({ + client: engineClient(), + model: process.env.MODEL_EXPLAIN || "nvidia/nemotron-3-super-120b-a12b", + search: tavilyKey ? tavilySearch(tavilyKey) : undefined, + }); + return explainer; +} diff --git a/apps/web/lib/run-core.ts b/apps/web/lib/run-core.ts index 84d54c1..5279115 100644 --- a/apps/web/lib/run-core.ts +++ b/apps/web/lib/run-core.ts @@ -4,6 +4,7 @@ import { deriveAmounts, extractPageTexts, runInvoice, + type Explainer, type RawExtraction, type TokenFactoryClient, type UserInput, @@ -23,6 +24,8 @@ export interface RunDeps { readonly client: TokenFactoryClient; readonly models: { readonly extract: string; readonly repair: string }; readonly validate: (xml: string) => Promise; + /** Explains rejections the local rule table does not cover (EXP-02). Optional. */ + readonly explain?: Explainer; } /** Shared by the API route and the gallery builder, so both produce identical event streams. */ @@ -58,6 +61,8 @@ export async function executeRun( ...(resume ? { extraction: resume.extraction, userInputs: resume.userInputs } : {}), }); const amounts = run.invoice ? deriveAmounts(run.invoice) : null; + const explanations = + deps.explain && run.verifier && !run.verifier.valid ? await deps.explain(run.verifier.errors) : []; const view: RunResultView = { state: run.state, invoice: run.invoice ?? run.partial, @@ -84,6 +89,7 @@ export async function executeRun( costUsd: run.costUsd, extraction: run.extraction, models: deps.models, + explanations, }; emit({ type: "result", result: view }); } catch (e) { diff --git a/apps/web/lib/run-protocol.ts b/apps/web/lib/run-protocol.ts index 278d970..d05532f 100644 --- a/apps/web/lib/run-protocol.ts +++ b/apps/web/lib/run-protocol.ts @@ -1,4 +1,4 @@ -import type { AuditEvent, MissingFact, Provenance, RejectedFact, RawExtraction, RunState } from "@verdict/core"; +import type { AuditEvent, Explanation, MissingFact, Provenance, RejectedFact, RawExtraction, RunState } from "@verdict/core"; /** * Server-sent events of one run. The UI draws the run sheet from `audit` events (each carries @@ -34,4 +34,6 @@ export interface RunResultView { readonly costUsd: string; readonly extraction: RawExtraction; readonly models: { readonly extract: string; readonly repair: string }; + /** Generated explanations for rejections outside the local rule table. Absent in older recordings. */ + readonly explanations?: readonly Explanation[]; } diff --git a/apps/web/lib/run-stream.ts b/apps/web/lib/run-stream.ts index 4b95f44..a62a313 100644 --- a/apps/web/lib/run-stream.ts +++ b/apps/web/lib/run-stream.ts @@ -1,6 +1,6 @@ import "server-only"; import type { RawExtraction, UserInput } from "@verdict/core"; -import { engineClient, MODELS } from "./engine.ts"; +import { engineClient, engineExplainer, MODELS } from "./engine.ts"; import { executeRun } from "./run-core.ts"; import type { RunEvent } from "./run-protocol.ts"; import { validateXRechnung } from "./verifier.ts"; @@ -12,5 +12,10 @@ export function streamRun( emit: (event: RunEvent) => void, resume?: { extraction: RawExtraction; userInputs: readonly UserInput[] }, ): Promise { - return executeRun({ client: engineClient(), models: MODELS, validate: validateXRechnung }, pdf, emit, resume); + return executeRun( + { client: engineClient(), models: MODELS, validate: validateXRechnung, explain: engineExplainer() }, + pdf, + emit, + resume, + ); } diff --git a/apps/web/scripts/build-gallery.ts b/apps/web/scripts/build-gallery.ts index 35c3b74..88c9ead 100644 --- a/apps/web/scripts/build-gallery.ts +++ b/apps/web/scripts/build-gallery.ts @@ -3,7 +3,7 @@ // pnpm --filter @verdict/web gallery:build (needs NEBIUS_API_KEY and a running verifier) import { copyFileSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join, resolve } from "node:path"; -import { TokenFactoryClient, type UserInput } from "@verdict/core"; +import { createExplainer, tavilySearch, TokenFactoryClient, type UserInput } from "@verdict/core"; import { executeRun } from "../lib/run-core.ts"; import type { RunEvent, RunResultView } from "../lib/run-protocol.ts"; @@ -61,13 +61,19 @@ async function record(deps: Parameters[0], pdf: Uint8Array, r return { events, result: result?.result }; } +const galleryClient = TokenFactoryClient.fromEnv(); const deps = { - client: TokenFactoryClient.fromEnv(), + client: galleryClient, models: { extract: process.env.MODEL_EXTRACT || "nvidia/nemotron-3-super-120b-a12b", repair: process.env.MODEL_REPAIR || "nvidia/Nemotron-3-Ultra-550b-a55b", }, validate, + explain: createExplainer({ + client: galleryClient, + model: process.env.MODEL_EXPLAIN || "nvidia/nemotron-3-super-120b-a12b", + search: process.env.TAVILY_API_KEY ? tavilySearch(process.env.TAVILY_API_KEY) : undefined, + }), }; // --only re-records one case and keeps the others. diff --git a/deploy/cloud-run.sh b/deploy/cloud-run.sh index b003af2..318e107 100755 --- a/deploy/cloud-run.sh +++ b/deploy/cloud-run.sh @@ -3,7 +3,7 @@ # Needs `gcloud auth login` and a project with billing enabled (the demo stays inside the # free tier: request-based billing, scale to zero, at most one instance). # deploy/cloud-run.sh build, push, deploy -# deploy/cloud-run.sh --secret (re)store NEBIUS_API_KEY from .env in Secret Manager first +# deploy/cloud-run.sh --secret (re)store NEBIUS_API_KEY and TAVILY_API_KEY from .env first set -euo pipefail PROJECT="${GCP_PROJECT:-$(gcloud config get-value project 2>/dev/null)}" @@ -25,21 +25,26 @@ fi gcloud services enable run.googleapis.com artifactregistry.googleapis.com \ secretmanager.googleapis.com --project "$PROJECT" --quiet -if [ "${1:-}" = "--secret" ]; then - # The key goes from .env straight into Secret Manager; it is never echoed or logged. - key="$(grep -E '^NEBIUS_API_KEY=' .env | head -1 | cut -d= -f2-)" - [ -n "$key" ] || { echo "NEBIUS_API_KEY is empty in .env" >&2; exit 1; } - if gcloud secrets describe nebius-api-key --project "$PROJECT" >/dev/null 2>&1; then - printf '%s' "$key" | gcloud secrets versions add nebius-api-key --data-file=- --project "$PROJECT" >/dev/null +# store_secret : moves one key from .env into Secret Manager without echoing it. +store_secret() { + local value sa + value="$(grep -E "^$2=" .env | head -1 | cut -d= -f2-)" + [ -n "$value" ] || { echo "$2 is empty in .env, skipped"; return 0; } + if gcloud secrets describe "$1" --project "$PROJECT" >/dev/null 2>&1; then + printf '%s' "$value" | gcloud secrets versions add "$1" --data-file=- --project "$PROJECT" >/dev/null else - printf '%s' "$key" | gcloud secrets create nebius-api-key --data-file=- \ + printf '%s' "$value" | gcloud secrets create "$1" --data-file=- \ --replication-policy=automatic --project "$PROJECT" >/dev/null fi - unset key sa="$(gcloud projects describe "$PROJECT" --format='value(projectNumber)')-compute@developer.gserviceaccount.com" - gcloud secrets add-iam-policy-binding nebius-api-key --project "$PROJECT" \ + gcloud secrets add-iam-policy-binding "$1" --project "$PROJECT" \ --member "serviceAccount:$sa" --role roles/secretmanager.secretAccessor >/dev/null - echo "Secret nebius-api-key stored." + echo "Secret $1 stored." +} + +if [ "${1:-}" = "--secret" ]; then + store_secret nebius-api-key NEBIUS_API_KEY + store_secret tavily-api-key TAVILY_API_KEY fi gcloud artifacts repositories describe "$REPO" --location "$REGION" --project "$PROJECT" >/dev/null 2>&1 \ @@ -65,11 +70,17 @@ git archive HEAD | tar -x -C "$STAGE" IMAGE="$REGION-docker.pkg.dev/$PROJECT/$REPO/$SERVICE:$(git rev-parse --short HEAD)" docker buildx build --platform linux/amd64 -f "$STAGE/deploy/Dockerfile" -t "$IMAGE" --push "$STAGE" +# Tavily is optional: without its secret, rule explanations run without web sources. +SECRETS="NEBIUS_API_KEY=nebius-api-key:latest" +if gcloud secrets describe tavily-api-key --project "$PROJECT" >/dev/null 2>&1; then + SECRETS="$SECRETS,TAVILY_API_KEY=tavily-api-key:latest" +fi + # max-instances 1: the rate limit and the daily model budget live in memory, so a second # instance would double both. It also caps what a traffic spike can cost. gcloud run deploy "$SERVICE" --image "$IMAGE" --region "$REGION" --project "$PROJECT" \ --port 7860 --cpu 1 --memory 2Gi --cpu-boost \ --min-instances 0 --max-instances 1 --concurrency 20 --timeout 300 \ - --set-secrets NEBIUS_API_KEY=nebius-api-key:latest \ + --set-secrets "$SECRETS" \ --allow-unauthenticated --quiet gcloud run services describe "$SERVICE" --region "$REGION" --project "$PROJECT" --format 'value(status.url)' diff --git a/packages/core/package.json b/packages/core/package.json index a3e27e0..b3b2938 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -4,7 +4,8 @@ "type": "module", "exports": { ".": "./src/index.ts", - "./rule-fields": "./src/agent/rule-fields.ts" + "./rule-fields": "./src/agent/rule-fields.ts", + "./rule-explanations": "./src/explain/rule-explanations.ts" }, "scripts": { "typecheck": "tsc -p tsconfig.json", diff --git a/packages/core/src/explain/explain-rule.test.ts b/packages/core/src/explain/explain-rule.test.ts new file mode 100644 index 0000000..5bbf7dd --- /dev/null +++ b/packages/core/src/explain/explain-rule.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { TokenFactoryClient } from "../llm/token-factory-client.ts"; +import { createExplainer, type WebSearch } from "./explain-rule.ts"; +import { RULE_EXPLANATIONS } from "./rule-explanations.ts"; + +const MODEL = "nvidia/NVIDIA-Nemotron-3-Nano-30B-A3B"; + +function client(content: string | (() => string), calls: { n: number }): TokenFactoryClient { + return new TokenFactoryClient({ + apiKey: "test", + baseUrl: "http://fake", + dailyBudgetUsd: "2.00", + fetchImpl: async () => { + calls.n++; + return new Response( + JSON.stringify({ + choices: [{ message: { content: typeof content === "string" ? content : content() } }], + usage: { prompt_tokens: 300, completion_tokens: 60 }, + }), + { status: 200 }, + ); + }, + }); +} + +const ANSWER = JSON.stringify({ meaning: "The rule needs a payee name.", fix: "Print the payee name." }); +const err = (ruleId: string, severity = "error") => ({ ruleId, severity, message: `[${ruleId}] something` }); + +describe("createExplainer", () => { + it("leaves rules from the local table to the table and calls no model", async () => { + const calls = { n: 0 }; + const explain = createExplainer({ client: client(ANSWER, calls), model: MODEL }); + expect(RULE_EXPLANATIONS["BR-DE-15"]).toBeDefined(); + expect(await explain([err("BR-DE-15")])).toEqual([]); + expect(calls.n).toBe(0); + }); + + it("explains an unknown rule once, with the search results as sources, then serves it from cache", async () => { + const calls = { n: 0 }; + const queries: string[] = []; + const search: WebSearch = async (q) => { + queries.push(q); + return [{ title: "XRechnung spec", url: "https://example.org/spec", content: "BT-59 payee name" }]; + }; + const explain = createExplainer({ client: client(ANSWER, calls), model: MODEL, search }); + const [first] = await explain([err("BR-17"), err("BR-17")]); + expect(first).toMatchObject({ + ruleId: "BR-17", + text: "The rule needs a payee name.", + fix: "Print the payee name.", + model: MODEL, + sources: [{ title: "XRechnung spec", url: "https://example.org/spec" }], + }); + await explain([err("BR-17")]); + expect(calls.n).toBe(1); + expect(queries).toHaveLength(1); + expect(queries[0]).toContain("BR-17"); + }); + + it("ignores warnings and caps model calls per run", async () => { + const calls = { n: 0 }; + const explain = createExplainer({ client: client(ANSWER, calls), model: MODEL, maxPerRun: 2 }); + const out = await explain([err("BR-90", "warning"), err("BR-91"), err("BR-92"), err("BR-93")]); + expect(out.map((e) => e.ruleId)).toEqual(["BR-91", "BR-92"]); + expect(calls.n).toBe(2); + }); + + it("does not cache a failure, so the next run tries again", async () => { + const calls = { n: 0 }; + let reply = "not json"; + const explain = createExplainer({ client: client(() => reply, calls), model: MODEL }); + expect(await explain([err("BR-17")])).toEqual([]); + await new Promise((r) => setTimeout(r, 0)); + reply = ANSWER; + expect(await explain([err("BR-17")])).toHaveLength(1); + expect(calls.n).toBe(2); + }); + + it("still explains when the search fails", async () => { + const calls = { n: 0 }; + const search: WebSearch = async () => { + throw new Error("offline"); + }; + const explain = createExplainer({ client: client(ANSWER, calls), model: MODEL, search }); + const [e] = await explain([err("BR-17")]); + expect(e?.sources).toEqual([]); + }); +}); diff --git a/packages/core/src/explain/explain-rule.ts b/packages/core/src/explain/explain-rule.ts new file mode 100644 index 0000000..5546c74 --- /dev/null +++ b/packages/core/src/explain/explain-rule.ts @@ -0,0 +1,129 @@ +import type { TokenFactoryClient } from "../llm/token-factory-client.ts"; +import { RULE_EXPLANATIONS } from "./rule-explanations.ts"; + +export interface ExplanationSource { + readonly title: string; + readonly url: string; +} + +/** EXP-02: an explanation for a rule the local table does not cover, generated at run time. */ +export interface Explanation { + readonly ruleId: string; + readonly text: string; + readonly fix: string; + readonly model: string; + readonly costUsd: string; + /** Web pages the model was given; empty when the search found nothing or is not configured. */ + readonly sources: readonly ExplanationSource[]; +} + +export interface SearchResult extends ExplanationSource { + readonly content: string; +} +export type WebSearch = (query: string) => Promise; + +/** Tavily search, top three results. Any failure yields no sources rather than no explanation. */ +export function tavilySearch(apiKey: string, fetchImpl: typeof fetch = fetch, timeoutMs = 8000): WebSearch { + return async (query) => { + const res = await fetchImpl("https://api.tavily.com/search", { + method: "POST", + headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json" }, + body: JSON.stringify({ query, max_results: 3, search_depth: "basic" }), + signal: AbortSignal.timeout(timeoutMs), + }); + if (!res.ok) throw new Error(`Tavily ${res.status}`); + const data = (await res.json()) as { results?: { title?: string; url?: string; content?: string }[] }; + return (data.results ?? []) + .filter((r) => typeof r.url === "string" && r.url.startsWith("https://")) + .slice(0, 3) + .map((r) => ({ title: (r.title ?? r.url!).slice(0, 160), url: r.url!, content: (r.content ?? "").slice(0, 700) })); + }; +} + +export const EXPLAIN_SCHEMA = { + type: "object", + properties: { + meaning: { type: "string", description: "What the rule demands, at most two short sentences." }, + fix: { type: "string", description: "What has to change on the invoice, one sentence." }, + }, + required: ["meaning", "fix"], + additionalProperties: false, +} as const; + +export const EXPLAIN_SYSTEM_PROMPT = `You explain e-invoice validation rules (EN 16931, German XRechnung) to the owner of a small business. +Use only the validator message and the numbered sources you are given. If the sources do not cover the rule, explain from the validator message alone. +Never invent business term numbers (BT-, BG-) that do not appear in the message or the sources. +Plain English, no jargon beyond the business term names. "meaning": at most two short sentences. "fix": one sentence.`; + +export interface ExplainerOptions { + readonly client: TokenFactoryClient; + readonly model: string; + readonly search?: WebSearch | undefined; + /** Model calls per run at most; further unknown rules stay unexplained. */ + readonly maxPerRun?: number; +} + +export type Explainer = ( + errors: readonly { readonly ruleId: string; readonly severity: string; readonly message: string }[], +) => Promise; + +/** + * Explains validator errors that the local table (EXP-01) does not cover. Results are cached per + * rule ID for the life of the process, so each unknown rule costs one model call at most. Every + * failure (budget, network, malformed output) leaves the rule unexplained; the run never fails + * because of an explanation. + */ +export function createExplainer(opts: ExplainerOptions): Explainer { + const cache = new Map>(); + const maxPerRun = opts.maxPerRun ?? 3; + + const explainOne = async (ruleId: string, message: string): Promise => { + const results = opts.search + ? await opts.search(`XRechnung EN 16931 rule ${ruleId} ${message.slice(0, 200)}`).catch(() => []) + : []; + const sourcesText = results.length + ? results.map((r, i) => `[${i + 1}] ${r.title} (${r.url})\n${r.content}`).join("\n\n") + : "(no sources found)"; + const res = await opts.client.chat({ + model: opts.model, + messages: [ + { role: "system", content: EXPLAIN_SYSTEM_PROMPT }, + { role: "user", content: `Rule: ${ruleId}\nValidator message: ${message}\n\nSources:\n${sourcesText}` }, + ], + jsonSchema: { name: "rule_explanation", schema: EXPLAIN_SCHEMA }, + maxTokens: 400, + }); + const parsed = JSON.parse(res.content) as { meaning?: unknown; fix?: unknown }; + if (typeof parsed.meaning !== "string" || typeof parsed.fix !== "string" || !parsed.meaning.trim()) return null; + return { + ruleId, + text: parsed.meaning.trim().slice(0, 400), + fix: parsed.fix.trim().slice(0, 300), + model: res.model, + costUsd: res.costUsd, + sources: results.map(({ title, url }) => ({ title, url })), + }; + }; + + return async (errors) => { + const unknown = new Map(); + for (const e of errors) { + if (e.severity !== "error" || RULE_EXPLANATIONS[e.ruleId] || unknown.has(e.ruleId)) continue; + unknown.set(e.ruleId, e.message); + } + const picked = [...unknown].slice(0, maxPerRun); + const out = await Promise.all( + picked.map(([ruleId, message]) => { + let pending = cache.get(ruleId); + if (!pending) { + pending = explainOne(ruleId, message).catch(() => null); + cache.set(ruleId, pending); + // Only successes stay cached, so a budget or network failure is retried next time. + void pending.then((r) => r ?? cache.delete(ruleId)); + } + return pending; + }), + ); + return out.filter((e): e is Explanation => e !== null); + }; +} diff --git a/packages/core/src/explain/rule-explanations.ts b/packages/core/src/explain/rule-explanations.ts new file mode 100644 index 0000000..b8d745f --- /dev/null +++ b/packages/core/src/explain/rule-explanations.ts @@ -0,0 +1,168 @@ +// Browser-safe: no Node imports, so the web client can import it via "@verdict/core/rule-explanations". + +/** + * EXP-01: plain-language explanations for the validator rules that occur in the corpus and in + * typical German invoices. Written from the rule texts in the XRechnung 3.0.2 configuration + * (2026-08-31); the validator's own message is always shown next to it as the source of truth. + */ +export interface RuleExplanation { + /** What the rule demands, in one or two sentences. */ + readonly en: string; + readonly de: string; + /** What has to change on the invoice (or what to enter) to satisfy the rule. */ + readonly fix: string; +} + +/** Official specification the table entries are written from. */ +export const RULE_SOURCE = { + title: "XRechnung 3.0.2 validator configuration (KoSIT)", + url: "https://github.com/itplr-kosit/validator-configuration-xrechnung/releases/tag/v2026-08-31", +} as const; + +export const RULE_EXPLANATIONS: Readonly> = { + "BR-02": { + en: "Every invoice needs an invoice number (BT-1).", + de: "Jede Rechnung braucht eine Rechnungsnummer (BT-1).", + fix: "Print the invoice number on the document.", + }, + "BR-03": { + en: "Every invoice needs an issue date (BT-2).", + de: "Jede Rechnung braucht ein Rechnungsdatum (BT-2).", + fix: "Print the invoice date on the document.", + }, + "BR-06": { + en: "The seller's name (BT-27) is mandatory.", + de: "Der Name des Verkäufers (BT-27) ist Pflicht.", + fix: "Print the seller's company name.", + }, + "BR-07": { + en: "The buyer's name (BT-44) is mandatory.", + de: "Der Name des Käufers (BT-44) ist Pflicht.", + fix: "Print the buyer's name.", + }, + "BR-16": { + en: "An invoice must contain at least one invoice line (BG-25).", + de: "Eine Rechnung muss mindestens eine Rechnungsposition (BG-25) enthalten.", + fix: "List at least one item or service with quantity, unit price and line amount.", + }, + "BR-62": { + en: "The seller's electronic address (BT-34) must say which kind of address it is, for example an e-mail address (scheme EM).", + de: "Die elektronische Adresse des Verkäufers (BT-34) braucht eine Schema-Kennung, zum Beispiel EM für E-Mail.", + fix: "Provide the seller's electronic address, usually the e-mail address that receives e-invoices.", + }, + "BR-63": { + en: "The buyer's electronic address (BT-49) must say which kind of address it is, for example an e-mail address (scheme EM).", + de: "Die elektronische Adresse des Käufers (BT-49) braucht eine Schema-Kennung, zum Beispiel EM für E-Mail.", + fix: "Provide the buyer's electronic address, usually the e-mail address that receives e-invoices.", + }, + "BR-CO-10": { + en: "The sum of line net amounts (BT-106) must equal the total of all invoice line amounts (BT-131).", + de: "Die Summe der Nettobeträge (BT-106) muss der Summe aller Positionsbeträge (BT-131) entsprechen.", + fix: "Check the line amounts; the printed sum does not match them.", + }, + "BR-CO-13": { + en: "The total without VAT (BT-109) must equal the line total minus document allowances plus document charges.", + de: "Der Nettogesamtbetrag (BT-109) muss Positionssumme minus Nachlässe plus Zuschläge auf Belegebene sein.", + fix: "Check the net total and any document-level discounts or surcharges.", + }, + "BR-CO-14": { + en: "The total VAT amount (BT-110) must equal the sum of the VAT amounts per category (BT-117).", + de: "Die Umsatzsteuer gesamt (BT-110) muss der Summe der Steuerbeträge je Kategorie (BT-117) entsprechen.", + fix: "Check the VAT breakdown; the categories do not add up to the printed VAT total.", + }, + "BR-CO-15": { + en: "The total with VAT (BT-112) must equal the total without VAT (BT-109) plus the total VAT (BT-110).", + de: "Der Bruttobetrag (BT-112) muss Nettobetrag (BT-109) plus Umsatzsteuer (BT-110) sein.", + fix: "Check the gross total; it is not net plus VAT.", + }, + "BR-CO-26": { + en: "The buyer must be able to identify the seller automatically: at least one of seller identifier (BT-29), registration number (BT-30) or VAT ID (BT-31) is required.", + de: "Der Käufer muss den Verkäufer automatisch erkennen können: Kennung (BT-29), Registernummer (BT-30) oder USt-IdNr. (BT-31) ist Pflicht.", + fix: "Print the seller's VAT ID (USt-IdNr.) or commercial register number.", + }, + "BR-CL-04": { + en: "The currency must be an ISO 4217 code such as EUR, not a symbol like €.", + de: "Die Währung muss ein ISO-4217-Code wie EUR sein, kein Symbol wie €.", + fix: "Verdict maps € and common currency words to ISO codes; if this remains, the printed currency is not one it recognizes.", + }, + "BR-CL-23": { + en: "Units of measure must be UN/ECE Recommendation 20 codes (for example H87 for piece, HUR for hour), not words like \"Stück\".", + de: "Mengeneinheiten müssen Codes nach UN/ECE-Empfehlung 20 sein (etwa H87 für Stück, HUR für Stunde), keine Wörter.", + fix: "Verdict maps common German unit words to these codes; if this remains, the printed unit is not one it recognizes.", + }, + "BR-S-02": { + en: "A line taxed at the standard rate (category S) requires the seller's VAT ID, tax registration or tax representative.", + de: "Eine Position mit Regelsteuersatz (Kategorie S) verlangt USt-IdNr., Steuernummer oder Fiskalvertreter des Verkäufers.", + fix: "Print the seller's VAT ID (USt-IdNr.) or tax number (Steuernummer).", + }, + "BR-S-08": { + en: "For each standard VAT rate, the taxable amount in the VAT breakdown must equal the sum of the lines at that rate.", + de: "Je Regelsteuersatz muss die Bemessungsgrundlage der Summe der Positionen mit diesem Satz entsprechen.", + fix: "Check which lines carry which VAT rate.", + }, + "BR-S-09": { + en: "The VAT amount for a standard-rate category must equal its taxable amount times the rate, rounded to two decimals.", + de: "Der Steuerbetrag je Regelsteuersatz muss Bemessungsgrundlage mal Satz sein, auf zwei Stellen gerundet.", + fix: "Check the VAT amount; it is not the taxable amount times the rate.", + }, + "BR-DE-1": { + en: "An XRechnung must contain payment instructions (BG-16), for example the seller's IBAN for a bank transfer.", + de: "Eine XRechnung muss Zahlungsanweisungen (BG-16) enthalten, etwa die IBAN des Verkäufers für eine Überweisung.", + fix: "Enter the IBAN the buyer should pay to.", + }, + "BR-DE-2": { + en: "The seller contact group (BG-6) is mandatory in an XRechnung.", + de: "Die Gruppe Verkäuferkontakt (BG-6) ist in der XRechnung Pflicht.", + fix: "Provide a contact person, phone number and e-mail address of the seller.", + }, + "BR-DE-3": { + en: "The seller's city (BT-37) is mandatory in an XRechnung.", + de: "Der Ort des Verkäufers (BT-37) ist in der XRechnung Pflicht.", + fix: "Enter the seller's city.", + }, + "BR-DE-4": { + en: "The seller's postcode (BT-38) is mandatory in an XRechnung.", + de: "Die Postleitzahl des Verkäufers (BT-38) ist in der XRechnung Pflicht.", + fix: "Enter the seller's postcode.", + }, + "BR-DE-5": { + en: "A seller contact point (BT-41), such as a name or department, is mandatory in an XRechnung.", + de: "Eine Kontaktstelle des Verkäufers (BT-41), etwa Name oder Abteilung, ist in der XRechnung Pflicht.", + fix: "Enter a contact person or department of the seller.", + }, + "BR-DE-6": { + en: "A seller contact phone number (BT-42) is mandatory in an XRechnung.", + de: "Eine Telefonnummer des Verkäuferkontakts (BT-42) ist in der XRechnung Pflicht.", + fix: "Enter the seller's phone number.", + }, + "BR-DE-7": { + en: "A seller contact e-mail address (BT-43) is mandatory in an XRechnung.", + de: "Eine E-Mail-Adresse des Verkäuferkontakts (BT-43) ist in der XRechnung Pflicht.", + fix: "Enter the seller's e-mail address.", + }, + "BR-DE-8": { + en: "The buyer's city (BT-52) is mandatory in an XRechnung.", + de: "Der Ort des Käufers (BT-52) ist in der XRechnung Pflicht.", + fix: "Enter the buyer's city.", + }, + "BR-DE-9": { + en: "The buyer's postcode (BT-53) is mandatory in an XRechnung.", + de: "Die Postleitzahl des Käufers (BT-53) ist in der XRechnung Pflicht.", + fix: "Enter the buyer's postcode.", + }, + "BR-DE-15": { + en: "The buyer reference (BT-10) is mandatory. For public-sector buyers in Germany this is the Leitweg-ID that routes the invoice to the right office.", + de: "Die Käuferreferenz (BT-10) ist Pflicht. Bei öffentlichen Auftraggebern ist das die Leitweg-ID.", + fix: "Enter the Leitweg-ID or the reference the buyer gave you.", + }, + "BR-DE-21": { + en: "The specification identifier (BT-24) must name the XRechnung standard.", + de: "Die Spezifikationskennung (BT-24) muss den XRechnung-Standard benennen.", + fix: "Nothing to do on the paper invoice; Verdict sets this identifier.", + }, + "BR-DE-TMP-32": { + en: "Warning only: an invoice should state the delivery or service date (BT-72) or a service period (BG-14).", + de: "Nur ein Hinweis: Eine Rechnung sollte ein Liefer-/Leistungsdatum (BT-72) oder einen Leistungszeitraum (BG-14) angeben.", + fix: "Optional: print the delivery date or service period. The XRechnung is valid without it.", + }, +}; diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 5e2d0e7..7ee87a1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -37,3 +37,5 @@ export * from "./repair/repair.ts"; export * from "./audit/audit-log.ts"; export * from "./agent/run-invoice.ts"; export { extractPageTexts } from "./pdf/extract-text.ts"; +export * from "./explain/explain-rule.ts"; +export { RULE_EXPLANATIONS, RULE_SOURCE, type RuleExplanation } from "./explain/rule-explanations.ts";