From dfb92bf5e2c098610db8e778d253a9641810408e Mon Sep 17 00:00:00 2001 From: Vasyka Date: Tue, 2 Jun 2026 19:36:07 +0000 Subject: [PATCH] feat: AI chat tool-use (Claude function calling) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- app/Services/Ai/AiAssistantService.php | 104 ++++++++++++---- app/Services/Ai/AiToolExecutor.php | 164 +++++++++++++++++++++++++ tests/Feature/AiToolUseTest.php | 116 +++++++++++++++++ 3 files changed, 359 insertions(+), 25 deletions(-) create mode 100644 app/Services/Ai/AiToolExecutor.php create mode 100644 tests/Feature/AiToolUseTest.php diff --git a/app/Services/Ai/AiAssistantService.php b/app/Services/Ai/AiAssistantService.php index 6343c8f..de896c3 100644 --- a/app/Services/Ai/AiAssistantService.php +++ b/app/Services/Ai/AiAssistantService.php @@ -117,36 +117,90 @@ TXT; protected function callClaude(string $key, AiChat $chat, string $msg, Company $company): array { $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))); - $r = Http::withHeaders([ - 'x-api-key' => $key, - 'anthropic-version' => '2023-06-01', - 'content-type' => 'application/json', - ]) - ->timeout(60) - ->post('https://api.anthropic.com/v1/messages', [ - 'model' => 'claude-sonnet-4-5', - 'max_tokens' => 1024, - 'system' => $this->buildSystemPrompt($company), - 'messages' => $messages, - ]); + // 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); - if (! $r->successful()) { - return ['❌ ' . ($r->json('error.message') ?? 'Anthropic API error ' . $r->status()), ['status' => $r->status()]]; + $headers = [ + 'x-api-key' => $key, + 'anthropic-version' => '2023-06-01', + 'content-type' => 'application/json', + ]; + $system = $this->buildSystemPrompt($company); + $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', + 'max_tokens' => 1024, + 'system' => $system, + 'tools' => $tools, + 'messages' => $messages, + ] + ); + + if (! $r->successful()) { + return ['❌ ' . ($r->json('error.message') ?? 'Anthropic API error ' . $r->status()), ['status' => $r->status()]]; + } + + $body = $r->json(); + $model = $body['model'] ?? $model; + $tokensIn += (int) ($body['usage']['input_tokens'] ?? 0); + $tokensOut += (int) ($body['usage']['output_tokens'] ?? 0); + + $blocks = $body['content'] ?? []; + $finalText = collect($blocks)->where('type', 'text')->pluck('text')->implode("\n"); + + 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]; } - $body = $r->json(); - $text = collect($body['content'] ?? []) - ->where('type', 'text') - ->pluck('text') - ->implode("\n"); - - return [$text ?: '(răspuns gol)', [ - 'model' => $body['model'] ?? null, - 'tokens_in' => $body['usage']['input_tokens'] ?? null, - 'tokens_out' => $body['usage']['output_tokens'] ?? null, + return [$finalText ?: '(răspuns gol)', [ + 'model' => $model, + 'tokens_in' => $tokensIn, + 'tokens_out' => $tokensOut, + 'tools' => $toolCalls, ]]; } diff --git a/app/Services/Ai/AiToolExecutor.php b/app/Services/Ai/AiToolExecutor.php new file mode 100644 index 0000000..4df7356 --- /dev/null +++ b/app/Services/Ai/AiToolExecutor.php @@ -0,0 +1,164 @@ + '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]; + } +} diff --git a/tests/Feature/AiToolUseTest.php b/tests/Feature/AiToolUseTest.php new file mode 100644 index 0000000..d2babc4 --- /dev/null +++ b/tests/Feature/AiToolUseTest.php @@ -0,0 +1,116 @@ +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'); + } +}