mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 04:50:54 +00:00
278 lines
8.6 KiB
TypeScript
278 lines
8.6 KiB
TypeScript
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<string>([
|
|
'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<readonly [RegExp, string]> = [
|
|
[/\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<string, number> = {
|
|
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<RegExp> = [
|
|
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 (/(?<!\d)40[\s-]?(?:ft|foot)\b/i.test(text)) return '40FT';
|
|
if (/(?<!\d)20[\s-]?(?:ft|foot)\b/i.test(text)) return '20FT';
|
|
if (/\bbulk\b/i.test(text)) return 'BULK';
|
|
if (/\b(?:vehicles?|cars?)\b/i.test(text)) return 'RO_RO';
|
|
return null;
|
|
}
|
|
|
|
private extractQuantity(text: string): number | null {
|
|
// "2x40ft", "2 x 40ft", "3x20ft", "1x20ft"
|
|
let match = text.match(/(\d+)\s*x\s*\d+\s*-?\s*(?:ft|foot)\b/i);
|
|
if (match) return parseInt(match[1], 10);
|
|
|
|
// "one 40ft container", "two containers"
|
|
match = text.match(
|
|
new RegExp(
|
|
String.raw`\b(${Object.keys(WORD_NUMBERS).join('|')})\s+(?:\d+\s*-?\s*(?:ft|foot)\s+)?containers?\b`,
|
|
'i',
|
|
),
|
|
);
|
|
if (match) return WORD_NUMBERS[match[1].toLowerCase()];
|
|
|
|
// "3 containers", "2 refrigerated containers"
|
|
match = text.match(/(\d+)\s+(?:[a-z]+\s+)?containers?\b/i);
|
|
if (match) return parseInt(match[1], 10);
|
|
|
|
// "5 vehicles", "3 cars"
|
|
match = text.match(/(\d+)\s+(?:vehicles?|cars?)\b/i);
|
|
if (match) return parseInt(match[1], 10);
|
|
|
|
return null;
|
|
}
|
|
|
|
private extractWeightKg(text: string): number | null {
|
|
const tons = text.match(/([\d,]+(?:\.\d+)?)\s*(?:tons?|tonnes?)\b/i);
|
|
if (tons) return Math.round(this.parseNumber(tons[1]) * 1000);
|
|
|
|
const kg = text.match(/([\d,]+(?:\.\d+)?)\s*kgs?\b/i);
|
|
if (kg) return Math.round(this.parseNumber(kg[1]));
|
|
|
|
return null;
|
|
}
|
|
|
|
private parseNumber(raw: string): number {
|
|
return parseFloat(raw.replace(/,/g, ''));
|
|
}
|
|
|
|
private extractFlag(
|
|
text: string,
|
|
kind: 'pickup' | 'delivery',
|
|
): boolean | null {
|
|
// "no pickup required" must read as false, so the negative wins.
|
|
if (new RegExp(String.raw`\bno\s+${kind}\b`, 'i').test(text)) return false;
|
|
if (new RegExp(String.raw`\b${kind}\s+required\b`, 'i').test(text)) {
|
|
return true;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
private validate(extracted: AiExtractedBooking): AiValidationResult {
|
|
const errors: string[] = [];
|
|
|
|
if (!extracted.customerName) errors.push('Customer name is missing');
|
|
if (!extracted.origin) errors.push('Origin is missing');
|
|
if (!extracted.destination) errors.push('Destination is missing');
|
|
if (!extracted.cargoType) errors.push('Cargo type is missing');
|
|
if (!extracted.containerType) errors.push('Container type is missing');
|
|
if (extracted.quantity === null) errors.push('Quantity is missing');
|
|
if (!extracted.direction) errors.push('Direction is missing');
|
|
|
|
return { valid: errors.length === 0, errors };
|
|
}
|
|
}
|