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>
This commit is contained in:
@@ -7,8 +7,12 @@ 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;
|
||||
|
||||
/**
|
||||
@@ -175,6 +179,235 @@ TXT;
|
||||
];
|
||||
}
|
||||
|
||||
// ─── 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 = [];
|
||||
|
||||
Reference in New Issue
Block a user