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 ( + + ); +} 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 && } {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";