Files
autocrm/app/Services/Ai/AiAssistantService.php
T
Vasyka 1ff888131f Stage 16 — AI Layer: VIN decoder + diagnostic / parts / price helpers
VinDecoder (deterministic, no API):
- ISO 3779/3780 parsing: WMI manufacturer (~60 brands), year (cyclical with
  post-2010 disambiguation via position 7), region, plant, NA checksum
- Strip non-VIN chars, accept dashes/spaces, reject I/O/Q per spec

AiAssistantService:
- Refactored provider HTTP into postClaude/postOpenAI/postGemini so both
  chat history and one-shot calls share the same transport
- singleShot(system, userPrompt, provider?) for fire-and-forget calls
- 4 specialized helpers with tight prompts:
  - suggestDiagnosis(WO) — diagnostician based on complaint + VIN info
  - suggestParts(WO, task) — OEM parts list for an operation
  - suggestPrice(Part) — markup recommendation with justification
  - vinRecommendations(vin, mileage) — scheduled maintenance from decoded VIN
- monthlyUsage() — token spend MTD by provider

Filament:
- VehicleResource: "Decode VIN" + "AI: recomandări" actions
- WorkOrderResource Edit: "AI: sugerează diagnostic" header action
- PartResource: "AI: preț recomandat" action
- Shared views: filament.tenant.ai-reply, filament.tenant.vin-decode
- AiAssistant page shows monthly token usage banner

Tests (13 new):
- 8 VinDecoder unit tests with real VIN samples (Honda 2003, VW 1999, Audi
  2014, Dacia, unknown WMI, lowercase/dashes, forbidden chars)
- 5 AiHelpers feature tests with Http::fake covering all providers + no-key
  fallback + token usage aggregation

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 20:24:09 +00:00

440 lines
18 KiB
PHP

