- 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>
232 lines
8.7 KiB
TypeScript
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;
|