Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
2 changes: 1 addition & 1 deletion .dockerignore
Original file line number Diff line number Diff line change
@@ -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
Expand Down
2 changes: 1 addition & 1 deletion .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
Expand Down
6 changes: 3 additions & 3 deletions .planning/REQUIREMENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
4 changes: 2 additions & 2 deletions .planning/ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
63 changes: 63 additions & 0 deletions apps/web/components/rule-violations.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<ul className="border-t border-void/40 text-sm">
{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 (
<li key={e.ruleId + e.location} className="border-b border-void/20 py-2">
<div className="text-ink">
<span className="mr-2 font-mono text-2xs text-void">{e.ruleId}</span>
{text}
</div>
{fix && <p className="mt-0.5 text-ink-2">{fix}</p>}
{!table && !field && generated && (
<p className="mt-1 text-2xs text-ink-3">
Explained by <span className="font-mono">{shortModel(generated.model)}</span>
{generated.sources.length > 0 && (
<>
{" "}from{" "}
{generated.sources.map((s, i) => (
<span key={s.url}>
{i > 0 && ", "}
<a href={s.url} target="_blank" rel="noopener noreferrer" className="text-stamp underline">
{new URL(s.url).hostname.replace(/^www\./, "")}
</a>
</span>
))}
</>
)}
. Check it against the validator's wording below.
</p>
)}
{/* The validator's own wording stays as evidence, without its duplicated rule prefix. */}
<p className="mt-0.5 text-2xs text-ink-3">{e.message.replace(/^\[[^\]]+\]\s*-?\s*/, "")}</p>
</li>
);
})}
</ul>
);
}
22 changes: 2 additions & 20 deletions apps/web/components/verdict-app.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,14 @@
"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";
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 {
Expand Down Expand Up @@ -213,24 +212,7 @@ export function VerdictApp() {
<NeedsInput result={result} prefill={galleryAnswer()} onSubmit={answer} />
)}
{result && <VoidedList result={result} pages={state.pages} />}
{result?.verifier && !result.verifier.valid && (
<ul className="border-t border-void/40 text-sm">
{result.verifier.errors
.filter((e) => e.severity === "error")
.map((e) => (
<li key={e.ruleId + e.location} className="border-b border-void/20 py-1.5">
<div className="text-ink">
<span className="mr-2 font-mono text-2xs text-void">{e.ruleId}</span>
{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."}
</div>
{/* The validator's own wording stays as evidence, without its duplicated rule prefix. */}
<p className="mt-0.5 text-2xs text-ink-3">{e.message.replace(/^\[[^\]]+\]\s*-?\s*/, "")}</p>
</li>
))}
</ul>
)}
{result && <RuleViolations result={result} />}
{result ? (
<FactsPanel result={result} partial={result.invoice} provenance={provenance} onFocus={setFocus} />
) : (
Expand Down
14 changes: 13 additions & 1 deletion apps/web/lib/engine.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
}
6 changes: 6 additions & 0 deletions apps/web/lib/run-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
deriveAmounts,
extractPageTexts,
runInvoice,
type Explainer,
type RawExtraction,
type TokenFactoryClient,
type UserInput,
Expand All @@ -23,6 +24,8 @@ export interface RunDeps {
readonly client: TokenFactoryClient;
readonly models: { readonly extract: string; readonly repair: string };
readonly validate: (xml: string) => Promise<VerifierVerdict>;
/** 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. */
Expand Down Expand Up @@ -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,
Expand All @@ -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) {
Expand Down
4 changes: 3 additions & 1 deletion apps/web/lib/run-protocol.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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[];
}
9 changes: 7 additions & 2 deletions apps/web/lib/run-stream.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -12,5 +12,10 @@ export function streamRun(
emit: (event: RunEvent) => void,
resume?: { extraction: RawExtraction; userInputs: readonly UserInput[] },
): Promise<void> {
return executeRun({ client: engineClient(), models: MODELS, validate: validateXRechnung }, pdf, emit, resume);
return executeRun(
{ client: engineClient(), models: MODELS, validate: validateXRechnung, explain: engineExplainer() },
pdf,
emit,
resume,
);
}
10 changes: 8 additions & 2 deletions apps/web/scripts/build-gallery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -61,13 +61,19 @@ async function record(deps: Parameters<typeof executeRun>[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 <id> re-records one case and keeps the others.
Expand Down
10 changes: 5 additions & 5 deletions deploy/hf-space/Dockerfile → deploy/Dockerfile
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
Expand Down
Loading
Loading