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>
117 lines
4.5 KiB
PHP
117 lines
4.5 KiB
PHP
<?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');
|
|
}
|
|
}
|