diff --git a/scripts/pest-bootstrap.php b/scripts/pest-bootstrap.php index faa678b86..6ea5bf5ed 100644 --- a/scripts/pest-bootstrap.php +++ b/scripts/pest-bootstrap.php @@ -382,6 +382,18 @@ public static function dispatchNow(...$arguments) { \Fleetbase\TestSupport\Dispa eval('namespace Fleetbase\Ai\Contracts; interface AIActionCapabilityInterface {}'); } +if (!interface_exists('Fleetbase\Ai\Contracts\AIToolCapabilityInterface')) { + eval('namespace Fleetbase\Ai\Contracts; interface AIToolCapabilityInterface {}'); +} + +if (!class_exists('Fleetbase\Ai\Support\AiAudience')) { + eval('namespace Fleetbase\Ai\Support; class AiAudience { public function __construct(public bool $isSystemAdmin = false, public array $granted = []) {} public function can(string $permission): bool { return $this->isSystemAdmin || in_array($permission, $this->granted, true); } public function canAll(array $permissions): bool { foreach ($permissions as $permission) { if (!$this->can($permission)) { return false; } } return true; } }'); +} + +if (!class_exists('Fleetbase\Ai\Support\AiToolContext')) { + eval('namespace Fleetbase\Ai\Support; class AiToolContext { public array $actionPreviews = []; public array $uiActions = []; public function __construct(public $task, public $audience) {} public function addActionPreview($capability, array $preview): array { $preview = array_merge(["preview_id" => "preview-" . (count($this->actionPreviews) + 1), "key" => $capability->key()], $preview); $this->actionPreviews[] = $preview; return $preview; } }'); +} + if (!class_exists('Fleetbase\Ai\Models\AiTask')) { eval('namespace Fleetbase\Ai\Models; class AiTask { public function __construct(array $attributes = []) { foreach ($attributes as $key => $value) { $this->{$key} = $value; } } }'); } diff --git a/server/src/Models/Sensor.php b/server/src/Models/Sensor.php index 8b20638ba..c1589065d 100644 --- a/server/src/Models/Sensor.php +++ b/server/src/Models/Sensor.php @@ -75,7 +75,6 @@ class Sensor extends Model */ protected $filterParams = [ 'type', - 'sensor_type', 'status', 'device_uuid', 'serial_number', @@ -214,7 +213,7 @@ protected static function boot() public function getSlugOptions(): SlugOptions { return SlugOptions::create() - ->generateSlugsFrom(['name', 'sensor_type']) + ->generateSlugsFrom(['name', 'type']) ->saveSlugsTo('slug') ->doNotGenerateSlugsOnUpdate(); } @@ -232,7 +231,6 @@ public function getActivitylogOptions(): LogOptions 'sensorable_uuid', 'sensorable_type', 'type', - 'sensor_type', 'internal_id', 'name', 'unit', @@ -396,7 +394,7 @@ public function getLastReadingFormattedAttribute(): ?string */ public function scopeByType($query, string $type) { - return $query->where('sensor_type', $type); + return $query->where('type', $type); } /** @@ -493,7 +491,7 @@ protected function checkThresholdAndCreateAlert($value): void 'message' => $this->generateThresholdAlertMessage($value, $thresholdStatus), 'context' => [ 'sensor_name' => $this->name, - 'sensor_type' => $this->sensor_type, + 'sensor_type' => $this->type, 'value' => $value, 'unit' => $this->unit, 'threshold_status' => $thresholdStatus, diff --git a/server/src/Providers/FleetOpsServiceProvider.php b/server/src/Providers/FleetOpsServiceProvider.php index 7203a85c5..93b8394a9 100644 --- a/server/src/Providers/FleetOpsServiceProvider.php +++ b/server/src/Providers/FleetOpsServiceProvider.php @@ -274,6 +274,12 @@ protected function registerAiCapabilities(): void }); } + if (Utils::classExists(\Fleetbase\Ai\Support\Commands\AiCommandRegistry::class)) { + $this->callAfterResolving(\Fleetbase\Ai\Support\Commands\AiCommandRegistry::class, function ($commands) { + $commands->registerMany(\Fleetbase\FleetOps\Support\Ai\FleetOpsAiConsoleCommands::all()); + }); + } + $this->callAfterResolving(\Fleetbase\Ai\Support\AiCapabilityRegistry::class, function (\Fleetbase\Ai\Support\AiCapabilityRegistry $registry) { $registry->register(new \Fleetbase\FleetOps\Support\Ai\Capabilities\SearchResourcesCapability()); $registry->register(new \Fleetbase\FleetOps\Support\Ai\Capabilities\OperationalQueryCapability()); @@ -284,6 +290,15 @@ protected function registerAiCapabilities(): void $registry->register(new \Fleetbase\FleetOps\Support\Ai\Capabilities\CreateOrderPreviewCapability()); $registry->register(new \Fleetbase\FleetOps\Support\Ai\Capabilities\OptimizeOrderRouteCapability()); $registry->register(new \Fleetbase\FleetOps\Support\Ai\Capabilities\ImportOrdersPreviewCapability()); + + // Tool-calling versions share their capability keys, so they replace the keyword-driven ones when + // the installed AI extension supports tools and leave older AI extensions unaffected. + if (interface_exists(\Fleetbase\Ai\Contracts\AIToolCapabilityInterface::class)) { + $registry->register(new \Fleetbase\FleetOps\Support\Ai\Tools\SearchResourcesTool()); + $registry->register(new \Fleetbase\FleetOps\Support\Ai\Tools\CreateOrderTool()); + $registry->register(new \Fleetbase\FleetOps\Support\Ai\Tools\OptimizeOrderRouteTool()); + $registry->register(new \Fleetbase\FleetOps\Support\Ai\Tools\ImportOrdersTool()); + } }); } } diff --git a/server/src/Support/Ai/Capabilities/AbstractFleetOpsAICapability.php b/server/src/Support/Ai/Capabilities/AbstractFleetOpsAICapability.php index a070a7f79..f9c86649d 100644 --- a/server/src/Support/Ai/Capabilities/AbstractFleetOpsAICapability.php +++ b/server/src/Support/Ai/Capabilities/AbstractFleetOpsAICapability.php @@ -11,6 +11,17 @@ abstract class AbstractFleetOpsAICapability extends AbstractAICapability implements AIContextCapabilityInterface { + /** + * Words that never identify a record and must not be used as search terms. + */ + protected const SEARCH_STOPWORDS = [ + 'the', 'and', 'for', 'with', 'from', 'this', 'that', 'what', 'where', 'when', 'which', 'who', 'how', 'many', 'much', + 'can', 'could', 'would', 'should', 'please', 'help', 'want', 'need', 'show', 'find', 'open', 'look', 'tell', 'about', + 'status', 'order', 'orders', 'vehicle', 'vehicles', 'driver', 'drivers', 'device', 'devices', 'sensor', 'sensors', + 'telematic', 'telematics', 'maintenance', 'maintenances', 'work', 'fleet', 'fleets', 'ops', 'create', 'new', 'all', + 'today', 'yesterday', 'tomorrow', 'week', 'month', 'fleetbase', 'import', 'template', 'download', 'dummy', 'test', + ]; + public function module(): string { return 'fleet-ops'; @@ -50,6 +61,18 @@ protected function can(string $permission): bool return Auth::can($permission); } + /** + * Applies the current user's IAM directives (record-level scoping) for a permission to a query. + */ + protected function scopeToPermission($query, string $permission) + { + if ($query instanceof Builder && Builder::hasGlobalMacro('applyDirectivesForPermissions')) { + return $query->applyDirectivesForPermissions($permission); + } + + return $query; + } + protected function canAll(array $permissions): bool { foreach ($permissions as $permission) { @@ -61,18 +84,52 @@ protected function canAll(array $permissions): bool return true; } + /** + * Extracts record-reference-like search terms from a prompt. + * + * Only terms that plausibly identify a record are kept: quoted phrases, tokens containing a digit + * (public ids, plates, tracking numbers), emails and other delimited identifiers, and capitalized + * names that are not the first word. Ordinary words are never used as LIKE terms, and an empty + * array is returned when the prompt does not reference a specific record. + */ protected function searchTerms(string $prompt): array { - preg_match_all('/[A-Z]{2,}[-_][A-Z0-9-_]+|[A-Za-z0-9][A-Za-z0-9-_]{2,}/', (string) $prompt, $matches); + $prompt = trim($prompt); + $terms = []; - $terms = collect($matches[0] ?? []) - ->reject(fn ($term) => in_array(Str::lower($term), ['find', 'show', 'open', 'order', 'orders', 'vehicle', 'vehicles', 'driver', 'drivers', 'work', 'status', 'about', 'fleet', 'ops'], true)) - ->unique() + preg_match_all('/"([^"]{2,64})"|\'([^\']{2,64})\'/u', $prompt, $quoted); + foreach (array_merge($quoted[1] ?? [], $quoted[2] ?? []) as $phrase) { + if (trim($phrase) !== '') { + $terms[] = trim($phrase); + } + } + + preg_match_all('/[\p{L}\p{N}][\p{L}\p{N}@._\-]*[\p{L}\p{N}]/u', $prompt, $matches, PREG_OFFSET_CAPTURE); + foreach ($matches[0] ?? [] as [$token, $offset]) { + if ($this->isReferenceTerm($token, $offset === 0)) { + $terms[] = $token; + } + } + + return collect($terms) + ->unique(fn ($term) => Str::lower($term)) ->take(6) ->values() ->all(); + } + + protected function isReferenceTerm(string $token, bool $isFirstWord = false): bool + { + if (mb_strlen($token) < 3 || in_array(Str::lower($token), static::SEARCH_STOPWORDS, true)) { + return false; + } + + // Public ids, plates, tracking numbers, emails, and upper-case codes such as ORDER-ABC. + if (preg_match('/\p{N}|[@_]/u', $token) || preg_match('/^[\p{Lu}\p{N}]+(?:-[\p{Lu}\p{N}]+)+$/u', $token)) { + return true; + } - return empty($terms) ? [trim((string) $prompt)] : $terms; + return !$isFirstWord && preg_match('/^\p{Lu}\p{Ll}+$/u', $token) === 1; } protected function whereLikeAny(Builder $builder, array $columns, array $terms): void diff --git a/server/src/Support/Ai/Capabilities/AssetStatusCapability.php b/server/src/Support/Ai/Capabilities/AssetStatusCapability.php index 8b105af02..c7e9956f7 100644 --- a/server/src/Support/Ai/Capabilities/AssetStatusCapability.php +++ b/server/src/Support/Ai/Capabilities/AssetStatusCapability.php @@ -28,7 +28,7 @@ public function description(): string public function permissions(): array { - return ['fleet-ops see vehicle', 'fleet-ops see device', 'fleet-ops see sensor', 'fleet-ops see telematic']; + return ['fleet-ops see driver', 'fleet-ops see vehicle', 'fleet-ops see device', 'fleet-ops see sensor', 'fleet-ops see telematic']; } public function resolve(AiTask $task): array @@ -92,27 +92,46 @@ protected function driverStatus(): array protected function totalForModel(string $modelClass): int { - return $modelClass::where('company_uuid', session('company'))->count(); + return $this->scopedQuery($modelClass)->count(); } protected function onlineCountForModel(string $modelClass): int { - return $modelClass::where('company_uuid', session('company'))->where('online', true)->count(); + return $this->scopedQuery($modelClass)->where('online', true)->count(); } protected function offlineCountForModel(string $modelClass): int { - return $modelClass::where('company_uuid', session('company'))->where(function ($query) { + return $this->scopedQuery($modelClass)->where(function ($query) { $query->where('online', false)->orWhereNull('online'); })->count(); } protected function countsByStatusForModel(string $modelClass): array { - return $modelClass::where('company_uuid', session('company')) + return $this->scopedQuery($modelClass) ->selectRaw('status, count(*) as aggregate') ->groupBy('status') ->pluck('aggregate', 'status') ->all(); } + + /** + * Company-scoped query with the user's IAM directives for listing the resource applied. + */ + protected function scopedQuery(string $modelClass) + { + $permission = match ($modelClass) { + Driver::class => 'fleet-ops list driver', + Vehicle::class => 'fleet-ops list vehicle', + Device::class => 'fleet-ops list device', + Sensor::class => 'fleet-ops list sensor', + Telematic::class => 'fleet-ops list telematic', + default => null, + }; + + $query = $modelClass::where('company_uuid', session('company')); + + return $permission ? $this->scopeToPermission($query, $permission) : $query; + } } diff --git a/server/src/Support/Ai/Capabilities/CreateOrderPreviewCapability.php b/server/src/Support/Ai/Capabilities/CreateOrderPreviewCapability.php index 18a740d76..c77fcd89f 100644 --- a/server/src/Support/Ai/Capabilities/CreateOrderPreviewCapability.php +++ b/server/src/Support/Ai/Capabilities/CreateOrderPreviewCapability.php @@ -222,8 +222,12 @@ protected function matchesPrompt(string $prompt): bool protected function buildDraft(AiTask $task, array $input = []): array { - $existing = (array) data_get($task->metadata, 'action_previews.0.draft', []); - $draft = array_replace_recursive($this->draftFromPrompt((string) $task->prompt), $existing, (array) data_get($input, 'draft', $input)); + // A tool call supplies structured fields, so the prompt is not re-parsed. A refresh edits the + // preview it was opened from, which the AI service passes as existing_draft. + $fromTool = data_get($input, 'source') === 'tool'; + $existing = (array) (data_get($input, 'existing_draft') ?? ($fromTool ? [] : data_get($task->metadata, 'action_previews.0.draft', []))); + $changes = (array) data_get($input, 'draft', Arr::except($input, ['source', 'existing_draft'])); + $draft = array_replace_recursive($this->draftFromPrompt($fromTool ? '' : (string) $task->prompt), $existing, $changes); $draft['dispatched'] = filter_var(data_get($draft, 'dispatched', false), FILTER_VALIDATE_BOOLEAN); $draft['payload'] = (array) data_get($draft, 'payload', []); diff --git a/server/src/Support/Ai/Capabilities/OrderInsightsCapability.php b/server/src/Support/Ai/Capabilities/OrderInsightsCapability.php index d266caf5b..231658113 100644 --- a/server/src/Support/Ai/Capabilities/OrderInsightsCapability.php +++ b/server/src/Support/Ai/Capabilities/OrderInsightsCapability.php @@ -34,17 +34,20 @@ public function resolve(AiTask $task): array return ['authorized' => false, 'message' => 'Current user cannot access Fleet-Ops orders.']; } - $prompt = $this->prompt($task); - $window = $this->dateWindow($prompt); - $amount = $this->amountThreshold($prompt); - $query = $this->orderQuery(session('company')); + $prompt = $this->prompt($task); + $window = $this->dateWindow($prompt); + $amount = $this->amountThreshold($prompt); + $currency = $this->currency(); + $minor = $amount !== null ? $this->toMinorUnits($amount, $currency) : null; + $query = $this->orderQuery(session('company')); if ($window) { $query->whereBetween('created_at', [$window['start'], $window['end']]); } - if ($amount !== null) { - $query->whereHas('transaction', fn ($transaction) => $transaction->where('amount', '>', $amount)); + if ($minor !== null) { + // Transaction amounts are stored in the currency's minor unit (e.g. cents). + $query->whereHas('transaction', fn ($transaction) => $transaction->where('amount', '>', $minor)); } $total = (clone $query)->count(); @@ -59,6 +62,7 @@ public function resolve(AiTask $task): array 'end' => $window['end']->toIso8601String(), ] : null, 'amount_threshold' => $amount, + 'amount_currency' => $amount !== null ? $currency : null, 'count' => $total, 'counts_by_status' => (clone $query) ->selectRaw('status, count(*) as aggregate') @@ -83,6 +87,35 @@ protected function amountThreshold(string $prompt): ?float return null; } + /** + * Converts a major-unit amount (e.g. 500.00) to the minor units transactions are stored in, + * honoring the currency's ISO-4217 exponent (JPY has none, BHD has three). + */ + protected function toMinorUnits(float $amount, string $currency): int + { + $exponent = 2; + + try { + if (class_exists(\Money\Currencies\ISOCurrencies::class)) { + $exponent = (new \Money\Currencies\ISOCurrencies())->subunitFor(new \Money\Currency(strtoupper($currency))); + } + } catch (\Throwable) { + $exponent = 2; + } + + return (int) round($amount * (10 ** $exponent)); + } + + /** + * @codeCoverageIgnore + */ + protected function currency(): string + { + $company = function_exists('session') ? \Fleetbase\Models\Company::where('uuid', session('company'))->first(['uuid', 'currency']) : null; + + return strtoupper((string) ($company?->currency ?: 'USD')); + } + protected function dateWindow(string $prompt): ?array { return $this->relativeDateResolver()->resolveWindow($prompt); @@ -90,7 +123,7 @@ protected function dateWindow(string $prompt): ?array protected function orderQuery(?string $companyUuid): mixed { - return Order::where('company_uuid', $companyUuid); + return $this->scopeToPermission(Order::where('company_uuid', $companyUuid), 'fleet-ops list order'); } protected function relativeDateResolver(): AiRelativeDateResolver diff --git a/server/src/Support/Ai/Capabilities/SearchResourcesCapability.php b/server/src/Support/Ai/Capabilities/SearchResourcesCapability.php index b63f00a01..1aed720db 100644 --- a/server/src/Support/Ai/Capabilities/SearchResourcesCapability.php +++ b/server/src/Support/Ai/Capabilities/SearchResourcesCapability.php @@ -48,19 +48,69 @@ public function resolve(AiTask $task): array $prompt = (string) $task->prompt; $terms = $this->searchTerms($prompt); - return [ + if (empty($terms)) { + return [ + 'query_terms' => [], + 'results' => [], + 'message' => 'The prompt did not reference a specific Fleet-Ops record, so no records were searched.', + ]; + } + + return $this->searchAll($terms); + } + + /** + * Search the given resource types (all when null) for the terms. A failing search is reported + * without discarding the others. + */ + protected function searchAll(array $terms, ?array $types = null): array + { + $results = []; + $failed = []; + $searches = [ + 'orders' => fn () => $this->orders($terms), + 'vehicles' => fn () => $this->vehicles($terms), + 'drivers' => fn () => $this->drivers($terms), + 'work_orders' => fn () => $this->workOrders($terms), + 'maintenances' => fn () => $this->maintenances($terms), + 'devices' => fn () => $this->devices($terms), + 'sensors' => fn () => $this->sensors($terms), + 'telematics' => fn () => $this->telematics($terms), + ]; + + if ($types !== null) { + $searches = array_intersect_key($searches, array_flip($types)); + } + + foreach ($searches as $resource => $search) { + try { + $results[$resource] = $search(); + } catch (\Throwable $e) { + $failed[] = $resource; + $this->reportSearchFailure($resource, $e); + } + } + + $payload = [ 'query_terms' => $terms, - 'results' => array_filter([ - 'orders' => $this->orders($terms), - 'vehicles' => $this->vehicles($terms), - 'drivers' => $this->drivers($terms), - 'work_orders' => $this->workOrders($terms), - 'maintenances' => $this->maintenances($terms), - 'devices' => $this->devices($terms), - 'sensors' => $this->sensors($terms), - 'telematics' => $this->telematics($terms), - ]), + 'results' => array_filter($results), ]; + + if (!empty($failed)) { + $payload['unavailable_search'] = $failed; + } + + return $payload; + } + + /** + * @codeCoverageIgnore + */ + protected function reportSearchFailure(string $resource, \Throwable $e): void + { + if (function_exists('report')) { + report($e); + } } protected function matchesPrompt(string $prompt): bool @@ -76,7 +126,7 @@ protected function orders(array $terms): array return $this->orderSearchQuery() ->where(function ($query) use ($terms) { - $this->whereLikeAny($query, ['public_id', 'internal_id', 'uuid', 'status', 'type'], $terms); + $this->whereLikeAny($query, ['public_id', 'internal_id'], $terms); $query->orWhereHas('trackingNumber', fn ($tracking) => $this->whereLikeAny($tracking, ['tracking_number', 'barcode'], $terms)); }) ->limit(5) @@ -127,7 +177,7 @@ protected function drivers(array $terms): array return $this->driverSearchQuery() ->where(function ($query) use ($terms) { - $this->whereLikeAny($query, ['public_id', 'uuid', 'drivers_license_number', 'status'], $terms); + $this->whereLikeAny($query, ['public_id', 'internal_id', 'drivers_license_number'], $terms); $query->orWhereHas('user', fn ($user) => $this->whereLikeAny($user, ['name', 'email', 'phone'], $terms)); }) ->limit(5) @@ -146,27 +196,27 @@ protected function drivers(array $terms): array protected function workOrders(array $terms): array { - return $this->generic(WorkOrder::class, 'fleet-ops see work-order', ['public_id', 'uuid', 'code', 'subject', 'status', 'priority'], 'console.fleet-ops.maintenance.work-orders.index.details', $terms); + return $this->generic(WorkOrder::class, 'fleet-ops see work-order', ['public_id', 'code', 'subject'], 'console.fleet-ops.maintenance.work-orders.index.details', $terms); } protected function maintenances(array $terms): array { - return $this->generic(Maintenance::class, 'fleet-ops see maintenance', ['public_id', 'uuid', 'status', 'type', 'summary', 'notes'], 'console.fleet-ops.maintenance.maintenances.index.details', $terms); + return $this->generic(Maintenance::class, 'fleet-ops see maintenance', ['public_id', 'summary', 'notes'], 'console.fleet-ops.maintenance.maintenances.index.details', $terms); } protected function devices(array $terms): array { - return $this->generic(Device::class, 'fleet-ops see device', ['public_id', 'uuid', 'name', 'device_id', 'imei', 'serial_number', 'status'], 'console.fleet-ops.connectivity.devices.index.details', $terms); + return $this->generic(Device::class, 'fleet-ops see device', ['public_id', 'name', 'device_id', 'imei', 'serial_number'], 'console.fleet-ops.connectivity.devices.index.details', $terms); } protected function sensors(array $terms): array { - return $this->generic(Sensor::class, 'fleet-ops see sensor', ['public_id', 'uuid', 'name', 'internal_id', 'serial_number', 'imei', 'type', 'sensor_type', 'status'], 'console.fleet-ops.connectivity.sensors.index.details', $terms); + return $this->generic(Sensor::class, 'fleet-ops see sensor', ['public_id', 'name', 'internal_id', 'serial_number', 'imei'], 'console.fleet-ops.connectivity.sensors.index.details', $terms); } protected function telematics(array $terms): array { - return $this->generic(Telematic::class, 'fleet-ops see telematic', ['public_id', 'uuid', 'name', 'provider', 'status'], 'console.fleet-ops.connectivity.telematics.details', $terms); + return $this->generic(Telematic::class, 'fleet-ops see telematic', ['public_id', 'name'], 'console.fleet-ops.connectivity.telematics.details', $terms); } protected function generic(string $modelClass, string $permission, array $columns, string $route, array $terms): array @@ -193,23 +243,35 @@ protected function generic(string $modelClass, string $permission, array $column protected function orderSearchQuery() { - return Order::with(['transaction', 'trackingNumber']) - ->where('company_uuid', session('company')); + return $this->scopeToPermission( + Order::with(['transaction', 'trackingNumber'])->where('company_uuid', session('company')), + 'fleet-ops list order' + ); } protected function vehicleSearchQuery() { - return Vehicle::where('company_uuid', session('company')); + return $this->scopeToPermission(Vehicle::where('company_uuid', session('company')), 'fleet-ops list vehicle'); } protected function driverSearchQuery() { - return Driver::with('user') - ->where('company_uuid', session('company')); + return $this->scopeToPermission(Driver::with('user')->where('company_uuid', session('company')), 'fleet-ops list driver'); } protected function genericSearchQuery(string $modelClass) { - return $modelClass::where('company_uuid', session('company')); + $permission = match ($modelClass) { + WorkOrder::class => 'fleet-ops list work-order', + Maintenance::class => 'fleet-ops list maintenance', + Device::class => 'fleet-ops list device', + Sensor::class => 'fleet-ops list sensor', + Telematic::class => 'fleet-ops list telematic', + default => null, + }; + + $query = $modelClass::where('company_uuid', session('company')); + + return $permission ? $this->scopeToPermission($query, $permission) : $query; } } diff --git a/server/src/Support/Ai/FleetOpsAiConsoleCommands.php b/server/src/Support/Ai/FleetOpsAiConsoleCommands.php new file mode 100644 index 000000000..68cdc910e --- /dev/null +++ b/server/src/Support/Ai/FleetOpsAiConsoleCommands.php @@ -0,0 +1,122 @@ + [singular, plural, list route, new route, detail route, permission resource, actions service, supports import, docs path, keywords] + 'orders' => ['order', 'Orders', 'operations.orders.index', 'operations.orders.index.new', 'operations.orders.index.details', 'order', 'order-actions', 'importOrders', 'operations/orders', ['dispatch', 'delivery', 'shipment', 'job']], + 'drivers' => ['driver', 'Drivers', 'management.drivers.index', 'management.drivers.index.new', 'management.drivers.index.details', 'driver', 'driver-actions', 'import', 'resources/drivers', ['courier', 'navigator']], + 'vehicles' => ['vehicle', 'Vehicles', 'management.vehicles.index', 'management.vehicles.index.new', 'management.vehicles.index.details', 'vehicle', 'vehicle-actions', 'import', 'resources/vehicles', ['truck', 'van', 'car', 'fleet']], + 'contacts' => ['contact', 'Contacts', 'management.contacts.index', 'management.contacts.index.new', 'management.contacts.index.details', 'contact', 'contact-actions', 'import', 'resources/contacts', ['person']], + 'customers' => ['customer', 'Customers', 'management.contacts.customers', 'management.contacts.customers.new', 'management.contacts.customers.details', 'contact', 'customer-actions', 'import', 'resources/contacts', ['client', 'contacts']], + 'places' => ['place', 'Places', 'management.places.index', 'management.places.index.new', 'management.places.index.details', 'place', 'place-actions', 'import', 'resources/places', ['address', 'location', 'depot', 'warehouse']], + 'fleets' => ['fleet', 'Fleets', 'management.fleets.index', 'management.fleets.index.new', 'management.fleets.index.details', 'fleet', 'fleet-actions', null, 'resources/fleets', ['team']], + 'issues' => ['issue', 'Issues', 'management.issues.index', 'management.issues.index.new', 'management.issues.index.details', 'issue', 'issue-actions', null, 'resources/issues', ['problem', 'incident']], + 'work_orders' => ['work order', 'Work Orders', 'maintenance.work-orders.index', 'maintenance.work-orders.index.new', 'maintenance.work-orders.index.details', 'work-order', 'work-order-actions', null, 'maintenance/work-orders', ['maintenance', 'repair']], + 'maintenances' => ['maintenance', 'Maintenances', 'maintenance.maintenances.index', 'maintenance.maintenances.index.new', 'maintenance.maintenances.index.details', 'maintenance', 'maintenance-actions', null, 'maintenance', ['service', 'repair']], + ]; + + $commands = []; + + foreach ($resources as $key => [$singular, $plural, $listRoute, $newRoute, $detailRoute, $permission, $service, $import, $docs, $keywords]) { + $base = [ + 'breadcrumb' => "Fleet-Ops › {$plural}", + 'keywords' => array_merge(['fleet-ops', $singular], $keywords), + 'docs_url' => static::DOCS . "/{$docs}", + 'module' => 'fleet-ops', + ]; + + $commands[] = $base + [ + 'id' => "fleet-ops.{$key}.open", + 'label' => "Open {$plural}", + 'description' => "Go to the Fleet-Ops {$plural} list.", + 'steps' => [['type' => 'navigate', 'route' => "console.fleet-ops.{$listRoute}"]], + 'permissions' => ["fleet-ops list {$permission}"], + ]; + + $commands[] = $base + [ + 'id' => "fleet-ops.{$key}.create", + 'label' => "Create {$singular}", + 'description' => "Open the new {$singular} form in Fleet-Ops.", + 'steps' => [['type' => 'navigate', 'route' => "console.fleet-ops.{$newRoute}"]], + 'permissions' => ["fleet-ops create {$permission}"], + 'keywords' => array_merge($base['keywords'], ['new', 'add', 'create']), + ]; + + $commands[] = $base + [ + 'id' => "fleet-ops.{$key}.view", + 'label' => "Open {$singular}", + 'description' => "Open a specific {$singular} by its public id.", + 'steps' => [['type' => 'navigate', 'route' => "console.fleet-ops.{$detailRoute}", 'models' => ['public_id']]], + 'permissions' => ["fleet-ops see {$permission}"], + 'params' => ['public_id' => ['type' => 'string', 'description' => "The {$singular} public id."]], + ]; + + if ($import) { + $commands[] = $base + [ + 'id' => "fleet-ops.{$key}.import", + 'label' => "Import {$plural}", + 'description' => "Go to {$plural} in Fleet-Ops and open the spreadsheet import, including the template download.", + 'steps' => [ + ['type' => 'navigate', 'route' => "console.fleet-ops.{$listRoute}"], + ['type' => 'service', 'engine' => static::ENGINE, 'service' => $service, 'method' => $import], + ], + 'permissions' => ["fleet-ops import {$permission}"], + 'keywords' => array_merge($base['keywords'], ['import', 'spreadsheet', 'csv', 'excel', 'xlsx', 'template', 'bulk', 'upload']), + ]; + } + } + + return $commands; + } + + public static function settings(): array + { + $settings = [ + 'map' => ['Map', 'map-settings', ['map', 'provider', 'google maps', 'leaflet', 'openstreetmap', 'tiles', 'view maps']], + 'navigator-app' => ['Navigator App', 'navigator-settings', ['navigator', 'driver app', 'mobile app', 'onboard']], + 'routing' => ['Routing', 'routing-settings', ['routing', 'route', 'optimization', 'osrm', 'distance']], + 'notifications' => ['Notifications', 'notification-settings', ['notification', 'alert', 'email', 'sms']], + 'custom-fields' => ['Custom Fields', 'custom-field', ['custom field', 'field', 'attribute']], + 'scheduling' => ['Scheduling', null, ['schedule', 'shift', 'calendar']], + ]; + + $commands = []; + + foreach ($settings as $route => [$label, $permission, $keywords]) { + $commands[] = [ + 'id' => 'fleet-ops.settings.' . str_replace('-', '_', $route) . '.open', + 'label' => "Open {$label} settings", + 'breadcrumb' => "Fleet-Ops › Settings › {$label}", + 'description' => "Go to the Fleet-Ops {$label} settings.", + 'steps' => [['type' => 'navigate', 'route' => "console.fleet-ops.settings.{$route}"]], + 'permissions' => $permission ? ["fleet-ops view {$permission}"] : [], + 'keywords' => array_merge(['fleet-ops', 'settings', 'configure'], $keywords), + 'docs_url' => static::DOCS . "/settings/{$route}", + 'module' => 'fleet-ops', + ]; + } + + return $commands; + } +} diff --git a/server/src/Support/Ai/Tools/CreateOrderTool.php b/server/src/Support/Ai/Tools/CreateOrderTool.php new file mode 100644 index 000000000..a7ca87412 --- /dev/null +++ b/server/src/Support/Ai/Tools/CreateOrderTool.php @@ -0,0 +1,86 @@ + 'object', + 'properties' => [ + 'pickup' => ['type' => 'string', 'description' => 'Pickup address or saved place name.'], + 'dropoff' => ['type' => 'string', 'description' => 'Dropoff address or saved place name.'], + 'scheduled_at' => ['type' => 'string', 'description' => 'Optional ISO 8601 date and time.'], + 'driver' => ['type' => 'string', 'description' => 'Optional driver name or public id.'], + 'vehicle' => ['type' => 'string', 'description' => 'Optional vehicle name, plate, or public id.'], + 'order_config' => ['type' => 'string', 'description' => 'Optional order configuration key, e.g. transport.'], + 'notes' => ['type' => 'string', 'description' => 'Optional notes for the order.'], + 'dispatch' => ['type' => 'boolean', 'description' => 'Dispatch immediately. Only true when the user asked for it.'], + ], + 'required' => ['pickup', 'dropoff'], + 'additionalProperties' => false, + ]; + } + + public function availableFor(AiToolContext $context): bool + { + return $context->audience->canAll($this->permissions()); + } + + public function invoke(AiTask $task, array $arguments, AiToolContext $context): array + { + $draft = array_filter([ + 'payload' => array_filter([ + 'pickup_query' => $this->argument($arguments, 'pickup'), + 'dropoff_query' => $this->argument($arguments, 'dropoff'), + ]), + 'scheduled_at' => $this->argument($arguments, 'scheduled_at'), + 'driver_query' => $this->argument($arguments, 'driver'), + 'vehicle_query' => $this->argument($arguments, 'vehicle'), + 'type' => $this->argument($arguments, 'order_config'), + 'notes' => $this->argument($arguments, 'notes'), + 'dispatched' => (bool) ($arguments['dispatch'] ?? false), + ], fn ($value) => $value !== null && $value !== []); + + $preview = $context->addActionPreview($this, $this->preview($task, ['source' => 'tool', 'draft' => $draft])); + + return [ + 'preview_id' => $preview['preview_id'], + 'ready' => $preview['ready'] ?? false, + 'missing_fields' => $preview['missing_fields'] ?? [], + 'fields' => $preview['fields'] ?? [], + 'status' => 'draft_shown_to_user', + 'message' => 'A draft order card is shown to the user. The order does not exist until the user reviews the card and clicks ' . ($preview['apply_label'] ?? 'Create order') . '.', + ]; + } + + protected function argument(array $arguments, string $key): ?string + { + $value = trim((string) ($arguments[$key] ?? '')); + + return $value === '' ? null : $value; + } +} diff --git a/server/src/Support/Ai/Tools/ImportOrdersTool.php b/server/src/Support/Ai/Tools/ImportOrdersTool.php new file mode 100644 index 000000000..3bb89fdce --- /dev/null +++ b/server/src/Support/Ai/Tools/ImportOrdersTool.php @@ -0,0 +1,53 @@ + 'object', + 'properties' => new \stdClass(), + 'additionalProperties' => false, + ]; + } + + public function availableFor(AiToolContext $context): bool + { + return $context->audience->canAll($this->permissions()); + } + + public function invoke(AiTask $task, array $arguments, AiToolContext $context): array + { + $requirements = $this->resolve($task); + + return [ + 'accepted_sources' => $requirements['accepted_sources'] ?? [], + 'minimum_columns' => $requirements['minimum_columns'] ?? [], + 'can_import_here' => false, + 'message' => $requirements['message'] ?? 'Fleetbase AI cannot process uploaded spreadsheets.', + ]; + } +} diff --git a/server/src/Support/Ai/Tools/OptimizeOrderRouteTool.php b/server/src/Support/Ai/Tools/OptimizeOrderRouteTool.php new file mode 100644 index 000000000..b3722692e --- /dev/null +++ b/server/src/Support/Ai/Tools/OptimizeOrderRouteTool.php @@ -0,0 +1,70 @@ + 'object', + 'properties' => [ + 'order' => ['type' => 'string', 'description' => 'The order public id, internal id, or UUID, exactly as the user gave it.'], + ], + 'required' => ['order'], + 'additionalProperties' => false, + ]; + } + + public function availableFor(AiToolContext $context): bool + { + return $context->audience->canAll($this->permissions()); + } + + public function invoke(AiTask $task, array $arguments, AiToolContext $context): array + { + $order = trim((string) ($arguments['order'] ?? '')); + + if ($order === '') { + return ['error' => 'invalid_arguments', 'message' => 'An order id is required to optimize a route.']; + } + + // The capability resolves the order from the prompt text, so hand it a copy carrying just the + // identifier the model supplied. The copy is never saved. + $probe = clone $task; + $probe->prompt = $order; + + $preview = $context->addActionPreview($this, $this->preview($probe)); + + return [ + 'preview_id' => $preview['preview_id'], + 'ready' => $preview['ready'] ?? false, + 'missing_fields' => $preview['missing_fields'] ?? [], + 'fields' => $preview['fields'] ?? [], + 'status' => 'proposal_shown_to_user', + 'message' => $preview['message'] ?? 'A route proposal card is shown to the user.', + ]; + } +} diff --git a/server/src/Support/Ai/Tools/SearchResourcesTool.php b/server/src/Support/Ai/Tools/SearchResourcesTool.php new file mode 100644 index 000000000..df0c8109f --- /dev/null +++ b/server/src/Support/Ai/Tools/SearchResourcesTool.php @@ -0,0 +1,73 @@ + 'object', + 'properties' => [ + 'query' => ['type' => 'string', 'description' => 'The identifier or name to look for, e.g. "order_yhkejdnzgz", "SBA1234Z", or "Jane Doe".'], + 'types' => ['type' => 'array', 'items' => ['type' => 'string', 'enum' => static::TYPES], 'description' => 'Optional record types to search. Searches all types when omitted.'], + ], + 'required' => ['query'], + 'additionalProperties' => false, + ]; + } + + public function availableFor(AiToolContext $context): bool + { + foreach ($this->permissions() as $permission) { + if ($context->audience->can($permission)) { + return true; + } + } + + return false; + } + + public function invoke(AiTask $task, array $arguments, AiToolContext $context): array + { + $query = trim((string) ($arguments['query'] ?? '')); + + if (mb_strlen($query) < 2) { + return ['error' => 'Provide an identifier or name to search for.']; + } + + // The model sends an intentional query, so the whole phrase is searched along with any ids in it. + $terms = collect([Str::limit($query, 100, '')]) + ->merge($this->searchTerms($query)) + ->unique(fn ($term) => Str::lower($term)) + ->take(4) + ->values() + ->all(); + + $types = array_values(array_intersect((array) ($arguments['types'] ?? []), static::TYPES)); + + return $this->searchAll($terms, $types ?: null); + } +} diff --git a/server/tests/AiOperationalQueryCapabilityTest.php b/server/tests/AiOperationalQueryCapabilityTest.php index e1a13be45..40448d3cd 100644 --- a/server/tests/AiOperationalQueryCapabilityTest.php +++ b/server/tests/AiOperationalQueryCapabilityTest.php @@ -406,6 +406,7 @@ function fleetopsAssetStatusCapabilityProbe(array $permissions = []): FleetOpsAs ->and($capability->label())->toBe('Fleet-Ops asset status') ->and($capability->description())->toContain('vehicle, device, sensor') ->and($capability->permissions())->toBe([ + 'fleet-ops see driver', 'fleet-ops see vehicle', 'fleet-ops see device', 'fleet-ops see sensor', diff --git a/server/tests/SupportJobAndAiCoverageTest.php b/server/tests/SupportJobAndAiCoverageTest.php index df2223f35..cc42249f9 100644 --- a/server/tests/SupportJobAndAiCoverageTest.php +++ b/server/tests/SupportJobAndAiCoverageTest.php @@ -889,8 +889,8 @@ function fleetopsInvokeSyncTelematicJob(SyncTelematicDevicesJob $job, string $me ->and($capability->shouldResolve($task))->toBeTrue() ->and($capability->promptMatches('tell me about sensor SENSOR-1'))->toBeTrue() ->and($capability->promptMatches('compose a friendly email'))->toBeFalse() - ->and($capability->termsFor('Find order ORDER-123 for driver driver_456'))->toBe(['ORDER-123', 'for', 'driver_456']) - ->and($capability->termsFor('show order'))->toBe(['show order']) + ->and($capability->termsFor('Find order ORDER-123 for driver driver_456'))->toBe(['ORDER-123', 'driver_456']) + ->and($capability->termsFor('show order'))->toBe([]) ->and($capability->genericWhenDenied())->toBe([]) ->and($capability->allResourceBranchesWhenDenied(['needle']))->toBe([ 'orders' => [], @@ -903,7 +903,7 @@ function fleetopsInvokeSyncTelematicJob(SyncTelematicDevicesJob $job, string $me 'telematics' => [], ]) ->and($capability->resolve($task))->toBe([ - 'query_terms' => ['DRV-123', 'and', 'TRUCK_9'], + 'query_terms' => ['DRV-123', 'TRUCK_9'], 'results' => [], ]); }); diff --git a/server/tests/Unit/Models/SensorTest.php b/server/tests/Unit/Models/SensorTest.php index af3e3811b..068b678df 100644 --- a/server/tests/Unit/Models/SensorTest.php +++ b/server/tests/Unit/Models/SensorTest.php @@ -116,7 +116,7 @@ function fleetopsSensorModelUseInMemoryConnection(): SQLiteConnection $sensor = new Sensor(); - expect($sensor->getSlugOptions()->generateSlugFrom)->toBe(['name', 'sensor_type']) + expect($sensor->getSlugOptions()->generateSlugFrom)->toBe(['name', 'type']) ->and($sensor->getSlugOptions()->slugField)->toBe('slug') ->and($sensor->getActivitylogOptions()->logOnlyDirty)->toBeTrue() ->and($sensor->telematic())->toBeInstanceOf(BelongsTo::class) @@ -222,7 +222,7 @@ function fleetopsSensorModelUseInMemoryConnection(): SQLiteConnection ->and($sensor->scopeActive($query))->toBe($query) ->and($sensor->scopeWithRecentReadings($query, 30))->toBe($query) ->and($sensor->scopeOutOfThreshold($query))->toBe($query) - ->and($query->calls[0])->toBe(['where', ['sensor_type', 'temperature']]) + ->and($query->calls[0])->toBe(['where', ['type', 'temperature']]) ->and($query->calls[1])->toBe(['where', ['status', 'active']]) ->and($query->calls[2][0])->toBe('where') ->and($query->calls[2][1][0])->toBe('last_reading_at') diff --git a/server/tests/Unit/Providers/FleetOpsAiRegistrationTest.php b/server/tests/Unit/Providers/FleetOpsAiRegistrationTest.php index 363d4ae85..b3364a1d5 100644 --- a/server/tests/Unit/Providers/FleetOpsAiRegistrationTest.php +++ b/server/tests/Unit/Providers/FleetOpsAiRegistrationTest.php @@ -4,6 +4,10 @@ eval('namespace Fleetbase\Ai\Support; class AiCapabilityRegistry { public array $registered = []; public function register($capability) { $this->registered[] = $capability; return $this; } }'); } +if (!class_exists('Fleetbase\Ai\Support\Commands\AiCommandRegistry', false)) { + eval('namespace Fleetbase\Ai\Support\Commands; class AiCommandRegistry { public array $registered = []; public function registerMany(array $commands) { $this->registered = array_merge($this->registered, $commands); return $this; } }'); +} + if (!class_exists('Fleetbase\Ai\Support\AiQueryRegistry', false)) { eval('namespace Fleetbase\Ai\Support; class AiQueryRegistry { public array $registered = []; public function register($resource = null) { $this->registered[] = $resource; return $this; } }'); } @@ -44,9 +48,18 @@ $capabilityRegistry = app(Fleetbase\Ai\Support\AiCapabilityRegistry::class); fwrite(STDERR, "\nDBG ai: " . json_encode([is_object($queryRegistry) ? get_class($queryRegistry) : gettype($queryRegistry), is_object($capabilityRegistry) ? get_class($capabilityRegistry) : gettype($capabilityRegistry), is_object($capabilityRegistry) ? count($capabilityRegistry->registered) : null]) . "\n"); - expect($capabilityRegistry->registered)->toHaveCount(9) + expect($capabilityRegistry->registered)->toHaveCount(13) ->and(collect($capabilityRegistry->registered)->map(fn ($capability) => get_class($capability))) - ->toContain(Fleetbase\FleetOps\Support\Ai\Capabilities\SearchResourcesCapability::class); + ->toContain( + Fleetbase\FleetOps\Support\Ai\Capabilities\SearchResourcesCapability::class, + Fleetbase\FleetOps\Support\Ai\Tools\SearchResourcesTool::class, + Fleetbase\FleetOps\Support\Ai\Tools\CreateOrderTool::class, + Fleetbase\FleetOps\Support\Ai\Tools\OptimizeOrderRouteTool::class, + Fleetbase\FleetOps\Support\Ai\Tools\ImportOrdersTool::class, + ); + + $commandRegistry = app(Fleetbase\Ai\Support\Commands\AiCommandRegistry::class); + expect(collect($commandRegistry->registered)->pluck('id'))->toContain('fleet-ops.orders.create', 'fleet-ops.settings.map.open'); // The query resources helper registers the fleet-ops queryables directly $freshQueryRegistry = new Fleetbase\Ai\Support\AiQueryRegistry(); diff --git a/server/tests/Unit/Support/Ai/AssetStatusCapabilityQueriesTest.php b/server/tests/Unit/Support/Ai/AssetStatusCapabilityQueriesTest.php index 08b9c56a0..6fe8eed4f 100644 --- a/server/tests/Unit/Support/Ai/AssetStatusCapabilityQueriesTest.php +++ b/server/tests/Unit/Support/Ai/AssetStatusCapabilityQueriesTest.php @@ -117,4 +117,46 @@ public function __call($method, $arguments) ->and($helper('vehicleSearchQuery')->count())->toBe(0) ->and($helper('driverSearchQuery')->count())->toBe(3) ->and($helper('genericSearchQuery', Fleetbase\FleetOps\Models\Contact::class)->count())->toBe(0); + + foreach ([Fleetbase\FleetOps\Models\WorkOrder::class, Fleetbase\FleetOps\Models\Maintenance::class, Fleetbase\FleetOps\Models\Device::class, Fleetbase\FleetOps\Models\Sensor::class, Fleetbase\FleetOps\Models\Telematic::class] as $modelClass) { + expect($helper('genericSearchQuery', $modelClass))->toBeInstanceOf(Illuminate\Database\Eloquent\Builder::class); + } +}); + +test('asset status scoped queries resolve a list permission for every supported model', function () { + fleetopsAssetStatusCapabilityBoot(); + + $capability = (new ReflectionClass(AssetStatusCapability::class))->newInstanceWithoutConstructor(); + $scoped = new ReflectionMethod(AssetStatusCapability::class, 'scopedQuery'); + $scoped->setAccessible(true); + + foreach ([Fleetbase\FleetOps\Models\Vehicle::class, Fleetbase\FleetOps\Models\Device::class, Fleetbase\FleetOps\Models\Sensor::class, Fleetbase\FleetOps\Models\Telematic::class, Fleetbase\FleetOps\Models\Contact::class] as $modelClass) { + expect($scoped->invoke($capability, $modelClass))->toBeInstanceOf(Illuminate\Database\Eloquent\Builder::class); + } +}); + +test('capability queries apply IAM directives for the list permission when the directive macro is available', function () { + fleetopsAssetStatusCapabilityBoot(); + + if (!Illuminate\Database\Eloquent\Builder::hasGlobalMacro('applyDirectivesForPermissions')) { + Illuminate\Database\Eloquent\Builder::macro('applyDirectivesForPermissions', function (string|array $names = []) { + $GLOBALS['fleetopsAppliedDirectivePermissions'][] = $names; + + return $this; + }); + } + + $GLOBALS['fleetopsAppliedDirectivePermissions'] = []; + + $capability = (new ReflectionClass(AssetStatusCapability::class))->newInstanceWithoutConstructor(); + $scope = new ReflectionMethod(AssetStatusCapability::class, 'scopeToPermission'); + $scope->setAccessible(true); + $plain = new stdClass(); + + expect($scope->invoke($capability, $plain, 'fleet-ops list driver'))->toBe($plain); + + $builder = Fleetbase\FleetOps\Models\Driver::query(); + + expect($scope->invoke($capability, $builder, 'fleet-ops list driver'))->toBe($builder) + ->and($GLOBALS['fleetopsAppliedDirectivePermissions'])->toContain('fleet-ops list driver'); }); diff --git a/server/tests/Unit/Support/Ai/Capabilities/OrderInsightsCapabilityTest.php b/server/tests/Unit/Support/Ai/Capabilities/OrderInsightsCapabilityTest.php index fcd291479..80fd077f6 100644 --- a/server/tests/Unit/Support/Ai/Capabilities/OrderInsightsCapabilityTest.php +++ b/server/tests/Unit/Support/Ai/Capabilities/OrderInsightsCapabilityTest.php @@ -123,6 +123,11 @@ protected function can(string $permission): bool return $this->allowed && $permission === 'fleet-ops see order'; } + protected function currency(): string + { + return 'USD'; + } + protected function orderQuery(?string $companyUuid): mixed { $this->companies[] = $companyUuid; @@ -183,6 +188,7 @@ function fleetopsOrderInsightsTask(string $prompt): AiTask 'authorized' => true, 'metric' => 'orders', 'amount_threshold' => 125.50, + 'amount_currency' => 'USD', 'count' => 7, 'counts_by_status' => ['completed' => 5, 'active' => 2], 'sample_order_ids' => ['order_1', 'order_2'], @@ -196,7 +202,7 @@ function fleetopsOrderInsightsTask(string $prompt): AiTask ->and($capability->companies)->toBe(['company-123']) ->and($capability->query->recordedCalls())->toContain( ['whereBetween', 'created_at', [$start, $end]], - ['whereHas', 'transaction', [['where', 'amount', '>', 125.50]]], + ['whereHas', 'transaction', [['where', 'amount', '>', 12550.0]]], ['count'], ['selectRaw', 'status, count(*) as aggregate'], ['groupBy', 'status'], @@ -224,3 +230,15 @@ function fleetopsOrderInsightsTask(string $prompt): AiTask ->and($capability->query->recordedCalls())->not->toContain(['whereBetween']) ->and($capability->query->recordedCalls())->not->toContain(['whereHas']); }); + +test('order insights converts major-unit thresholds using the currency exponent', function () { + $capability = new FleetOpsOrderInsightsCapabilityFake(); + $method = new ReflectionMethod(OrderInsightsCapability::class, 'toMinorUnits'); + $method->setAccessible(true); + + expect($method->invoke($capability, 500.0, 'USD'))->toBe(50000) + ->and($method->invoke($capability, 125.5, 'usd'))->toBe(12550) + ->and($method->invoke($capability, 1000.0, 'JPY'))->toBe(1000) + ->and($method->invoke($capability, 1.5, 'BHD'))->toBe(1500) + ->and($method->invoke($capability, 10.0, 'NOT-A-CURRENCY'))->toBe(1000); +}); diff --git a/server/tests/Unit/Support/Ai/Capabilities/SearchResourcesCapabilityTest.php b/server/tests/Unit/Support/Ai/Capabilities/SearchResourcesCapabilityTest.php index 1bedf0d84..62c0e0f7b 100644 --- a/server/tests/Unit/Support/Ai/Capabilities/SearchResourcesCapabilityTest.php +++ b/server/tests/Unit/Support/Ai/Capabilities/SearchResourcesCapabilityTest.php @@ -67,6 +67,17 @@ class FleetOpsSearchResourcesCapabilityProbe extends SearchResourcesCapability public array $queries = []; public array $appliedLikes = []; + public array $failing = []; + + public function exposeSearchTerms(string $prompt): array + { + return $this->searchTerms($prompt); + } + + protected function reportSearchFailure(string $resource, Throwable $e): void + { + } + public function exposePromptMatches(string $prompt): bool { return $this->matchesPrompt($prompt); @@ -103,6 +114,10 @@ protected function driverSearchQuery() protected function genericSearchQuery(string $modelClass) { + if ($modelClass === Sensor::class && in_array('sensors', $this->failing, true)) { + throw new RuntimeException("SQLSTATE[42S22]: Column not found: 1054 Unknown column 'sensor_type'"); + } + return match ($modelClass) { WorkOrder::class => $this->queries['work_orders'], Maintenance::class => $this->queries['maintenances'], @@ -197,7 +212,7 @@ function fleetopsSearchResourcesModel(string $class, array $attributes) $result = $capability->resolve(new AiTask(['prompt' => 'Find order ORDER-1 vehicle VEH-1 driver DRV-1 device DEV-1 sensor SNS-1 telematic TEL-1'])); expect($capability->exposePromptMatches('look up work order WO-1'))->toBeTrue() - ->and($result['query_terms'])->toBe(['ORDER-1', 'VEH-1', 'DRV-1', 'device', 'DEV-1', 'sensor']) + ->and($result['query_terms'])->toBe(['ORDER-1', 'VEH-1', 'DRV-1', 'DEV-1', 'SNS-1', 'TEL-1']) ->and($result['results'])->toHaveKeys(['orders', 'vehicles', 'drivers', 'work_orders', 'maintenances', 'devices', 'sensors', 'telematics']) ->and($result['results']['orders'][0])->toMatchArray([ 'id' => 'order_public', @@ -235,3 +250,72 @@ function fleetopsSearchResourcesModel(string $class, array $attributes) ->and($capability->queries['orders']->calls)->toContain(['limit', 5], ['get']) ->and($capability->appliedLikes)->not->toBeEmpty(); }); + +function fleetopsSearchResourcesEmptyQueries(): array +{ + return collect(['orders', 'vehicles', 'drivers', 'work_orders', 'maintenances', 'devices', 'sensors', 'telematics']) + ->mapWithKeys(fn ($key) => [$key => new FleetOpsSearchResourcesQueryFake()]) + ->all(); +} + +test('search terms ignore ordinary words from real production prompts', function () { + $capability = new FleetOpsSearchResourcesCapabilityProbe(); + + expect($capability->exposeSearchTerms('i want turn this into a platform where i add cisterns and on a different page BUSINESS leave they orders'))->toBe([]) + ->and($capability->exposeSearchTerms('Create a few dummy orders so we can test with it'))->toBe([]) + ->and($capability->exposeSearchTerms('download order import template'))->toBe([]) + ->and($capability->exposeSearchTerms('config driver app ยังไง'))->toBe([]) + ->and($capability->exposeSearchTerms('como activar un conductor'))->toBe([]); +}); + +test('search terms keep record references such as public ids, plates, emails, quoted and capitalized names', function () { + $capability = new FleetOpsSearchResourcesCapabilityProbe(); + + expect($capability->exposeSearchTerms('status of order order_yhkejdnzgz'))->toBe(['order_yhkejdnzgz']) + ->and($capability->exposeSearchTerms('vehicle plate SBA1234Z'))->toBe(['SBA1234Z']) + ->and($capability->exposeSearchTerms('driver with email ada@example.com'))->toBe(['ada@example.com']) + ->and($capability->exposeSearchTerms('where is driver Jane Doe right now'))->toBe(['Jane', 'Doe']) + ->and($capability->exposeSearchTerms('find "north depot" vehicles'))->toBe(['north depot']); +}); + +test('search resources skips querying when the prompt has no record reference', function () { + $capability = new FleetOpsSearchResourcesCapabilityProbe(); + $capability->allowedPermissions = $capability->permissions(); + $capability->queries = fleetopsSearchResourcesEmptyQueries(); + + $result = $capability->resolve(new AiTask(['prompt' => 'how do I create a new order'])); + + expect($result['query_terms'])->toBe([]) + ->and($result['results'])->toBe([]) + ->and($capability->appliedLikes)->toBe([]); +}); + +test('search resources never searches the dropped sensor_type column or status/uuid columns', function () { + $capability = new FleetOpsSearchResourcesCapabilityProbe(); + $capability->allowedPermissions = $capability->permissions(); + $capability->queries = fleetopsSearchResourcesEmptyQueries(); + + $capability->resolve(new AiTask(['prompt' => 'find SNS-1'])); + + $columns = collect($capability->appliedLikes)->flatMap(fn ($like) => $like[0])->unique()->all(); + + expect($columns)->not->toContain('sensor_type') + ->and($columns)->not->toContain('status') + ->and($columns)->not->toContain('uuid') + ->and($columns)->not->toContain('type'); +}); + +test('a failing resource search does not discard the other results or leak the error', function () { + $vehicle = fleetopsSearchResourcesModel(Vehicle::class, ['public_id' => 'vehicle_public', 'uuid' => 'vehicle-uuid', 'name' => 'Van 12']); + + $capability = new FleetOpsSearchResourcesCapabilityProbe(); + $capability->allowedPermissions = $capability->permissions(); + $capability->failing = ['sensors']; + $capability->queries = array_merge(fleetopsSearchResourcesEmptyQueries(), ['vehicles' => new FleetOpsSearchResourcesQueryFake([$vehicle])]); + + $result = $capability->resolve(new AiTask(['prompt' => 'find VEH-12'])); + + expect($result['results'])->toHaveKey('vehicles') + ->and($result['unavailable_search'])->toBe(['sensors']) + ->and(json_encode($result))->not->toContain('SQLSTATE'); +}); diff --git a/server/tests/Unit/Support/Ai/FleetOpsAiConsoleCommandsTest.php b/server/tests/Unit/Support/Ai/FleetOpsAiConsoleCommandsTest.php new file mode 100644 index 000000000..d29d700f4 --- /dev/null +++ b/server/tests/Unit/Support/Ai/FleetOpsAiConsoleCommandsTest.php @@ -0,0 +1,31 @@ +pluck('id')->duplicates())->toBeEmpty() + ->and($commands->every(fn ($command) => filled($command['label']) && filled($command['breadcrumb']) && filled($command['description']) && !empty($command['steps'])))->toBeTrue() + ->and($commands->every(fn ($command) => collect($command['steps'])->every(fn ($step) => $step['type'] === 'navigate' + ? str_starts_with($step['route'], 'console.fleet-ops.') + : $step['type'] === 'service' && $step['engine'] === FleetOpsAiConsoleCommands::ENGINE && preg_match('/^[a-zA-Z]+$/', $step['method']))))->toBeTrue() + ->and($commands->every(fn ($command) => ($command['audience'] ?? 'end_user') === 'end_user'))->toBeTrue(); +}); + +test('fleet-ops console commands cover create, import, view, and settings', function () { + $commands = collect(FleetOpsAiConsoleCommands::all())->keyBy('id'); + + expect($commands['fleet-ops.orders.create']['steps'])->toBe([['type' => 'navigate', 'route' => 'console.fleet-ops.operations.orders.index.new']]) + ->and($commands['fleet-ops.orders.create']['permissions'])->toBe(['fleet-ops create order']) + ->and($commands['fleet-ops.orders.import']['steps'][1])->toBe(['type' => 'service', 'engine' => '@fleetbase/fleetops-engine', 'service' => 'order-actions', 'method' => 'importOrders']) + ->and($commands['fleet-ops.customers.import']['steps'][1]['service'])->toBe('customer-actions') + ->and($commands['fleet-ops.customers.import']['permissions'])->toBe(['fleet-ops import contact']) + ->and($commands)->not->toHaveKey('fleet-ops.fleets.import') + ->and($commands['fleet-ops.drivers.view']['steps'][0]['models'])->toBe(['public_id']) + ->and($commands['fleet-ops.drivers.view']['params'])->toHaveKey('public_id') + ->and($commands['fleet-ops.settings.map.open']['steps'][0]['route'])->toBe('console.fleet-ops.settings.map') + ->and($commands['fleet-ops.settings.map.open']['permissions'])->toBe(['fleet-ops view map-settings']) + ->and($commands['fleet-ops.settings.map.open']['keywords'])->toContain('google maps', 'view maps') + ->and($commands['fleet-ops.settings.scheduling.open']['permissions'])->toBe([]); +}); diff --git a/server/tests/Unit/Support/Ai/Tools/FleetOpsAiToolsTest.php b/server/tests/Unit/Support/Ai/Tools/FleetOpsAiToolsTest.php new file mode 100644 index 000000000..766b84930 --- /dev/null +++ b/server/tests/Unit/Support/Ai/Tools/FleetOpsAiToolsTest.php @@ -0,0 +1,246 @@ +buildDraft($task, $input); + } + + protected function can(string $permission): bool + { + return true; + } + + protected function draftFromPrompt(string $prompt): array + { + return array_filter([ + 'order_config_uuid' => 'order-config-uuid', + 'type' => 'transport', + 'payload' => $prompt !== '' ? ['pickup_query' => 'Parsed from prompt'] : [], + 'dispatched' => false, + ], fn ($value) => $value !== []) + ['payload' => []]; + } + + protected function resolvePlace(?string $query): ?array + { + return $this->resolvedPlaces[$query] ?? null; + } + + protected function resolveOrderConfig(array $draft): ?OrderConfig + { + $config = new OrderConfig(); + $config->setRawAttributes(['uuid' => 'order-config-uuid', 'key' => 'transport', 'name' => 'Transport'], true); + + return $config; + } + + protected function resolveDriver(array $draft): ?Fleetbase\FleetOps\Models\Driver + { + return null; + } + + protected function resolveVehicle(array $draft): ?Fleetbase\FleetOps\Models\Vehicle + { + return null; + } +} + +class FleetOpsSearchResourcesToolProbe extends SearchResourcesTool +{ + public array $searched = []; + + protected function searchAll(array $terms, ?array $types = null): array + { + $this->searched[] = [$terms, $types]; + + return ['query_terms' => $terms, 'results' => []]; + } +} + +test('create order tool prepares one draft preview per call from structured fields without parsing the prompt', function () { + $tool = new FleetOpsCreateOrderToolProbe(); + $task = new AiTask(['prompt' => 'Create some dummy orders with made up data', 'metadata' => []]); + $context = new AiToolContext($task, new AiAudience(false, ['fleet-ops create order'])); + + $first = $tool->invoke($task, [ + 'pickup' => 'Kabelweg 57, Amsterdam', + 'dropoff' => 'Danzigerkade 15, Amsterdam', + 'scheduled_at' => '2026-09-16T09:30:00+02:00', + 'driver' => ' ', + 'notes' => 'Test order', + ], $context); + $second = $tool->invoke($task, ['pickup' => 'Diemerhof 32, Diemen', 'dropoff' => 'Herikerbergweg 238, Amsterdam', 'dispatch' => true], $context); + + expect($first['preview_id'])->toBe('preview-1') + ->and($second['preview_id'])->toBe('preview-2') + ->and($first['status'])->toBe('draft_shown_to_user') + ->and($first['message'])->toContain('does not exist until the user reviews the card') + ->and($context->actionPreviews)->toHaveCount(2) + ->and($context->actionPreviews[0]['draft']['payload']['pickup_query'])->toBe('Kabelweg 57, Amsterdam') + ->and($context->actionPreviews[0]['draft']['scheduled_at'])->toBe('2026-09-16T09:30:00+02:00') + ->and($context->actionPreviews[0]['draft']['notes'])->toBe('Test order') + ->and($context->actionPreviews[0]['draft'])->not->toHaveKey('driver_query') + ->and($context->actionPreviews[0]['draft']['dispatched'])->toBeFalse() + ->and($context->actionPreviews[1]['draft']['payload']['pickup_query'])->toBe('Diemerhof 32, Diemen') + ->and($context->actionPreviews[1]['draft']['dispatched'])->toBeTrue() + ->and(json_encode($context->actionPreviews))->not->toContain('Parsed from prompt') + ->and($tool->toolName())->toBe('propose_create_order') + ->and($tool->key())->toBe('fleet-ops.create_order') + ->and($tool->toolDescription())->toContain('NOT created by this tool') + ->and($tool->toolParameters()['required'])->toBe(['pickup', 'dropoff']) + ->and($tool->availableFor($context))->toBeTrue() + ->and($tool->availableFor(new AiToolContext($task, new AiAudience(false, []))))->toBeFalse(); +}); + +test('create order drafts refresh from the edited preview and still parse prompts in keyword mode', function () { + $tool = new FleetOpsCreateOrderToolProbe(); + $task = new AiTask(['prompt' => 'create order', 'metadata' => ['action_previews' => [['draft' => ['notes' => 'first preview']]]]]); + + $refreshed = $tool->exposeBuildDraft($task, ['existing_draft' => ['notes' => 'second preview'], 'draft' => ['dispatched' => true]]); + $keyword = $tool->exposeBuildDraft($task, ['dispatched' => false]); + + expect($refreshed['notes'])->toBe('second preview') + ->and($refreshed['dispatched'])->toBeTrue() + ->and($refreshed['payload']['pickup_query'])->toBe('Parsed from prompt') + ->and($keyword['notes'])->toBe('first preview') + ->and($keyword)->not->toHaveKey('existing_draft'); +}); + +test('search tool searches the requested phrase and identifiers for the chosen record types', function () { + $tool = new FleetOpsSearchResourcesToolProbe(); + $task = new AiTask(['prompt' => 'where is Jane']); + $context = new AiToolContext($task, new AiAudience(false, ['fleet-ops see driver'])); + + $tool->invoke($task, ['query' => 'Jane Doe', 'types' => ['drivers', 'pets']], $context); + $tool->invoke($task, ['query' => 'order order_yhkejdnzgz'], $context); + + expect($tool->searched[0])->toBe([['Jane Doe', 'Doe'], ['drivers']]) + ->and($tool->searched[1])->toBe([['order order_yhkejdnzgz', 'order_yhkejdnzgz'], null]) + ->and($tool->invoke($task, ['query' => ' x '], $context))->toBe(['error' => 'Provide an identifier or name to search for.']) + ->and($tool->toolName())->toBe('fleetops_search') + ->and($tool->key())->toBe('fleet-ops.search_resources') + ->and($tool->toolDescription())->toContain('particular record') + ->and($tool->toolParameters()['properties']['types']['items']['enum'])->toBe(SearchResourcesTool::TYPES) + ->and($tool->availableFor($context))->toBeTrue() + ->and($tool->availableFor(new AiToolContext($task, new AiAudience(false, []))))->toBeFalse(); +}); + +test('search tool narrows the real search to the requested types', function () { + $tool = new class extends SearchResourcesTool { + public array $called = []; + + protected function orders(array $terms): array + { + $this->called[] = 'orders'; + + return [['id' => 'order_1']]; + } + + protected function drivers(array $terms): array + { + $this->called[] = 'drivers'; + + return []; + } + }; + + $result = $tool->invoke(new AiTask(['prompt' => '']), ['query' => 'ORDER-1', 'types' => ['orders']], new AiToolContext(new AiTask(), new AiAudience(true))); + + expect($tool->called)->toBe(['orders']) + ->and($result['results'])->toBe(['orders' => [['id' => 'order_1']]]); +}); + +class FleetOpsOptimizeOrderRouteToolProbe extends Fleetbase\FleetOps\Support\Ai\Tools\OptimizeOrderRouteTool +{ + public array $previewedPrompts = []; + + public function preview(AiTask $task, array $input = []): array + { + $this->previewedPrompts[] = $task->prompt; + + return [ + 'action' => $this->key(), + 'ready' => true, + 'message' => 'Fleetbase AI prepared an optimized waypoint sequence. Review it before applying.', + 'missing_fields' => [], + 'fields' => [['label' => 'Order', 'value' => 'order_abc']], + ]; + } +} + +test('optimize route tool proposes a reviewable preview for the order the model named', function () { + $tool = new FleetOpsOptimizeOrderRouteToolProbe(); + $task = new AiTask(['prompt' => 'can you optimise the route please', 'metadata' => []]); + $context = new AiToolContext($task, new AiAudience(false, ['fleet-ops optimize order', 'fleet-ops update-route-for order'])); + + $result = $tool->invoke($task, ['order' => 'order_abc'], $context); + + expect($result['preview_id'])->toBe('preview-1') + ->and($result['ready'])->toBeTrue() + ->and($result['status'])->toBe('proposal_shown_to_user') + ->and($result['fields'])->toBe([['label' => 'Order', 'value' => 'order_abc']]) + ->and($result['missing_fields'])->toBe([]) + // The capability finds the order in the prompt text, so the tool hands it the id the model gave. + ->and($tool->previewedPrompts)->toBe(['order_abc']) + ->and($task->prompt)->toBe('can you optimise the route please') + ->and($context->actionPreviews)->toHaveCount(1) + ->and($tool->toolName())->toBe('propose_optimize_order_route') + ->and($tool->key())->toBe('fleet-ops.optimize_order_route') + ->and($tool->toolDescription())->toContain('NOT changed by this tool') + ->and($tool->toolParameters()['required'])->toBe(['order']) + ->and($tool->availableFor($context))->toBeTrue() + ->and($tool->availableFor(new AiToolContext($task, new AiAudience(false, ['fleet-ops optimize order']))))->toBeFalse(); +}); + +test('optimize route tool refuses to build a preview without an order', function () { + $tool = new FleetOpsOptimizeOrderRouteToolProbe(); + $task = new AiTask(['prompt' => 'optimise a route', 'metadata' => []]); + $context = new AiToolContext($task, new AiAudience(false, ['fleet-ops optimize order', 'fleet-ops update-route-for order'])); + + expect($tool->invoke($task, ['order' => ' '], $context))->toBe([ + 'error' => 'invalid_arguments', + 'message' => 'An order id is required to optimize a route.', + ]) + ->and($tool->invoke($task, [], $context)['error'])->toBe('invalid_arguments') + ->and($context->actionPreviews)->toBe([]) + ->and($tool->previewedPrompts)->toBe([]); +}); + +class FleetOpsImportOrdersToolProbe extends Fleetbase\FleetOps\Support\Ai\Tools\ImportOrdersTool +{ + protected function can(string $permission): bool + { + return true; + } +} + +test('import tool reports what an import needs and never claims to have imported anything', function () { + $tool = new FleetOpsImportOrdersToolProbe(); + $task = new AiTask(['prompt' => 'can I import orders from a spreadsheet', 'metadata' => []]); + $context = new AiToolContext($task, new AiAudience(false, ['fleet-ops import order'])); + + $result = $tool->invoke($task, [], $context); + + expect($result['accepted_sources'])->toBe(['xlsx', 'csv']) + ->and($result['minimum_columns'])->toContain('pickup address or pickup place') + ->and($result['can_import_here'])->toBeFalse() + ->and($result['message'])->toContain('will not process uploaded spreadsheets') + ->and($context->actionPreviews)->toBe([]) + ->and($tool->toolName())->toBe('fleetops_import_requirements') + ->and($tool->key())->toBe('fleet-ops.import_orders_preview') + ->and($tool->toolDescription())->toContain('does NOT import anything') + ->and($tool->toolParameters()['properties'])->toBeInstanceOf(stdClass::class) + ->and(json_encode($tool->toolParameters()['properties']))->toBe('{}') + ->and($tool->availableFor($context))->toBeTrue() + ->and($tool->availableFor(new AiToolContext($task, new AiAudience(false, []))))->toBeFalse(); +}); diff --git a/server/tests/Unit/Support/AiCapabilityPermissionsTest.php b/server/tests/Unit/Support/AiCapabilityPermissionsTest.php index b619610e6..61f6dc4a6 100644 --- a/server/tests/Unit/Support/AiCapabilityPermissionsTest.php +++ b/server/tests/Unit/Support/AiCapabilityPermissionsTest.php @@ -96,7 +96,7 @@ function fleetopsAiPermissionsHelper(string $method): ReflectionMethod ->and(fn () => fleetopsAiPermissionsHelper('canAll')->invoke($capability, ['fleet-ops list order']))->toThrow(TypeError::class); }); -test('search terms extract identifiers and fall back to the raw prompt', function () { +test('search terms extract identifiers and never fall back to the raw prompt', function () { fleetopsAiPermissionsBoot(); $capability = new OperationalQueryCapability(); $searchTerms = fleetopsAiPermissionsHelper('searchTerms'); @@ -105,8 +105,8 @@ function fleetopsAiPermissionsHelper(string $method): ReflectionMethod expect($terms)->toContain('TRK-12345', 'atlas99') ->and($terms)->not->toContain('find', 'order'); - // Stop-word-only prompts fall back to the trimmed prompt - expect($searchTerms->invoke($capability, 'find order'))->toBe(['find order']); + // Prompts without a record reference produce no search terms, so nothing is searched + expect($searchTerms->invoke($capability, 'find order'))->toBe([]); }); test('where like any matches across columns and terms', function () {