Files
autocrm/tests/Feature/AiToolUseTest.php
T
Vasyka dfb92bf5e2 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>
2026-06-02 19:36:07 +00:00

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');
}
}