diff --git a/app/Livewire/ChatBot.php b/app/Livewire/ChatBot.php new file mode 100644 index 0000000..34d909b --- /dev/null +++ b/app/Livewire/ChatBot.php @@ -0,0 +1,444 @@ + 'user'|'assistant', 'content' => '…'] + * + * The full history is passed to GeminiChatbotService on every turn so + * Gemini can resolve follow-up messages (e.g. answering a clarification + * question) in context. + */ + public array $chatHistory = []; + + // ───────────────────────────────────────────────────────────────────────── + + public function mount(): void + { + $this->plants = DB::table('plants') + ->whereNull('deleted_at') + ->orderBy('name') + ->get(['id', 'name']) + ->toArray(); + + $this->dateFrom = now()->startOfMonth()->format('Y-m-d'); + $this->dateTo = now()->format('Y-m-d'); + } + + // ── Mode switching ──────────────────────────────────────────────────────── + + public function setMode(string $mode): void + { + $this->mode = $mode; + } + + public function setReportType(string $type): void + { + $this->reportType = $type; + + // Clear previous results when switching report type + $this->result = ''; + $this->hasResult = false; + $this->invoiceResult = ''; + $this->hasInvoiceResult = false; + $this->invoiceStatusResult = ''; + $this->hasInvoiceStatusResult = false; + } + + // ── Basic mode — Production helpers ────────────────────────────────────── + + public function updatedSelectedPlantId(): void + { + $this->selectedLineId = null; + $this->lines = []; + $this->result = ''; + $this->hasResult = false; + + if ($this->selectedPlantId) { + $this->lines = DB::table('lines') + ->whereNull('deleted_at') + ->where('plant_id', $this->selectedPlantId) + ->orderBy('name') + ->get(['id', 'name']) + ->toArray(); + } + } + + public function updatedSelectedLineId(): void + { + $this->result = ''; + $this->hasResult = false; + } + + public function fetchProduction(): void + { + if (! $this->selectedPlantId) { + $this->result = 'Please select a plant.'; + $this->hasResult = true; + return; + } + + $query = DB::table('production_quantities') + ->whereNull('deleted_at') + ->where('plant_id', $this->selectedPlantId) + ->whereDate('created_at', '>=', $this->dateFrom) + ->whereDate('created_at', '<=', $this->dateTo); + + if ($this->selectedLineId) { + $query->where('line_id', $this->selectedLineId); + } + + $count = $query->count(); + + $plantName = collect($this->plants) + ->firstWhere('id', $this->selectedPlantId)?->name ?? 'Unknown Plant'; + + $lineName = $this->selectedLineId + ? (collect($this->lines)->firstWhere('id', $this->selectedLineId)?->name ?? 'Unknown Line') + : 'All Lines'; + + $from = \Carbon\Carbon::parse($this->dateFrom)->format('d M Y'); + $to = \Carbon\Carbon::parse($this->dateTo)->format('d M Y'); + + $this->result = "Production count for {$plantName} / {$lineName} from {$from} to {$to}: {$count} records."; + $this->hasResult = true; + } + + // ── Basic mode — Invoice report (type lookup) ───────────────────────────── + + public function updatedInvoicePlantId(): void + { + $this->invoiceResult = ''; + $this->hasInvoiceResult = false; + } + + public function updatedInvoiceItemCode(): void + { + $this->invoiceResult = ''; + $this->hasInvoiceResult = false; + } + + public function fetchInvoiceReport(): void + { + if (! $this->invoicePlantId) { + $this->invoiceResult = 'Please select a plant.'; + $this->hasInvoiceResult = true; + return; + } + + $itemCode = trim($this->invoiceItemCode); + + if ($itemCode === '') { + $this->invoiceResult = 'Please enter an item code.'; + $this->hasInvoiceResult = true; + return; + } + + $plantName = collect($this->plants) + ->firstWhere('id', $this->invoicePlantId)?->name ?? 'Unknown Plant'; + + 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 + ", [$plantName, $itemCode]); + + } catch (\Exception $e) { + Log::error('ChatBot: invoice report query failed', [ + 'plant' => $plantName, + 'item_code' => $itemCode, + 'error' => $e->getMessage(), + ]); + + $this->invoiceResult = "Sorry, I couldn't fetch data. Please try again or contact support."; + $this->hasInvoiceResult = true; + return; + } + + if (empty($rows)) { + $this->invoiceResult = "No data found for plant \"{$plantName}\". Please verify the plant selection."; + $this->hasInvoiceResult = true; + return; + } + + $row = $rows[0]; + + if ((int) $row->exists_flag === 0) { + $this->invoiceResult = 'Provided item code does not exist in the item table.'; + } else { + switch ($row->invoice_description) { + case 'no match found': + $this->invoiceResult = "Item not found in sticker master for the plant {$row->plant_name}."; + break; + case 'serial invoice': + $this->invoiceResult = 'It is a serial invoice item.'; + break; + case 'material invoice': + $this->invoiceResult = 'It is a material invoice item.'; + break; + default: + $this->invoiceResult = 'Unexpected result. Please contact support.'; + } + } + + $this->hasInvoiceResult = true; + } + + // ── Basic mode — Invoice status (scan status) ───────────────────────────── + + public function updatedInvoiceNumber(): void + { + $this->invoiceStatusResult = ''; + $this->hasInvoiceStatusResult = false; + } + + /** + * Looks up how many serials within an invoice have been scanned / not scanned. + * Delegates to ChatbotService so the DB query and formatting logic live in one place. + */ + public function fetchInvoiceStatus(): void + { + $invoiceNumber = trim(preg_replace('/\s+/', '', $this->invoiceNumber)); + + if (empty($invoiceNumber)) { + $this->invoiceStatusResult = 'Please enter a valid invoice number.'; + $this->hasInvoiceStatusResult = true; + return; + } + + try { + /** @var ChatbotService $service */ + $service = app(ChatbotService::class); + $this->invoiceStatusResult = $service->ask("invoice = {$invoiceNumber}"); + } catch (\Throwable $e) { + Log::error('ChatBot: invoice status fetch failed', [ + 'invoice' => $invoiceNumber, + 'error' => $e->getMessage(), + ]); + $this->invoiceStatusResult = "Sorry, I couldn't fetch data for invoice {$invoiceNumber}. " + . 'Please try again or contact support.'; + } + + $this->hasInvoiceStatusResult = true; + } + + // ── Advanced mode (Gemini-powered) ──────────────────────────────────────── + + /** + * Handles a free-text user message in advanced mode. + * + * Steps: + * 1. Appends the user message to chatHistory immediately (UI feedback). + * 2. Calls GeminiChatbotService with the full prior history for context. + * 3. Gemini classifies the intent, extracts params, and either: + * a) runs the appropriate DB query and returns the result, or + * b) returns a clarification question if intent is ambiguous. + * 4. Appends the assistant reply to chatHistory. + */ + public function askAdvanced(): void + { + $question = trim($this->advancedQuestion); + + if (empty($question)) { + return; + } + + // Show the user's message in the chat bubble immediately + $this->chatHistory[] = [ + 'role' => 'user', + 'content' => $question, + ]; + + $this->advancedQuestion = ''; + $this->isAdvancedLoading = true; + + try { + /** @var GeminiChatbotService $gemini */ + $gemini = app(GeminiChatbotService::class); + + // Pass history *without* the turn we just appended — the new user + // message is passed separately so Gemini sees it as the latest turn. + $priorHistory = array_slice($this->chatHistory, 0, -1); + $answer = $gemini->processMessage($priorHistory, $question); + + } catch (\Throwable $e) { + Log::error('ChatBot: advanced ask failed', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + $answer = 'Sorry, something went wrong. Please try again.'; + } + + $this->chatHistory[] = [ + 'role' => 'assistant', + 'content' => $answer, + ]; + + $this->isAdvancedLoading = false; + } + + public function clearAdvancedChat(): void + { + $this->chatHistory = []; + $this->advancedQuestion = ''; + $this->isAdvancedLoading = false; + } + + // ── Panel controls ──────────────────────────────────────────────────────── + + public function toggleChat(): void + { + $this->isOpen = ! $this->isOpen; + } + + public function resetForm(): void + { + // Basic mode — shared + $this->reportType = ''; + + // Basic mode — production + $this->selectedPlantId = null; + $this->selectedLineId = null; + $this->lines = []; + $this->result = ''; + $this->hasResult = false; + $this->dateFrom = now()->startOfMonth()->format('Y-m-d'); + $this->dateTo = now()->format('Y-m-d'); + + // Basic mode — invoice type lookup + $this->invoicePlantId = null; + $this->invoiceItemCode = ''; + $this->invoiceResult = ''; + $this->hasInvoiceResult = false; + + // Basic mode — invoice scan status + $this->invoiceNumber = ''; + $this->invoiceStatusResult = ''; + $this->hasInvoiceStatusResult = false; + + // Advanced mode + $this->clearAdvancedChat(); + + // Go back to mode selector + $this->mode = 'select'; + } + + public function render() + { + return view('livewire.chat-bot'); + } +} diff --git a/app/Providers/Filament/AdminPanelProvider.php b/app/Providers/Filament/AdminPanelProvider.php index de279b8..a8260c6 100644 --- a/app/Providers/Filament/AdminPanelProvider.php +++ b/app/Providers/Filament/AdminPanelProvider.php @@ -29,6 +29,7 @@ use App\Filament\Auth\CustomLogin as AuthCustomLogin; use App\Filament\Pages\CustomLogin; use Filament\View\PanelsRenderHook; use Filament\Support\Facades\FilamentView; +use Illuminate\Support\Facades\Blade; class AdminPanelProvider extends PanelProvider @@ -160,5 +161,10 @@ class AdminPanelProvider extends PanelProvider } return ''; }); + + FilamentView::registerRenderHook( + PanelsRenderHook::BODY_END, + fn (): string => Blade::render("@livewire('chat-bot')"), + ); } } diff --git a/app/Services/ChatbotService.php b/app/Services/ChatbotService.php new file mode 100644 index 0000000..9e03a4d --- /dev/null +++ b/app/Services/ChatbotService.php @@ -0,0 +1,313 @@ + '/inv(?:oice)?(?:\s*(?:number|num|no\.?))?\s*(?:=|is| |equal\s+to)\s*([^\s,\.]+)/i', + 'handler' => 'handleInvoice', + ], + + // ── Invoice report: item type lookup ────────────────────────────────── + // Accepts patterns like: + // item = 674071 plant = Vahinie Unit 2 + // item code = 674071 plant = Vahinie Unit 2 + // check item 674071 for plant Vahinie Unit 2 + [ + 'pattern' => '/item(?:\s*code)?\s*(?:=|is|:)?\s*([^\s,]+)\s+(?:for\s+)?plant\s*(?:=|is|:)?\s*(.+)/i', + 'handler' => 'handleInvoiceReport', + ], + + // ── Add more commands here ──────────────────────────────────────────── + // Example: + // [ + // 'pattern' => '/^\s*serial\s*=\s*(.+)/i', + // 'handler' => 'handleSerial', + // ], + ]; + + // ───────────────────────────────────────────────────────────────────────── + // Public entry point + // ───────────────────────────────────────────────────────────────────────── + + /** + * Dispatch the user's input to the matching handler. + */ + public function ask(string $input): string + { + $input = trim($input); + + foreach ($this->handlers as $entry) { + if (preg_match($entry['pattern'], $input, $matches)) { + $value = trim($matches[1] ?? ''); + $value2 = trim($matches[2] ?? ''); + return $this->{$entry['handler']}($value, $value2); + } + } + + return $this->unknownCommand($input); + } + + // ───────────────────────────────────────────────────────────────────────── + // Handler: invoice = + // ───────────────────────────────────────────────────────────────────────── + + /** + * Looks up scan status for an invoice number in invoice_validations. + */ + private function handleInvoice(string $invoiceNumber, string $_unused = ''): string + { + // Strip any whitespace within the invoice number itself + $invoiceNumber = preg_replace('/\s+/', '', $invoiceNumber); + + if (empty($invoiceNumber)) { + return 'Please provide a valid invoice number. Example: invoice = 3RA0013333'; + } + + try { + $rows = DB::select(" + SELECT + COALESCE(scanned_status, 'not scanned') AS status, + COUNT(*) AS total_count, + STRING_AGG( + CASE + WHEN scanned_status IS NULL THEN serial_number::text + END, + ', ' + ) AS serial_numbers_not_scanned + FROM invoice_validations + WHERE invoice_number = ? + GROUP BY scanned_status + ", [$invoiceNumber]); + } catch (\Exception $e) { + Log::error('ChatbotService: invoice query failed', [ + 'invoice' => $invoiceNumber, + 'error' => $e->getMessage(), + ]); + + return "Sorry, I couldn't fetch data for invoice {$invoiceNumber}. " + . 'Please try again or contact support if this keeps happening.'; + } + + if (empty($rows)) { + return "No records found for invoice number {$invoiceNumber}. " + . 'Please double-check the invoice number and try again.'; + } + + return $this->formatInvoiceResponse($invoiceNumber, $rows); + } + + /** + * Turn the raw DB rows into a plain-English summary. + */ + private function formatInvoiceResponse(string $invoiceNumber, array $rows): string + { + $totalScanned = 0; + $totalNotScanned = 0; + $unscannedList = null; + + foreach ($rows as $row) { + if ($row->status === 'not scanned') { + $totalNotScanned = (int) $row->total_count; + $unscannedList = $row->serial_numbers_not_scanned; + } else { + $totalScanned += (int) $row->total_count; + } + } + + $grandTotal = $totalScanned + $totalNotScanned; + $itemWord = $grandTotal === 1 ? 'serial number' : 'serial numbers'; + + // ── All scanned ─────────────────────────────────────────────────────── + if ($totalNotScanned === 0) { + return "For invoice number {$invoiceNumber}, all {$grandTotal} {$itemWord} " + . ($grandTotal === 1 ? 'has' : 'have') . ' been scanned. ✅'; + } + + // ── None scanned ────────────────────────────────────────────────────── + if ($totalScanned === 0) { + $msg = "For invoice number {$invoiceNumber}, there " + . ($grandTotal === 1 ? 'is' : 'are') . " {$grandTotal} {$itemWord} " + . 'and none have been scanned.'; + + if ($unscannedList) { + $msg .= " Unscanned serial numbers are: {$unscannedList}."; + } + + return $msg; + } + + // ── Mixed ───────────────────────────────────────────────────────────── + $msg = "For invoice number {$invoiceNumber}, there " + . ($grandTotal === 1 ? 'is' : 'are') . " {$grandTotal} {$itemWord} in total. " + . "Out of which {$totalScanned} " + . ($totalScanned === 1 ? 'has' : 'have') . ' been scanned and ' + . "{$totalNotScanned} " + . ($totalNotScanned === 1 ? 'has' : 'have') . ' not been scanned.'; + + if ($unscannedList) { + $msg .= " Unscanned serial numbers are: {$unscannedList}."; + } + + return $msg; + } + + // ───────────────────────────────────────────────────────────────────────── + // Handler: item = plant = + // ───────────────────────────────────────────────────────────────────────── + + /** + * Determines whether an item is a serial invoice or material invoice + * for a given plant, using the sticker_masters table. + * + * @param string $itemCode Extracted item code (capture group 1) + * @param string $plantName Extracted plant name (capture group 2) + */ + private function handleInvoiceReport(string $itemCode, string $plantName): string + { + $itemCode = trim($itemCode); + $plantName = trim($plantName); + + if (empty($itemCode)) { + return 'Please provide an item code. Example: item = 674071 plant = Vahinie Unit 2'; + } + + if (empty($plantName)) { + return 'Please provide a plant name. Example: item = 674071 plant = Vahinie Unit 2'; + } + + 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 + ", [$plantName, $itemCode]); + + } catch (\Exception $e) { + Log::error('ChatbotService: invoice report query failed', [ + 'plant' => $plantName, + 'item_code' => $itemCode, + 'error' => $e->getMessage(), + ]); + + return "Sorry, I couldn't fetch data for item {$itemCode} in plant {$plantName}. " + . 'Please try again or contact support.'; + } + + if (empty($rows)) { + return "No data found for plant \"{$plantName}\". Please check the plant name and try again."; + } + + $row = $rows[0]; + + if ((int) $row->exists_flag === 0) { + return 'Provided item code does not exist in the item table.'; + } + + return match ($row->invoice_description) { + 'no match found' => "Item not found in sticker master for the plant {$row->plant_name}.", + 'serial invoice' => 'It is a serial invoice item.', + 'material invoice' => 'It is a material invoice item.', + default => 'Unexpected result. Please contact support.', + }; + } + + // ───────────────────────────────────────────────────────────────────────── + // Fallback for unrecognised input + // ───────────────────────────────────────────────────────────────────────── + + private function unknownCommand(string $input): string + { + return "I didn't recognise that command. Please include a supported keyword with a value after '='.\n\n" + . "• Invoice scan status:\n" + . " invoice = 3RA0013333\n" + . " what is the status of invoice = 3RA0013333\n\n" + . "• Invoice type lookup:\n" + . " item = 674071 plant = Vahinie Unit 2\n" + . " item code = 674071 plant = Vahinie Unit 2\n\n" + . 'Any sentence containing keyword = value will work.'; + } +} diff --git a/app/Services/GeminiChatbotService.php b/app/Services/GeminiChatbotService.php new file mode 100644 index 0000000..b8ec342 --- /dev/null +++ b/app/Services/GeminiChatbotService.php @@ -0,0 +1,819 @@ +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?"; + } +} diff --git a/config/services.php b/config/services.php index 27a3617..5ec9271 100644 --- a/config/services.php +++ b/config/services.php @@ -18,6 +18,11 @@ return [ 'token' => env('POSTMARK_TOKEN'), ], + 'gemini' => [ + 'api_key' => env('GEMINI_API_KEY'), + 'model' => env('GEMINI_MODEL', 'gemini-3-flash-preview'), + ], + 'ses' => [ 'key' => env('AWS_ACCESS_KEY_ID'), 'secret' => env('AWS_SECRET_ACCESS_KEY'), diff --git a/resources/views/livewire/chat-bot.blade.php b/resources/views/livewire/chat-bot.blade.php new file mode 100644 index 0000000..303d91c --- /dev/null +++ b/resources/views/livewire/chat-bot.blade.php @@ -0,0 +1,622 @@ +
+ + + {{-- ── Chat Panel ──────────────────────────────────────────────────────── --}} + @if($isOpen) +
+ + {{-- ── Header ── --}} +
+
+ + + + Report Assistant ⒶⓇ + + {{-- Mode badge --}} + @if($mode !== 'select') + + {{ $mode }} + + @endif +
+ +
+ {{-- Back to mode select (only when in a mode) --}} + @if($mode !== 'select') + + @endif + + {{-- Reset --}} + + + {{-- Close --}} + +
+
+ + {{-- ══════════════════════════════════════════════════════════════════ --}} + {{-- ── MODE SELECT SCREEN ── --}} + {{-- ══════════════════════════════════════════════════════════════════ --}} + @if($mode === 'select') +
+ +
+

