Added chat bot pages
Some checks failed
Scan for leaked secrets using Kingfisher / kingfisher-secrets-scan (push) Has been cancelled
Gemini PR Review / Gemini PR Review (pull_request) Has been cancelled
Scan for leaked secrets using Kingfisher / kingfisher-secrets-scan (pull_request) Has been cancelled
Laravel Larastan / larastan (pull_request) Has been cancelled
Laravel Pint / pint (pull_request) Has been cancelled

This commit is contained in:
dhanabalan
2026-05-20 11:21:57 +05:30
parent d9f531fbc6
commit 203d09712b
6 changed files with 2209 additions and 0 deletions

444
app/Livewire/ChatBot.php Normal file
View File

@@ -0,0 +1,444 @@
<?php
namespace App\Livewire;
use App\Services\ChatbotService;
use App\Services\GeminiChatbotService;
use Livewire\Component;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
class ChatBot extends Component
{
// ── Panel state ───────────────────────────────────────────────────────────
public bool $isOpen = false;
/**
* 'select' mode-picker screen shown first
* 'basic' structured query UI (production or invoice report)
* 'advanced' free-text natural-language UI (Gemini-powered)
*/
public string $mode = 'select';
// ── Basic mode — shared ───────────────────────────────────────────────────
/**
* Which report the user picked inside basic mode.
* '' report-type picker shown
* 'production' production form
* 'invoice' invoice type-lookup form
* 'invoice_status' invoice scan-status form
*/
public string $reportType = '';
public array $plants = [];
// ── Basic mode — Production report ───────────────────────────────────────
public string $result = '';
public bool $hasResult = false;
public ?int $selectedPlantId = null;
public ?int $selectedLineId = null;
public string $dateFrom = '';
public string $dateTo = '';
public array $lines = [];
// ── Basic mode — Invoice report (type lookup) ─────────────────────────────
public ?int $invoicePlantId = null;
public string $invoiceItemCode = '';
public string $invoiceResult = '';
public bool $hasInvoiceResult = false;
// ── Basic mode — Invoice status (scan status) ─────────────────────────────
public string $invoiceNumber = '';
public string $invoiceStatusResult = '';
public bool $hasInvoiceStatusResult = false;
// ── Advanced mode ─────────────────────────────────────────────────────────
public string $advancedQuestion = '';
public string $advancedResult = '';
public bool $hasAdvancedResult = false;
public bool $isAdvancedLoading = false;
/**
* Conversation history shown in advanced mode.
* Each entry: ['role' => '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');
}
}

View File

@@ -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')"),
);
}
}

View File

@@ -0,0 +1,313 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Log;
/**
* ChatbotService (Advanced Mode)
* ─────────────────────────────────────────────────────────────────────────────
* Parses STRUCTURED user inputs via regex no LLM involved.
*
* HOW TO ADD A NEW COMMAND:
* 1. Add an entry to $handlers with a regex pattern and a handler method name.
* 2. Write the private handler method (receives the captured group as $value).
* 3. Add a usage example to unknownCommand() so users know about it.
*
* Pattern convention: /keyword\s*=\s*(.+)/i
* - The first capture group is the raw value after "=".
* - The handler receives it already trimmed.
*/
class ChatbotService
{
/**
* Registry of structured-command handlers.
*
* Each entry:
* 'pattern' PCRE regex; capture group 1 is the extracted value.
* 'handler' name of the private method that processes the command.
*
* For two-value commands (e.g. invoice report), group 1 and group 2 are
* both passed to the handler; single-value handlers simply ignore $value2.
*/
private array $handlers = [
[
'pattern' => '/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 = <invoice_number>
// ─────────────────────────────────────────────────────────────────────────
/**
* 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 = <item_code> plant = <plant_name>
// ─────────────────────────────────────────────────────────────────────────
/**
* 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.';
}
}

View File

@@ -0,0 +1,819 @@
<?php
namespace App\Services;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Log;
/**
* GeminiChatbotService
* ─────────────────────────────────────────────────────────────────────────────
* Powers the "Advanced" chatbot mode with plain-English understanding.
*
* FLOW:
* 1. Takes the full chat history + new user message.
* 2. Sends them to Gemini with a structured system prompt.
* 3. Gemini returns JSON: { task, params, missing, clarification }.
* 4. If task is a known, complete query runs the matching DB handler.
* 5. If task is "unknown" sends the message to Gemini again as a free-form
* conversational assistant so it can answer or ask the user what they need.
* 6. If required params are missing Gemini's clarification question is returned.
*
* SUPPORTED TASKS:
* - invoice_status scan status of an invoice number
* - invoice_report serial/material invoice type for an item + plant
* - production_report production count for a plant / line / date range
* - unknown handled via a free-form Gemini conversation turn
*
* CONFIGURATION (.env):
* GEMINI_API_KEY=your_key_here
* GEMINI_MODEL=gemini-2.5-flash-preview set whichever model your key supports
*
* HOW TO ADD A NEW TASK:
* 1. Describe it in buildSystemPrompt() under TASKS.
* 2. Add a private handle*() method below.
* 3. Add a match arm in processMessage().
*/
class GeminiChatbotService
{
private string $apiKey;
/**
* Full REST endpoint model name is part of the URL path, e.g.:
* https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash-preview:generateContent
*
* Built in __construct() from config('services.gemini.model').
* Change GEMINI_MODEL in .env to switch models without editing code.
*/
private string $apiUrl;
public function __construct()
{
$this->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 <<<PROMPT
You are a factory operations assistant that classifies plain-English user queries into structured tasks.
TASKS:
1. "invoice_status"
The user wants to check how many serial numbers in an invoice have been scanned / not scanned.
Required params: invoice_number
Examples:
- "check invoice 3RA0013333"
- "is invoice 3RA0013333 fully scanned?"
- "what's the scan status of 3RA0013333"
- "show me unscanned serials for invoice ABC123"
2. "invoice_report"
The user wants to know whether an item is a serial invoice or material invoice for a given plant.
Required params: item_code, plant_name
Examples:
- "check item 674071 for plant Vahinie Unit 2"
- "is item 500100 a serial or material invoice in Chennai plant?"
- "what type is item code 200300 at Vahinie?"
3. "production_report"
The user wants the production count for a plant and optional line over a date range.
Required params: plant_name
Optional params: line_name, date_from, date_to
Default dates: date_from = {$startOfMonth}, date_to = {$today}
Interpret relative dates: "this month", "last week", "yesterday", "today", etc.
Examples:
- "show production for Chennai plant this month"
- "how many units were produced in line 1 of Vahinie Unit 2 last week?"
- "production report for all plants from 2024-01-01 to 2024-01-31"
4. "unknown"
The query cannot be clearly matched to any of the above tasks.
Use this when the user is asking something general, greeting, asking what the bot can do,
asking a follow-up question that doesn't map to a task, or if you need more context.
In this case, set "clarification" to a friendly, helpful response it may be a question,
a helpful explanation, or guidance toward the supported tasks.
CONVERSATION CONTEXT:
Consider the full conversation history. If the previous assistant message asked a clarifying question
(e.g. "Do you mean Invoice Status or Invoice Report?") and the user is now answering that question,
classify accordingly based on the combined context.
OUTPUT FORMAT (return ONLY this JSON, no markdown fences, no extra text):
{
"task": "invoice_status | invoice_report | production_report | unknown",
"params": {
"invoice_number": "...",
"item_code": "...",
"plant_name": "...",
"line_name": "...",
"date_from": "YYYY-MM-DD",
"date_to": "YYYY-MM-DD"
},
"missing": ["list of required params that were not found in the user input"],
"clarification": "Friendly response for unknown tasks or missing-param prompts. Set to null when task is clear and params are complete."
}
RULES:
- Only include params relevant to the detected task.
- If a required param is missing, add it to "missing" and set a helpful "clarification" asking only for that missing value.
- If task is "unknown", always set "missing" to [] and put your full helpful response in "clarification".
- When task is clear and all required params are present, set "missing" to [] and "clarification" to null.
PROMPT;
}
/**
* System prompt for Phase 2 (conversational fallback) plain-text output.
*
* Used only when the classifier returns "unknown" AND produced no clarification.
*/
private function buildConversationalSystemPrompt(): string
{
return <<<PROMPT
You are a helpful factory operations assistant integrated into an internal management panel.
You can perform these tasks when the user gives you enough information:
Invoice Status check how many serial numbers in an invoice have been scanned and list any unscanned ones. Requires: invoice number.
Invoice Report find out whether an item is a serial invoice or material invoice for a given plant. Requires: item code and plant name.
Production Report get the production count for a plant, optionally filtered by line and date range. Requires: plant name.
When the user's request does not match any of the above:
- Answer general questions helpfully and concisely.
- If you need more information to perform a task, ask for only the missing detail.
- Guide the user toward one of the supported tasks when relevant.
- Keep replies short and conversational (2-4 sentences max).
- Do NOT mention JSON, APIs, or technical internals.
PROMPT;
}
/**
* Convert Livewire chat history + new user message into Gemini's contents array.
* Gemini uses "model" for the assistant role (not "assistant").
*/
private function buildGeminiContents(array $history, string $userInput): array
{
$contents = [];
// Include the last 8 turns at most to stay within token limits
foreach (array_slice($history, -8) as $msg) {
$contents[] = [
'role' => $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<string>
*/
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?";
}
}