PageSourceSearch

https://dc-connected.ai/assets/index-BdfsyKpY.js

js dc-connected.ai collected 2026-10-05 16:16:51 UTC 80,557 bytes, 1,652 lines download raw bytes

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.