<?php
namespace App\Services\Ai;
use App\Models\Central\Company;
use App\Models\Tenant\AiChat;
use App\Models\Tenant\AiMessage;
use App\Models\Tenant\Client;
use App\Models\Tenant\Lead;
use App\Models\Tenant\Part;
use App\Models\Tenant\Vehicle;
use App\Models\Tenant\WorkOrder;
use App\Tenancy\TenantManager;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
/**
* Multi-provider AI client (Claude / GPT / Gemini). Provider + API key are
* read from tenant settings.ai. Chat history persists in ai_chats/ai_messages.
*/
class AiAssistantService
{
public function ask(AiChat $chat, string $userMessage): AiMessage
{
// Persist user message.
AiMessage::create([
'company_id' => $chat->company_id,
'ai_chat_id' => $chat->id,
'role' => 'user',
'content' => $userMessage,
]);
$company = Company::withoutGlobalScopes()->findOrFail($chat->company_id);
$aiCfg = (array) ($company->settings['ai'] ?? []);
$provider = $chat->provider ?: ($aiCfg['default_provider'] ?? 'claude');
$apiKey = $aiCfg["{$provider}_key"] ?? null;
$start = microtime(true);
if (! $apiKey) {
$reply = '⚠️ API key pentru ' . strtoupper($provider) . ' nu e configurat. Mergi la Setări → AI și adaugă cheia.';
$meta = ['error' => 'no_api_key'];
} else {
try {
[$reply, $meta] = match ($provider) {
'claude' => $this->callClaude($apiKey, $chat, $userMessage, $company),
'gpt' => $this->callOpenAI($apiKey, $chat, $userMessage, $company),
'gemini' => $this->callGemini($apiKey, $chat, $userMessage, $company),
default => ['Provider necunoscut: ' . $provider, []],
};
} catch (\Throwable $e) {
$reply = '❌ Eroare API: ' . $e->getMessage();
$meta = ['error' => $e->getMessage()];
}
}
$meta['latency_ms'] = (int) ((microtime(true) - $start) * 1000);
$meta['provider'] = $provider;
$msg = AiMessage::create([
'company_id' => $chat->company_id,
'ai_chat_id' => $chat->id,
'role' => 'assistant',
'content' => $reply,
'meta' => $meta,
]);
// Update chat title from first user message.
if ($chat->messages()->where('role', 'user')->count() === 1) {
$chat->update(['title' => mb_substr($userMessage, 0, 60)]);
}
$chat->touch();
return $msg;
}
protected function buildSystemPrompt(Company $company): string
{
$clientsCount = Client::count();
$vehiclesCount = Vehicle::count();
$newLeads = Lead::where('status', 'new')->count();
$openWO = WorkOrder::whereNotIn('status', ['done', 'cancelled'])->count();
$debtTotal = (float) WorkOrder::where('pay_status', '!=', 'paid')
->whereNotIn('status', ['cancelled'])
->get()->sum(fn ($w) => max(0, (float) $w->total - (float) $w->payments()->sum('amount')));
return <<<TXT
Ești asistent CRM pentru autoservice "{$company->name}" din {$company->city}.
Răspunzi în română (sau în limba folosită de utilizator). Fii concis, profesional, util.
Statistici curente:
- {$clientsCount} clienți, {$vehiclesCount} mașini
- {$newLeads} cereri noi neprocesate
- {$openWO} fișe de lucru active
- Total datorii clienți: {$debtTotal} MDL
Poți ajuta cu: sugestii diagnostic auto, calcul costuri, sumar performanță,
recomandări piese, redactare mesaje pentru clienți, planificare. NU inventezi
date care nu apar în context — dacă nu știi, recunoaște.
TXT;
}
/** Convert chat history to provider-specific message format. */
protected function historyMessages(AiChat $chat, int $limit = 20): array
{
return $chat->messages()
->latest('created_at')
->limit($limit)
->get()
->reverse()
->values()
->map(fn (AiMessage $m) => ['role' => $m->role, 'content' => $m->content])
->all();
}
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,
]);
if (! $r->successful()) {
return ['❌ ' . ($r->json('error.message') ?? 'Anthropic API error ' . $r->status()), ['status' => $r->status()]];
}
$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,
]];
}
protected function callOpenAI(string $key, AiChat $chat, string $msg, Company $company): array
{
$messages = array_merge(
[['role' => 'system', 'content' => $this->buildSystemPrompt($company)]],
$this->historyMessages($chat),
);
$r = Http::withHeaders(['Authorization' => 'Bearer ' . $key, 'content-type' => 'application/json'])
->timeout(60)
->post('https://api.openai.com/v1/chat/completions', [
'model' => 'gpt-4o-mini',
'messages' => $messages,
'max_tokens' => 1024,
]);
if (! $r->successful()) {
return ['❌ ' . ($r->json('error.message') ?? 'OpenAI error ' . $r->status()), ['status' => $r->status()]];
}
$body = $r->json();
return [
$body['choices'][0]['message']['content'] ?? '(răspuns gol)',
[
'model' => $body['model'] ?? null,
'tokens_in' => $body['usage']['prompt_tokens'] ?? null,
'tokens_out' => $body['usage']['completion_tokens'] ?? null,
],
];
}
// ─── One-shot AI helpers ─────────────────────────────────────────
/**
* Single-prompt call without persisting a chat. Used by quick-action buttons
* (diagnose / suggest parts / suggest price / VIN recommendations).
*
* @return array{0:string, 1:array} [reply, meta]
*/
public function singleShot(string $systemPrompt, string $userPrompt, ?string $provider = null): array
{
$company = $this->currentCompany();
if (! $company) return ['Niciun tenant rezolvat.', ['error' => 'no_tenant']];
$aiCfg = (array) ($company->settings['ai'] ?? []);
$provider ??= $aiCfg['default_provider'] ?? 'claude';
$key = $aiCfg["{$provider}_key"] ?? null;
if (! $key) {
return ['⚠️ API key pentru ' . strtoupper($provider) . ' lipsește în Setări → AI.',
['error' => 'no_api_key', 'provider' => $provider]];
}
$start = microtime(true);
try {
[$reply, $meta] = match ($provider) {
'claude' => $this->postClaude($key, $systemPrompt, [['role' => 'user', 'content' => $userPrompt]]),
'gpt' => $this->postOpenAI($key, $systemPrompt, [['role' => 'user', 'content' => $userPrompt]]),
'gemini' => $this->postGemini($key, $systemPrompt, [['role' => 'user', 'content' => $userPrompt]]),
default => ['Provider necunoscut: ' . $provider, []],
};
} catch (\Throwable $e) {
$reply = '❌ Eroare API: ' . $e->getMessage();
$meta = ['error' => $e->getMessage()];
}
$meta['latency_ms'] = (int) ((microtime(true) - $start) * 1000);
$meta['provider'] = $provider;
return [$reply, $meta];
}
public function suggestDiagnosis(WorkOrder $wo): array
{
$vehicle = $wo->vehicle;
$vinInfo = '';
if ($vehicle?->vin) {
$d = app(VinDecoder::class)->decode($vehicle->vin);
if (! empty($d['manufacturer'])) {
$vinInfo = " (VIN decode: {$d['manufacturer']} {$d['year']})";
}
}
$sys = "Ești diagnostician auto cu 20 de ani experiență. Răspunzi scurt, structurat, "
. "în română. Listează în ordine de probabilitate cauzele posibile + verificările "
. "necesare. Nu inventezi date — dacă plângerea e vagă, sugerezi întrebări de clarificat.";
$user = sprintf(
"Mașina: %s %s %s%s, %s km.\nPlângere client: %s\n\nDă-mi top 3 cauze probabile și ce trebuie verificat la fiecare.",
(string) ($vehicle->make ?? '?'),
(string) ($vehicle->model ?? '?'),
(string) ($vehicle->year ?? ''),
$vinInfo,
number_format((float) ($vehicle->mileage ?? 0), 0, '.', ' '),
$wo->complaint ?: '(nu e completată)',
);
return $this->singleShot($sys, $user);
}
public function suggestParts(WorkOrder $wo, string $task): array
{
$vehicle = $wo->vehicle;
$sys = "Ești expert piese auto. Pentru mașina dată și operațiunea solicitată, "
. "listează piesele necesare cu coduri OEM tipice când le știi (sau familie generică). "
. "Răspunde în română, format listă.";
$user = sprintf(
"Mașina: %s %s %s.\nOperațiune: %s.\nListează piesele necesare + coduri OEM dacă există + cantități.",
(string) ($vehicle->make ?? '?'),
(string) ($vehicle->model ?? '?'),
(string) ($vehicle->year ?? ''),
$task,
);
return $this->singleShot($sys, $user);
}
public function suggestPrice(Part $part): array
{
$company = $this->currentCompany();
$currency = (string) data_get($company?->settings, 'currency', 'MDL');
$sys = "Ești manager comercial pentru un magazin de piese auto din Moldova. "
. "Sugerezi preț de vânzare bazat pe costul de achiziție, categorie și brand. "
. "Răspunde scurt cu: preț recomandat, markup %, justificare 1-2 fraze.";
$user = sprintf(
"Piesa: %s\nBrand: %s\nCategorie: %s\nCost achiziție: %.2f %s\nPreț actual: %.2f %s",
$part->name,
$part->brand ?? '?',
$part->category ?? '?',
(float) $part->buy_price, $currency,
(float) $part->sell_price, $currency,
);
return $this->singleShot($sys, $user);
}
public function vinRecommendations(string $vin, ?int $mileage = null): array
{
$decoded = app(VinDecoder::class)->decode($vin);
if (empty($decoded['manufacturer'])) {
return ["VIN nu poate fi decodat — verifică formatul (17 caractere).", ['error' => 'invalid_vin']];
}
$sys = "Ești expert service auto. Pe baza datelor mașinii și kilometrajului, "
. "sugerezi mentenanța programată recomandată de producător + verificările "
. "tipice pentru vârsta mașinii. Format listă scurtă.";
$user = sprintf(
"Mașina decodată din VIN: %s din %d (%s).\n%sCe verificări și mentenanță programată recomandăm acum?",
$decoded['manufacturer'],
$decoded['year'],
$decoded['country'] ?? '?',
$mileage ? "Kilometraj actual: " . number_format($mileage, 0, '.', ' ') . " km.\n" : '',
);
[$reply, $meta] = $this->singleShot($sys, $user);
$meta['vin_decoded'] = $decoded;
return [$reply, $meta];
}
// ─── Token usage tracking ────────────────────────────────────────
/**
* Aggregate token spend for current month, grouped by provider.
*
* @return array<string, array{tokens_in:int, tokens_out:int, calls:int}>
*/
public function monthlyUsage(): array
{
$start = Carbon::now()->startOfMonth();
$rows = AiMessage::where('role', 'assistant')
->where('created_at', '>=', $start)
->get(['meta']);
$out = [];
foreach ($rows as $r) {
$meta = (array) $r->meta;
$provider = (string) ($meta['provider'] ?? 'unknown');
$out[$provider] ??= ['tokens_in' => 0, 'tokens_out' => 0, 'calls' => 0];
$out[$provider]['tokens_in'] += (int) ($meta['tokens_in'] ?? 0);
$out[$provider]['tokens_out'] += (int) ($meta['tokens_out'] ?? 0);
$out[$provider]['calls'] += 1;
}
return $out;
}
// ─── Provider HTTP — common form, used by both chat + single-shot ─
protected function postClaude(string $key, string $system, array $messages): array
{
$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' => $system,
'messages' => $messages,
]);
if (! $r->successful()) {
return ['❌ ' . ($r->json('error.message') ?? 'Anthropic ' . $r->status()), ['status' => $r->status()]];
}
$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,
]];
}
protected function postOpenAI(string $key, string $system, array $messages): array
{
$r = Http::withHeaders(['Authorization' => 'Bearer ' . $key, 'content-type' => 'application/json'])
->timeout(60)
->post('https://api.openai.com/v1/chat/completions', [
'model' => 'gpt-4o-mini',
'messages' => array_merge([['role' => 'system', 'content' => $system]], $messages),
'max_tokens' => 1024,
]);
if (! $r->successful()) {
return ['❌ ' . ($r->json('error.message') ?? 'OpenAI ' . $r->status()), ['status' => $r->status()]];
}
$body = $r->json();
return [
$body['choices'][0]['message']['content'] ?? '(răspuns gol)',
[
'model' => $body['model'] ?? null,
'tokens_in' => $body['usage']['prompt_tokens'] ?? null,
'tokens_out' => $body['usage']['completion_tokens'] ?? null,
],
];
}
protected function postGemini(string $key, string $system, array $messages): array
{
$contents = [];
foreach ($messages as $m) {
$contents[] = [
'role' => $m['role'] === 'assistant' ? 'model' : 'user',
'parts' => [['text' => $m['content']]],
];
}
$r = Http::withHeaders(['content-type' => 'application/json'])
->timeout(60)
->post('https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=' . $key, [
'systemInstruction' => ['parts' => [['text' => $system]]],
'contents' => $contents,
'generationConfig' => ['maxOutputTokens' => 1024],
]);
if (! $r->successful()) {
return ['❌ Gemini ' . $r->status(), ['status' => $r->status()]];
}
$body = $r->json();
$text = $body['candidates'][0]['content']['parts'][0]['text'] ?? '(răspuns gol)';
return [$text, ['model' => 'gemini-1.5-flash', 'tokens' => $body['usageMetadata'] ?? null]];
}
protected function currentCompany(): ?Company
{
$id = app(TenantManager::class)->currentId();
if (! $id) return null;
return Company::withoutGlobalScopes()->find($id);
}
protected function callGemini(string $key, AiChat $chat, string $msg, Company $company): array
{
$contents = [];
foreach ($this->historyMessages($chat) as $m) {
$contents[] = [
'role' => $m['role'] === 'assistant' ? 'model' : 'user',
'parts' => [['text' => $m['content']]],
];
}
$r = Http::withHeaders(['content-type' => 'application/json'])
->timeout(60)
->post('https://generativelanguage.googleapis.com/v1beta/models/gemini-1.5-flash:generateContent?key=' . $key, [
'systemInstruction' => ['parts' => [['text' => $this->buildSystemPrompt($company)]]],
'contents' => $contents,
'generationConfig' => ['maxOutputTokens' => 1024],
]);
if (! $r->successful()) {
return ['❌ Gemini error ' . $r->status() . ': ' . $r->body(), ['status' => $r->status()]];
}
$body = $r->json();
$text = $body['candidates'][0]['content']['parts'][0]['text'] ?? '(răspuns gol)';
return [$text, [
'model' => 'gemini-1.5-flash',
'tokens' => $body['usageMetadata'] ?? null,
]];
}
}