diff --git a/web/ai-extraction-review/.gitignore b/web/ai-extraction-review/.gitignore new file mode 100644 index 00000000..b90a368f --- /dev/null +++ b/web/ai-extraction-review/.gitignore @@ -0,0 +1,2 @@ +node_modules +.next diff --git a/web/ai-extraction-review/app/ExtractionReviewCard.test.tsx b/web/ai-extraction-review/app/ExtractionReviewCard.test.tsx new file mode 100644 index 00000000..6da43d7e --- /dev/null +++ b/web/ai-extraction-review/app/ExtractionReviewCard.test.tsx @@ -0,0 +1,132 @@ +import { describe, expect, it, vi } from "vitest"; +import { render, screen, waitFor } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { ExtractionReviewCard } from "./ExtractionReviewCard"; +import type { InvoiceExtraction } from "../lib/api"; + +const EXTRACTION: InvoiceExtraction = { + fields: { + invoice_number: { value: "2026-0042", present: true, confidence: 0.95, plausible: true }, + total_amount: { value: "12450.00", present: true, confidence: 0.9, plausible: true }, + due_date: { value: "2026-01-17", present: true, confidence: 0.6, plausible: true }, + sender: { value: "Musterfirma GmbH", present: true, confidence: 0.85, plausible: true }, + }, + overallScore: 0.825, + valid: true, +}; + +function renderCard(overrides?: Partial[0]>) { + const onConfirm = vi.fn(); + const utils = render( + EXTRACTION} + onConfirm={onConfirm} + {...overrides} + />, + ); + return { onConfirm, ...utils }; +} + +describe("ExtractionReviewCard", () => { + // AK1: Vorschlaege werden mit Konfidenz angezeigt. + it("zeigt jeden Vorschlag mit seiner Konfidenz an", async () => { + renderCard(); + + await waitFor(() => { + expect(screen.getByDisplayValue("2026-0042")).toBeInTheDocument(); + }); + expect(screen.getByText(/Konfidenz: 95%/)).toBeInTheDocument(); + expect(screen.getByText(/Konfidenz: 60%/)).toBeInTheDocument(); + }); + + // AK2 / Pruefung 1: Bestaetigungsfluss vollstaendig durchlaufen. + it("uebergibt die Werte an onConfirm, wenn der Nutzer bestaetigt", async () => { + const user = userEvent.setup(); + const { onConfirm } = renderCard(); + + await waitFor(() => { + expect(screen.getByDisplayValue("2026-0042")).toBeInTheDocument(); + }); + await user.click(screen.getByRole("button", { name: "Bestätigen" })); + + expect(onConfirm).toHaveBeenCalledWith({ + invoice_number: "2026-0042", + total_amount: "12450.00", + due_date: "2026-01-17", + sender: "Musterfirma GmbH", + }); + expect(screen.getByText(/Bestätigt und übernommen/)).toBeInTheDocument(); + }); + + // AK2/AK3 / Pruefung 2: Korrektur eines einzelnen Felds ohne Seiteneffekt + // auf die uebrigen Felder, und der korrigierte Wert wird uebernommen. + it("erlaubt die Korrektur eines einzelnen Felds ohne die anderen zu veraendern", async () => { + const user = userEvent.setup(); + const { onConfirm } = renderCard(); + + await waitFor(() => { + expect(screen.getByDisplayValue("2026-0042")).toBeInTheDocument(); + }); + + const invoiceNumberInput = screen.getByLabelText(/Rechnungsnummer/) as HTMLInputElement; + const senderInput = screen.getByLabelText(/Absender/) as HTMLInputElement; + const amountInput = screen.getByLabelText(/^Betrag/) as HTMLInputElement; + + await user.clear(invoiceNumberInput); + await user.type(invoiceNumberInput, "2026-0099"); + + expect(invoiceNumberInput.value).toBe("2026-0099"); + expect(senderInput.value).toBe("Musterfirma GmbH"); + expect(amountInput.value).toBe("12450.00"); + + await user.click(screen.getByRole("button", { name: "Bestätigen" })); + expect(onConfirm).toHaveBeenCalledWith( + expect.objectContaining({ invoice_number: "2026-0099", sender: "Musterfirma GmbH" }), + ); + }); + + // Pruefung 3: Abbruch ohne Uebernahme. + it("ruft onConfirm nicht auf, wenn der Nutzer abbricht", async () => { + const user = userEvent.setup(); + const { onConfirm } = renderCard(); + + await waitFor(() => { + expect(screen.getByDisplayValue("2026-0042")).toBeInTheDocument(); + }); + await user.click(screen.getByRole("button", { name: "Abbrechen" })); + + expect(onConfirm).not.toHaveBeenCalled(); + expect(screen.getByText(/Abgebrochen, keine Werte übernommen/)).toBeInTheDocument(); + }); + + it("markiert einen unplausiblen Wert deutlich", async () => { + renderCard({ + load: async () => ({ + ...EXTRACTION, + fields: { + ...EXTRACTION.fields, + total_amount: { value: "-5.00", present: true, confidence: 0.9, plausible: false, reasons: ["wert -5 unterschreitet minimum 0"] }, + }, + }), + }); + + await waitFor(() => { + expect(screen.getByRole("alert")).toHaveTextContent(/Unplausibel/); + }); + }); + + it("zeigt eine Fehlermeldung, wenn das Laden fehlschlaegt", async () => { + renderCard({ + load: async () => { + throw new Error("Dienst nicht erreichbar"); + }, + }); + + await waitFor(() => { + expect(screen.getByRole("alert")).toHaveTextContent(/Dienst nicht erreichbar/); + }); + }); +}); diff --git a/web/ai-extraction-review/app/ExtractionReviewCard.tsx b/web/ai-extraction-review/app/ExtractionReviewCard.tsx new file mode 100644 index 00000000..6cf2772d --- /dev/null +++ b/web/ai-extraction-review/app/ExtractionReviewCard.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { FieldSuggestion, InvoiceExtraction } from "../lib/api"; + +interface ExtractionReviewCardProps { + title: string; + sourceUrl: string; + sourceLabel: string; + load: () => Promise; + onConfirm: (values: Record) => void; +} + +type State = + | { status: "loading" } + | { status: "loaded"; fields: Record; values: Record } + | { status: "confirmed"; values: Record } + | { status: "cancelled" } + | { status: "error"; message: string }; + +const FIELD_LABELS: Record = { + invoice_number: "Rechnungsnummer", + total_amount: "Betrag", + due_date: "Fälligkeitsdatum", + sender: "Absender", +}; + +export function ExtractionReviewCard({ title, sourceUrl, sourceLabel, load, onConfirm }: ExtractionReviewCardProps) { + const [state, setState] = useState({ status: "loading" }); + + useEffect(() => { + let cancelled = false; + setState({ status: "loading" }); + load() + .then((extraction) => { + if (cancelled) return; + const values: Record = {}; + for (const [name, field] of Object.entries(extraction.fields)) { + values[name] = field.value; + } + setState({ status: "loaded", fields: extraction.fields, values }); + }) + .catch((err: Error) => { + if (!cancelled) setState({ status: "error", message: err.message }); + }); + return () => { + cancelled = true; + }; + }, [load]); + + function handleFieldChange(name: string, value: string) { + if (state.status !== "loaded") return; + // Aendert ausschliesslich den Wert des betroffenen Felds (AK3: Korrektur + // pro Feld einzeln moeglich, ohne die uebrigen Felder zu beruehren). + setState({ ...state, values: { ...state.values, [name]: value } }); + } + + function handleConfirm() { + if (state.status !== "loaded") return; + onConfirm(state.values); + setState({ status: "confirmed", values: state.values }); + } + + function handleCancel() { + // AK2/Pruefung 3: Abbruch ohne Uebernahme — onConfirm wird bewusst NICHT + // aufgerufen. + setState({ status: "cancelled" }); + } + + return ( +
+

{title}

+ {state.status === "loading" &&

Vorschläge werden geladen…

} + {state.status === "error" &&

Vorschläge konnten nicht geladen werden: {state.message}

} + {state.status === "cancelled" &&

Abgebrochen, keine Werte übernommen.

} + {state.status === "confirmed" &&

Bestätigt und übernommen.

} + {state.status === "loaded" && ( +
{ + e.preventDefault(); + handleConfirm(); + }} + > + {Object.entries(state.fields).map(([name, field]) => { + const label = FIELD_LABELS[name] ?? name; + const confidencePercent = Math.round(field.confidence * 100); + return ( +
+ + handleFieldChange(name, e.target.value)} + style={{ width: "100%", padding: 8 }} + /> +
+ ); + })} + + +
+ )} +

