mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
Cleaned warehouse related UI and made all 15 pages consisitent
This commit is contained in:
@@ -93,6 +93,7 @@ import { FirstMileModule } from "./modules/first-mile/first-mile.module";
|
||||
import { LastMileModule } from "./modules/last-mile/last-mile.module";
|
||||
import { InterchangeDocumentsModule } from "./modules/interchange-documents/interchange-documents.module";
|
||||
import { ImportOperationsModule } from "./modules/import-operations/import-operations.module";
|
||||
import { AiModule } from "./modules/ai/ai.module";
|
||||
import { LoggerMiddleware } from "./logger.middleware";
|
||||
|
||||
@Module({
|
||||
@@ -188,6 +189,7 @@ import { LoggerMiddleware } from "./logger.middleware";
|
||||
ImportOperationsModule,
|
||||
VerifaydaModule,
|
||||
FleetHistoryModule,
|
||||
AiModule,
|
||||
],
|
||||
providers: [
|
||||
EdrOrgSeeder,
|
||||
|
||||
31
apps/edr-freight-api/src/modules/ai/ai.controller.ts
Normal file
31
apps/edr-freight-api/src/modules/ai/ai.controller.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { Body, Controller, HttpCode, HttpStatus, Post } from '@nestjs/common';
|
||||
import { ApiOkResponse, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { Public } from '@edr/api-common';
|
||||
|
||||
import { AiBookingRequestDto } from './dto/ai-booking-request.dto';
|
||||
import { AiBookingResult } from './types/ai-booking-result.type';
|
||||
import { MockAiService } from './mock-ai.service';
|
||||
|
||||
// @Public() — TODO: swap for real guard when this leaves dev/testing.
|
||||
// Safe while public: extracts + validates text only, never creates or
|
||||
// dispatches anything.
|
||||
@Public()
|
||||
@ApiTags('AI Assistant (mock)')
|
||||
@Controller('ai')
|
||||
export class AiController {
|
||||
constructor(private readonly mockAiService: MockAiService) {}
|
||||
|
||||
@Post('booking/extract')
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ApiOperation({
|
||||
summary:
|
||||
'Mock AI: extract structured booking fields from free-text request',
|
||||
})
|
||||
@ApiOkResponse({
|
||||
description:
|
||||
'Extracted fields, validation result, and next-step recommendation',
|
||||
})
|
||||
extractBooking(@Body() dto: AiBookingRequestDto): AiBookingResult {
|
||||
return this.mockAiService.extractBooking(dto.text);
|
||||
}
|
||||
}
|
||||
11
apps/edr-freight-api/src/modules/ai/ai.module.ts
Normal file
11
apps/edr-freight-api/src/modules/ai/ai.module.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
|
||||
import { AiController } from './ai.controller';
|
||||
import { MockAiService } from './mock-ai.service';
|
||||
|
||||
@Module({
|
||||
controllers: [AiController],
|
||||
providers: [MockAiService],
|
||||
exports: [MockAiService],
|
||||
})
|
||||
export class AiModule {}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class AiBookingRequestDto {
|
||||
@ApiProperty({
|
||||
description: 'Free-text customer booking request to extract fields from',
|
||||
example:
|
||||
'Book 2x40ft containers from Djibouti to Indode. Cargo electronics. Customer ABC Logistics.',
|
||||
minLength: 5,
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty({ message: 'text must not be empty' })
|
||||
@MinLength(5, { message: 'text must be at least 5 characters' })
|
||||
text!: string;
|
||||
}
|
||||
277
apps/edr-freight-api/src/modules/ai/mock-ai.service.ts
Normal file
277
apps/edr-freight-api/src/modules/ai/mock-ai.service.ts
Normal file
@@ -0,0 +1,277 @@
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
export const AI_CONTAINER_TYPES = ['20FT', '40FT', 'BULK', 'RO_RO'] as const;
|
||||
export type AiContainerType = (typeof AI_CONTAINER_TYPES)[number];
|
||||
|
||||
export const AI_DIRECTIONS = ['IMPORT', 'EXPORT'] as const;
|
||||
export type AiDirection = (typeof AI_DIRECTIONS)[number];
|
||||
|
||||
export const AI_RECOMMENDATION_ACTIONS = [
|
||||
'CREATE_DRAFT_BOOKING',
|
||||
'REQUEST_MISSING_INFORMATION',
|
||||
] as const;
|
||||
export type AiRecommendationAction = (typeof AI_RECOMMENDATION_ACTIONS)[number];
|
||||
|
||||
export interface AiExtractedBooking {
|
||||
customerName: string | null;
|
||||
origin: string | null;
|
||||
destination: string | null;
|
||||
cargoType: string | null;
|
||||
containerType: AiContainerType | null;
|
||||
quantity: number | null;
|
||||
direction: AiDirection | null;
|
||||
weightKg: number | null;
|
||||
pickupRequired: boolean | null;
|
||||
deliveryRequired: boolean | null;
|
||||
}
|
||||
|
||||
export interface AiValidationResult {
|
||||
valid: boolean;
|
||||
errors: string[];
|
||||
}
|
||||
|
||||
export interface AiRecommendation {
|
||||
action: AiRecommendationAction;
|
||||
message: string;
|
||||
confidence: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Payload returned by the extract endpoint. The global
|
||||
* ResponseTransformInterceptor wraps it as
|
||||
* `{ success: true, data: AiBookingResult, timestamp }` on the wire.
|
||||
*/
|
||||
export interface AiBookingResult {
|
||||
provider: 'mock';
|
||||
extracted: AiExtractedBooking;
|
||||
validation: AiValidationResult;
|
||||
recommendation: AiRecommendation;
|
||||
}
|
||||
Reference in New Issue
Block a user