apiKey = config('services.gemini.api_key', ''); $model = config('services.gemini.model', 'gemini-3-flash-preview'); $this->apiUrl = "https://generativelanguage.googleapis.com/v1beta/models/{$model}:generateContent"; } // ───────────────────────────────────────────────────────────────────────── // Public entry point // ───────────────────────────────────────────────────────────────────────── /** * Process a user message in context of the prior conversation. * * @param array $chatHistory Previous turns: [['role'=>'user'|'assistant','content'=>'…'], …] * @param string $userInput The new message just typed. * @return string Plain-text reply to show in the chat bubble. */ public function processMessage(array $chatHistory, string $userInput): string { if (empty($this->apiKey)) { return '⚠️ AI features are not configured. Please set GEMINI_API_KEY in your .env file.'; } // ── Step 1: Classify the intent ─────────────────────────────────────── try { $classification = $this->classifyWithGemini($chatHistory, $userInput); } catch (\RuntimeException $e) { // Surface the specific error directly in the chat bubble return $e->getMessage(); } $task = $classification['task'] ?? 'unknown'; $params = $classification['params'] ?? []; $missing = $classification['missing'] ?? []; $clarification = $classification['clarification'] ?? null; // ── Step 2: Missing required params → ask user for them ─────────────── if (! empty($missing) && ! empty($clarification)) { return $clarification; } // ── Step 3: Dispatch to the appropriate handler ─────────────────────── return match ($task) { 'invoice_status' => $this->handleInvoiceStatus($params), 'invoice_report' => $this->handleInvoiceReport($params), 'production_report' => $this->handleProductionReport($params), // ── Unknown intent: hand off to Gemini as a free-form assistant ── 'unknown' => $this->handleUnknown($chatHistory, $userInput, $clarification), default => $this->handleUnknown($chatHistory, $userInput, null), }; } // ───────────────────────────────────────────────────────────────────────── // Gemini API calls // ───────────────────────────────────────────────────────────────────────── /** * Phase 1 — Classify the user's intent and extract structured params. * * @return array|null Parsed JSON array, or null on failure. */ /** * @throws \RuntimeException with a user-facing message describing exactly what failed. */ private function classifyWithGemini(array $history, string $userInput): array { $requestBody = [ 'system_instruction' => [ 'parts' => [['text' => $this->buildSystemPrompt()]], ], 'contents' => $this->buildGeminiContents($history, $userInput), 'generationConfig' => [ 'temperature' => 0.1, 'responseMimeType' => 'application/json', ], ]; // ── HTTP call ───────────────────────────────────────────────────────── try { $response = Http::withHeaders(['Content-Type' => 'application/json']) ->timeout(15) ->post($this->apiUrl . '?key=' . $this->apiKey, $requestBody); } catch (\Exception $e) { Log::error('GeminiChatbotService: HTTP exception', ['error' => $e->getMessage()]); throw new \RuntimeException( '⚠️ Could not reach the Gemini API. Check your network or firewall. (' . $e->getMessage() . ')' ); } // ── HTTP-level error ────────────────────────────────────────────────── if (! $response->successful()) { $status = $response->status(); $body = $response->body(); Log::error('GeminiChatbotService: API HTTP error', [ 'status' => $status, 'body' => $body, ]); // Parse Google's error message when available $googleMsg = $response->json('error.message') ?? $body; throw new \RuntimeException( "⚠️ Gemini API returned HTTP {$status}: {$googleMsg}" ); } // ── Extract text from response ──────────────────────────────────────── $text = $response->json('candidates.0.content.parts.0.text'); if (empty($text)) { // Check for prompt-blocking $blockReason = $response->json('promptFeedback.blockReason'); $finishReason = $response->json('candidates.0.finishReason'); Log::error('GeminiChatbotService: empty response text', [ 'blockReason' => $blockReason, 'finishReason' => $finishReason, 'full' => $response->json(), ]); $hint = $blockReason ? "prompt was blocked (reason: {$blockReason})" : ($finishReason ? "finish reason: {$finishReason}" : 'no text returned'); throw new \RuntimeException("⚠️ Gemini returned no content — {$hint}"); } // ── JSON decode ─────────────────────────────────────────────────────── $clean = preg_replace('/^```json\s*/i', '', trim($text)); $clean = preg_replace('/\s*```$/i', '', $clean); $parsed = json_decode($clean, true); if (json_last_error() !== JSON_ERROR_NONE) { Log::error('GeminiChatbotService: JSON decode failed', ['raw' => $text]); throw new \RuntimeException( '⚠️ Gemini returned a non-JSON response: ' . mb_substr($text, 0, 200) ); } return $parsed; } /** * Phase 2 (unknown task only) — Ask Gemini to respond conversationally. * * Sends the full chat history + new user message to Gemini as a friendly * factory-operations assistant (no JSON constraint). Gemini can ask for * clarification, answer general questions, or guide the user to one of the * supported tasks. * * @param string|null $hintFromClassification Optional clarification from the * classification step — prepended as assistant context if present. * @return string Plain-text reply from Gemini. */ private function callGeminiConversational( array $history, string $userInput, ?string $hintFromClassification = null ): ?string { // If the classifier already produced a good clarification question, use it // directly and skip the second API call to save latency + quota. if (! empty($hintFromClassification)) { return $hintFromClassification; } $requestBody = [ 'system_instruction' => [ 'parts' => [['text' => $this->buildConversationalSystemPrompt()]], ], 'contents' => $this->buildGeminiContents($history, $userInput), 'generationConfig' => [ 'temperature' => 0.7, // more natural conversational tone 'maxOutputTokens' => 400, ], ]; try { $response = Http::withHeaders(['Content-Type' => 'application/json']) ->timeout(20) ->post($this->apiUrl . '?key=' . $this->apiKey, $requestBody); if (! $response->successful()) { Log::error('GeminiChatbotService: API error (conversational)', [ 'status' => $response->status(), 'body' => $response->body(), ]); return null; } return $response->json('candidates.0.content.parts.0.text'); } catch (\Exception $e) { Log::error('GeminiChatbotService: exception (conversational)', ['error' => $e->getMessage()]); return null; } } // ───────────────────────────────────────────────────────────────────────── // Prompt builders // ───────────────────────────────────────────────────────────────────────── /** * System prompt for Phase 1 (classification) — forces JSON output. */ private function buildSystemPrompt(): string { $today = now()->format('Y-m-d'); $startOfMonth = now()->startOfMonth()->format('Y-m-d'); return << $msg['role'] === 'user' ? 'user' : 'model', 'parts' => [['text' => $msg['content']]], ]; } $contents[] = [ 'role' => 'user', 'parts' => [['text' => $userInput]], ]; return $contents; } // ───────────────────────────────────────────────────────────────────────── // Task handlers // ───────────────────────────────────────────────────────────────────────── /** * Invoice Status — delegate to ChatbotService (regex-based, same as basic mode). */ private function handleInvoiceStatus(array $params): string { $invoiceNumber = trim(preg_replace('/\s+/', '', $params['invoice_number'] ?? '')); if (empty($invoiceNumber)) { return 'I need the invoice number to check the scan status. What is the invoice number?'; } /** @var ChatbotService $svc */ $svc = app(ChatbotService::class); return $svc->ask("invoice = {$invoiceNumber}"); } /** * Invoice Report — resolves plant name with fuzzy matching, then runs the * same CTE query as ChatBot::fetchInvoiceReport() directly. * * We bypass ChatbotService::ask() here so that resolvePlant()'s multi-strategy * fuzzy logic is applied rather than the simpler LIKE inside ChatbotService. */ private function handleInvoiceReport(array $params): string { $itemCode = trim($params['item_code'] ?? ''); $plantName = trim($params['plant_name'] ?? ''); if (empty($itemCode)) { return 'I need the item code to look up the invoice type. What is the item code?'; } if (empty($plantName)) { return 'I need the plant name to look up the invoice type. Which plant are you asking about?'; } // ── Fuzzy-resolve the plant name ────────────────────────────────────── $plant = $this->resolvePlant($plantName); if ($plant === null) { return "I couldn't find a plant matching \"{$plantName}\". " . 'Please check the plant name and try again.'; } // ── Run the same CTE as ChatBot::fetchInvoiceReport() ───────────────── try { $rows = DB::select(" WITH plant_item AS ( SELECT ? AS user_plant, ? AS user_item_code ), t1 AS ( SELECT plants.id AS plant_id, plants.name AS plant_name, ARRAY_AGG(items.code) AS item_codes FROM plants LEFT JOIN items ON plants.id = items.plant_id GROUP BY plants.id, plants.name ), t2 AS ( SELECT t1.plant_id, t1.plant_name, CASE WHEN plant_item.user_item_code = ANY(t1.item_codes) THEN 1 ELSE 0 END AS exists_flag FROM t1 CROSS JOIN plant_item WHERE t1.plant_name = plant_item.user_plant ), t3 AS ( SELECT t2.plant_id, t2.plant_name, t2.exists_flag, plant_item.user_item_code FROM t2 LEFT JOIN plant_item ON plant_item.user_plant = t2.plant_name ), t4 AS ( SELECT items.id AS item_id, t3.plant_id, t3.plant_name, t3.exists_flag, t3.user_item_code FROM t3 LEFT JOIN items ON t3.plant_id = items.plant_id AND t3.user_item_code = items.code ) SELECT t4.item_id, t4.plant_id, t4.plant_name, t4.exists_flag, t4.user_item_code, COALESCE(sticker_masters.material_type, 0) AS material_type, CASE WHEN sticker_masters.item_id IS NULL THEN 'no match found' WHEN COALESCE(sticker_masters.material_type, 0) = 0 THEN 'serial invoice' ELSE 'material invoice' END AS invoice_description FROM t4 LEFT JOIN sticker_masters ON sticker_masters.plant_id = t4.plant_id AND sticker_masters.item_id = t4.item_id ", [$plant->name, $itemCode]); } catch (\Exception $e) { Log::error('GeminiChatbotService: invoice report query failed', [ 'plant' => $plant->name, 'item_code' => $itemCode, 'error' => $e->getMessage(), ]); return "Sorry, I couldn't fetch data. Please try again or contact support."; } if (empty($rows)) { return "No data found for plant \"{$plant->name}\". Please verify the plant name."; } $row = $rows[0]; if ((int) $row->exists_flag === 0) { return 'The provided item code does not exist in the item table.'; } return match ($row->invoice_description) { 'serial invoice' => 'It is a serial invoice item.', 'material invoice' => 'It is a material invoice item.', 'no match found' => "Item not found in sticker master for plant {$plant->name}.", default => 'Unexpected result. Please contact support.', }; } /** * Production Report — resolves plant/line names to IDs and runs the count query. * Mirrors ChatBot::fetchProduction() but works with plain names instead of IDs, * using resolvePlant() for robust fuzzy matching. */ private function handleProductionReport(array $params): string { $plantName = trim($params['plant_name'] ?? ''); $lineName = trim($params['line_name'] ?? ''); $dateFrom = $params['date_from'] ?? now()->startOfMonth()->format('Y-m-d'); $dateTo = $params['date_to'] ?? now()->format('Y-m-d'); if (empty($plantName)) { return 'I need a plant name to fetch the production report. Which plant are you asking about?'; } // ── Fuzzy-resolve the plant name ────────────────────────────────────── $plant = $this->resolvePlant($plantName); if ($plant === null) { return "I couldn't find a plant matching \"{$plantName}\". " . 'Please check the plant name and try again.'; } // ── Base query ──────────────────────────────────────────────────────── $query = DB::table('production_quantities') ->whereNull('deleted_at') ->where('plant_id', $plant->id) ->whereDate('created_at', '>=', $dateFrom) ->whereDate('created_at', '<=', $dateTo); $lineLabel = 'All Lines'; // ── Optionally filter by line (fuzzy LIKE match) ────────────────────── if (! empty($lineName)) { $line = $this->resolveLine($lineName, $plant->id); if ($line === null) { return "I couldn't find a line matching \"{$lineName}\" " . "in plant \"{$plant->name}\". Please check the line name."; } $query->where('line_id', $line->id); $lineLabel = $line->name; } try { $count = $query->count(); } catch (\Exception $e) { Log::error('GeminiChatbotService: production query failed', [ 'plant' => $plant->name, 'line' => $lineLabel, 'error' => $e->getMessage(), ]); return "Sorry, I couldn't fetch production data for {$plant->name}. " . 'Please try again or contact support.'; } $from = \Carbon\Carbon::parse($dateFrom)->format('d M Y'); $to = \Carbon\Carbon::parse($dateTo)->format('d M Y'); return "📊 Production count for {$plant->name} / {$lineLabel} " . "from {$from} to {$to}: {$count} records."; } // ───────────────────────────────────────────────────────────────────────── // Fuzzy name resolvers // ───────────────────────────────────────────────────────────────────────── /** * Resolve a user-supplied plant name to the best matching DB row. * * Strategy cascade (stops at first hit): * 1. Exact case-insensitive match → "ransar industries-i" == "Ransar Industries-I" * 2. Normalised LIKE match → strips hyphens/spaces, swaps I↔1 * 3. Every significant word present (LIKE) → "ransar unit 2" matches "Ransar Industries Unit 2" * 4. Best token-overlap score → picks the DB row sharing the most words * * @return object|null stdClass with {id, name} or null if no match. */ private function resolvePlant(string $userInput): ?object { $allPlants = DB::table('plants') ->whereNull('deleted_at') ->get(['id', 'name']); $norm = $this->normaliseForMatching($userInput); // ── Strategy 1: exact normalised match ──────────────────────────────── foreach ($allPlants as $plant) { if ($this->normaliseForMatching($plant->name) === $norm) { return $plant; } } // ── Strategy 2: normalised LIKE (user input contained in plant name or vice-versa) ── foreach ($allPlants as $plant) { $dbNorm = $this->normaliseForMatching($plant->name); if (str_contains($dbNorm, $norm) || str_contains($norm, $dbNorm)) { return $plant; } } // ── Strategy 3: all significant user words appear in the plant name ─── $userTokens = $this->significantTokens($norm); if (count($userTokens) >= 1) { foreach ($allPlants as $plant) { $dbNorm = $this->normaliseForMatching($plant->name); $allFound = true; foreach ($userTokens as $token) { if (! str_contains($dbNorm, $token)) { $allFound = false; break; } } if ($allFound) { return $plant; } } } // ── Strategy 4: best token-overlap score ───────────────────────────── $bestPlant = null; $bestScore = 0; foreach ($allPlants as $plant) { $dbTokens = $this->significantTokens($this->normaliseForMatching($plant->name)); $shared = count(array_intersect($userTokens, $dbTokens)); // Require at least half the user tokens to match to avoid false positives $threshold = max(1, (int) ceil(count($userTokens) / 2)); if ($shared >= $threshold && $shared > $bestScore) { $bestScore = $shared; $bestPlant = $plant; } } return $bestPlant; } /** * Resolve a user-supplied line name within a specific plant. * Uses the same normalisation + token strategies as resolvePlant(). * * @return object|null stdClass with {id, name} or null if no match. */ private function resolveLine(string $userInput, int $plantId): ?object { $allLines = DB::table('lines') ->whereNull('deleted_at') ->where('plant_id', $plantId) ->get(['id', 'name']); $norm = $this->normaliseForMatching($userInput); // Strategy 1: exact normalised foreach ($allLines as $line) { if ($this->normaliseForMatching($line->name) === $norm) { return $line; } } // Strategy 2: normalised LIKE foreach ($allLines as $line) { $dbNorm = $this->normaliseForMatching($line->name); if (str_contains($dbNorm, $norm) || str_contains($norm, $dbNorm)) { return $line; } } // Strategy 3: all user tokens found in line name $userTokens = $this->significantTokens($norm); foreach ($allLines as $line) { $dbNorm = $this->normaliseForMatching($line->name); $allFound = true; foreach ($userTokens as $token) { if (! str_contains($dbNorm, $token)) { $allFound = false; break; } } if ($allFound) { return $line; } } // Strategy 4: best token-overlap $bestLine = null; $bestScore = 0; foreach ($allLines as $line) { $dbTokens = $this->significantTokens($this->normaliseForMatching($line->name)); $shared = count(array_intersect($userTokens, $dbTokens)); $threshold = max(1, (int) ceil(count($userTokens) / 2)); if ($shared >= $threshold && $shared > $bestScore) { $bestScore = $shared; $bestLine = $line; } } return $bestLine; } /** * Normalise a plant/line name for fuzzy comparison: * - lowercase * - replace Roman numeral suffixes I/II/III/IV → 1/2/3/4 (and vice-versa digits → numerals as a canonical form) * - collapse hyphens, underscores, extra spaces into a single space * - strip leading/trailing whitespace * * Both the user input AND the DB value are passed through this before comparing, * so the comparison is always apples-to-apples. */ private function normaliseForMatching(string $value): string { $v = strtolower($value); // 1. Punctuation/separators → space $v = str_replace(['-', '_', '.', ','], ' ', $v); // 2. Split any letter→digit or digit→letter boundary with a space. // e.g. "industries1" → "industries 1", "unit2" → "unit 2", "2unit" → "2 unit" // This must happen BEFORE Roman numeral conversion so isolated digits are // already separated from words. $v = preg_replace('/([a-z])(\d)/', '$1 $2', $v); $v = preg_replace('/(\d)([a-z])/', '$1 $2', $v); // 3. Convert standalone Roman numerals to digits. // Applied AFTER splitting so "industries" is never touched — // the \b boundary ensures only whole tokens are matched. // Order matters: longer patterns first (iii before ii before i). $romanMap = [ '/\bviii\b/' => '8', '/\bvii\b/' => '7', '/\bvi\b/' => '6', '/\biv\b/' => '4', '/\biii\b/' => '3', '/\bii\b/' => '2', '/\bv\b/' => '5', '/\bi\b/' => '1', // last — single i only after all others consumed ]; foreach ($romanMap as $pattern => $digit) { $v = preg_replace($pattern, $digit, $v); } // 4. Collapse multiple spaces $v = preg_replace('/\s+/', ' ', $v); return trim($v); } /** * Split a normalised string into significant tokens (drops noise words). * * @return array */ private function significantTokens(string $normalised): array { $stopWords = ['and', 'the', 'of', 'for', 'at', 'in', 'a']; $tokens = explode(' ', $normalised); return array_values(array_filter($tokens, function (string $t) use ($stopWords) { return strlen($t) >= 2 && ! in_array($t, $stopWords, true); })); } // ───────────────────────────────────────────────────────────────────────── /** * Unknown task — let Gemini respond conversationally. * * If the classification step already produced a useful clarification string * (e.g. "Could you clarify — are you asking about scan status or invoice type?"), * we return that directly without a second API call. * Otherwise we hit Gemini again with a conversational system prompt. */ private function handleUnknown( array $chatHistory, string $userInput, ?string $clarificationFromClassifier ): string { $reply = $this->callGeminiConversational( $chatHistory, $userInput, $clarificationFromClassifier ); return $reply ?? "I'm not sure I understood that. I can help you with:\n\n" . "• Invoice Status — scan progress of an invoice\n" . "• Invoice Report — serial vs material type for an item\n" . "• Production Report — unit count for a plant / line\n\n" . "What would you like to check?"; } }