import { Injectable } from '@nestjs/common'; import { AiBookingResult, AiContainerType, AiDirection, AiExtractedBooking, AiRecommendation, AiValidationResult, } from './types/ai-booking-result.type'; /** * Deterministic keyword/regex "AI" for the booking assistant workflow. * No external AI calls — this class is the single seam to swap for a real * provider later (OllamaAiService / ClaudeAiService / OpenAiService): keep * the `extractBooking(text): AiBookingResult` contract and replace the body. */ const KNOWN_LOCATIONS = [ 'Djibouti', 'Indode', 'Modjo', 'Adama', 'Dire Dawa', 'Addis Ababa', ] as const; const INLAND_LOCATIONS = new Set([ 'Indode', 'Modjo', 'Adama', 'Dire Dawa', 'Addis Ababa', ]); // Longest names first so "Dire Dawa" wins before a shorter partial could. const LOCATION_ALTERNATION = [...KNOWN_LOCATIONS] .sort((a, b) => b.length - a.length) .map((name) => name.replace(/\s+/g, '\\s+')) .join('|'); // Checked in order; first hit wins, so specific cargo words beat the // generic "refrigerated" fallback. const CARGO_KEYWORDS: ReadonlyArray = [ [/\belectronics\b/i, 'electronics'], [/\bcoffee\b/i, 'coffee'], [/\bwheat\b/i, 'wheat'], [/\bfertilizers?\b/i, 'fertilizer'], [/\bchemicals?\b/i, 'chemical'], [/\bmachinery\b/i, 'machinery'], [/\bmedicines?\b/i, 'medicine'], [/\bsesame\b/i, 'sesame'], [/\b(?:vehicles?|cars?)\b/i, 'vehicles'], [/\brefrigerated\b/i, 'refrigerated cargo'], ]; const WORD_NUMBERS: Record = { one: 1, two: 2, three: 3, four: 4, five: 5, six: 6, seven: 7, eight: 8, nine: 9, ten: 10, }; // A capitalized-word run: "ABC Logistics", "Auto Import PLC", "Ethio Coffee // Export". Stops at the first lowercase word ("wants", "needs", …). const NAME_CAPTURE = String.raw`([A-Z][A-Za-z0-9&.'-]*(?:\s+[A-Z][A-Za-z0-9&.'-]*)*)`; // No `i` flag: the capture relies on case ([A-Z] word starts) to know where // the company name ends ("Customer ABC Logistics wants…" → "ABC Logistics"). const CUSTOMER_PATTERNS: ReadonlyArray = [ new RegExp(String.raw`\b[Cc]ustomer(?:\s+is)?\s*:?\s+${NAME_CAPTURE}`), new RegExp(String.raw`\b[Ff]or\s+${NAME_CAPTURE}`), ]; const RECOMMEND_CREATE: AiRecommendation = { action: 'CREATE_DRAFT_BOOKING', message: 'Booking data looks complete. User can review and create a draft booking.', confidence: 0.85, }; const RECOMMEND_MISSING: AiRecommendation = { action: 'REQUEST_MISSING_INFORMATION', message: 'Some required booking information is missing. Ask the customer for the missing fields before creating a draft booking.', confidence: 0.45, }; @Injectable() export class MockAiService { extractBooking(text: string): AiBookingResult { const input = text.trim(); const { origin, destination } = this.extractRoute(input); const extracted: AiExtractedBooking = { customerName: this.extractCustomerName(input), origin, destination, cargoType: this.extractCargoType(input), containerType: this.extractContainerType(input), quantity: this.extractQuantity(input), direction: this.resolveDirection(origin, destination), weightKg: this.extractWeightKg(input), pickupRequired: this.extractFlag(input, 'pickup'), deliveryRequired: this.extractFlag(input, 'delivery'), }; const validation = this.validate(extracted); return { provider: 'mock', extracted, validation, recommendation: validation.valid ? RECOMMEND_CREATE : RECOMMEND_MISSING, }; } private extractCustomerName(text: string): string | null { for (const pattern of CUSTOMER_PATTERNS) { const match = text.match(pattern); if (match?.[1]) { const name = match[1].replace(/[.,;:!?]+$/, '').trim(); if (name) return name; } } return null; } private extractRoute(text: string): { origin: string | null; destination: string | null; } { const fromMatch = text.match( new RegExp(String.raw`\bfrom\s+(${LOCATION_ALTERNATION})\b`, 'i'), ); const toMatch = text.match( new RegExp(String.raw`\bto\s+(${LOCATION_ALTERNATION})\b`, 'i'), ); let origin = fromMatch ? this.canonicalLocation(fromMatch[1]) : null; let destination = toMatch ? this.canonicalLocation(toMatch[1]) : null; if (!origin || !destination) { // Fall back to order of appearance ("Djibouti to Indode" without // "from", or a bare location mention). const mentions: string[] = []; const all = text.matchAll( new RegExp(String.raw`\b(${LOCATION_ALTERNATION})\b`, 'gi'), ); for (const m of all) { const canonical = this.canonicalLocation(m[1]); if (canonical && !mentions.includes(canonical)) mentions.push(canonical); } if (!origin && !destination) { origin = mentions[0] ?? null; destination = mentions[1] ?? null; } else if (!origin) { origin = mentions.find((loc) => loc !== destination) ?? null; } else { destination = mentions.find((loc) => loc !== origin) ?? null; } } return { origin, destination }; } private canonicalLocation(raw: string): string | null { const normalized = raw.replace(/\s+/g, ' ').toLowerCase(); return ( KNOWN_LOCATIONS.find((loc) => loc.toLowerCase() === normalized) ?? null ); } private resolveDirection( origin: string | null, destination: string | null, ): AiDirection | null { if (!origin || !destination) return null; if (origin === 'Djibouti' && INLAND_LOCATIONS.has(destination)) { return 'IMPORT'; } if (INLAND_LOCATIONS.has(origin) && destination === 'Djibouti') { return 'EXPORT'; } return null; } private extractCargoType(text: string): string | null { for (const [pattern, cargo] of CARGO_KEYWORDS) { if (pattern.test(text)) return cargo; } return null; } private extractContainerType(text: string): AiContainerType | null { // Lookbehind instead of \b: "2x40ft" has no word boundary before "40", // but "140ft" must not read as a 40ft container. if (/(?