From 9b72307e853b84240417cf009e67e4032d82aa65 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Fri, 18 Sep 2026 12:22:51 +0800 Subject: [PATCH 1/2] fix(ai): repair resource search, add AI tools and console commands Hotfixes found while auditing the AI extension against production logs, where `search_resources` failed on every call and leaked the SQL error to the provider: - `Sensor` and the resource search referenced `sensor_type`, a column dropped by an earlier migration. Use `type`. - Search each resource type in its own try/catch so one failure no longer takes down the whole search, and report it as an unavailable search instead. - Build search terms from reference-like tokens only: never run LIKE against `status`, `type` or `uuid`, and skip the search when nothing useful remains. - Convert order amount thresholds from dollars to minor units using the currency exponent, and expose the currency on insights. - Scope the AI capability queries with `applyDirectivesForPermissions` so answers stay within what the user may see. Adds the tool-calling surface the AI extension now drives: - `propose_create_order` and `fleetops_search` tools, registered when the AI extension is installed. - `FleetOpsAiConsoleCommands`: navigate, create, view and import commands for orders, drivers, vehicles, contacts, customers, places, fleets, issues, work orders, maintenances and the Fleet-Ops settings pages. The AI may only propose these; the user confirms and the server re-authorizes before anything runs. --- scripts/pest-bootstrap.php | 12 ++ server/src/Models/Sensor.php | 8 +- .../src/Providers/FleetOpsServiceProvider.php | 13 ++ .../AbstractFleetOpsAICapability.php | 67 +++++++- .../Ai/Capabilities/AssetStatusCapability.php | 29 +++- .../CreateOrderPreviewCapability.php | 8 +- .../Capabilities/OrderInsightsCapability.php | 47 ++++- .../SearchResourcesCapability.php | 110 +++++++++--- .../Support/Ai/FleetOpsAiConsoleCommands.php | 122 +++++++++++++ .../src/Support/Ai/Tools/CreateOrderTool.php | 86 ++++++++++ .../Support/Ai/Tools/SearchResourcesTool.php | 73 ++++++++ .../AiOperationalQueryCapabilityTest.php | 1 + server/tests/SupportJobAndAiCoverageTest.php | 6 +- server/tests/Unit/Models/SensorTest.php | 4 +- .../Providers/FleetOpsAiRegistrationTest.php | 15 +- .../Ai/AssetStatusCapabilityQueriesTest.php | 42 +++++ .../OrderInsightsCapabilityTest.php | 20 ++- .../SearchResourcesCapabilityTest.php | 86 +++++++++- .../Ai/FleetOpsAiConsoleCommandsTest.php | 31 ++++ .../Support/Ai/Tools/FleetOpsAiToolsTest.php | 161 ++++++++++++++++++ .../Support/AiCapabilityPermissionsTest.php | 6 +- 21 files changed, 887 insertions(+), 60 deletions(-) create mode 100644 server/src/Support/Ai/FleetOpsAiConsoleCommands.php create mode 100644 server/src/Support/Ai/Tools/CreateOrderTool.php create mode 100644 server/src/Support/Ai/Tools/SearchResourcesTool.php create mode 100644 server/tests/Unit/Support/Ai/FleetOpsAiConsoleCommandsTest.php create mode 100644 server/tests/Unit/Support/Ai/Tools/FleetOpsAiToolsTest.php 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..d02ea07cf 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,13 @@ 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()); + } }); } } 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/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..a6883f2db 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,16 @@ $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(11) ->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, + ); + + $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..4093b228e --- /dev/null +++ b/server/tests/Unit/Support/Ai/Tools/FleetOpsAiToolsTest.php @@ -0,0 +1,161 @@ +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']]]); +}); 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 () { From e9977788be11d2c315df9d71a558b25e29cfb22f Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sun, 20 Sep 2026 20:21:10 +0800 Subject: [PATCH 2/2] fix(ai): expose route optimization and import requirements as tools Tool calling only reaches capabilities implementing `AIToolCapabilityInterface`, so the seven Fleet-Ops capabilities without a tool definition were unreachable once the AI extension defaulted to tools. Two of them had no replacement anywhere: route optimization and order import guidance simply stopped working. `OptimizeOrderRouteTool` wraps the existing preview capability so the model can propose a resequenced route for one order. As before, the proposal is a card the user confirms; nothing changes until they apply it. `ImportOrdersTool` reports the accepted file formats and required spreadsheet columns. It deliberately cannot read an uploaded file, and says so, so the model does not claim rows were imported. The remaining five are genuinely superseded by the core tools: `docs_help` by `search_docs`, `console_navigation` by `find_console_commands`, and `operational_query`, `order_insights` and `asset_status` by the generic record counting and listing tools. --- .../src/Providers/FleetOpsServiceProvider.php | 2 + .../src/Support/Ai/Tools/ImportOrdersTool.php | 53 ++++++++++++ .../Ai/Tools/OptimizeOrderRouteTool.php | 70 +++++++++++++++ .../Providers/FleetOpsAiRegistrationTest.php | 4 +- .../Support/Ai/Tools/FleetOpsAiToolsTest.php | 85 +++++++++++++++++++ 5 files changed, 213 insertions(+), 1 deletion(-) create mode 100644 server/src/Support/Ai/Tools/ImportOrdersTool.php create mode 100644 server/src/Support/Ai/Tools/OptimizeOrderRouteTool.php diff --git a/server/src/Providers/FleetOpsServiceProvider.php b/server/src/Providers/FleetOpsServiceProvider.php index d02ea07cf..93b8394a9 100644 --- a/server/src/Providers/FleetOpsServiceProvider.php +++ b/server/src/Providers/FleetOpsServiceProvider.php @@ -296,6 +296,8 @@ protected function registerAiCapabilities(): void 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/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/tests/Unit/Providers/FleetOpsAiRegistrationTest.php b/server/tests/Unit/Providers/FleetOpsAiRegistrationTest.php index a6883f2db..b3364a1d5 100644 --- a/server/tests/Unit/Providers/FleetOpsAiRegistrationTest.php +++ b/server/tests/Unit/Providers/FleetOpsAiRegistrationTest.php @@ -48,12 +48,14 @@ $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(11) + expect($capabilityRegistry->registered)->toHaveCount(13) ->and(collect($capabilityRegistry->registered)->map(fn ($capability) => get_class($capability))) ->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); diff --git a/server/tests/Unit/Support/Ai/Tools/FleetOpsAiToolsTest.php b/server/tests/Unit/Support/Ai/Tools/FleetOpsAiToolsTest.php index 4093b228e..766b84930 100644 --- a/server/tests/Unit/Support/Ai/Tools/FleetOpsAiToolsTest.php +++ b/server/tests/Unit/Support/Ai/Tools/FleetOpsAiToolsTest.php @@ -159,3 +159,88 @@ protected function drivers(array $terms): array 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(); +});