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`.
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/.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 c4e4748..ba0dd35 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
+- [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
@@ -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/.planning/ROADMAP.md b/.planning/ROADMAP.md
index 4192b44..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: Koyeb-Deployment (OPS-05), 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: 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).
+- 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.
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/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..318e107
--- /dev/null
+++ b/deploy/cloud-run.sh
@@ -0,0 +1,86 @@
+#!/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 and TAVILY_API_KEY from .env 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
+
+# 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' "$value" | gcloud secrets create "$1" --data-file=- \
+ --replication-policy=automatic --project "$PROJECT" >/dev/null
+ fi
+ sa="$(gcloud projects describe "$PROJECT" --format='value(projectNumber)')-compute@developer.gserviceaccount.com"
+ gcloud secrets add-iam-policy-binding "$1" --project "$PROJECT" \
+ --member "serviceAccount:$sa" --role roles/secretmanager.secretAccessor >/dev/null
+ 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 \
+ || 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.
+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"
+
+# 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 "$SECRETS" \
+ --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.
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";