How would you like to query?

+

Choose a mode to get started

+
+ +
+ + {{-- Basic card --}} + + + {{-- Advanced card --}} + + +
+ + {{-- Hint --}} +
+

+ 💡 Tip: Use Advanced to just describe what you need in plain English — Gemini will figure out the rest +

+
+ +
+ @endif + + {{-- ══════════════════════════════════════════════════════════════════ --}} + {{-- ── BASIC MODE ── --}} + {{-- ══════════════════════════════════════════════════════════════════ --}} + @if($mode === 'basic') +
+ + {{-- ── Report Type Selector (dropdown) ──────────────────────────── --}} +
+ + {{-- Custom wrapper gives us the chevron icon and accent border on selection --}} +
+ + {{-- Chevron icon --}} +
+ + + +
+
+
+ + {{-- ── Placeholder when no report type selected ─────────────────── --}} + @if($reportType === '') +
+ + + +

Select a report type from the dropdown to get started

+
+ @endif + + {{-- ══════════════════════════════════════════════════════════════ --}} + {{-- ── PRODUCTION REPORT FORM ── --}} + {{-- ══════════════════════════════════════════════════════════════ --}} + @if($reportType === 'production') + + {{-- Plant --}} +
+ + +
+ + {{-- Line --}} +
+ + + @if(!$selectedPlantId) +

