1const e=`/** 2 * CLASSIFY-EMAIL-STATUS â AI-powered email â case status classification + data enrichment 3 * 4 * Receives partner email text, uses AI to determine the intended case status, 5 * extracts case-relevant data (name, plate, make/model, VIN), compares against 6 * existing case data, and logs changes as a changelog. 7 * 8 * Input: { case_id, email_text, sender_email, organization_id?, provider? } 9 * Output: { classified_status, confidence_score, reasoning, data_changes } 10 */ 11 12import { serve } from "https://deno.land/[email protected]/http/server.ts"; 13import { createClient } from "npm:@supabase/supabase-js@2"; 14import { corsHeaders, handleCors } from "../_shared/cors.ts"; 15import { resolveCreditRate, computeUnitCredits } from "../_shared/credit-rates.ts"; 16import { linkAdacTaskAndEmit } from "../_shared/adac-task-id.ts"; 17import { validateInternalCaller } from "../_shared/internal-auth.ts"; 18import { mirrorTowingStatus } from "../_shared/mobility/inbound.ts"; 19import { canonicalBrand, canonicalModel, resolveBrandFromCatalogue } from "../_shared/vehicle-alias.ts"; 20 21const VALID_STATUSES = [ 22 "new", "in_review", "assigned", "dispatched", "in_progress", 23 "waiting_for_partner", "resolved", "escalated", "cancelled", 24]; 25 26interface ClassifyRequest { 27 case_id: string; 28 email_text: string; 29 email_subject?: string; 30 sender_email?: string; 31 organization_id?: string; 32 provider?: string; 33 /** Optional source inbound email id â persisted on emitted case_events so the timeline can render the original mail. */ 34 inbound_email_id?: string; 35 /** When true: classify + segment only, return the result, write nothing. */ 36 dry_run?: boolean; 37} 38 39import { normalizePlate as normalizePlateDisplay, stripFixedWidthColumn } from "../_shared/license-plate.ts"; 40import { 41 segmentAdacEmail, 42 renderBlocksForPrompt, 43 detectStagesByKeyword, 44 detectAuxStagesByKeyword, 45 stableHash, 46 type AdacEmailBlock, 47} from "../_shared/adac-email-blocks.ts"; 48import { isAuxStage } from "../_shared/adac-task-id.ts"; 49import { callInboundModel, InboundModelError } from "../_shared/inbound-ai.ts"; 50import { 51 parseAdacHeader, 52 parseAdacWorkshop, 53 parseAdacEtaMinutes, 54} from "../_shared/adac-header-parse.ts"; 55 56 57/** Fields we track for enrichment */ 58const ENRICHMENT_FIELDS = [ 59 "customer_name", "vehicle_license_plate", "vehicle_make", "vehicle_model", 60 "vehicle_vin", 61] as const; 62 63type EnrichmentField = typeof ENRICHMENT_FIELDS[number]; 64 65serve(async (req) => { 66 if (req.method === "OPTIONS") { 67 return new Response(null, { headers: corsHeaders }); 68 } 69 70 const auth = validateInternalCaller(req); 71 if (!auth.valid) { 72 return new Response(JSON.stringify({ error: "Unauthorized" }), { 73 status: 401, 74 headers: { ...corsHeaders, "Content-Type": "application/json" }, 75 }); 76 } 77 78 const supabaseUrl = Deno.env.get("SUPABASE_URL")!; 79 const supabaseServiceKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!; 80 const lovableApiKey = Deno.env.get("LOVABLE_API_KEY"); 81 const supabase = createClient(supabaseUrl, supabaseServiceKey); 82 83 // ââ RECLASSIFY MODE ââ 84 const url = new URL(req.url); 85 const isReclassify = url.searchParams.get("reclassify") === "true"; 86 const reclassifyLimit = parseInt(url.searchParams.get("limit") || "50", 10); 87 /** Only mails received in the last N days (targeted backfills). */ 88 const reclassifySinceDays = parseFloat(url.searchParams.get("since_days") || "0"); 89 /** Only mails that carry no classification audit yet (never status-classified). */ 90 const onlyMissingAudit = url.searchParams.get("only_missing_audit") === "true"; 91 92 if (isReclassify) { 93 try { 94 console.log(\`[RECLASSIFY] Starting reclassification of up to \${reclassifyLimit} matched emails\`); 95 96 // Fetch all matched emails that have body content 97 let reclassifyQuery = supabase 98 .from("inbound_emails") 99 .select("id, matched_case_id, subject, body_text, body_html, from_address, received_at, classification_result, organization_id") 100 .not("matched_case_id", "is", null) 101 .order("received_at", { ascending: true }) 102 .limit(reclassifyLimit); 103 if (reclassifySinceDays > 0) { 104 reclassifyQuery = reclassifyQuery.gte( 105 "received_at", 106 new Date(Date.now() - reclassifySinceDays * 86400_000).toISOString(), 107 ); 108 } 109 if (onlyMissingAudit) { 110 reclassifyQuery = reclassifyQuery.is("classification_result->email_classification_audit", null); 111 } 112 const { data: emails, error: fetchErr } = await reclassifyQuery; 113 114 115 if (fetchErr) { 116 return jsonResponse({ error: "Failed to fetch emails: " + fetchErr.message }, 500); 117 } 118 119 const results: Array<{ email_id: string; case_id: string; status: string; data_changes: number; error?: string }> = []; 120 let successCount = 0; 121 let errorCount = 0; 122 123 for (const email of emails || []) { 124 const emailText = email.body_text || email.body_html || ""; 125 if (!emailText.trim()) { 126 results.push({ email_id: email.id, case_id: email.matched_case_id, status: "skipped_no_body", data_changes: 0 }); 127 continue; 128 } 129 // Never reclassify our own outbound mail echoed back via IMAP. 130 const fromAddr = (email.from_address || "").toLowerCase(); 131 if (fromAddr.includes("dc-connected.") || fromAddr.includes("d2connected.")) { 132 results.push({ email_id: email.id, case_id: email.matched_case_id, status: "skipped_self_sent", data_changes: 0 }); 133 continue; 134 } 135 136 137 try { 138 // Call ourselves (the classify logic) internally 139 const classifyResponse = await fetch(\`\${supabaseUrl}/functions/v1/classify-email-status\`, { 140 method: "POST", 141 headers: { 142 Authorization: \`Bearer \${supabaseServiceKey}\`, 143 "Content-Type": "application/json", 144 }, 145 body: JSON.stringify({ 146 case_id: email.matched_case_id, 147 email_text: emailText, 148 email_subject: email.subject, 149 sender_email: email.from_address, 150 organization_id: email.organization_id, 151 provider: "ADAC", 152 inbound_email_id: email.id, 153 }), 154 }); 155 156 const classifyResult = await classifyResponse.json(); 157 158 // Update the email's classification_result with fresh data 159 const updatedClassification = { 160 ...(email.classification_result as Record<string, unknown> || {}), 161 ...classifyResult, 162 reclassified_at: new Date().toISOString(), 163 }; 164 165 await supabase 166 .from("inbound_emails") 167 .update({ classification_result: updatedClassification }) 168 .eq("id", email.id); 169 170 results.push({ 171 email_id: email.id, 172 case_id: email.matched_case_id, 173 status: classifyResult.classified_status || "unknown", 174 data_changes: classifyResult.data_changes?.length || 0, 175 ...(classifyResult.error ? { error: classifyResult.error } : {}), 176 }); 177 178 successCount++; 179 } catch (err) { 180 errorCount++; 181 results.push({ 182 email_id: email.id, 183 case_id: email.matched_case_id, 184 status: "error", 185 data_changes: 0,
186 error: err instanceof Error ? err.message : String(err), 187 }); 188 } 189 190 // Small delay to avoid rate limiting 191 await new Promise((r) => setTimeout(r, 500)); 192 } 193 194 console.log(\`[RECLASSIFY] Done: \${successCount} success, \${errorCount} errors out of \${(emails || []).length} emails\`); 195 196 return jsonResponse({ 197 mode: "reclassify", 198 total_emails: (emails || []).length, 199 success: successCount, 200 errors: errorCount, 201 results, 202 }); 203 } catch (error) { 204 const msg = error instanceof Error ? error.message : String(error); 205 console.error("[RECLASSIFY] Error:", msg); 206 return jsonResponse({ error: msg }, 500); 207 } 208 } 209 210 // ââ NORMAL SINGLE-EMAIL MODE ââ 211 try { 212 const body: ClassifyRequest = await req.json(); 213 console.log("[CLASSIFY-EMAIL] Request for case:", body.case_id); 214 215 if (!body.case_id || !body.email_text) { 216 return jsonResponse({ error: "case_id and email_text are required" }, 400); 217 } 218 219 // Hard-skip: never classify or enrich from our own outbound mail. 220 // Self-sent system mails (towing requests, transfer notifications, etc.) 221 // are not authoritative third-party data sources. 222 if (body.provider === "SELF") { 223 console.log(\`[CLASSIFY-EMAIL] Skipping self-sent email for case \${body.case_id}\`); 224 return jsonResponse({ 225 skipped: true, 226 reason: "self_sent_email", 227 case_id: body.case_id, 228 }); 229 } 230 231 // Fetch current case for context 232 const { data: currentCase, error: caseErr } = await supabase 233 .from("cases") 234 .select("id, status, organization_id, client_case_id, service_type, handled_by, customer_name, customer_phone, vehicle_license_plate, vehicle_make, vehicle_model, vehicle_vin, assigned_partner_id, location_address, location_city, location_postal_code, location_lat, location_lng, mobility_provided") 235 .eq("id", body.case_id) 236 .single(); 237 238 if (caseErr || !currentCase) { 239 return jsonResponse({ error: "Case not found" }, 404); 240 } 241 242 // Fetch previous emails for this case for richer context. 243 // We send the ALREADY CLASSIFIED result per mail (status + lifecycle stages 244 // with their block timestamps) instead of raw body excerpts: it is far more 245 // informative per token and lets the model see what is already recorded. 246 const { data: previousEmails } = await supabase 247 .from("inbound_emails") 248 .select("subject, from_address, received_at, classification_result") 249 .eq("matched_case_id", body.case_id) 250 .order("received_at", { ascending: true }) 251 .limit(10); 252 253 const HISTORY_MAX_CHARS = 4000; 254 const historyLines = (previousEmails || []).map((e: any, i: number) => { 255 const cr = (e.classification_result || {}) as Record<string, any>; 256 const events = Array.isArray(cr.lifecycle_events) ? cr.lifecycle_events : []; 257 const stages = events 258 .filter((ev: any) => ev?.stage && ev.stage !== "none") 259 .map((ev: any) => \`\${ev.stage}@\${ev.occurredAtLocal ?? ev.occurred_at_local ?? "?"}\`) 260 .join(", "); 261 return \`Email \${i + 1} (\${e.received_at}) from \${e.from_address || "?"} â subject: \${ 262 e.subject || "(none)" 263 } | classified status: \${cr.status ?? "unknown"} | recorded events: \${stages || "none"}\`; 264 }); 265 266 // Keep the NEWEST mails when the digest gets long. 267 const keptHistory: string[] = []; 268 let historyBudget = HISTORY_MAX_CHARS; 269 for (let i = historyLines.length - 1; i >= 0; i--) { 270 const line = historyLines[i].slice(0, 600); 271 if (line.length > historyBudget) break; 272 historyBudget -= line.length + 1; 273 keptHistory.unshift(line); 274 } 275 const emailHistory = keptHistory.join("\\n"); 276 277 278 if (!lovableApiKey && !Deno.env.get("GEMINI_API_KEY")) { 279 return jsonResponse({ error: "No AI provider configured (GEMINI_API_KEY / LOVABLE_API_KEY)" }, 500); 280 } 281 282 const emailCount = (previousEmails || []).length; 283 284 // Build AI classification + extraction prompt 285 const systemPrompt = \`You are a case status classifier AND data extractor for an automotive service platform (ADAC roadside assistance). 286Given an email from a partner/provider, determine:
2871. What internal case status the email implies 2882. Any case-relevant data mentioned (customer name, license plate, vehicle make/model, VIN, external case ID) 289 290Current case status: "\${currentCase.status}" 291Valid target statuses: \${VALID_STATUSES.join(", ")} 292\${emailCount > 0 ? \`\\nThis is email #\${emailCount + 1} for this case. CASE MAIL HISTORY (chronological, already classified â these events are already recorded):\\n\${emailHistory}\\nUse this history to order the current mail's blocks correctly and to recognise repeated (already known) blocks. Still return an entry for every block of the CURRENT mail, but make clear in the reasoning what is NEW compared to the history.\\n\` : ""} 293 294Status rules â use these German phrase patterns for ADAC emails: 295- "dispatched": partner confirms sending someone / order placed / VERMITTLUNG AN (HERSTELLER/ASS/PARTNER) / WIRD ABGESCHLEPPT / ABSCHLEPPAUFTRAG / DER AUFTRAG WURDE ERTEILT / PANNENHILFE WURDE BEAUFTRAGT 296- "in_progress": work has started, technician arrived / TECHNIKER VOR ORT / REPARATUR BEGONNEN 297- "waiting_for_partner": partner needs more info or is delayed / FALSCHER STANDORT / KUNDE NICHT ERREICHBAR / KUNDE MUSS ERNEUT ANRUFEN / RÃCKFRAGE 298- "resolved": job completed, issue fixed / ABSCHLEPPVORGANG ABGESCHLOSSEN / PANNE BEHOBEN / AUFTRAG ERLEDIGT / FAHRZEUG ABGELIEFERT 299- "cancelled": partner cannot fulfill the MAIN towing/roadside task / STORNIERT / AUFTRAG STORNIERT. IMPORTANT: A STORNO of a sub-task (Mietwagen/Ersatzmobilität/Rental/Ride/VEX/Freigabe für Mietwagenorganisation) is NOT a case cancellation â return the current status, never "cancelled". 300- "escalated": partner reports critical issue / ESKALATION / DRINGEND 301 302IMPORTANT: "VERMITTLUNG AN HERSTELLER" means "forwarded to manufacturer assistance" â this is ALWAYS "dispatched", never "in_review". 303 304Provider lifecycle events â partner status emails (especially ADAC) are CUMULATIVE: each new email contains the FULL historical log with one timestamped block per event. The body below is ALREADY SEGMENTED into numbered blocks (some mails print no timestamps; those blocks are segmented by section header or paragraph and show "(no timestamp)"). Return ONE entry in lifecycle_events for EACH block (in chronological order) and set block_index to that block's number. Use stage "none" for blocks that carry no lifecycle meaning â never skip a block silently. lifecycle_events must NEVER be empty: if truly nothing is a status update, return one entry with stage "none" for block 0. 305 306Stage vocabulary (partner-agnostic): 307- "dispatch_confirmed": Partner confirms they WILL handle the job / planned workshop is named but vehicle is NOT yet there. ADAC triggers: "WIRD ABGESCHLEPPT" (future tense), "Information zum Abschleppvorgang â¦" header, "Fahrzeug steht bei der Werkstatt: â¦" used as planned destination, "Abschleppauftrag wurde erteilt", "EINSATZMITTELEMPFEHLUNG: FAHRZEUG MUSS ABGESCHLEPPT", "ORGA 001/002" (Auftrag disponiert). 308- "technician_on_site": Helfer/Pannenhelfer ist eingetroffen / vor Ort. ADAC: "PANNENHELFER EINGETROFFEN", "VOR ORT", "EINGETROFFEN AM EINSATZORT". 309- "roadside_resolved": Panne wurde vor Ort behoben, kein Abschlepp nötig. ADAC: "Information zur erfolgreichen Pannenhilfe", "DIE PANNENHILFE WAR ERFOLGREICH", "IHR KUNDE KANN SEINE FAHRT FORTSETZEN", "ROADSIDE ASSISTANCE HAS ALREADY BEEN PROVIDED", "ERFOLGREICHE PANNENHILFE", "Panne behoben vor Ort". 310- "no_fault_found": Am Fahrzeug wurde KEIN Defekt festgestellt, der Einsatz war nicht nötig (Fehlalarm / Kunde meldet, dass alles wieder funktioniert). ADAC: "LAUT KUNDE FUNKTIONIERT DAS AUTO KORREKT, ALSO HIER IST KEIN GRUND FÃR BREAKDOWNFALL", "GESTERN HAT DAS FAHRZEUG WIEDER FUNKTIONIERT", "KEIN DEFEKT FESTGESTELLT", "NO FAULT FOUND". Nicht mit roadside_resolved verwechseln: dort wurde tatsächlich repariert. 311- "towed_to_workshop": Fahrzeug NACHWEISLICH in der Zielwerkstatt angekommen. PAST TENSE REQUIRED. ADAC: "WURDE ABGESCHLEPPT", "erfolgreich abgeschleppt nach â¦", "ABGESCHLEPPT NACH/ZUR/ZUM", "TRANSPORTED TO", "Zielort erreicht", "Fahrzeug übergeben", "in der Werkstatt eingetroffen". NEVER use this for "WIRD ABGESCHLEPPT" â that is dispatch_confirmed. 312- "handed_off_to_manufacturer": ADAC: "Information Herstellerweiterleitung", "HERSTELLERWEITERLEITUNG", "WEITERLEITUNG AN HERSTELLER", "AN HERSTELLER". 313- "tow_empty_run": Schlepper war da, konnte aber nicht laden und fährt leer ab. ADAC: "FEHLFAHRT", "LEERFAHRT", "SCHLEPPER FÃHRT AB", "FÃHRT AB VOM SCHADENSORT", "TOWER LEFT", "LEFT THE VEHICLE AT THE CURRENT LOCATION". 314- "tow_failed": Auftrag konnte nicht ausgeführt werden / abgebrochen. ADAC: "AUFTRAG NICHT AUSGEFÃHRT", "VEHICLE NOT FOUND", "KUNDE NICHT ERREICHBAR" (when in the final block). 315- "tow_cancelled": Der ABSCHLEPP-/PANNENAUFTRAG selbst wurde storniert. ADAC: "AUFTRAG STORNIERT", "STORNO AUFTRAG", "EINSATZ STORNIERT". A STORNO of a rental/mobility sub-task is NOT this â that is "none".
316- "none": block is about something else (rental, billing, dispatch metadata header listing driver/plate/location, internal note, e-mail correspondence without status change). 317 318For each lifecycle_events entry: 319- block_index: the number of the block this entry describes (from "[block N]"). 320- occurred_at_local: the wall-clock timestamp printed for that block, formatted "YYYY-MM-DD HH:mm". Empty string if the block has no timestamp. 321- occurred_at_tz: "Europe/Berlin" for ADAC/German partners. Default to "Europe/Berlin" if unsure. 322- evidence_quote: a verbatim quote (max 200 chars) from THAT block. 323- confidence: 0..1. 324- Be tolerant of typos and non-German text (ADAC bodies routinely contain typos like "PLATERN RAIFEN", "PANENORT"). 325- Ignore the dispatch metadata header (driver name, plate, location, diagnosis). That is "none". 326- If a block contains BOTH on-site and resolved signals ("Pannenhelfer eingetroffen ⦠Panne behoben"), emit TWO entries with the same block_index, technician_on_site then roadside_resolved. 327 328The overall "status" field must reflect the NEWEST (last) meaningful block, not the earliest one. 329 330ALSO populate the legacy tow_lifecycle field with the LAST non-"none" entry so older consumers keep working. 331 332Auxiliary signals (aux_events) â status-neutral information that must be visible on the timeline but must NEVER change the case status: 333- "mobility_rental_requested": ADAC asks the booking centre to offer/release a replacement vehicle, or reports the driver is still waiting for one. Typical: "FREIGABE FÃR MIETWAGENORGANISATION", "Kunde benötigt ein Ersatzfahrzeug", "still waiting for a replacement car". 334- "mobility_clarification_requested": ADAC asks to clarify possible replacement mobility with the driver ("Klärung einer möglichen Ersatzmobilität"). 335- "mobility_rental_organized" / "mobility_taxi_organized" / "mobility_uber_organized" / "mobility_hotel_arranged": the alternative transport was ALREADY arranged by ADAC. 336- "waiting_time_reported": a longer waiting time is announced with or without a duration ("Wartezeit ca. 2 Stunden", "längere Wartezeit"). 337- "delay_announced": arrival/tow is delayed, no concrete duration ("VERZÃGERUNG", "verspätet sich"). 338- "destination_changed": the tow destination changed after it had already been set. 339- "mobility_booking_failed": the partner could NOT organise the alternative transport (no provider available, app error) â e.g. "KEIN UBER KANN ORGANISIERT WERDEN, FEHLER IM APP". 340- "callback_requested": the partner asks the booking centre to call back ("BITTE UM RÃCKRUF"). 341- "customer_unreachable": the driver could not be reached â as a note only, not as a failed job. 342Report these even when the same mail also contains a lifecycle stage. Set waiting_minutes whenever a duration is stated (hours â minutes). Do not invent signals: only report what is written. 343 344Unknown wording (unclassified_blocks): if a block clearly reports something operational about this job that fits NEITHER the stage vocabulary NOR the auxiliary list, list it there with a verbatim quote and a one-sentence interpretation instead of silently returning "none". Dispatch header data, billing text, greetings and signatures are NOT unclassified â they are plain "none". 345 346 347Data extraction rules: 348- Extract customer name, license plate, vehicle make, vehicle model, VIN, and external case/order ID if mentioned
349- License plates: extract exactly as written (e.g. "B-AB 1234") 350- Vehicle make/model: e.g. "BMW", "3er", "VW Golf", "Mercedes C-Klasse" 351- External case ID: ADAC Auftragsnummer, Schadennummer, Vorgangsnummer etc. 352- Also determine if the case was handled by an AI agent or a human 353 354You MUST respond using the classify_and_extract tool.\`; 355 356 // ââ Deterministic block segmentation ââ 357 // ADAC mails are cumulative logs; the NEWEST block sits at the very end, 358 // so a naive slice(0, 3000) would drop the most recent status. 359 const segments = segmentAdacEmail(body.email_text); 360 const blockByIndex = new Map<number, AdacEmailBlock>( 361 segments.blocks.map((b) => [b.index, b]), 362 ); 363 const renderedBody = segments.blocks.length > 0 364 ? renderBlocksForPrompt(segments) 365 : body.email_text.slice(0, 6000); 366 367 // ââ Deterministic header / workshop / ETA extraction ââ 368 // The ADAC header is a fixed-width \`LABEL : VALUE\` document; regex beats 369 // the LLM here and gives us fields the model never returned (location, 370 // coordinates, driver phone, destination workshop, promised ETA). 371 const isAdac = (body.provider || "").toUpperCase().includes("ADAC"); 372 const parsedHeader = isAdac ? parseAdacHeader(body.email_text) : null; 373 const parsedWorkshop = isAdac ? parseAdacWorkshop(body.email_text) : null; 374 const parsedEtaMinutes = isAdac ? parseAdacEtaMinutes(body.email_text) : null; 375 376 377 const userPrompt = \`Email subject: \${body.email_subject || "(none)"} 378Email body: 379\${renderedBody}\`; 380 381 382 const classifyTool = { 383 name: "classify_and_extract", 384 description: "Classify the email status and extract case-relevant data", 385 parameters: { 386 type: "object", 387 properties: { 388 status: { 389 type: "string", 390 enum: VALID_STATUSES, 391 description: "The internal case status this email implies", 392 }, 393 confidence: { 394 type: "number", 395 description: "Confidence score 0.0-1.0", 396 }, 397 reasoning: { 398 type: "string", 399 description: "Brief explanation of classification", 400 }, 401 handled_by: { 402 type: "string", 403 enum: ["ai", "human", "unknown"], 404 description: "Whether the case was handled by AI agent or human", 405 }, 406 customer_name: { 407 type: "string", 408 description: "Customer/driver name if mentioned", 409 }, 410 phone_number: { 411 type: "string", 412 description: "Customer phone number if mentioned", 413 }, 414 license_plate: { 415 type: "string", 416 description: "Vehicle license plate if mentioned", 417 }, 418 vehicle_make: { 419 type: "string", 420 description: "Vehicle make/brand (e.g. BMW, VW, Mercedes, FORD)", 421 }, 422 vehicle_model: { 423 type: "string", 424 description: "Vehicle model (e.g. 3er, Golf, FOCUS SW 1.0EB AT)", 425 }, 426 vin: { 427 type: "string", 428 description: "Vehicle Identification Number (FIN/VIN) if mentioned, e.g. WF0PXXGCHPSA28949", 429 }, 430 case_reference: { 431 type: "string", 432 description: "External case/order reference number (Vorgangsnummer, Auftragsnummer)", 433 }, 434 rental_contract_number: { 435 type: "string", 436 description: "Rental contract number (Mietvertragsnummer) if mentioned", 437 }, 438 mileage: { 439 type: "string", 440 description: "Vehicle mileage/Kilometerstand if mentioned (e.g. '9430')", 441 }, 442 damage_description: { 443 type: "string", 444 description: "Description of the damage/breakdown (Pannenart), e.g. 'Schneller Luftverlust im Reifen'", 445 }, 446 deployment_recommendation: { 447 type: "string", 448 description: "Deployment/service recommendation (Einsatzmittelempfehlung), e.g. 'Fahrzeug muss abgeschleppt werden'", 449 }, 450 location_street: { 451 type: "string", 452 description: "Street address of incident (Strasse), e.g. 'Mühlenstiege 3'", 453 }, 454 location_city_zip: { 455 type: "string", 456 description: "City and postal code (PLZ/Stadt), e.g. '49808 Lingen (Ems)'", 457 }, 458 location_description: { 459 type: "string", 460 description: "Location description (Standortbezeichnung), e.g. 'StraÃe', 'Parkplatz'", 461 }, 462 location_lat: { 463 type: "string", 464 description: "Latitude coordinate if mentioned", 465 }, 466 location_lon: { 467 type: "string", 468 description: "Longitude coordinate if mentioned", 469 }, 470 remark: { 471 type: "string", 472 description: "General remark/Bemerkung from the email", 473 }, 474 incident_date: { 475 type: "string", 476 description: "Date of the incident (Datum) if mentioned", 477 }, 478 incident_time: { 479 type: "string", 480 description: "Time/urgency of the incident (Uhrzeit) if mentioned, e.g. 'sofort'", 481 }, 482 tow_lifecycle: { 483 type: "object", 484 description: "ADAC physical tow lifecycle stage that this email reports. Used to drive structured timeline events.", 485 properties: { 486 stage: { 487 type: "string", 488 enum: [ 489 "dispatch_confirmed", 490 "technician_on_site", 491 "roadside_resolved", 492 "no_fault_found", 493 "towed_to_workshop", 494 "handed_off_to_manufacturer", 495 "tow_empty_run", 496 "tow_failed", 497 "tow_cancelled", 498 "none", 499 ], 500 501 description: "Physical tow stage. See system prompt for German-tense rules. Default 'none' if not a tow status email.", 502 }, 503 is_future_tense: { 504 type: "boolean", 505 description: "True if the email talks about the tow in future tense (e.g. 'WIRD ABGESCHLEPPT'). Future-tense always means stage='dispatch_confirmed', never 'towed_to_workshop'.", 506 }, 507 destination_workshop_named: { 508 type: "boolean", 509 description: "True if a planned destination workshop is named (e.g. 'Fahrzeug steht bei der Werkstatt: A.T.U â¦'). Naming a destination does
509NOT mean the vehicle has arrived.", 510 }, 511 evidence_quote: { 512 type: "string", 513 description: "Verbatim German quote from the email body that justifies the chosen stage. If you cannot quote a phrase, set stage='none'.", 514 }, 515 }, 516 required: ["stage"], 517 additionalProperties: false, 518 }, 519 lifecycle_events: { 520 type: "array", 521 description: "One entry per numbered block in the segmented body. ADAC emails are cumulative â return every block in chronological order, using stage 'none' for blocks without lifecycle meaning.", 522 items: { 523 type: "object", 524 properties: { 525 block_index: { 526 type: "number", 527 description: "Number of the source block ('[block N]') this entry describes.", 528 }, 529 stage: { 530 type: "string", 531 enum: [ 532 "dispatch_confirmed", 533 "technician_on_site", 534 "roadside_resolved", 535 "no_fault_found", 536 "towed_to_workshop", 537 "handed_off_to_manufacturer", 538 "tow_empty_run", 539 "tow_failed", 540 "tow_cancelled", 541 "none", 542 ], 543 description: "Lifecycle stage for this block. See system prompt.", 544 }, 545 occurred_at_local: { 546 type: "string", 547 description: "Wall-clock timestamp printed above this block, formatted 'YYYY-MM-DD HH:mm'. Empty string if the block has no timestamp.", 548 }, 549 occurred_at_tz: { 550 type: "string", 551 description: "IANA timezone of occurred_at_local. Default 'Europe/Berlin'.", 552 }, 553 evidence_quote: { 554 type: "string", 555 description: "Verbatim quote (â¤200 chars) from THIS block.", 556 }, 557 confidence: { 558 type: "number", 559 description: "0..1.", 560 }, 561 }, 562 required: ["stage"], 563 additionalProperties: false, 564 }, 565 }, 566 567 aux_events: { 568 type: "array", 569 description: 570 "Status-neutral operational signals reported in the mail (replacement mobility, waiting times, delays, destination changes, unreachable customer). These NEVER change the case status â they only enrich the timeline. Return an empty array when the mail contains none.", 571 items: { 572 type: "object", 573 properties: { 574 block_index: { 575 type: "number", 576 description: "Number of the source block ('[block N]') this signal comes from.", 577 }, 578 stage: { 579 type: "string", 580 enum: [ 581 "mobility_rental_requested", 582 "mobility_clarification_requested", 583 "mobility_rental_organized", 584 "mobility_taxi_organized", 585 "mobility_uber_organized", 586 "mobility_hotel_arranged", 587 "mobility_booking_failed", 588 "callback_requested", 589 "waiting_time_reported", 590 "delay_announced", 591 "destination_changed", 592 "customer_unreachable", 593 ], 594 description: 595 "mobility_rental_requested: ADAC asks the booking centre to OFFER/RELEASE a replacement vehicle, or the driver is still waiting for one. mobility_clarification_requested: ADAC asks to CLARIFY replacement mobility with the driver. mobility_*_organized / hotel_arranged: the alternative transport was already arranged. waiting_time_reported: a longer waiting time is announced (e.g. 'Wartezeit ca. 2 Stunden'). delay_announced: the arrival/tow is delayed without a concrete duration. destination_changed: the tow destination was changed after it had been set. mobility_booking_failed: the alternative transport could NOT be organised (no provider, app error). callback_requested: the partner asks for a callback. customer_unreachable: the driver could not be reached (note only, not a failure).", 596 }, 597 occurred_at_local: { 598 type: "string", 599 description: "Timestamp of the block, 'YYYY-MM-DD HH:mm'. Empty string when the block has none.", 600 }, 601 waiting_minutes: { 602 type: "number", 603 description: "Announced waiting/delay duration in MINUTES when a duration is stated (2 hours â 120). Omit when no duration is given.", 604 }, 605 evidence_quote: { 606 type: "string", 607 description: "Verbatim quote (â¤200 chars) from THIS block that justifies the signal.", 608 }, 609 confidence: { 610 type: "number", 611 description: "0..1.", 612 }, 613 }, 614 required: ["stage"], 615 additionalProperties: false, 616 }, 617 }, 618 619 unclassified_blocks: { 620 type: "array", 621 description: 622 "Blocks that clearly report something operational about this job (status, mobility, logistics, customer contact) but do NOT fit any stage in the vocabularies above. Use this instead of silently returning 'none', so unknown partner wording can be reviewed. Do NOT list dispatch header data, billing text, greetings or signatures here.", 623 items: { 624 type: "object", 625 properties: { 626 block_index: { type: "number", description: "Number of the source block." }, 627 quote: { type: "string", description: "Verbatim quote (â¤200 chars) of the unmapped statement." }, 628 interpretation: { type: "string", description: "One short sentence: what this block seems to report." }, 629 }, 630 required: ["quote"], 631 additionalProperties: false, 632 }, 633 }, 634 635 }, 636 required: ["status", "confidence", "reasoning", "handled_by"], 637 additionalProperties: false, 638 }, 639 }; 640 641 let modelCall; 642 try { 643 modelCall = await callInboundModel<Record<string, any>>( 644 systemPrompt, 645 userPrompt, 646 classifyTool, 647 { model: "gemini-2.5-flash", maxOutputTokens: 8192 }, 648 ); 649 } catch (e) { 650 const status = e instanceof InboundModelError ? e.status : 500; 651 console.error("[CLASSIFY-EMAIL] AI error:", status, (e as Error).message); 652 if (status === 429) return jsonResponse({ error: "Rate limit exceeded, try again later" }, 429); 653 if (status === 402) return jsonResponse({ error: "AI credits exhausted" }, 402); 654 return jsonResponse({ error: "AI classification failed", details: (e as Error).message }, 500); 655 } 656 657 const result = modelCall.args; 658 console.log( 659 \`[CLASSIFY-EMAIL] Result (\${modelCall.provider}/\${modelCall.model}):\`, 660 JSON.stringify(result), 661 ); 662 663 const { status: aiStatus, confidence, reasoning, handled_by: detectedHandler } = result; 664 665 // ââ Normalize lifecycle events against the deterministic blocks ââ 666 // One ADAC mail carries several status updates. Each returned event is 667 // anchored to its source block so the timestamp and the idempotency key 668 // come from the mail itself, not from the model. 669 const rawLifecycleEvents = Array.isArray(result.lifecycle_events) ? result.lifecycle_events : []; 670 const normalizedEvents = rawLifecycleEvents 671 .filter((e: any) => e && typeof e.stage === "string") 672 .map((e: any, i: number) => { 673 const idx = typeof e.block_index === "number" ? e.block_index : null; 674 const block = idx !== null ? blockByIndex.get(idx) : undefined; 675 const llmTs = typeof e.occurred_at_local === "string" && e.occurred_at_local.trim() 676 ? e.occurred_at_local.trim() 677 : null; 678 return { 679 stage: e.stage as string,
680 blockIndex: idx, 681 blockHash: block?.hash ?? null, 682 // The printed block timestamp always wins over the model's copy. 683 occurredAtLocal: block?.localTs ?? llmTs, 684 occurredAtTz: typeof e.occurred_at_tz === "string" && e.occurred_at_tz.trim() 685 ? e.occurred_at_tz.trim() 686 : "Europe/Berlin", 687 evidenceQuote: typeof e.evidence_quote === "string" ? e.evidence_quote : null, 688 confidence: typeof e.confidence === "number" ? e.confidence : 1, 689 order: i, 690 }; 691 }); 692 693 // ââ Safety net: the model returned no usable lifecycle event ââ 694 // Fall back to deterministic keyword detection over the same blocks so the 695 // case timeline never stays empty for a real status mail. 696 let lifecycleSource: "llm" | "keyword_fallback" | "none" = "llm"; 697 let events = normalizedEvents; 698 if (!events.some((e: any) => e.stage && e.stage !== "none")) { 699 let hits = detectStagesByKeyword(segments.blocks); 700 let fromHeader = false;
701 if (hits.length === 0) { 702 // Some ADAC mails carry the status only in the subject / dispatch 703 // header (e.g. "Information zur erfolgreichen Pannenhilfe"), never in 704 // a timestamped block. Scan those as a synthetic block 0. 705 const headerText = \`\${body.email_subject || ""} \${segments.header || ""}\`.trim(); 706 if (headerText) { 707 hits = detectStagesByKeyword([ 708 { index: 0, localTs: null, text: headerText, hash: "header", source: "header" as const }, 709 ]); 710 fromHeader = hits.length > 0; 711 } 712 } 713 if (hits.length > 0) { 714 lifecycleSource = "keyword_fallback"; 715 events = hits.map((h, i) => { 716 const block = fromHeader ? undefined : blockByIndex.get(h.blockIndex); 717 return { 718 stage: h.stage, 719 blockIndex: fromHeader ? null : h.blockIndex, 720 blockHash: fromHeader ? \`hdr-\${h.stage}\` : (block?.hash ?? null), 721 occurredAtLocal: (fromHeader ? parsedHeader?.reportedAtLocal : block?.localTs) ?? null, 722 occurredAtTz: "Europe/Berlin", 723 evidenceQuote: h.evidence.slice(0, 200), 724 confidence: 0.6, 725 order: i, 726 }; 727 }); 728 console.log( 729 \`[CLASSIFY-EMAIL] Keyword fallback produced \${events.length} lifecycle event(s) (source=\${fromHeader ? "header" : "blocks"})\`, 730 ); 731 } else { 732 lifecycleSource = "none"; 733 console.warn( 734 \`[CLASSIFY-EMAIL] no_lifecycle_event â \${segments.blocks.length} block(s), strategy=\${segments.strategy}\`, 735 ); 736 } 737 } 738 739 // ââ Auxiliary (status-neutral) events: mobility, waiting time, ETA, destination ââ 740 // Primary source is the MODEL (it understands unseen wording); the keyword 741 // detector runs afterwards as a safety net for stages the model missed. 742 // Either way these events must NEVER influence the case status. 743 const auxEvents: any[] = []; 744 const auxSeen = new Set<string>(); 745 let auxFromLlm = 0; 746 let auxFromKeyword = 0; 747 748 // Model aux stages that reuse an existing event kind. 749 const AUX_STAGE_ALIAS: Record<string, string> = { 750 delay_announced: "waiting_time_reported", 751 destination_changed: "tow_destination_set", 752 }; 753 // Reported by the model for context only â no timeline event exists yet. 754 // Every model aux stage now has a timeline event; nothing is audit-only. 755 const AUX_STAGE_AUDIT_ONLY = new Set<string>(); 756 const auditOnlySignals: any[] = []; 757 758 if (isAdac) { 759 const rawAux = Array.isArray(result.aux_events) ? result.aux_events : []; 760 for (const a of rawAux) { 761 if (!a || typeof a.stage !== "string") continue; 762 const block = typeof a.block_index === "number" ? blockByIndex.get(a.block_index) : undefined; 763 const quote = typeof a.evidence_quote === "string" ? a.evidence_quote.slice(0, 200) : null; 764 if (AUX_STAGE_AUDIT_ONLY.has(a.stage)) { 765 auditOnlySignals.push({ stage: a.stage, quote }); 766 continue; 767 } 768 const stage = AUX_STAGE_ALIAS[a.stage] ?? a.stage; 769 if (!isAuxStage(stage)) continue; 770 // One event per stage per mail â cumulative blocks repeat the phrase. 771 if (auxSeen.has(stage)) continue; 772 auxSeen.add(stage); 773 774 const vars: Record<string, unknown> = {}; 775 if (stage === "waiting_time_reported" && typeof a.waiting_minutes === "number" && a.waiting_minutes > 0) { 776 if (a.waiting_minutes % 60 === 0) vars.waiting_hours = a.waiting_minutes / 60; 777 else vars.waiting_minutes = a.waiting_minutes; 778 } 779 if (a.stage !== stage) vars.reported_as = a.stage; 780 781 auxFromLlm++; 782 auxEvents.push({ 783 stage, 784 blockIndex: typeof a.block_index === "number" ? a.block_index : null, 785 blockHash: block?.hash ?? null, 786 occurredAtLocal: block?.localTs 787 ?? (typeof a.occurred_at_local === "string" && a.occurred_at_local.trim() ? a.occurred_at_local.trim() : null), 788 occurredAtTz: "Europe/Berlin", 789 evidenceQuote: quote, 790 confidence: typeof a.confidence === "number" ? a.confidence : 0.8, 791 templateVars: vars, 792 detectedBy: "llm", 793 }); 794 } 795 796 const auxHits = detectAuxStagesByKeyword(segments.blocks); 797 for (const h of auxHits) { 798 if (auxSeen.has(h.stage)) continue; 799 auxSeen.add(h.stage); 800 const block = blockByIndex.get(h.blockIndex); 801 // For waiting-time notices, pull the announced duration out of the 802 // evidence so the timeline can render "> 2 h". 803 let vars: Record<string, unknown> = {}; 804 if (h.stage === "waiting_time_reported") { 805 const hours = h.evidence.match(/(\\d{1,2})\\s*(STUNDE|STD|H)\\w*/i); 806 const mins = h.evidence.match(/(\\d{1,3})\\s*MIN/i); 807 if (hours) vars = { waiting_hours: parseInt(hours[1], 10) }; 808 else if (mins) vars = { waiting_minutes: parseInt(mins[1], 10) }; 809 } 810 auxFromKeyword++; 811 auxEvents.push({ 812 stage: h.stage, 813 blockIndex: h.blockIndex, 814 blockHash: block?.hash ?? null, 815 occurredAtLocal: block?.localTs ?? null, 816 occurredAtTz: "Europe/Berlin", 817 evidenceQuote: h.evidence.slice(0, 200), 818 confidence: 0.8, 819 templateVars: vars, 820 detectedBy: "keyword", 821 }); 822 } 823 824 if (parsedEtaMinutes) { 825 auxEvents.push({ 826 stage: "eta_promised", 827 blockIndex: null, 828 blockHash: \`eta\${parsedEtaMinutes}\`, 829 occurredAtLocal: parsedHeader?.reportedAtLocal ?? null, 830 occurredAtTz: "Europe/Berlin", 831 evidenceQuote: \`ETA \${parsedEtaMinutes} min\`, 832 confidence: 0.9, 833 templateVars: { eta_minutes: parsedEtaMinutes, provider: "ADAC" }, 834 }); 835 } 836 } 837 838 // Only real tow-lifecycle stages may drive the case status. 839 const meaningfulEvents = events 840 .filter((e: any) =>
840 e.stage && e.stage !== "none" && !isAuxStage(e.stage)) 841 .sort((a: any, b: any) => 842 (a.occurredAtLocal || "").localeCompare(b.occurredAtLocal || "") || 843 (a.blockIndex ?? a.order) - (b.blockIndex ?? b.order) 844 ); 845 const latestEvent = meaningfulEvents.length > 0 846 ? meaningfulEvents[meaningfulEvents.length - 1] 847 : null; 848 849 // Status derived from the NEWEST block. Every stage maps to exactly one 850 // status so the same block can never end up as "escalated" once and 851 // "waiting_for_partner" the next time. tow_cancelled stays with the model 852 // because the sub-task STORNO guards below decide what happens with it. 853 const STAGE_STATUS: Record<string, string> = { 854 dispatch_confirmed: "dispatched", 855 technician_on_site: "in_progress", 856 roadside_resolved: "resolved", 857 no_fault_found: "resolved", 858 towed_to_workshop: "resolved", 859 handed_off_to_manufacturer: "dispatched", 860 // Vehicle is still on site and needs a new dispatch. 861 tow_empty_run: "waiting_for_partner", 862 tow_failed: "waiting_for_partner", 863 }; 864 const stageStatus = latestEvent ? STAGE_STATUS[latestEvent.stage] : undefined; 865 const classifiedStatus = stageStatus ?? aiStatus; 866 if (stageStatus && stageStatus !== aiStatus) { 867 console.log( 868 \`[CLASSIFY-EMAIL] Status from newest block (\${latestEvent?.stage} @ \${latestEvent?.occurredAtLocal}) \` + 869 \`â "\${stageStatus}" (whole-mail classification was "\${aiStatus}")\`, 870 ); 871 } 872 873 // The vehicle only counts as delivered once a tow-completion stage was seen. 874 const towCompleted = meaningfulEvents.some((e: any) => e.stage === "towed_to_workshop"); 875 if (parsedWorkshop) { 876 auxEvents.push({ 877 stage: towCompleted ? "tow_destination_reached" : "tow_destination_set", 878 blockIndex: null, 879 blockHash: \`ws\${stableHash(\`\${parsedWorkshop.name}|\${parsedWorkshop.address}|\${towCompleted}\`)}\`, 880 occurredAtLocal: latestEvent?.occurredAtLocal ?? parsedHeader?.reportedAtLocal ?? null, 881 occurredAtTz: "Europe/Berlin", 882 evidenceQuote: [parsedWorkshop.name, parsedWorkshop.address].filter(Boolean).join(", ").slice(0, 200), 883 confidence: 0.9, 884 templateVars: { 885 workshop_name: parsedWorkshop.name, 886 workshop_address: parsedWorkshop.address, 887 workshop_city: parsedWorkshop.city, 888 workshop_phone: parsedWorkshop.phone, 889 destination_kind: towCompleted ? "actual" : "planned", 890 }, 891 }); 892 } 893 894 // ââ Classification audit ââ 895 // One compact record per classified mail so LLM vs keyword coverage and 896 // unknown partner wording can be measured without ad-hoc SQL. 897 const unclassifiedBlocks = (Array.isArray(result.unclassified_blocks) ? result.unclassified_blocks : []) 898 .filter((u: any) => u && typeof u.quote === "string" && u.quote.trim()) 899 .slice(0, 10) 900 .map((u: any) => ({ 901 block_index: typeof u.block_index === "number" ? u.block_index : null, 902 quote: String(u.quote).slice(0, 200), 903 interpretation: typeof u.interpretation === "string" ? u.interpretation.slice(0, 200) : null, 904 })); 905 906 const classificationAudit = { 907 classified_at: new Date().toISOString(), 908 provider: modelCall.provider, 909 model: modelCall.model, 910 lifecycle_source: lifecycleSource, 911 blocks_detected: segments.blocks.length, 912 segmentation_strategy: segments.strategy, 913 stages: meaningfulEvents.map((e: any) => e.stage), 914 aux_stages: auxEvents.map((e: any) => e.stage), 915 aux_from_llm: auxFromLlm, 916 aux_from_keyword: auxFromKeyword, 917 unclassified_count: unclassifiedBlocks.length, 918 unclassified_blocks: unclassifiedBlocks, 919 audit_only_signals: auditOnlySignals, 920 }; 921 if (unclassifiedBlocks.length > 0) { 922 console.warn( 923 \`[CLASSIFY-EMAIL] \${unclassifiedBlocks.length} unclassified block(s) for case \${body.case_id}:\`, 924 JSON.stringify(unclassifiedBlocks), 925 ); 926 } 927 928 // ââ Dry-run: classify only, never touch the database ââ 929 // Used by the internal ADAC test harness to validate block segmentation 930 // and multi-event extraction against real production mails. 931 if (body.dry_run) { 932 return jsonResponse({ 933 dry_run: true, 934 case_id: body.case_id, 935 segmentation_strategy: segments.strategy,
936 blocks: segments.blocks.map((b) => ({ 937 index: b.index, 938 local_ts: b.localTs, 939 hash: b.hash, 940 source: b.source, 941 preview: b.text.slice(0, 160), 942 })), 943 ai_status: aiStatus, 944 classified_status: classifiedStatus, 945 confidence, 946 reasoning, 947 lifecycle_source: lifecycleSource, 948 lifecycle_events: events, 949 meaningful_events: meaningfulEvents, 950 latest_event: latestEvent, 951 aux_events: auxEvents, 952 classification_audit: classificationAudit, 953 parsed_header: parsedHeader, 954 parsed_workshop: parsedWorkshop, 955 parsed_eta_minutes: parsedEtaMinutes, 956 destination_kind: parsedWorkshop ? (towCompleted ? "actual" : "planned") : null, 957 legacy_tow_lifecycle: result.extracted_data?.tow_lifecycle ?? null, 958 }); 959 } 960 961 // Persist the audit on the source mail (best effort, never fatal). 962 if (body.inbound_email_id) { 963 try { 964 const { data: cur } = await supabase 965 .from("inbound_emails") 966 .select("classification_result") 967 .eq("id", body.inbound_email_id) 968 .maybeSingle(); 969 await supabase 970 .from("inbound_emails") 971 .update({ 972 classification_result: { 973 ...((cur?.classification_result as Record<string, unknown>) || {}), 974 lifecycle_source: lifecycleSource, 975 email_classification_audit: classificationAudit, 976 }, 977 }) 978 .eq("id", body.inbound_email_id); 979 } catch (e) { 980 console.warn("[CLASSIFY-EMAIL] audit persist failed:", (e as Error).message); 981 } 982 } 983 984 985 986 987 988 989 // ââ Data enrichment: compare extracted data against case ââ 990 const dataChanges: Array<{ field: string; old: string | null; new: string; source: string }> = []; 991 const caseUpdates: Record<string, unknown> = {}; 992 993 // Map AI result fields to case columns. Provider task ids extracted from 994 // status emails belong on rsa_towing_requests.external_towing_task_id 995 // (handled by linkAdacTaskAndEmit below); the customer-facing 996 // reference is set at case creation and is never overwritten by a 997 // status email. 998 const fieldMapping: Record<string, { aiKey: string; normalize?: (v: string) => string | null }> = { 999 customer_name: { aiKey: "customer_name" }, 1000 // The LLM copies the plate "exactly as written" from the fixed-width header, 1001 // so a second column ("HHOA781E S") ends up glued to the plate. Cut the 1002 // column first and prefer the deterministic header parse when available. 1003 vehicle_license_plate: { 1004 aiKey: "license_plate", 1005 normalize: (v) => normalizePlateDisplay(parsedHeader?.licensePlate || stripFixedWidthColumn(v)), 1006 }, 1007 vehicle_make: { aiKey: "vehicle_make", normalize: (v) => canonicalBrand(v) }, 1008 vehicle_model: { aiKey: "vehicle_model", normalize: (v) => canonicalModel(v, (currentCase as any)?.vehicle_make ?? null) }, 1009 vehicle_vin: { aiKey: "vin" }, 1010 }; 1011 1012 // Vehicle brand/model on the case come from the verified fleet feed at 1013 // intake. A partner status mail must never overwrite them â it may only 1014 // fill a gap. Everything else stays "email data is trusted". 1015 const FILL_IF_EMPTY_ONLY = new Set(["vehicle_make", "vehicle_model"]); 1016 1017 for (const [caseCol, config] of Object.entries(fieldMapping)) { 1018 const extractedValue = result[config.aiKey]; 1019 if (!extractedValue || typeof extractedValue !== "string" || !extractedValue.trim()) continue; 1020 1021 let newValue = config.normalize ? config.normalize(extractedValue) : extractedValue.trim(); 1022 if (!newValue) continue; 1023 1024 const currentValue = (currentCase as any)[caseCol] as string | null; 1025 if (FILL_IF_EMPTY_ONLY.has(caseCol) && currentValue && currentValue.trim()) continue; 1026 1027 // Resolve the brand against the persisted brand catalogue so we only 1028 // ever store a spelling that exists in public.vehicle_makes. 1029 if (caseCol === "vehicle_make") { 1030 newValue = (await resolveBrandFromCatalogue(supabase, newValue)) || newValue; 1031 } 1032 1033 // Update if: case has no value, or value is different (email data is trusted) 1034 if (!currentValue || currentValue.trim().toLowerCase() !== newValue.trim().toLowerCase()) { 1035 dataChanges.push({ 1036 field: caseCol, 1037 old: currentValue || null, 1038 new: newValue, 1039 source: body.provider ? \`\${body.provider.toLowerCase()}_email\` : "email", 1040 }); 1041 caseUpdates[caseCol] = newValue; 1042 } 1043 } 1044 1045 1046 // ââ Header enrichment (fill-if-empty only) ââ 1047 // The deterministic header parser is additive: it never overwrites data 1048 // the case already has, it only fills gaps the intake left open. 1049 if (parsedHeader) { 1050 const fillIfEmpty = (col: string, value: string | number | null | undefined) => { 1051 if (value === null || value === undefined || value === "") return; 1052 if ((currentCase as any)[col] !== null && (currentCase as any)[col] !== undefined && (currentCase as any)[col] !== "") return; 1053 caseUpdates[col] = value; 1054 dataChanges.push({ 1055 field: col,
1056 old: null, 1057 new: String(value), 1058 source: "adac_email_header", 1059 }); 1060 }; 1061 fillIfEmpty("customer_phone", parsedHeader.driverPhone); 1062 fillIfEmpty("location_address", parsedHeader.location?.address); 1063 fillIfEmpty("location_city", parsedHeader.location?.city); 1064 fillIfEmpty("location_postal_code", parsedHeader.location?.postalCode); 1065 fillIfEmpty("location_lat", parsedHeader.location?.lat); 1066 fillIfEmpty("location_lng", parsedHeader.location?.lng); 1067 } 1068 1069 // ââ Mobility KPI calibration ââ 1070 // An ADAC-confirmed Uber / taxi / rental car counts as mobility provided. 1071 // auxEvents carry the raw stage names ("mobility_<means>_organized"); 1072 // accept the mapped event codes too, in case callers pass those. 1073 const MOBILITY_STAGES = new Set([ 1074 "mobility_uber_organized", 1075 "mobility_taxi_organized", 1076 "mobility_rental_organized", 1077 "mobility_organized_uber", 1078 "mobility_organized_taxi", 1079 "mobility_organized_rental", 1080 ]); 1081 const mobilityConfirmed = auxEvents.some((e) => MOBILITY_STAGES.has(e.stage)); 1082 if (mobilityConfirmed && !currentCase.mobility_provided) { 1083 caseUpdates.mobility_provided = true; 1084 dataChanges.push({ 1085 field: "mobility_provided", 1086 old: "false", 1087 new: "true", 1088 source: "adac_email", 1089 }); 1090 } 1091 1092 1093 // Update handled_by if detected and currently unknown 1094 if (detectedHandler && detectedHandler !== "unknown" && currentCase.handled_by === "unknown") { 1095 caseUpdates.handled_by = detectedHandler; 1096 console.log(\`[CLASSIFY-EMAIL] Updated handled_by to "\${detectedHandler}" for case \${body.case_id}\`); 1097 } 1098 1099 // ââ Partner auto-assignment ââ 1100 // Only auto-assign from inbound email when (a) the case has no partner yet 1101 // AND (b) we have NOT already API-dispatched to a partner via 1102 // rsa_towing_requests. The API dispatch is the source of truth â an 1103 // inbound ADAC-branded status mail must not flip a Hlpy-dispatched case 1104 // to ADAC just because the on-the-ground tower also operates under ADAC. 1105 const providerName = (body.provider || "").trim(); 1106 let hasApiDispatch = false; 1107 if (providerName && !currentCase.assigned_partner_id) { 1108 const { data: existingTow } = await supabase 1109 .from("rsa_towing_requests") 1110 .select("id, assigned_partner_id") 1111 .eq("case_id", body.case_id) 1112 .not("assigned_partner_id", "is", null) 1113 .limit(1); 1114 hasApiDispatch = !!(existingTow && existingTow.length > 0); 1115 if (hasApiDispatch) { 1116 console.log( 1117 \`[CLASSIFY-EMAIL] Skipping partner auto-assign for case \${body.case_id} â already API-dispatched (provider="\${providerName}")\`, 1118 ); 1119 } 1120 } 1121 if (providerName && !currentCase.assigned_partner_id && !hasApiDispatch) { 1122 // Look up partner by name (case-insensitive partial match) 1123 const { data: partnerMatch } = await supabase 1124 .from("partners") 1125 .select("id, name") 1126 .ilike("name", \`%\${providerName}%\`) 1127 .limit(1) 1128 .single(); 1129 1130 if (partnerMatch) { 1131 // Verify partner is linked to this organization 1132 const { data: orgLink } = await supabase 1133 .from("organization_partners") 1134 .select("id") 1135 .eq("organization_id", currentCase.organization_id) 1136 .eq("partner_id", partnerMatch.id) 1137 .limit(1) 1138 .single(); 1139 1140 if (orgLink) { 1141 caseUpdates.assigned_partner_id = partnerMatch.id; 1142 caseUpdates.assigned_at = new Date().toISOString(); 1143 console.log(\`[CLASSIFY-EMAIL] Auto-assigned partner "\${partnerMatch.name}" (\${partnerMatch.id}) to case \${body.case_id}\`); 1144 } else { 1145 console.warn(\`[CLASSIFY-EMAIL] Partner "\${partnerMatch.name}" found but not linked to org \${currentCase.organization_id}\`); 1146 } 1147 } else { 1148 console.warn(\`[CLASSIFY-EMAIL] No partner found matching provider "\${providerName}"\`); 1149 } 1150 } 1151 1152 // ââ ADAC: persist Vorgangsnummer + emit structured tow events ââ 1153 // ADAC dispatch is e-mail-based, so external_towing_task_id is NULL on 1154 // rsa_towing_requests until their first reply arrives. Link it now and 1155 // emit tow_request_confirmed/dispatched/completed so the timeline lines 1156 // up with Hlpy/MSA. Idempotent. 1157 if ((body.provider || "").toUpperCase().includes("ADAC")) {
1158 try { 1159 // Resolve the email's actual arrival time so emitted case_events 1160 // are timestamped when ADAC sent the mail â not when the classifier 1161 // happened to run. Falls back to now() inside the helper if unknown. 1162 let occurredAt: string | undefined; 1163 if (body.inbound_email_id) { 1164 const { data: ie } = await supabase 1165 .from("inbound_emails") 1166 .select("received_at, created_at") 1167 .eq("id", body.inbound_email_id) 1168 .maybeSingle(); 1169 const ts = (ie as any)?.received_at ?? (ie as any)?.created_at; 1170 if (ts) occurredAt = new Date(ts).toISOString(); 1171 } 1172 1173 // Prefer the multi-event lifecycle array (one entry per block in the 1174 // cumulative ADAC body). Fall back to the legacy single tow_lifecycle. 1175 // Auxiliary events (mobility, hotel, ETA, destination) ride along â 1176 // they enrich the timeline but never drive the case status. 1177 const emitEvents = [...events, ...auxEvents].map((e: any) => ({ 1178 stage: e.stage, 1179 occurredAtLocal: e.occurredAtLocal, 1180 occurredAtTz: e.occurredAtTz, 1181 evidenceQuote: e.evidenceQuote, 1182 confidence: e.confidence, 1183 blockHash: e.blockHash, 1184 blockIndex: e.blockIndex, 1185 templateVars: e.templateVars ?? null, 1186 })); 1187 1188 1189 await linkAdacTaskAndEmit(supabase, { 1190 caseId: body.case_id, 1191 classifiedStatus, 1192 subject: body.email_subject, 1193 body: body.email_text, 1194 extractedCaseReference: result.case_reference || null, 1195 clientCaseId: (currentCase as any).client_case_id || null, 1196 events: emitEvents.length > 0 ? emitEvents : undefined, 1197 // Back-compat single-stage path for callers that only set tow_lifecycle. 1198 llmStage: emitEvents.length === 0 ? (result.tow_lifecycle?.stage ?? null) : null, 1199 llmStageEvidence: emitEvents.length === 0 ? (result.tow_lifecycle?.evidence_quote ?? null) : null, 1200 inboundEmailId: body.inbound_email_id ?? null, 1201 occurredAt, 1202 }); 1203 1204 // ââ Destination workshop: planned vs. actually reached ââ 1205 // Planned = the workshop we/ADAC selected up front. Actual = where the 1206 // tow truck really delivered the vehicle (only once a tow completion 1207 // stage was seen). Both are kept so we can measure deviations. 1208 if (parsedWorkshop) { 1209 const { data: towRow } = await supabase 1210 .from("rsa_towing_requests") 1211 .select("id, workshop_name, actual_workshop_name") 1212 .eq("case_id", body.case_id) 1213 .order("created_at", { ascending: false }) 1214 .limit(1) 1215 .maybeSingle(); 1216 1217 if (towRow) { 1218 const towUpdates: Record<string, unknown> = {}; 1219 if (!towRow.workshop_name) { 1220 towUpdates.workshop_name = parsedWorkshop.name; 1221 towUpdates.workshop_address = parsedWorkshop.address; 1222 towUpdates.workshop_city = parsedWorkshop.city; 1223 towUpdates.workshop_phone = parsedWorkshop.phone; 1224 towUpdates.workshop_source = "adac_email"; 1225 } 1226 if (towCompleted) { 1227 towUpdates.actual_workshop_name = parsedWorkshop.name; 1228 towUpdates.actual_workshop_address = parsedWorkshop.address; 1229 towUpdates.actual_workshop_city = parsedWorkshop.city; 1230 towUpdates.actual_workshop_phone = parsedWorkshop.phone; 1231 towUpdates.actual_destination_source = "adac_email"; 1232 towUpdates.actual_destination_confirmed_at = new Date().toISOString(); 1233 } 1234 if (Object.keys(towUpdates).length > 0) { 1235 const { error: towErr } = await supabase 1236 .from("rsa_towing_requests") 1237 .update(towUpdates) 1238 .eq("id", towRow.id); 1239 if (towErr) { 1240 console.warn(\`[CLASSIFY-EMAIL] workshop update failed for case \${body.case_id}:\`, towErr.message); 1241 } 1242 } 1243 } 1244 } 1245 1246 // ââ Provider ETA ("BIS 45 MIN") â towing request ââ 1247 // Always overwrite: the newest ADAC mail carries the most current ETA. 1248 if (parsedEtaMinutes) { 1249 const { data: etaTow } = await supabase 1250 .from("rsa_towing_requests") 1251 .select("id") 1252 .eq("case_id", body.case_id) 1253 .order("created_at", { ascending: false }) 1254 .limit(1) 1255 .maybeSingle(); 1256 1257 if (etaTow?.id) { 1258 const { error: etaErr } = await supabase 1259 .from("rsa_towing_requests") 1260 .update({ 1261 provider_eta_minutes: parsedEtaMinutes, 1262 provider_eta_at: new Date(Date.now() + parsedEtaMinutes * 60_000).toISOString(), 1263 provider_eta_source: "adac_email", 1264 }) 1265 .eq("id", etaTow.id); 1266 if (etaErr) { 1267 console.warn(\`[CLASSIFY-EMAIL] ETA update failed for case \${body.case_id}:\`, etaErr.message); 1268 } 1269 } 1270 } 1271 1272 // Mirror the towing lifecycle onto the canonical mobility service row 1273 // (index row â rsa_towing_requests). No-op unless mobility_flow_v2. 1274 const mirrorStage = latestEvent?.stage ?? result.tow_lifecycle?.stage ?? null; 1275 if (mirrorStage) { 1276 const { data: mirrorTow } = await supabase 1277 .from("rsa_towing_requests") 1278 .select("id") 1279 .eq("case_id", body.case_id) 1280 .order("created_at", { ascending: false }) 1281 .limit(1) 1282 .maybeSingle(); 1283 if (mirrorTow?.id) { 1284 await mirrorTowingStatus(supabase, { 1285 towingRequestId: mirrorTow.id, 1286 caseId: body.case_id, 1287 rawStatus: mirrorStage, 1288 payloadPatch: { source: "adac_email", inbound_email_id: body.inbound_email_id ?? null }, 1289 }); 1290 } 1291 } 1292 } catch (e) { 1293 console.warn(\`[CLASSIFY-EMAIL] linkAdacTaskAndEmit failed for case \${body.case_id}:\`, (e as Error).message); 1294 } 1295 1296 } 1297
1298 // ââ Clear any pending status-inquiry / escalation timer ââ 1299 // ANY matched inbound partner email means the case is alive again; reset 1300 // the inquiry clock so the watcher does not escalate it. This must run 1301 // regardless of the detected provider string (classification sometimes 1302 // leaves \`provider\` empty even for ADAC mails, which previously skipped 1303 // this block and led to unnecessary status inquiries). 1304 try { 1305 // Use the email's actual arrival time (not processing time) so the 1306 // 72h watcher clock starts when the partner actually replied. 1307 let replyAt = new Date().toISOString(); 1308 if (body.inbound_email_id) { 1309 const { data: replyEmail } = await supabase 1310 .from("inbound_emails") 1311 .select("received_at, created_at") 1312 .eq("id", body.inbound_email_id) 1313 .maybeSingle(); 1314 const ts = (replyEmail as any)?.received_at ?? (replyEmail as any)?.created_at; 1315 if (ts) replyAt = new Date(ts).toISOString(); 1316 } 1317 // Update ALL towing requests of the case, not just the newest one: a case 1318 // can carry superseded duplicate tow rows, and a stale open row would 1319 // otherwise keep triggering status inquiries after ADAC already replied. 1320 const { error: replyErr } = await supabase 1321 .from("rsa_towing_requests") 1322 .update({ adac_last_reply_at: replyAt, adac_inquiry_sent_at: null }) 1323 .eq("case_id", body.case_id); 1324 if (replyErr) { 1325 console.warn( 1326 \`[CLASSIFY-EMAIL] Failed to reset inquiry timer for case \${body.case_id}:\`, 1327 replyErr.message, 1328 ); 1329 } else { 1330 console.log(\`[CLASSIFY-EMAIL] Reset inquiry timer for case \${body.case_id}\`); 1331 } 1332 } catch (e) { 1333 console.warn(\`[CLASSIFY-EMAIL] Inquiry reset failed for case \${body.case_id}:\`, (e as Error).message); 1334 } 1335 1336 if (Object.keys(caseUpdates).length > 0) { 1337 const { error: updateErr } = await supabase.from("cases").update(caseUpdates).eq("id", body.case_id); 1338 if (updateErr) { 1339 console.error(\`[CLASSIFY-EMAIL] Failed to update case \${body.case_id}:\`, updateErr.message); 1340 } else { 1341 console.log(\`[CLASSIFY-EMAIL] Updated case \${body.case_id} with:\`, Object.keys(caseUpdates)); 1342 } 1343 } 1344 1345 // Log data enrichment changelog if there were changes 1346 if (dataChanges.length > 0) { 1347 await supabase.from("case_status_history").insert({ 1348 case_id: body.case_id, 1349 old_status: currentCase.status, 1350 new_status: currentCase.status, 1351 source: "email_enrichment", 1352 notes: body.provider ? \`Data updated from \${body.provider} email\` : "Data updated from email", 1353 metadata: { 1354 type: "data_enrichment", 1355 changes: dataChanges, 1356 sender_email: body.sender_email, 1357 email_subject: body.email_subject, 1358 }, 1359 }); 1360 console.log(\`[CLASSIFY-EMAIL] Logged \${dataChanges.length} data changes for case \${body.case_id}\`); 1361 } 1362 1363 // Only auto-update status if confidence is above threshold 1364 const CONFIDENCE_THRESHOLD = 0.7; 1365 let statusUpdated = false; 1366 1367 // ââ GUARD 1: Sub-task STORNO â case STORNO ââ 1368 // Rental / mobility / ride sub-tasks frequently get cancelled or "freigegeben" 1369 // by the partner (e.g. ADAC sends a separate STORNO for the rental side). 1370 // That cancellation does NOT mean the parent case (towing) is cancelled. 1371 const subjectAndHead = \`\${(body.email_subject || "").toUpperCase()} \${body.email_text.slice(0, 400).toUpperCase()}\`; 1372 const SUBTASK_KEYWORDS = ["MIETWAGEN", "MIETW", "ERSATZMOBIL", "ERSATZWAGEN", "RENTAL", "MOBILITÃT", "MOBILITAET", "TAXI", "RIDE", "VEX"]; 1373 const STORNO_KEYWORDS = ["STORNO", "STORNIERT", "FREIGABE", "RELEASED", "CANCEL"]; 1374 const looksLikeSubtaskStorno = 1375 classifiedStatus === "cancelled" && 1376 SUBTASK_KEYWORDS.some((k) => subjectAndHead.includes(k)) && 1377 STORNO_KEYWORDS.some((k) => subjectAndHead.includes(k)); 1378 1379 // ââ GUARD 2: Tow-active window ââ
1380 // If the case was put into a tow-active state (dispatched / in_progress / 1381 // waiting_for_partner / assigned) within the last 24h, never auto-cancel 1382 // it from a partner email. Surface internally instead. 1383 let towActiveRecently = false; 1384 if (classifiedStatus === "cancelled") { 1385 const TOW_ACTIVE = ["dispatched", "in_progress", "waiting_for_partner", "assigned"]; 1386 if (TOW_ACTIVE.includes(currentCase.status)) { 1387 towActiveRecently = true; 1388 } else { 1389 const { data: recentTowActive } = await supabase 1390 .from("case_status_history") 1391 .select("id, new_status, created_at") 1392 .eq("case_id", body.case_id) 1393 .in("new_status", TOW_ACTIVE) 1394 .gte("created_at", new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString()) 1395 .limit(1); 1396 towActiveRecently = !!(recentTowActive && recentTowActive.length > 0); 1397 } 1398 } 1399 1400 const blockCancel = looksLikeSubtaskStorno || towActiveRecently; 1401 1402 if (blockCancel) { 1403 const reason = looksLikeSubtaskStorno ? "subtask_storno" : "tow_active_within_24h"; 1404 console.warn( 1405 \`[CLASSIFY-EMAIL] Refusing to auto-cancel case \${body.case_id} (\${reason}). \` + 1406 \`Logging internal sub-task event instead.\`, 1407 ); 1408 await supabase.from("case_status_history").insert({ 1409 case_id: body.case_id, 1410 old_status: currentCase.status, 1411 new_status: currentCase.status, 1412 source: "email_ai", 1413 confidence_score: confidence, 1414 notes: looksLikeSubtaskStorno 1415 ? "Mietwagen/Sub-Task storniert â Hauptfall bleibt aktiv / Rental sub-task cancelled â main case stays active" 1416 : "Stornierung ignoriert: Abschleppauftrag aktiv / Cancellation ignored: tow currently active", 1417 metadata: { 1418 type: looksLikeSubtaskStorno ? "rental_request_cancelled" : "cancel_blocked_tow_active", 1419 guard: reason, 1420 ai_classified_status: classifiedStatus, 1421 reasoning, 1422 sender_email: body.sender_email, 1423 email_subject: body.email_subject, 1424 provider: body.provider, 1425 }, 1426 }); 1427 } 1428 1429 if (!blockCancel && confidence >= CONFIDENCE_THRESHOLD && classifiedStatus !== currentCase.status) { 1430 const updateResponse = await fetch(\`\${supabaseUrl}/functions/v1/update-case-status\`, { 1431 method: "POST", 1432 headers: { 1433 Authorization: \`Bearer \${supabaseServiceKey}\`, 1434 "Content-Type": "application/json", 1435 }, 1436 body: JSON.stringify({ 1437 case_id: body.case_id, 1438 new_status: classifiedStatus, 1439 source: "email_ai", 1440 confidence_score: confidence, 1441 notes: \`AI classified from email: \${reasoning}\`, 1442 metadata: { 1443 sender_email: body.sender_email, 1444 email_subject: body.email_subject, 1445 provider: body.provider, 1446 handled_by_detected: detectedHandler, 1447 }, 1448 }), 1449 }); 1450 1451 const updateResult = await updateResponse.json(); 1452 statusUpdated = updateResult.success === true; 1453 1454 if (!statusUpdated) { 1455 console.warn("[CLASSIFY-EMAIL] Status update failed:", updateResult); 1456 1457 // Auditable trace: a real provider update was detected but the state 1458 // machine refused it. Without this row the drop is invisible. 1459 // Idempotent per lifecycle block so ADAC resends don't pile up rows. 1460 const blockedHash = latestEvent?.blockHash ?? \`st-\${classifiedStatus}\`; 1461 const { data: alreadyLogged } = await supabase 1462 .from("case_status_history") 1463 .select("id") 1464 .eq("case_id", body.case_id) 1465 .eq("metadata->>type", "status_update_blocked") 1466 .eq("metadata->>block_hash", blockedHash) 1467 .limit(1); 1468 1469 if (!alreadyLogged?.length) { 1470 await supabase.from("case_status_history").insert({ 1471 case_id: body.case_id, 1472 old_status: currentCase.status, 1473 new_status: currentCase.status, 1474 source: "email_ai", 1475 confidence_score: confidence, 1476 notes: 1477 \`Statusänderung abgelehnt: \${currentCase.status} â \${classifiedStatus} / \` + 1478 \`Status update rejected: \${currentCase.status} â \${classifiedStatus}\`, 1479 metadata: { 1480 type: "status_update_blocked", 1481 attempted_status: classifiedStatus, 1482 current_status: currentCase.status, 1483 block_hash: blockedHash, 1484 reason: updateResult?.error || updateResult?.message || "update_case_status_failed", 1485 ai_reasoning: reasoning, 1486 sender_email: body.sender_email, 1487 email_subject: body.email_subject, 1488 provider: body.provider, 1489 inbound_email_id: body.inbound_email_id ?? null, 1490 }, 1491 }); 1492 } 1493 } 1494 } 1495 1496 // ââ Auto-create outcome feedback for terminal statuses ââ 1497 const TERMINAL_STATUSES = ["resolved", "cancelled"]; 1498 let feedbackCreated = false;
1499 1500 if (confidence >= CONFIDENCE_THRESHOLD && TERMINAL_STATUSES.includes(classifiedStatus)) { 1501 // Map classified status to feedback outcome 1502 const outcomeMap: Record<string, string> = { 1503 resolved: "resolved", 1504 cancelled: "cancelled", 1505 }; 1506 1507 // Map AI reasoning to reason code 1508 const reasonCodeMap: Record<string, string> = { 1509 resolved: inferReasonCode(result, body.email_text), 1510 cancelled: inferCancellationReason(result, body.email_text), 1511 }; 1512 1513 // Determine source from provider 1514 const sourceMap: Record<string, string> = { 1515 ADAC: "adac", 1516 HLPY: "hlpy", 1517 }; 1518 const feedbackSource = sourceMap[(body.provider || "").toUpperCase()] || "internal"; 1519 1520 // Check if tow was actually performed. Prefer the NEWEST classified 1521 // block (only \`towed_to_workshop\` means the vehicle is at the workshop), 1522 // then the legacy single stage, then a strict past-tense regex. 1523 const llmStage = (latestEvent?.stage as string | undefined) ?? 1524 (result.tow_lifecycle?.stage as string | undefined); 1525 let towPerformed: boolean; 1526 if (llmStage && llmStage !== "none") { 1527 towPerformed = llmStage === "towed_to_workshop"; 1528 } else { 1529 const emailUpper = body.email_text.toUpperCase(); 1530 towPerformed = /WURDE\\s+ABGESCHLEPPT|ERFOLGREICH\\s+ABGESCHLEPPT|ABGESCHLEPPT\\s+(NACH|ZUR|ZUM)|ZIELORT\\s+ERREICHT|FAHRZEUG\\s+(ÃBERGEBEN|UEBERGEBEN)|IN\\s+DER\\s+WERKSTATT\\s+EINGETROFFEN/i.test(emailUpper); 1531 } 1532 1533 1534 const feedbackData = { 1535 case_id: body.case_id, 1536 source: feedbackSource, 1537 outcome: outcomeMap[classifiedStatus] || "resolved", 1538 reason_code: reasonCodeMap[classifiedStatus] || "other", 1539 tow_performed: towPerformed, 1540 success: classifiedStatus === "resolved", 1541 notes: \`Auto-generated from \${body.provider || "partner"} email (confidence: \${(confidence * 100).toFixed(0)}%): \${reasoning}\`, 1542 }; 1543 1544 const { error: feedbackErr } = await supabase 1545 .from("case_outcome_feedback") 1546 .insert(feedbackData); 1547 1548 if (feedbackErr) { 1549 console.error(\`[CLASSIFY-EMAIL] Failed to create outcome feedback for case \${body.case_id}:\`, feedbackErr.message); 1550 } else { 1551 feedbackCreated = true; 1552 console.log(\`[CLASSIFY-EMAIL] Auto-created outcome feedback for case \${body.case_id}: \${feedbackData.outcome} / \${feedbackData.reason_code}\`); 1553 } 1554 } 1555 1556 // NOTE: email_parsing credit metering moved to DB trigger 1557 // \`meter_inbound_email_parsing\` on \`inbound_emails\`. This guarantees 1558 // every processed inbound email is billed exactly once, regardless of 1559 // which code path (classifier, status update, orphan recovery) handled it. 1560 1561 return jsonResponse({ 1562 classified_status: classifiedStatus, 1563 ai_status: aiStatus, 1564 confidence_score: confidence, 1565 reasoning, 1566 handled_by_detected: detectedHandler, 1567 auto_updated: statusUpdated, 1568 requires_review: confidence < CONFIDENCE_THRESHOLD, 1569 current_status: currentCase.status, 1570 // Full per-block audit trail so mis-parses can be reviewed / replayed. 1571 blocks_detected: segments.blocks.length, 1572 segmentation_strategy: segments.strategy, 1573 lifecycle_source: lifecycleSource, 1574 classification_audit: classificationAudit, 1575 ...(lifecycleSource === "none" ? { no_lifecycle_event: true } : {}), 1576 lifecycle_events: events.map((e: any) => ({ 1577 block_index: e.blockIndex, 1578 block_hash: e.blockHash, 1579 stage: e.stage, 1580 occurred_at_local: e.occurredAtLocal, 1581 occurred_at_tz: e.occurredAtTz, 1582 evidence_quote: e.evidenceQuote, 1583 confidence: e.confidence, 1584 })), 1585 latest_stage: latestEvent?.stage ?? null, 1586 data_changes: dataChanges, 1587 data_updated: dataChanges.length > 0, 1588 1589 feedback_created: feedbackCreated, 1590 extracted_data: { 1591 case_reference: result.case_reference || null, 1592 customer_name: result.customer_name || null, 1593 phone_number: result.phone_number || null, 1594 license_plate: result.license_plate || null, 1595 vehicle_brand: result.vehicle_make || null, 1596 vehicle_model: result.vehicle_model || null, 1597 vin: result.vin || null, 1598 rental_contract_number: result.rental_contract_number || null, 1599 mileage: result.mileage || null, 1600 damage_description: result.damage_description || null, 1601 deployment_recommendation: result.deployment_recommendation || null, 1602 location_street: result.location_street || null, 1603 location_city_zip: result.location_city_zip || null, 1604 location_description: result.location_description || null, 1605 location_lat: result.location_lat || null, 1606 location_lon: result.location_lon || null, 1607 location_hint: result.location_street ? \`\${result.location_street}, \${result.location_city_zip || ""}\`.trim() : null, 1608 remark: result.remark || null, 1609 incident_date: result.incident_date || null, 1610 incident_time: result.incident_time || null, 1611 }, 1612 }); 1613 } catch (error) {
1614 const msg = error instanceof Error ? error.message : String(error); 1615 console.error("[CLASSIFY-EMAIL] Error:", msg); 1616 return jsonResponse({ error: msg }, 500); 1617 } 1618}); 1619 1620/** Infer reason code for resolved cases from email content */ 1621function inferReasonCode(aiResult: any, emailText: string): string { 1622 const upper = emailText.toUpperCase(); 1623 if (upper.includes("ABGESCHLEPPT") || upper.includes("ABSCHLEPPVORGANG")) return "towed_to_workshop"; 1624 if ( 1625 upper.includes("KEIN GRUND F") || upper.includes("KEIN DEFEKT") || 1626 upper.includes("FUNKTIONIERT KORREKT") || upper.includes("WIEDER FUNKTIONIERT") || 1627 upper.includes("NO FAULT FOUND") 1628 ) return "customer_self_resolved"; 1629 if (upper.includes("PANNE BEHOBEN") || upper.includes("REPARATUR") || upper.includes("VOR ORT")) return "roadside_fix"; 1630 if (upper.includes("KUNDE") && (upper.includes("SELBST") || upper.includes("EIGENSTÃNDIG"))) return "customer_self_resolved"; 1631 return "other"; 1632} 1633 1634/** Infer reason code for cancelled cases from email content */ 1635function inferCancellationReason(aiResult: any, emailText: string): string { 1636 const upper = emailText.toUpperCase(); 1637 if (upper.includes("KUNDE") && upper.includes("STORNIERT")) return "cancelled_by_customer"; 1638 if (upper.includes("PARTNER") && upper.includes("STORNIERT")) return "cancelled_by_partner"; 1639 if (upper.includes("NICHT ERREICHBAR")) return "no_response"; 1640 if (upper.includes("FALSCHER STANDORT") || upper.includes("FALSCHE ADRESSE")) return "wrong_location"; 1641 if (upper.includes("DUPLIKAT") || upper.includes("DOPPELT")) return "duplicate_case"; 1642 if (upper.includes("PARTNER NICHT VERFÃGBAR") || upper.includes("KEINE KAPAZITÃT")) return "partner_unavailable"; 1643 return "cancelled_by_partner"; 1644} 1645 1646function jsonResponse(body: unknown, status = 200) { 1647 return new Response(JSON.stringify(body), { 1648 status, 1649 headers: { ...corsHeaders, "Content-Type": "application/json" }, 1650 }); 1651} 1652`;export{e as default};
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.