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:
2026-05-27 20:24:09 +00:00
parent 85ef2f6e00
commit 1ff888131f
11 changed files with 818 additions and 0 deletions
@@ -0,0 +1,26 @@
@php
$isError = isset($meta['error']);
$provider = $meta['provider'] ?? null;
$tokensIn = $meta['tokens_in'] ?? null;
$tokensOut = $meta['tokens_out'] ?? null;
$latency = $meta['latency_ms'] ?? null;
@endphp
<div class="space-y-3">
@if ($isError)
<div class="p-3 rounded-md bg-yellow-50 dark:bg-yellow-900/30 border border-yellow-200 dark:border-yellow-700 text-sm text-yellow-900 dark:text-yellow-100">
{{ $reply }}
</div>
@else
<div class="prose prose-sm dark:prose-invert max-w-none whitespace-pre-wrap leading-relaxed text-gray-900 dark:text-gray-100">{{ $reply }}</div>
@endif
@if (! $isError && ($provider || $tokensIn !== null))
<div class="text-xs text-gray-500 dark:text-gray-400 border-t border-gray-200 dark:border-gray-700 pt-2 flex flex-wrap gap-3">
@if ($provider) <span>Provider: <strong>{{ strtoupper($provider) }}</strong></span> @endif
@if ($tokensIn !== null || $tokensOut !== null)
<span>Tokens: <strong>{{ ($tokensIn ?? 0) + ($tokensOut ?? 0) }}</strong> ({{ $tokensIn ?? 0 }} in / {{ $tokensOut ?? 0 }} out)</span>
@endif
@if ($latency !== null) <span>Latency: {{ $latency }} ms</span> @endif
</div>
@endif
</div>
@@ -86,8 +86,21 @@
@php
$chat = $this->getChat();
$chats = $this->getChats();
$usage = $this->getUsage();
$totalTokens = collect($usage)->sum(fn ($u) => $u['tokens_in'] + $u['tokens_out']);
$totalCalls = collect($usage)->sum('calls');
@endphp
@if ($totalCalls > 0)
<div style="margin-bottom:12px;padding:10px 14px;background:#eff6ff;border:1px solid #bfdbfe;border-radius:8px;font-size:12px;color:#1e3a8a;">
<strong>Tokens consumate luna aceasta:</strong>
{{ number_format($totalTokens) }} tokens · {{ $totalCalls }} cereri
@foreach ($usage as $provider => $u)
· {{ strtoupper($provider) }}: {{ number_format($u['tokens_in'] + $u['tokens_out']) }}
@endforeach
</div>
@endif
<div class="ai-wrap" x-data x-init="$nextTick(() => { const m = $refs.msgs; if (m) m.scrollTop = m.scrollHeight; })">
{{-- Sidebar: chat history --}}
<div class="ai-side">
@@ -0,0 +1,43 @@
@php
$valid = $info['valid_length'] ?? false;
@endphp
<div class="space-y-4">
@if (! $valid)
<div class="p-3 rounded-md bg-yellow-50 dark:bg-yellow-900/30 border border-yellow-200 dark:border-yellow-700 text-sm text-yellow-900 dark:text-yellow-100">
{{ $info['reason'] ?? 'VIN invalid.' }}
</div>
@else
<dl class="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
<dt class="text-gray-500 dark:text-gray-400">VIN</dt>
<dd class="font-mono text-gray-900 dark:text-gray-100">{{ $info['vin'] }}</dd>
<dt class="text-gray-500 dark:text-gray-400">Regiune</dt>
<dd class="text-gray-900 dark:text-gray-100">{{ $info['region'] ?? '—' }}</dd>
<dt class="text-gray-500 dark:text-gray-400">Țară</dt>
<dd class="text-gray-900 dark:text-gray-100">{{ $info['country'] ?? '—' }}</dd>
<dt class="text-gray-500 dark:text-gray-400">Producător</dt>
<dd class="text-gray-900 dark:text-gray-100 font-semibold">{{ $info['manufacturer'] ?? '— (WMI necunoscut)' }}</dd>
<dt class="text-gray-500 dark:text-gray-400">An model</dt>
<dd class="text-gray-900 dark:text-gray-100 font-semibold">{{ $info['year'] ?? '—' }}</dd>
<dt class="text-gray-500 dark:text-gray-400">Cod uzină</dt>
<dd class="font-mono text-gray-900 dark:text-gray-100">{{ $info['plant'] ?? '—' }}</dd>
<dt class="text-gray-500 dark:text-gray-400">Checksum (ISO)</dt>
<dd class="text-gray-900 dark:text-gray-100">
@if ($info['checksum_valid'])
<span class="text-green-600 dark:text-green-400"> valid</span>
@else
<span class="text-gray-500"> (multe VIN-uri europene nu respectă checksum-ul NA)</span>
@endif
</dd>
</dl>
<div class="text-xs text-gray-500 dark:text-gray-400 border-t border-gray-200 dark:border-gray-700 pt-2">
Detalii granulare (model, motorizare) necesită bază TecDoc/NHTSA. Folosește butonul „AI: recomandări" pentru sugestii bazate pe an + producător.
</div>
@endif
</div>