Select a plant first

+ @endif +
+ + {{-- Date Range --}} +
+
+ + +
+
+ + +
+
+ + {{-- Fetch Button --}} + + + {{-- Production Result --}} + @if($hasResult) +
+ + + +

{{ $result }}

+
+ @endif + + @endif {{-- end production --}} + + {{-- ══════════════════════════════════════════════════════════════ --}} + {{-- ── INVOICE REPORT FORM (type lookup) ── --}} + {{-- ══════════════════════════════════════════════════════════════ --}} + @if($reportType === 'invoice') + + {{-- Plant --}} +
+ + +
+ + {{-- Item Code --}} +
+ + +

Press Enter or click the button below

+
+ + {{-- Fetch Button --}} + + + {{-- Invoice Report Result --}} + @if($hasInvoiceResult) +
+ @if(str_contains($invoiceResult, 'serial invoice')) + + + + + @elseif(str_contains($invoiceResult, 'material invoice')) + + + + @elseif(str_contains($invoiceResult, 'does not exist') || str_contains($invoiceResult, 'not found')) + + + + @else + + + + @endif +

{{ $invoiceResult }}

+
+ @endif + + @endif {{-- end invoice report --}} + + {{-- ══════════════════════════════════════════════════════════════ --}} + {{-- ── INVOICE STATUS FORM (scan status) ── NEW ── --}} + {{-- ══════════════════════════════════════════════════════════════ --}} + @if($reportType === 'invoice_status') + + {{-- Description banner --}} +
+ + + +

