Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions scripts/pest-bootstrap.php
Original file line number Diff line number Diff line change
Expand Up @@ -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; } } }');
}
Expand Down
8 changes: 3 additions & 5 deletions server/src/Models/Sensor.php
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,6 @@ class Sensor extends Model
*/
protected $filterParams = [
'type',
'sensor_type',
'status',
'device_uuid',
'serial_number',
Expand Down Expand Up @@ -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();
}
Expand All @@ -232,7 +231,6 @@ public function getActivitylogOptions(): LogOptions
'sensorable_uuid',
'sensorable_type',
'type',
'sensor_type',
'internal_id',
'name',
'unit',
Expand Down Expand Up @@ -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);
}

/**
Expand Down Expand Up @@ -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,
Expand Down
15 changes: 15 additions & 0 deletions server/src/Providers/FleetOpsServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand All @@ -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());
}
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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) {
Expand All @@ -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
Expand Down
29 changes: 24 additions & 5 deletions server/src/Support/Ai/Capabilities/AssetStatusCapability.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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', []);
Expand Down
47 changes: 40 additions & 7 deletions server/src/Support/Ai/Capabilities/OrderInsightsCapability.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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')
Expand All @@ -83,14 +87,43 @@ 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);
}

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
Expand Down
Loading
Loading