+ {sourceLabel} +

+
+ ); +} diff --git a/web/ai-extraction-review/app/api/invoices/[id]/extract/route.ts b/web/ai-extraction-review/app/api/invoices/[id]/extract/route.ts new file mode 100644 index 00000000..b886b021 --- /dev/null +++ b/web/ai-extraction-review/app/api/invoices/[id]/extract/route.ts @@ -0,0 +1,47 @@ +import { NextRequest, NextResponse } from "next/server"; +import { EXAMPLE_INVOICE } from "../../../../../lib/examples"; + +function backendURL(): string { + const base = process.env.AI_EXTRACTION_API_URL; + if (!base) { + throw new Error("AI_EXTRACTION_API_URL ist nicht gesetzt (Umgebungsvariable erforderlich)"); + } + return base.replace(/\/$/, ""); +} + +function serviceToken(): string { + const token = process.env.AI_EXTRACTION_SERVICE_TOKEN; + if (!token) { + throw new Error("AI_EXTRACTION_SERVICE_TOKEN ist nicht gesetzt (Umgebungsvariable erforderlich)"); + } + return token; +} + +export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) { + const { id } = await params; + if (id !== EXAMPLE_INVOICE.id) { + return NextResponse.json({ error: "Unbekannte Beispielrechnung" }, { status: 404 }); + } + + let backendResponse: Response; + try { + backendResponse = await fetch(`${backendURL()}/invoices/extract`, { + method: "POST", + headers: { "Content-Type": "application/json", "X-Service-Token": serviceToken() }, + body: JSON.stringify({ text: EXAMPLE_INVOICE.text }), + cache: "no-store", + }); + } catch (err) { + return NextResponse.json({ error: `KI-Dienst nicht erreichbar: ${(err as Error).message}` }, { status: 502 }); + } + const body = await backendResponse.json().catch(() => null); + if (!backendResponse.ok) { + return NextResponse.json(body ?? { error: "Extraktion konnte nicht geladen werden" }, { status: backendResponse.status }); + } + return NextResponse.json({ + fields: body.fields, + overallScore: body.overall_score, + valid: body.valid, + rejectReason: body.reject_reason, + }); +} diff --git a/web/ai-extraction-review/app/layout.tsx b/web/ai-extraction-review/app/layout.tsx new file mode 100644 index 00000000..e0334991 --- /dev/null +++ b/web/ai-extraction-review/app/layout.tsx @@ -0,0 +1,45 @@ +import Script from "next/script"; +import { ThemeProvider, I18nProvider, ToastProvider, typography, themeInitScript, focusVisibleStyles } from "@nexarch/shl"; + +export const metadata = { + title: "NEXARCH Extraktions-Korrektur", +}; + +const focusVisibleStyle = ` + :focus-visible { + outline: 3px solid var(--shl-color-focus-ring, #1D4ED8); + outline-offset: 2px; + } +`; + +export default function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + return ( + + + + + + + + + + {children} + + + + + ); +} diff --git a/web/ai-extraction-review/app/page.tsx b/web/ai-extraction-review/app/page.tsx new file mode 100644 index 00000000..4705752c --- /dev/null +++ b/web/ai-extraction-review/app/page.tsx @@ -0,0 +1,26 @@ +"use client"; + +import { ExtractionReviewCard } from "./ExtractionReviewCard"; +import { getInvoiceExtraction } from "../lib/api"; +import { EXAMPLE_INVOICE } from "../lib/examples"; + +export default function ExtractionReviewPage() { + return ( +
+

Extraktions-Korrektur

+ getInvoiceExtraction(EXAMPLE_INVOICE.id)} + onConfirm={(values) => { + // Die eigentliche Uebernahme in die Metadaten des aufrufenden + // Moduls (DMS/Mail) ist nicht Bestandteil dieser Kachel (EXT-04 + // "Nicht Bestandteil") — hier nur die bestaetigten Werte + // entgegennehmen. + console.log("Bestaetigte Werte:", values); + }} + /> +
+ ); +} diff --git a/web/ai-extraction-review/lib/api.ts b/web/ai-extraction-review/lib/api.ts new file mode 100644 index 00000000..81699ebe --- /dev/null +++ b/web/ai-extraction-review/lib/api.ts @@ -0,0 +1,40 @@ +// Duenner Client fuer die eigene Backend-for-Frontend-Route unter app/api — +// kein direkter Zugriff auf die AI-API vom Client aus (Rolle: "Ruft +// ausschliesslich die vorhandene Schnittstelle auf"), gleiches Muster wie +// web/ai-summaries. + +export class ApiError extends Error { + status: number; + constructor(message: string, status: number) { + super(message); + this.status = status; + } +} + +export interface FieldSuggestion { + value: string; + present: boolean; + confidence: number; + plausible: boolean; + reasons?: string[]; +} + +export interface InvoiceExtraction { + fields: Record; + overallScore: number; + valid: boolean; + rejectReason?: string; +} + +async function parse(res: Response, fallback: string): Promise { + const body = await res.json().catch(() => null); + if (!res.ok) { + throw new ApiError(body?.error ?? `${fallback} (${res.status})`, res.status); + } + return body as T; +} + +export async function getInvoiceExtraction(invoiceId: string): Promise { + const res = await fetch(`/api/invoices/${encodeURIComponent(invoiceId)}/extract`, { cache: "no-store" }); + return parse(res, "Extraktion konnte nicht geladen werden"); +} diff --git a/web/ai-extraction-review/lib/examples.ts b/web/ai-extraction-review/lib/examples.ts new file mode 100644 index 00000000..f56f5978 --- /dev/null +++ b/web/ai-extraction-review/lib/examples.ts @@ -0,0 +1,20 @@ +// Beispieldaten fuer die Anzeige (Pruefung 1: Bestaetigungsfluss im Test +// vollstaendig durchlaufen) — echte DMS-/Mail-Anbindung ist nicht +// Bestandteil dieser Kachel (EXT-04 "Nicht Bestandteil"). + +export interface ExampleInvoice { + id: string; + title: string; + sourceUrl: string; + text: string; +} + +export const EXAMPLE_INVOICE: ExampleInvoice = { + id: "beispiel-rechnung-1", + title: "Rechnung 2026-0042", + sourceUrl: "/dms/documents/beispiel-rechnung-1", + text: + "Rechnung Nr. 2026-0042 vom 3. Januar 2026 an die Musterfirma GmbH ueber " + + "die Lieferung von 50 Buerostuehlen. Gesamtbetrag: 12.450,00 EUR " + + "zzgl. 19% Umsatzsteuer. Faelligkeitsdatum: 17. Januar 2026.", +}; diff --git a/web/ai-extraction-review/next.config.mjs b/web/ai-extraction-review/next.config.mjs new file mode 100644 index 00000000..aa96725f --- /dev/null +++ b/web/ai-extraction-review/next.config.mjs @@ -0,0 +1,5 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = { + transpilePackages: ["@nexarch/shl"], +}; +export default nextConfig; diff --git a/web/ai-extraction-review/package.json b/web/ai-extraction-review/package.json new file mode 100644 index 00000000..ecbf069f --- /dev/null +++ b/web/ai-extraction-review/package.json @@ -0,0 +1,27 @@ +{ + "name": "nexarch-ai-extraction-review", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "test": "vitest run" + }, + "dependencies": { + "@nexarch/shl": "file:../shl", + "next": "16.3.5", + "react": "19.3.0", + "react-dom": "19.3.0" + }, + "devDependencies": { + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "16.3.3", + "@testing-library/user-event": "14.5.2", + "@types/node": "^22.20.4", + "@types/react": "19.3.0", + "@types/react-dom": "19.3.0", + "jsdom": "24.1.0", + "typescript": "5.5.3", + "vitest": "^5.0.1" + } +} diff --git a/web/ai-extraction-review/tsconfig.json b/web/ai-extraction-review/tsconfig.json new file mode 100644 index 00000000..1c9cb74f --- /dev/null +++ b/web/ai-extraction-review/tsconfig.json @@ -0,0 +1,21 @@ +{ + "compilerOptions": { + "target": "ES2017", + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": false, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "plugins": [{ "name": "next" }], + "paths": { "@/*": ["./*"] } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/web/ai-extraction-review/vitest.config.ts b/web/ai-extraction-review/vitest.config.ts new file mode 100644 index 00000000..c5cdeca9 --- /dev/null +++ b/web/ai-extraction-review/vitest.config.ts @@ -0,0 +1,15 @@ +import path from "node:path"; +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + resolve: { + alias: { + react: path.resolve(__dirname, "node_modules/react"), + "react-dom": path.resolve(__dirname, "node_modules/react-dom"), + }, + }, + test: { + environment: "jsdom", + setupFiles: ["./vitest.setup.ts"], + }, +}); diff --git a/web/ai-extraction-review/vitest.setup.ts b/web/ai-extraction-review/vitest.setup.ts new file mode 100644 index 00000000..7cf3bd6c --- /dev/null +++ b/web/ai-extraction-review/vitest.setup.ts @@ -0,0 +1,5 @@ +import "@testing-library/jest-dom/vitest"; +import { afterEach } from "vitest"; +import { cleanup } from "@testing-library/react"; + +afterEach(cleanup);