Files
craftvia/src/lib/imports/extraction.ts
T
msolarczekandClaude Opus 5 12a764786a 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>
2026-09-14 12:17:13 +02:00

232 lines
8.7 KiB
TypeScript

import { z } from "zod";
import type { ExtractedField, WorkOrderExtraction } from "@/server/ai/providers";
/**
* Client-safe extraction model for the order import (spec §9.4/§9.5).
*
* - `EXTRACTION_FIELDS`: canonical field order (review mask, corrections, tests).
* - `workOrderExtractionSchema`: Zod validation of provider output (null → undefined for sub-objects).
* - `extractionJsonSchema()`: JSON schema for Claude structured outputs (all keys required,
* nullable via anyOf, additionalProperties:false — the structured-output subset).
* - `StoredExtraction`: shape persisted in ImportJob.extraction.
*/
export const EXTRACTION_VERSION = "craftvia-import-v1";
export const EXTRACTION_FIELDS = [
"orderNumber",
"offerNumber",
"customerNumber",
"companyName",
"customerFirstName",
"customerLastName",
"customerAddress",
"siteName",
"siteAddress",
"contactName",
"phone",
"email",
"orderDate",
"documentDate",
"plannedStart",
"plannedEnd",
"title",
"description",
"positions",
"notes",
"totalAmount",
"references",
] as const satisfies readonly (keyof WorkOrderExtraction)[];
export type ExtractionFieldKey = (typeof EXTRACTION_FIELDS)[number];
export type Address = { street?: string; houseNumber?: string; postalCode?: string; city?: string; country?: string };
export type Position = { position?: string; name: string; articleNumber?: string; quantity?: number; unit?: string; isMaterial?: boolean };
const confidence = z.number().min(0).max(1).catch(0);
const source = z.string().nullish().transform((v) => v ?? undefined);
const optStr = z.string().nullish().transform((v) => (v == null || v.trim() === "" ? undefined : v.trim()));
function field<T extends z.ZodTypeAny>(value: T) {
return z.object({ value: value.nullable().catch(null), confidence, source });
}
const addressSchema = z.object({
street: optStr,
houseNumber: optStr,
postalCode: optStr,
city: optStr,
country: optStr,
});
const positionSchema = z.object({
position: optStr,
name: z.string().min(1),
articleNumber: optStr,
quantity: z.number().nullish().transform((v) => (v == null || Number.isNaN(v) ? undefined : v)),
unit: optStr,
isMaterial: z.boolean().nullish().transform((v) => v ?? undefined),
});
const str = z.string().transform((v) => v.trim());
export const workOrderExtractionSchema = z.object({
orderNumber: field(str),
offerNumber: field(str),
customerNumber: field(str),
companyName: field(str),
customerFirstName: field(str),
customerLastName: field(str),
customerAddress: field(addressSchema),
siteName: field(str),
siteAddress: field(addressSchema),
contactName: field(str),
phone: field(str),
email: field(str),
orderDate: field(str),
documentDate: field(str),
plannedStart: field(str),
plannedEnd: field(str),
title: field(str),
description: field(str),
positions: field(z.array(positionSchema)),
notes: field(str),
totalAmount: field(z.number()),
references: field(z.array(z.string())),
});
/** Parse arbitrary provider output into a WorkOrderExtraction (throws on structurally invalid input). */
export function parseExtraction(raw: unknown): WorkOrderExtraction {
const parsed = workOrderExtractionSchema.parse(raw);
// Empty strings count as "not found".
const out = {} as Record<string, ExtractedField<unknown>>;
for (const key of EXTRACTION_FIELDS) {
const f = parsed[key] as ExtractedField<unknown>;
const empty = f.value === null || (typeof f.value === "string" && f.value === "");
out[key] = { value: empty ? null : f.value, confidence: empty ? 0 : f.confidence, ...(f.source ? { source: f.source } : {}) };
}
return out as unknown as WorkOrderExtraction;
}
/** Extraction with every field empty (no provider / manual entry). */
export function emptyExtraction(): WorkOrderExtraction {
const out = {} as Record<string, ExtractedField<unknown>>;
for (const key of EXTRACTION_FIELDS) out[key] = { value: null, confidence: 0 };
return out as unknown as WorkOrderExtraction;
}
// ---------- JSON schema for structured outputs ----------
type Json = Record<string, unknown>;
const nullable = (schema: Json): Json => ({ anyOf: [schema, { type: "null" }] });
const obj = (properties: Record<string, Json>): Json => ({
type: "object",
properties,
required: Object.keys(properties),
additionalProperties: false,
});
const addressJson = obj({
street: nullable({ type: "string" }),
houseNumber: nullable({ type: "string" }),
postalCode: nullable({ type: "string" }),
city: nullable({ type: "string" }),
country: nullable({ type: "string", description: "ISO 3166-1 alpha-2, e.g. DE" }),
});
const positionJson = obj({
position: nullable({ type: "string" }),
name: { type: "string" },
articleNumber: nullable({ type: "string" }),
quantity: nullable({ type: "number" }),
unit: nullable({ type: "string" }),
isMaterial: nullable({ type: "boolean" }),
});
const fieldJson = (value: Json, description: string): Json => ({
...obj({
value: nullable(value),
confidence: { type: "number", description: "0..1 — how certain the value is correct" },
source: nullable({ type: "string", description: "short verbatim snippet from the document" }),
}),
description,
});
const FIELD_DESCRIPTIONS: Record<ExtractionFieldKey, [Json, string]> = {
orderNumber: [{ type: "string" }, "Order / confirmation number of the issuer (Auftragsnummer)"],
offerNumber: [{ type: "string" }, "Offer number (Angebotsnummer)"],
customerNumber: [{ type: "string" }, "Customer number (Kundennummer)"],
companyName: [{ type: "string" }, "Company name of the customer (the ordering party, NOT the craft business)"],
customerFirstName: [{ type: "string" }, "First name if the customer is a private person"],
customerLastName: [{ type: "string" }, "Last name if the customer is a private person"],
customerAddress: [addressJson, "Billing / postal address of the customer"],
siteName: [{ type: "string" }, "Name of the site / construction site (Objekt, Baustelle)"],
siteAddress: [addressJson, "Address where the work is carried out"],
contactName: [{ type: "string" }, "Contact person"],
phone: [{ type: "string" }, "Phone number of the customer or contact"],
email: [{ type: "string" }, "E-mail address of the customer or contact"],
orderDate: [{ type: "string" }, "Order date, ISO 8601 (YYYY-MM-DD)"],
documentDate: [{ type: "string" }, "Document date, ISO 8601 (YYYY-MM-DD)"],
plannedStart: [{ type: "string" }, "Planned start of execution, ISO 8601 (YYYY-MM-DD)"],
plannedEnd: [{ type: "string" }, "Planned end of execution, ISO 8601 (YYYY-MM-DD)"],
title: [{ type: "string" }, "Short title of the job (max. 80 characters)"],
description: [{ type: "string" }, "Description of services (Leistungsbeschreibung)"],
positions: [{ type: "array", items: positionJson }, "Line items with quantities; isMaterial=true for material/articles"],
notes: [{ type: "string" }, "Special notes (access, deadlines, safety)"],
totalAmount: [{ type: "number" }, "Total gross amount in EUR as a number"],
references: [{ type: "array", items: { type: "string" } }, "Other references (project no., purchase order no.)"],
};
/** JSON schema of the complete provider answer: full text + structured fields. */
export function extractionJsonSchema(): Json {
const fields: Record<string, Json> = {};
for (const key of EXTRACTION_FIELDS) {
const [value, description] = FIELD_DESCRIPTIONS[key];
fields[key] = fieldJson(value, description);
}
return obj({
text: { type: "string", description: "Full recognised text of the document (reading order)" },
extraction: obj(fields),
});
}
// ---------- persisted shape ----------
export type PlausibilityHintCode =
| "date_invalid"
| "date_implausible"
| "postal_code_invalid"
| "email_invalid"
| "phone_invalid"
| "end_before_start"
| "manual_entry";
export type PlausibilityHint = { field: ExtractionFieldKey | null; code: PlausibilityHintCode };
export type DuplicateCandidate = { customerId: string; score: number; reasons: string[] };
export type SiteCandidate = { siteId: string; customerId: string; name: string; score: number; reasons: string[] };
export type StoredExtraction = {
fields: WorkOrderExtraction;
hints: PlausibilityHint[];
siteCandidates: SiteCandidate[];
};
export function readStoredExtraction(raw: unknown): StoredExtraction {
const r = (raw ?? {}) as Partial<StoredExtraction>;
let fields: WorkOrderExtraction;
try {
fields = r.fields ? parseExtraction(r.fields) : emptyExtraction();
} catch {
fields = emptyExtraction();
}
return {
fields,
hints: Array.isArray(r.hints) ? r.hints : [],
siteCandidates: Array.isArray(r.siteCandidates) ? r.siteCandidates : [],
};
}
/** Fields below this confidence are marked "unsicher" in the review mask. */
export const LOW_CONFIDENCE = 0.8;