$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 <<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); $messages = array_values(array_filter($messages, fn ($m) => in_array($m['role'], ['user', 'assistant'], true))); // Normalize history to the structured content-block form (Claude tool-use // requires content blocks for the assistant turn that emitted tool_use). foreach ($messages as &$m) { if (is_string($m['content'])) { $m['content'] = [['type' => 'text', 'text' => $m['content']]]; } } unset($m); $headers = [ 'x-api-key' => $key, 'anthropic-version' => '2023-06-01', 'content-type' => 'application/json', ]; $system = $this->buildSystemPrompt($company); $tools = AiToolExecutor::TOOLS; $executor = app(AiToolExecutor::class); $tokensIn = 0; $tokensOut = 0; $toolCalls = []; $finalText = ''; $model = null; // Loop on tool_use up to 5 rounds, then bail out with whatever text we have. for ($round = 0; $round < 5; $round++) { $r = Http::withHeaders($headers)->timeout(60)->post( 'https://api.anthropic.com/v1/messages', [ 'model' => 'claude-sonnet-4-5', 'max_tokens' => 1024, 'system' => $system, 'tools' => $tools, 'messages' => $messages, ] ); if (! $r->successful()) { return ['❌ ' . ($r->json('error.message') ?? 'Anthropic API error ' . $r->status()), ['status' => $r->status()]]; } $body = $r->json(); $model = $body['model'] ?? $model; $tokensIn += (int) ($body['usage']['input_tokens'] ?? 0); $tokensOut += (int) ($body['usage']['output_tokens'] ?? 0); $blocks = $body['content'] ?? []; $finalText = collect($blocks)->where('type', 'text')->pluck('text')->implode("\n"); if (($body['stop_reason'] ?? null) !== 'tool_use') { break; } // Append the assistant turn (with the tool_use block) to history. $messages[] = ['role' => 'assistant', 'content' => $blocks]; // Execute every tool_use block and build the user reply with tool_results. $toolResults = []; foreach ($blocks as $b) { if (($b['type'] ?? '') !== 'tool_use') continue; $name = $b['name']; $input = (array) ($b['input'] ?? []); try { $out = $executor->execute($name, $input); } catch (\Throwable $e) { $out = ['error' => $e->getMessage()]; } $toolCalls[] = ['name' => $name, 'input' => $input]; $toolResults[] = [ 'type' => 'tool_result', 'tool_use_id' => $b['id'], 'content' => json_encode($out, JSON_UNESCAPED_UNICODE), ]; } $messages[] = ['role' => 'user', 'content' => $toolResults]; } return [$finalText ?: '(răspuns gol)', [ 'model' => $model, 'tokens_in' => $tokensIn, 'tokens_out' => $tokensOut, 'tools' => $toolCalls, ]]; } 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 */ 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, ]]; } }