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(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>; for (const key of EXTRACTION_FIELDS) { const f = parsed[key] as ExtractedField; 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>; 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; const nullable = (schema: Json): Json => ({ anyOf: [schema, { type: "null" }] }); const obj = (properties: Record): 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 = { 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 = {}; 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; 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;