L3 Auftragsimport: Extraktion, Plausibilität, Dubletten, Bestätigung (Services + Tests)
- Claude-Extraktion (PDF nativ/Bild, Structured Output, Konfidenzen, Volltext), FakeProvider - Plausibilitätsprüfung, Mapping Extraktion → Formular, Korrektur-Diff - Services Upload/Verarbeitung/Bestätigung/Verwerfen/Neu verarbeiten, Processor import-extraction - Stubs: storeFile (§4.3), findDuplicateCustomers (L1), createWorkOrder (L2) - Tests test-import-rules/-flow/-live, Beispiel-PDFs + Generator Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,132 @@
|
||||
import Anthropic from "@anthropic-ai/sdk";
|
||||
import { AI_MODEL, getAnthropic } from "@/server/ai/client";
|
||||
import type { DocumentExtractionProvider, WorkOrderExtraction, ProviderMeta } from "@/server/ai/providers";
|
||||
import { extractionJsonSchema, parseExtraction } from "@/lib/imports/extraction";
|
||||
|
||||
/**
|
||||
* Claude-based document extraction (spec §9.4, ARCHITEKTUR §4.5).
|
||||
*
|
||||
* - PDF is sent natively as a base64 `document` block (text layer + page images, so scanned
|
||||
* PDFs work without a separate OCR step); JPG/PNG as `image` block.
|
||||
* - Structured output via `output_config.format` (JSON schema) — the response is guaranteed to
|
||||
* match the schema; we still validate with Zod (`parseExtraction`).
|
||||
* - Streaming + `finalMessage()`: the full text of long documents can be large.
|
||||
* - Refusals are handled explicitly; for Claude Opus 5 / Fable 5.1 the server-side fallback
|
||||
* (`fallbacks: "default"`) re-runs a declined request on the recommended fallback model.
|
||||
*/
|
||||
|
||||
const SUPPORTED_IMAGE = new Set(["image/jpeg", "image/png"]);
|
||||
const FALLBACK_BETA = "server-side-fallback-2026-07-01";
|
||||
const MAX_TOKENS = 64_000;
|
||||
|
||||
const SYSTEM_PROMPT = `You extract structured data from order documents (Auftragsbestätigungen, Aufträge, Bestellungen) of German craft and installation businesses.
|
||||
|
||||
Rules:
|
||||
- Return the full recognised text of the document in "text" (reading order, line breaks preserved).
|
||||
- Extract each field only from what is written in the document. Never invent, guess or complete values. If a field is not present, set value to null and confidence to 0.
|
||||
- confidence is 0..1 and reflects how certain you are that the value is correct and belongs to this field. Use values below 0.8 whenever the value is ambiguous, hard to read (scan quality, handwriting), inferred from context, or could belong to a different party. Use values of 0.95 and above only for values printed clearly and labelled unambiguously.
|
||||
- source: a short verbatim snippet (max. 120 characters) from the document that the value was taken from.
|
||||
- The customer is the ordering party (Auftraggeber / Rechnungsempfänger), not the business issuing the document (letterhead, footer, bank details).
|
||||
- The site (Objekt / Baustelle / Einsatzort / Lieferadresse) is where the work is carried out. If the document names no separate site, leave siteName and siteAddress null.
|
||||
- Normalise German formats: dates to ISO 8601 (YYYY-MM-DD; "15.03.2026" → "2026-03-15"), numbers with decimal comma to JSON numbers ("1.234,50" → 1234.5), country to ISO 3166-1 alpha-2 (default "DE" only if the address is clearly German).
|
||||
- Split street and house number ("Hafenstraße 12a" → street "Hafenstraße", houseNumber "12a").
|
||||
- A planned execution period like "KW 12/2026" or "ab 03.04." may be converted to dates only if the year is unambiguous; otherwise keep the value null and mention it in notes.
|
||||
- positions: every line item with quantity and unit; set isMaterial=true for physical material/articles, false for labour/services.
|
||||
- title: a short, factual job title in German (max. 80 characters) derived from the document subject or main service.`;
|
||||
|
||||
export class AnthropicExtractionProvider implements DocumentExtractionProvider {
|
||||
readonly name = "anthropic";
|
||||
readonly model: string;
|
||||
|
||||
constructor(
|
||||
private readonly client: Anthropic,
|
||||
model: string = AI_MODEL,
|
||||
) {
|
||||
this.model = model;
|
||||
}
|
||||
|
||||
async extract(input: { bytes: Buffer; mimeType: string; fileName: string }): Promise<{
|
||||
text: string;
|
||||
extraction: WorkOrderExtraction;
|
||||
meta: ProviderMeta;
|
||||
}> {
|
||||
const data = input.bytes.toString("base64");
|
||||
let fileBlock: Anthropic.Beta.BetaContentBlockParam;
|
||||
if (input.mimeType === "application/pdf") {
|
||||
fileBlock = { type: "document", source: { type: "base64", media_type: "application/pdf", data }, title: input.fileName };
|
||||
} else if (SUPPORTED_IMAGE.has(input.mimeType)) {
|
||||
fileBlock = { type: "image", source: { type: "base64", media_type: input.mimeType as "image/jpeg" | "image/png", data } };
|
||||
} else {
|
||||
throw new Error(`unsupported mime type for extraction: ${input.mimeType}`);
|
||||
}
|
||||
|
||||
const useFallback = /^claude-(opus-5|fable-5-1|mythos-5-1)/.test(this.model);
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
let message: Anthropic.Beta.BetaMessage;
|
||||
try {
|
||||
const stream = this.client.beta.messages.stream({
|
||||
model: this.model,
|
||||
max_tokens: MAX_TOKENS,
|
||||
thinking: { type: "adaptive" },
|
||||
system: SYSTEM_PROMPT,
|
||||
output_config: { format: { type: "json_schema", schema: extractionJsonSchema() } },
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
fileBlock,
|
||||
{ type: "text", text: `Extract the order data from this document. Today is ${today} (for resolving incomplete dates).` },
|
||||
],
|
||||
},
|
||||
],
|
||||
...(useFallback ? { betas: [FALLBACK_BETA], fallbacks: "default" as const } : {}),
|
||||
});
|
||||
message = await stream.finalMessage();
|
||||
} catch (err) {
|
||||
if (err instanceof Anthropic.APIError) {
|
||||
// No document content in the message — only status/type for the import job's error field.
|
||||
throw new Error(`Claude API error ${err.status ?? "?"} (${err.name})`);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (message.stop_reason === "refusal") {
|
||||
throw new Error("Claude declined to process the document (refusal)");
|
||||
}
|
||||
if (message.stop_reason === "max_tokens") {
|
||||
throw new Error("Claude response truncated (max_tokens)");
|
||||
}
|
||||
const textBlock = message.content.find((b): b is Anthropic.Beta.BetaTextBlock => b.type === "text");
|
||||
if (!textBlock) throw new Error("Claude response contained no text block");
|
||||
|
||||
let parsed: { text?: unknown; extraction?: unknown };
|
||||
try {
|
||||
parsed = JSON.parse(textBlock.text) as typeof parsed;
|
||||
} catch {
|
||||
throw new Error("Claude response was not valid JSON");
|
||||
}
|
||||
|
||||
return {
|
||||
text: typeof parsed.text === "string" ? parsed.text : "",
|
||||
extraction: parseExtraction(parsed.extraction),
|
||||
meta: {
|
||||
provider: this.name,
|
||||
model: message.model ?? this.model,
|
||||
inputTokens: message.usage.input_tokens,
|
||||
outputTokens: message.usage.output_tokens,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Configured extraction provider or `null` (graceful degradation → manual entry).
|
||||
* `AI_EXTRACTION_PROVIDER` may be unset or "anthropic"; any other value disables extraction.
|
||||
*/
|
||||
export function getExtractionProvider(): DocumentExtractionProvider | null {
|
||||
const configured = process.env.AI_EXTRACTION_PROVIDER?.trim().toLowerCase();
|
||||
if (configured && configured !== "anthropic") return null;
|
||||
const client = getAnthropic();
|
||||
return client ? new AnthropicExtractionProvider(client) : null;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import type { DocumentExtractionProvider, ProviderMeta, WorkOrderExtraction } from "@/server/ai/providers";
|
||||
import { emptyExtraction } from "@/lib/imports/extraction";
|
||||
|
||||
/**
|
||||
* Deterministic provider for tests and demos: returns a fixed extraction (merged over an empty
|
||||
* one) or throws the configured error. Records every call.
|
||||
*/
|
||||
export class FakeExtractionProvider implements DocumentExtractionProvider {
|
||||
readonly name = "fake";
|
||||
readonly model = "fake-extraction-1";
|
||||
readonly calls: Array<{ mimeType: string; fileName: string; size: number }> = [];
|
||||
|
||||
constructor(
|
||||
private readonly opts: {
|
||||
extraction?: Partial<WorkOrderExtraction>;
|
||||
text?: string;
|
||||
fail?: Error;
|
||||
} = {},
|
||||
) {}
|
||||
|
||||
async extract(input: { bytes: Buffer; mimeType: string; fileName: string }): Promise<{
|
||||
text: string;
|
||||
extraction: WorkOrderExtraction;
|
||||
meta: ProviderMeta;
|
||||
}> {
|
||||
this.calls.push({ mimeType: input.mimeType, fileName: input.fileName, size: input.bytes.byteLength });
|
||||
if (this.opts.fail) throw this.opts.fail;
|
||||
return {
|
||||
text: this.opts.text ?? "",
|
||||
extraction: { ...emptyExtraction(), ...structuredClone(this.opts.extraction ?? {}) },
|
||||
meta: { provider: this.name, model: this.model, inputTokens: 100, outputTokens: 50 },
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user