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:
@@ -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>
|
||||
Reference in New Issue
Block a user