+ Enter an invoice number to see how many serial numbers have been scanned, how many are pending, and the list of unscanned serials. +

+
+ + {{-- Invoice Number input --}} +
+ + +

Press Enter or click the button below

+
+ + {{-- Fetch Button --}} + + + {{-- Invoice Status Result --}} + @if($hasInvoiceStatusResult) +
+ @if(str_contains($invoiceStatusResult, '✅')) + {{-- All scanned — green check --}} + + + + @elseif(str_contains($invoiceStatusResult, 'No records') || str_contains($invoiceStatusResult, 'valid') || str_contains($invoiceStatusResult, "couldn't")) + {{-- Not found / error — warning --}} + + + + @else + {{-- Partial / none scanned — barcode --}} + + + + + @endif +

{{ $invoiceStatusResult }}

+
+ @endif + + @endif {{-- end invoice_status --}} + +
+ @endif + + {{-- ══════════════════════════════════════════════════════════════════ --}} + {{-- ── ADVANCED MODE ── --}} + {{-- ══════════════════════════════════════════════════════════════════ --}} + @if($mode === 'advanced') +
+ + {{-- ── Conversation area ── --}} +
+ + @if(empty($chatHistory)) + {{-- ── Empty state ── --}} +
+
+ + + +
+
+

AI-Powered Assistant

+

Ask anything in plain English — tap an example below to get started

+
+ + {{-- Example prompt cards --}} +
+ + {{-- Card 1 — Invoice scan status --}} + + + {{-- Card 2 — Invoice type lookup --}} + + + {{-- Card 3 — Production report --}} + + +
+
+ @endif + + {{-- ── Chat bubbles ── --}} + @foreach($chatHistory as $message) + @if($message['role'] === 'user') +
+
{{ $message['content'] }}
+
+ @else +
+
+ + + +
+
{{ $message['content'] }}
+
+ @endif + @endforeach + + {{-- ── Typing indicator ── --}} + @if($isAdvancedLoading) +
+
+ + + +
+
+ + + +
+
+ @endif + +
+ + {{-- ── Input bar ── --}} +
+
+ + +
+

+ Press Enter to send · Ask anything in plain English +

+
+ +
+ @endif + +
+ @endif + + {{-- ── FAB Toggle Button ───────────────────────────────────────────────── --}} + +
+