feat: AI chat tool-use (Claude function calling)
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>
This commit is contained in:
@@ -117,36 +117,90 @@ TXT;
|
|||||||
protected function callClaude(string $key, AiChat $chat, string $msg, Company $company): array
|
protected function callClaude(string $key, AiChat $chat, string $msg, Company $company): array
|
||||||
{
|
{
|
||||||
$messages = $this->historyMessages($chat);
|
$messages = $this->historyMessages($chat);
|
||||||
// Anthropic requires alternating user/assistant; system is separate.
|
|
||||||
$messages = array_values(array_filter($messages, fn ($m) => in_array($m['role'], ['user', 'assistant'], true)));
|
$messages = array_values(array_filter($messages, fn ($m) => in_array($m['role'], ['user', 'assistant'], true)));
|
||||||
|
|
||||||
$r = Http::withHeaders([
|
// Normalize history to the structured content-block form (Claude tool-use
|
||||||
|
// requires content blocks for the assistant turn that emitted tool_use).
|
||||||
|
foreach ($messages as &$m) {
|
||||||
|
if (is_string($m['content'])) {
|
||||||
|
$m['content'] = [['type' => 'text', 'text' => $m['content']]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($m);
|
||||||
|
|
||||||
|
$headers = [
|
||||||
'x-api-key' => $key,
|
'x-api-key' => $key,
|
||||||
'anthropic-version' => '2023-06-01',
|
'anthropic-version' => '2023-06-01',
|
||||||
'content-type' => 'application/json',
|
'content-type' => 'application/json',
|
||||||
])
|
];
|
||||||
->timeout(60)
|
$system = $this->buildSystemPrompt($company);
|
||||||
->post('https://api.anthropic.com/v1/messages', [
|
$tools = AiToolExecutor::TOOLS;
|
||||||
|
$executor = app(AiToolExecutor::class);
|
||||||
|
|
||||||
|
$tokensIn = 0;
|
||||||
|
$tokensOut = 0;
|
||||||
|
$toolCalls = [];
|
||||||
|
$finalText = '';
|
||||||
|
$model = null;
|
||||||
|
|
||||||
|
// Loop on tool_use up to 5 rounds, then bail out with whatever text we have.
|
||||||
|
for ($round = 0; $round < 5; $round++) {
|
||||||
|
$r = Http::withHeaders($headers)->timeout(60)->post(
|
||||||
|
'https://api.anthropic.com/v1/messages',
|
||||||
|
[
|
||||||
'model' => 'claude-sonnet-4-5',
|
'model' => 'claude-sonnet-4-5',
|
||||||
'max_tokens' => 1024,
|
'max_tokens' => 1024,
|
||||||
'system' => $this->buildSystemPrompt($company),
|
'system' => $system,
|
||||||
|
'tools' => $tools,
|
||||||
'messages' => $messages,
|
'messages' => $messages,
|
||||||
]);
|
]
|
||||||
|
);
|
||||||
|
|
||||||
if (! $r->successful()) {
|
if (! $r->successful()) {
|
||||||
return ['❌ ' . ($r->json('error.message') ?? 'Anthropic API error ' . $r->status()), ['status' => $r->status()]];
|
return ['❌ ' . ($r->json('error.message') ?? 'Anthropic API error ' . $r->status()), ['status' => $r->status()]];
|
||||||
}
|
}
|
||||||
|
|
||||||
$body = $r->json();
|
$body = $r->json();
|
||||||
$text = collect($body['content'] ?? [])
|
$model = $body['model'] ?? $model;
|
||||||
->where('type', 'text')
|
$tokensIn += (int) ($body['usage']['input_tokens'] ?? 0);
|
||||||
->pluck('text')
|
$tokensOut += (int) ($body['usage']['output_tokens'] ?? 0);
|
||||||
->implode("\n");
|
|
||||||
|
|
||||||
return [$text ?: '(răspuns gol)', [
|
$blocks = $body['content'] ?? [];
|
||||||
'model' => $body['model'] ?? null,
|
$finalText = collect($blocks)->where('type', 'text')->pluck('text')->implode("\n");
|
||||||
'tokens_in' => $body['usage']['input_tokens'] ?? null,
|
|
||||||
'tokens_out' => $body['usage']['output_tokens'] ?? null,
|
if (($body['stop_reason'] ?? null) !== 'tool_use') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Append the assistant turn (with the tool_use block) to history.
|
||||||
|
$messages[] = ['role' => 'assistant', 'content' => $blocks];
|
||||||
|
|
||||||
|
// Execute every tool_use block and build the user reply with tool_results.
|
||||||
|
$toolResults = [];
|
||||||
|
foreach ($blocks as $b) {
|
||||||
|
if (($b['type'] ?? '') !== 'tool_use') continue;
|
||||||
|
$name = $b['name'];
|
||||||
|
$input = (array) ($b['input'] ?? []);
|
||||||
|
try {
|
||||||
|
$out = $executor->execute($name, $input);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$out = ['error' => $e->getMessage()];
|
||||||
|
}
|
||||||
|
$toolCalls[] = ['name' => $name, 'input' => $input];
|
||||||
|
$toolResults[] = [
|
||||||
|
'type' => 'tool_result',
|
||||||
|
'tool_use_id' => $b['id'],
|
||||||
|
'content' => json_encode($out, JSON_UNESCAPED_UNICODE),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
$messages[] = ['role' => 'user', 'content' => $toolResults];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$finalText ?: '(răspuns gol)', [
|
||||||
|
'model' => $model,
|
||||||
|
'tokens_in' => $tokensIn,
|
||||||
|
'tokens_out' => $tokensOut,
|
||||||
|
'tools' => $toolCalls,
|
||||||
]];
|
]];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
<?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];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Central\Company;
|
||||||
|
use App\Models\Central\Plan;
|
||||||
|
use App\Models\Tenant\AiChat;
|
||||||
|
use App\Models\Tenant\AiMessage;
|
||||||
|
use App\Models\Tenant\Client;
|
||||||
|
use App\Models\Tenant\Part;
|
||||||
|
use App\Models\Tenant\User;
|
||||||
|
use App\Services\Ai\AiAssistantService;
|
||||||
|
use App\Services\Ai\AiToolExecutor;
|
||||||
|
use App\Tenancy\TenantManager;
|
||||||
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Illuminate\Support\Facades\Http;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class AiToolUseTest extends TestCase
|
||||||
|
{
|
||||||
|
use RefreshDatabase;
|
||||||
|
|
||||||
|
public function test_tool_use_round_executes_tool_then_returns_final_text(): void
|
||||||
|
{
|
||||||
|
$ctx = $this->makeCtx();
|
||||||
|
// Seed a part that the AI will "find" via the tool.
|
||||||
|
Part::create([
|
||||||
|
'name' => 'Filtru ulei MANN', 'article' => 'W811/80', 'brand' => 'MANN',
|
||||||
|
'category' => 'Filtre', 'qty' => 5, 'min_qty' => 2, 'unit' => 'buc',
|
||||||
|
'sell_price' => 75, 'buy_price' => 50, 'is_active' => true,
|
||||||
|
]);
|
||||||
|
|
||||||
|
// First API response: Claude requests the tool.
|
||||||
|
// Second: Claude returns the final answer.
|
||||||
|
Http::fakeSequence('api.anthropic.com/*')
|
||||||
|
->push([
|
||||||
|
'content' => [
|
||||||
|
['type' => 'text', 'text' => 'Caut piesa…'],
|
||||||
|
['type' => 'tool_use', 'id' => 'toolu_1', 'name' => 'find_parts', 'input' => ['query' => 'MANN']],
|
||||||
|
],
|
||||||
|
'stop_reason' => 'tool_use',
|
||||||
|
'usage' => ['input_tokens' => 100, 'output_tokens' => 20],
|
||||||
|
'model' => 'claude-sonnet-4-5',
|
||||||
|
])
|
||||||
|
->push([
|
||||||
|
'content' => [['type' => 'text', 'text' => 'Am găsit Filtru ulei MANN, stoc 5 buc la 75 MDL.']],
|
||||||
|
'stop_reason' => 'end_turn',
|
||||||
|
'usage' => ['input_tokens' => 120, 'output_tokens' => 35],
|
||||||
|
'model' => 'claude-sonnet-4-5',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$chat = AiChat::create([
|
||||||
|
'user_id' => $ctx['user']->id, 'provider' => 'claude',
|
||||||
|
]);
|
||||||
|
|
||||||
|
$reply = app(AiAssistantService::class)->ask($chat, 'Ai filtru de ulei MANN?');
|
||||||
|
|
||||||
|
$this->assertEquals('assistant', $reply->role);
|
||||||
|
$this->assertStringContainsString('MANN', $reply->content);
|
||||||
|
$this->assertStringContainsString('75', $reply->content);
|
||||||
|
$this->assertEquals('find_parts', $reply->meta['tools'][0]['name']);
|
||||||
|
$this->assertEquals(220, $reply->meta['tokens_in']); // 100 + 120 cumulated
|
||||||
|
$this->assertEquals(55, $reply->meta['tokens_out']);
|
||||||
|
|
||||||
|
// Verify both API rounds happened with tools defined.
|
||||||
|
$rounds = 0;
|
||||||
|
Http::assertSent(function ($req) use (&$rounds) {
|
||||||
|
if (! str_contains($req->url(), 'api.anthropic.com')) return false;
|
||||||
|
$body = json_decode($req->body(), true);
|
||||||
|
$this->assertArrayHasKey('tools', $body);
|
||||||
|
$this->assertCount(5, $body['tools']);
|
||||||
|
$rounds++;
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
$this->assertEquals(2, $rounds);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_executor_finds_parts_by_brand(): void
|
||||||
|
{
|
||||||
|
$this->makeCtx();
|
||||||
|
Part::create(['name' => 'X', 'brand' => 'MANN', 'qty' => 1, 'unit' => 'buc',
|
||||||
|
'sell_price' => 10, 'buy_price' => 5, 'is_active' => true]);
|
||||||
|
Part::create(['name' => 'Y', 'brand' => 'Bosch', 'qty' => 1, 'unit' => 'buc',
|
||||||
|
'sell_price' => 10, 'buy_price' => 5, 'is_active' => true]);
|
||||||
|
|
||||||
|
$r = app(AiToolExecutor::class)->execute('find_parts', ['query' => 'MANN']);
|
||||||
|
$this->assertEquals(1, $r['count']);
|
||||||
|
$this->assertEquals('X', $r['rows'][0]['name']);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_executor_unknown_tool_returns_error(): void
|
||||||
|
{
|
||||||
|
$this->makeCtx();
|
||||||
|
$r = app(AiToolExecutor::class)->execute('does_not_exist', []);
|
||||||
|
$this->assertArrayHasKey('error', $r);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function makeCtx(): array
|
||||||
|
{
|
||||||
|
$plan = Plan::firstOrCreate(['slug' => 'test'], ['name' => 'T', 'price' => 0, 'features' => []]);
|
||||||
|
$company = Company::create([
|
||||||
|
'plan_id' => $plan->id, 'slug' => 'tool-' . uniqid(),
|
||||||
|
'name' => 'Tool Co', 'city' => 'Chișinău', 'status' => 'active',
|
||||||
|
'settings' => ['ai' => ['default_provider' => 'claude', 'claude_key' => 'sk-fake']],
|
||||||
|
]);
|
||||||
|
app(TenantManager::class)->setCurrent($company);
|
||||||
|
|
||||||
|
$user = User::create([
|
||||||
|
'company_id' => $company->id, 'name' => 'Op', 'email' => 'op-' . uniqid() . '@example.com',
|
||||||
|
'password' => bcrypt('x'), 'role' => 'admin', 'status' => 'active',
|
||||||
|
]);
|
||||||
|
$this->actingAs($user);
|
||||||
|
|
||||||
|
return compact('company', 'user');
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user