dfb92bf5e2
The Asistent AI chat can now query the tenant DB directly through Claude tool use. AiToolExecutor exposes 5 read-only tools (search_clients, get_vehicle, find_parts, recent_workorders, low_stock_parts) all scoped to the current tenant via BelongsToTenant. AiAssistantService::callClaude loops on stop_reason=tool_use up to 5 rounds: - normalize message history to content blocks - send `tools` definitions + messages to Anthropic API - on tool_use: execute each tool, append tool_results as user turn, recall - on end_turn: collect text + cumulative token counts + tool-call audit in AiMessage.meta.tools Single-shot helpers (suggestDiagnosis, suggestPrice, vinRecommendations, suggestParts) are unchanged — only the conversational chat gets tool-use. Tests (3 new): - two-round tool_use → execute → final text; verify 5 tools sent both rounds; cumulative tokens - executor finds part by brand - unknown tool name returns error blob Full suite: 109 passed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
165 lines
6.4 KiB
PHP
165 lines
6.4 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Ai;
|
|
|
|
use App\Models\Tenant\Client;
|
|
use App\Models\Tenant\Part;
|
|
use App\Models\Tenant\Vehicle;
|
|
use App\Models\Tenant\WorkOrder;
|
|
|
|
/**
|
|
* Executes tool calls dispatched by the chat AI. Each tool runs against the
|
|
* current tenant context (BelongsToTenant scopes apply automatically) and
|
|
* returns a small, AI-digestible result (capped row counts, truncated fields).
|
|
*/
|
|
class AiToolExecutor
|
|
{
|
|
public const TOOLS = [
|
|
[
|
|
'name' => 'search_clients',
|
|
'description' => 'Caută clienți după nume sau telefon (snippet, case-insensitive). Returnează max 10.',
|
|
'input_schema' => [
|
|
'type' => 'object',
|
|
'properties' => [
|
|
'query' => ['type' => 'string', 'description' => 'Fragment nume sau telefon'],
|
|
],
|
|
'required' => ['query'],
|
|
],
|
|
],
|
|
[
|
|
'name' => 'get_vehicle',
|
|
'description' => 'Detalii vehicul după placă (plate) sau VIN, plus ultima fișă de lucru.',
|
|
'input_schema' => [
|
|
'type' => 'object',
|
|
'properties' => [
|
|
'plate_or_vin' => ['type' => 'string', 'description' => 'Numărul de înmatriculare sau VIN'],
|
|
],
|
|
'required' => ['plate_or_vin'],
|
|
],
|
|
],
|
|
[
|
|
'name' => 'find_parts',
|
|
'description' => 'Caută piese în catalog după nume/cod/brand. Returnează max 15 cu stoc și preț.',
|
|
'input_schema' => [
|
|
'type' => 'object',
|
|
'properties' => [
|
|
'query' => ['type' => 'string'],
|
|
],
|
|
'required' => ['query'],
|
|
],
|
|
],
|
|
[
|
|
'name' => 'recent_workorders',
|
|
'description' => 'Ultimele fișe de lucru deschise/recente. Folosește pentru a răspunde la „ce lucrăm acum".',
|
|
'input_schema' => [
|
|
'type' => 'object',
|
|
'properties' => [
|
|
'limit' => ['type' => 'integer', 'description' => 'Câte (max 20)'],
|
|
'only_open' => ['type' => 'boolean', 'description' => 'Doar cele necelinate (default true)'],
|
|
],
|
|
],
|
|
],
|
|
[
|
|
'name' => 'low_stock_parts',
|
|
'description' => 'Listează piesele cu stoc sub minimum (alertă reaprovizionare).',
|
|
'input_schema' => [
|
|
'type' => 'object',
|
|
'properties' => [
|
|
'limit' => ['type' => 'integer'],
|
|
],
|
|
],
|
|
],
|
|
];
|
|
|
|
/** @return array result payload, JSON-encodable */
|
|
public function execute(string $name, array $input): array
|
|
{
|
|
return match ($name) {
|
|
'search_clients' => $this->searchClients((string) ($input['query'] ?? '')),
|
|
'get_vehicle' => $this->getVehicle((string) ($input['plate_or_vin'] ?? '')),
|
|
'find_parts' => $this->findParts((string) ($input['query'] ?? '')),
|
|
'recent_workorders' => $this->recentWorkOrders(
|
|
(int) ($input['limit'] ?? 5),
|
|
(bool) ($input['only_open'] ?? true),
|
|
),
|
|
'low_stock_parts' => $this->lowStockParts((int) ($input['limit'] ?? 15)),
|
|
default => ['error' => "unknown tool: {$name}"],
|
|
};
|
|
}
|
|
|
|
private function searchClients(string $q): array
|
|
{
|
|
if (trim($q) === '') return ['rows' => []];
|
|
$like = '%' . $q . '%';
|
|
$rows = Client::where(fn ($w) => $w->where('name', 'like', $like)
|
|
->orWhere('phone', 'like', $like)
|
|
->orWhere('email', 'like', $like))
|
|
->limit(10)
|
|
->get(['id', 'name', 'phone', 'email', 'status'])
|
|
->toArray();
|
|
return ['count' => count($rows), 'rows' => $rows];
|
|
}
|
|
|
|
private function getVehicle(string $key): array
|
|
{
|
|
if (trim($key) === '') return ['found' => false];
|
|
$v = Vehicle::with('client:id,name,phone')
|
|
->where(fn ($w) => $w->where('plate', $key)->orWhere('vin', $key))
|
|
->first();
|
|
if (! $v) return ['found' => false];
|
|
$lastWo = WorkOrder::where('vehicle_id', $v->id)
|
|
->latest('opened_at')->first(['number', 'opened_at', 'status', 'total']);
|
|
return [
|
|
'found' => true,
|
|
'id' => $v->id,
|
|
'make' => $v->make, 'model' => $v->model, 'year' => $v->year,
|
|
'plate' => $v->plate, 'vin' => $v->vin, 'mileage' => $v->mileage,
|
|
'client' => $v->client ? ['name' => $v->client->name, 'phone' => $v->client->phone] : null,
|
|
'last_workorder' => $lastWo,
|
|
];
|
|
}
|
|
|
|
private function findParts(string $q): array
|
|
{
|
|
if (trim($q) === '') return ['rows' => []];
|
|
$like = '%' . $q . '%';
|
|
$rows = Part::where('is_active', true)
|
|
->where(fn ($w) => $w->where('name', 'like', $like)
|
|
->orWhere('article', 'like', $like)
|
|
->orWhere('brand', 'like', $like))
|
|
->limit(15)
|
|
->get(['id', 'name', 'article', 'brand', 'category', 'qty', 'unit', 'sell_price'])
|
|
->toArray();
|
|
return ['count' => count($rows), 'rows' => $rows];
|
|
}
|
|
|
|
private function recentWorkOrders(int $limit, bool $onlyOpen): array
|
|
{
|
|
$limit = max(1, min(20, $limit));
|
|
$q = WorkOrder::with(['client:id,name', 'vehicle:id,plate'])
|
|
->orderByDesc('opened_at');
|
|
if ($onlyOpen) $q->whereNotIn('status', ['done', 'cancelled']);
|
|
$rows = $q->limit($limit)->get(['id', 'number', 'client_id', 'vehicle_id', 'status', 'opened_at', 'total']);
|
|
return ['count' => $rows->count(), 'rows' => $rows->map(fn ($w) => [
|
|
'number' => $w->number,
|
|
'client' => $w->client?->name,
|
|
'plate' => $w->vehicle?->plate,
|
|
'status' => $w->status,
|
|
'opened_at' => $w->opened_at?->toDateString(),
|
|
'total' => (float) $w->total,
|
|
])->all()];
|
|
}
|
|
|
|
private function lowStockParts(int $limit): array
|
|
{
|
|
$limit = max(1, min(50, $limit));
|
|
$rows = Part::where('is_active', true)
|
|
->whereColumn('qty', '<=', 'min_qty')
|
|
->orderBy('qty')
|
|
->limit($limit)
|
|
->get(['id', 'name', 'article', 'qty', 'min_qty', 'unit'])
|
|
->toArray();
|
|
return ['count' => count($rows), 'rows' => $rows];
|
|
}
|
|
}
|