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:
@@ -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