ERP Chatbot Dashboard
An intent is a label that classifies what the user wants (for example work_order_status or invoice_overdue). A reply is the markdown string the chatbot returns. In IRIS, intents are detected by a hybrid regex + LLM engine, then routed to a DataHandler that fetches ERP data, then to a Formatter that renders the data to markdown. This page is the developer reference for that pipeline.
Important: intents and replies in this system are defined entirely in code — PHP config files and service classes — not in database tables. The only chatbot-related DB table is chatbot_query_logs, which logs queries (with the detected intent and a response preview) but does not store intent or reply definitions. So the "schema" below is the config-array and class structure, not a SQL schema.
Audience: a developer who needs to add a new intent or change a reply format. Every fact here is derived live from the codebase (counts and lists are read from the actual config/classes by IntentGuideController); the steps and gotchas come from a verified study of the source. For the authoritative deep dive, see .cursor/guide/INTENT_SYSTEM_GUIDE.md.
Three views of the IRIS intent + reply pipeline. Switch tabs to compare the flowchart, the runtime sequence, and the class structure. Diagrams are rendered live with React Flow.
POST /api/v1/chatbot/chat → ChatbotService::processQuery().
isGeneralKnowledgeQuestion() — if the query has no ERP keywords, intent detection is skipped and the query goes straight to the LLM (intent general).
detectIntentWithRegex() iterates config/intent_patterns.php. First matching group wins (conversational & help intents checked first). Zero LLM calls.
LlmIntentDetectionService calls Ollama (qwen3:30b) with a classification prompt. Returns JSON with intent, entities, confidence. A confidence threshold (0.7) gates low-confidence results.
isConversationalIntent) → hardcoded markdown / LLM reply.isErpDataQuery) → step 6.getErpData() iterates the 14 handlers, finds one whose supports($intent) is true, calls handle($intent, $entities) → returns a payload array that must include a type key.
formatErpData() iterates the 14 formatters in registration order, finds one whose canFormat($data) matches the payload's type, calls format($data) → returns a markdown string.
buildResponse() appends Excel/Word export buttons (for exportable intents) and image galleries (for trip photos) to the markdown, then returns. If no handler matched, the self-improvement loop may be triggered.
An intent is defined in three places that must be kept consistent by hand. They are not generated from each other — an intent can exist in one and not the others. There is no database table for intents.
| Source | File | Role | Live count | Key fields |
|---|---|---|---|---|
| LLM catalog | config/chatbot_intents.php | The list the LLM chooses from during classification. | 166 intents | category, description, examples[], entities[] |
| Regex catalog | config/intent_patterns.php | The fast-path regex groups. First matching pattern wins. | 120 groups | '<intent>' => [ '/pattern/i', ... ] |
| Runtime allow-list | IntentDetectionService::isErpDataQuery() | Intents the orchestrator treats as ERP data queries (vs. conversational). Without this, an intent never reaches a DataHandler. | 155 intents | $erpIntents = [ '...', 'new_feature_...', ] |
The category field is used for grouping and reporting. The examples feed the LLM classification prompt (up to 3 are shown per intent). The entities list declares which entity keys the matching DataHandler expects to receive.
This is the manual recipe for adding a first-class ERP intent. It touches all three definition sources plus the handler/formatter pair. Use real file paths.
config/chatbot_intents.php and add an entry with category (pick an existing category), description (one line), examples (trigger phrases), and entities (entity keys the handler expects). Required fields: all four. The key must be unique — duplicate keys are silently shadowed (see Gotchas).config/intent_patterns.php and add a group. Patterns are case-insensitive (/.../i) and should capture the key entity when possible. The first group whose any pattern matches the lower-cased query wins, so order matters.app/Services/ChatbotServices/Core/IntentDetectionService.php and add the intent to the array returned by isErpDataQuery(). Without this, the orchestrator treats the intent as conversational and never offers it to a handler.DataHandler (e.g. WorkOrderDataHandler.php): (a) add the intent to the $supportedIntents array, and (b) add a branch in the handle() switch (or a private method) that fetches the data and returns a payload array that always includes a type key, e.g. ['type' => 'my_new_intent', ...].Formatter (e.g. WorkOrderFormatter.php): (a) add an $identifyingKeys entry that claims the payload, e.g. ['type' => 'my_new_intent'], and (b) add a branch in format() that renders the payload to markdown using the BaseFormatter helpers (see the Reply format guide below).routes/api/chatbot/<domain>.php file and a controller method in app/Http/Controllers/Api/Chatbot/<Domain>Controller.php.POST /api/v1/chatbot/test-intent and POST /api/v1/chatbot/test-intent-batch (in routes/api/chatbot/general.php) exercise intent detection directly. Then send a real chat message through POST /api/v1/chatbot/chat and confirm the handler is dispatched and the formatter renders.IntentDetectionService::detectIntent() always runs regex first, then LLM. The intent_detection.mode config (currently llm) is declared in config/chatbot.php, but the live detectIntent() code performs the regex-then-LLM sequence regardless of mode (so in practice the engine behaves as hybrid).
Iterates config/intent_patterns.php in order. The first group whose any pattern matches the lower-cased query wins. Special pre-processing:
greeting, thanks are checked first — but only if the query has no ERP content.isHelpIntent()), e.g. help, menu, what can you do.detectWorkOrdersByPartIntent() & detectBomIntent() distinguish exact vs partial part numbers → work_orders_by_part vs work_orders_by_part_list, and bom_details vs bom_search.isNewFeatureRequest() — if the query has ≥2 ERP keywords but no pattern matched → new_erp_feature_request.unknown, which falls through to the LLM.Calls Ollama with a classification prompt built by buildClassificationPrompt(). The prompt includes the full intent list (grouped by category), the entity types below, CRITICAL RULES (general-knowledge vs ERP, multilingual EN/BM, greeting+ERP, part-number patterns), and a 7-factor decision framework with weights:
| Factor | Weight | What it scores |
|---|---|---|
| Entity specificity | 25% | exact vs partial vs ambiguous entity |
| Action keywords | 20% | list / search / details / status |
| Quantifiers | 15% | limit, all, latest, first |
| Temporal context | 15% | today / this week / date range |
| Relationship indicators | 10% | for / by / from / with |
| Domain context | 10% | work_order / sales_order / inventory / ... |
| Conversation history | 5% | follow-ups, previous entities |
The LLM returns JSON: has_intent, confidence, intent, entities, clarification_needed, factor_analysis, alternative_intents[]. When alternatives exist, selectBestCandidate() picks the best by combined_score = (confidence × 0.7) + (entity_score × 0.3). Confidence bands: 0.9–1.0 very clear, 0.7–0.89 clear, 0.5–0.69 moderate, <0.5 low. Results below the threshold (0.7) fall through to the conversational/unknown path.
| Entity key | Meaning / format |
|---|---|
| work_order_number | 8-digit number starting with year (e.g., 25080011) |
| sales_order_number | 8-digit number starting with year (e.g., 25010001) |
| purchase_order_number | 8-digit number starting with year (e.g., 25110530) |
| invoice_number | 8-digit number starting with 11 or 12 (e.g., 11250001) |
| part_number | Part/BOM codes — letters, numbers, dashes, dots, slashes (e.g., 714-249101, 4022.656.6685, BS-M5-2) |
| customer_name | Company or customer name (e.g., CELESTICA ELECTRONICS (M) SDN BHD) |
| supplier_name | Vendor or supplier name |
| customer_po_number | Customer's PO reference |
| trip_number | Trip ID with T prefix (e.g., T25001) |
| start_date | Date in YYYY-MM-DD or relative ("this week", "last month") |
| end_date | Date in YYYY-MM-DD or relative |
| interval | today, tomorrow, this_week, next_week, last_week, this_month, ... |
| week_number | Week number (1-52) |
| year | Year (e.g., 2025) |
| currency_code | 3-letter currency code (USD, MYR, EUR) |
| location_name | Warehouse / store location name |
| employee_name | Employee name for production queries |
| limit | Number of records to return ("list 20", "top 10", "last 50") |
| show_all | Boolean true if user says "all" or "show all" (no limit) |
| process_name | Manufacturing process name (e.g., BENDING, WELDING) |
| staff_id | Staff / employee ID (4-6 digits, e.g., 70000) |
| position | Job position / department (engineer, operator, technician) |
| station_id | Station ID for production stations |
normalizeEntities() cleans the result: WO/SO/PO numbers are forced to 8 digits, invoice numbers must start with 11/12, the T prefix is stripped from trip numbers, natural dates are parsed to Y-m-d, currency codes upper-cased to 3 letters. A regex-based EntityExtractionService supplements LLM entities with dates and document numbers when the LLM returns none.
A reply is not a stored row. It is generated at runtime: a DataHandler returns a payload array, and a Formatter renders that array to a markdown string. The contract between them is the payload's type key.
| Element | Where | Rule |
|---|---|---|
| Payload | DataHandler::handle() return | An array that must include a type key (e.g. ['type' => 'work_orders_by_part_list', 'parts' => [...]]). This is how the formatter claims it. |
| identifyingKeys | Formatter::$identifyingKeys | An array of matchers. Each is either an associative array (value match, e.g. ['type' => 'work_orders_by_part']) or a sequential array of required keys (e.g. ['work_order', 'product']). canFormat() returns true on the first match. |
| format() | Formatter::format() | Receives the payload, returns a markdown string built with the BaseFormatter helpers. |
14 DataHandlers and 14 Formatters, paired 1:1 by domain. Formatter registration order matters — more specific formatters are registered first so a generic one does not swallow a specific payload (see ChatbotService::registerFormatters()).
| DataHandler (supported intents) | Paired Formatter (identifying-key shapes) |
|---|---|
| WorkOrderDataHandler 33 intents | WorkOrderFormatter 21 shapes |
| SalesOrderDataHandler 34 intents | SalesOrderFormatter 20 shapes |
| PurchaseOrderDataHandler 21 intents | PurchaseOrderFormatter 15 shapes |
| InvoiceDataHandler 40 intents | InvoiceFormatter 14 shapes |
| InventoryDataHandler 13 intents | InventoryFormatter 18 shapes |
| BomDataHandler 10 intents | BomFormatter 10 shapes |
| DeliveryDataHandler 6 intents | DeliveryFormatter 5 shapes |
| FinanceDataHandler 9 intents | FinanceFormatter 6 shapes |
| CustomerDataHandler 11 intents | CustomerFormatter 3 shapes |
| SupplierDataHandler 6 intents | SupplierFormatter 0 shapes |
| EmployeeDataHandler 6 intents | EmployeeFormatter 7 shapes |
| ProductionDataHandler 3 intents | ProductionFormatter 4 shapes registered first |
| BulkLookupDataHandler 3 intents | BulkLookupFormatter 2 shapes |
| SpecialFeatureDataHandler 22 intents | SpecialFeatureFormatter 22 shapes registered first |
Counts are read live via reflection (without invoking constructors), so they always match the deployed code. A handler's count is its $supportedIntents size; a formatter's count is its $identifyingKeys size (the number of payload shapes it claims).
The chatbot writes one row per query to chatbot_query_logs (model ChatbotQueryLog, owned by the ERP schema). It records the outcome of intent matching — not the definitions. Key columns: conversation_id, user_id, client (web/mobile/desktop), question, detected_intent, extracted_entities (JSON), response_preview, was_successful, response_time_ms. Scopes: dateRange, byUser, byIntent, byClient, failed. (No migration exists for it in either project; the table is created/maintained by the ERP.)
A formatter builds the reply as an array of markdown lines, then joins them with finishOutput(). Use the BaseFormatter helpers below — do not hand-concatenate strings. The web/mobile clients render this markdown (headings, bold, lists, tables, images, links).
| Helper | Produces | Note |
|---|---|---|
| startOutput($title) | ## {$title} | Opens the output array; optionally adds an H2 title. |
| addSection(&$output, $title, $level=3) | ### {$title} | Adds a section header (H3 by default; \$level controls depth). |
| addLine(&$output, $label, $value) | **{$label}:** {$value} | Adds a bold key-value line. Arrays are json_encode'd. |
| tableHeader($columns) | | col1 | col2 | |---|---| | Returns [header, separator] for a markdown table. |
| tableRow($values) | | a | b | | Returns one markdown table row. |
| addImage(&$output, $url, $alt) |  | Embeds an image; skips the placeholder no-image-icon.png. |
| addBomImage(&$output, $data, $pn) |  | Adds a BOM/product image from data['image_url'] / bom.image_url / product.image_url. |
| formatCurrency($value, $currency='MYR') | MYR 1,234.56 | Currency + number_format with 2 decimals. |
| formatNumber($value, $decimals=2) | 1,234.56 | number_format with commas. |
| formatDate($date, 'Y-m-d') | 2026-07-30 | Formats a date; "N/A" if empty. |
| truncate($text, $max=30) | truncated... | Truncates with a "..." suffix. |
| finishOutput($output) | joined string | implode("\n", \$output) — final markdown string. |
For exportable intents, ChatbotService::buildResponse() appends Excel/Word download links to the markdown and adds an actions[] array to the JSON response:
For trip-photo payloads, a media gallery object ({ type: 'gallery', photos_by_trip, gallery_url, total_photos }) is added to the JSON response for the client's image viewer. These are not separate rich-content types you author in a formatter — they are appended automatically based on the payload shape.
Conversational intents (greeting, thanks, help, farewell, clarification_needed, general/unknown) return hardcoded markdown from handleConversationalQuery(), or an LLM-generated reply for general. They never touch a DataHandler/Formatter.
A separate set of static templates live in config/chatbot.php under templates. They use {field} placeholders (single braces). These are reference/legacy templates; the live pipeline uses the formatter classes above.
| Template key | Placeholders | Preview |
|---|---|---|
| work_order | wo_number, status, so_number, customer_po, customer_name, part_number, part_name, quantity, progress_percent, completed_qty, target_qty, remaining_qty, total_processes, completed_processes, in_progress_processes, pending_processes, ask_progress_details | ## Work Order: {wo_number} **Status:** {status} **Sales Order:** {so_number} **Customer PO:** {customer_po} **Customer:** {customer_name} ### Product - **Part Number:** {part_number} - **Part Name:** {part_name} - **Quantity:** {quantity} ### Progress - **Overall Progress:** {progress_percent}% - **Completed:** {completed_qty} / {target_qty} - **Remaining:** {remaining_qty} ### Process Summary - **Total Processes:** {total_processes} - **Completed:** {completed_processes} - **In Progress:** {in_progress_processes} - **Pending:** {pending_processes} {ask_progress_details} |
| work_order_progress | wo_number, process_list | ## Process Traveller Progress for WO {wo_number} {process_list} |
| process_item | sequence, process_name, status, progress_percent, completed_qty, target_qty, employees, station, location | ### Process {sequence}: {process_name} - **Status:** {status} - **Progress:** {progress_percent}% ({completed_qty}/{target_qty}) - **Employees:** {employees} - **Station:** {station} - **Location:** {location} |
| no_data | I could not find ERP data for your query. Please provide: - A valid **Work Order number** (e.g., 25080011) - A valid **Sales Order number** (e.g., SO 25080004) - A valid **Purchase Order number** (e.g., PO 12345) |
What is NOT supported: there are no button / card / quick-reply / list-template rich-content types as separate authorable formats. Everything is markdown; interactivity (export buttons, galleries) is added by ChatbotService as JSON actions/media alongside the markdown, and rendered by the client.
work_order_statusA worked example of one intent + reply, start to finish. The snippets below are the actual live definitions read from the codebase by the controller.
Expected: regex matches work_order_status → WorkOrderDataHandler fetches the WO → WorkOrderFormatter renders the markdown detail card (with BOM image if available).
| Issue | Detail & how to handle it |
|---|---|
| No DB No intent/reply tables | Intents and replies live in code (config + classes), not the database. Do not look for or create migrations for them. The only chatbot DB table is chatbot_query_logs (logs, owned by the ERP). |
| 3 sources Three places, no generator | chatbot_intents.php, intent_patterns.php, and isErpDataQuery() are maintained by hand and drift apart. There is no test asserting consistency. When adding an intent, touch all three plus the handler/formatter. |
Orphan progress_query |
progress_query is set as the intent in ChatbotService.php and ConversationService.php but no DataHandler lists it in supportedIntents. Result: intentHasHandler() returns false → no ERP data → falls through to self-improvement/general. Implement a handler for it or stop setting it. |
| Dupes Duplicate array keys | PHP silently keeps the last definition of a duplicate key. chatbot_intents.php has 9 duplicated intent keys (e.g. customer_search, sales_order_daily_summary); intent_patterns.php has 2 (low_stock, out_of_stock). The earlier definitions are dead. Deduplicate and merge the best examples/entities. |
Drift new_feature_* out of sync |
20 new_feature_<ts> intents exist in chatbot_intents.php but only 15 are listed in isErpDataQuery(). Some generated intents will never be treated as ERP queries. Reconcile the two lists. |
| Order Formatter order matters | ChatbotService::registerFormatters() registers SpecialFeatureFormatter and ProductionFormatter first on purpose (specific shapes before generic ones, e.g. ProductionFormatter claims summary before CustomerFormatter). If you add a formatter, place it carefully — a generic canFormat() can swallow payloads meant for a specific formatter. |
Type key Payload must have type |
A handler's return array must include a type key, and the formatter's $identifyingKeys must claim that exact shape. A payload with no matching formatter falls through to formatGenericData() (a poor fallback). Always pair them. |
Mode intent_detection.mode vs reality |
The config default is llm, but detectIntent() always runs regex first then LLM. Treat the engine as hybrid in practice. Do not assume setting mode=regex disables the LLM path without checking the code. |
| Bypass General-knowledge bypass | Queries with no ERP keyword skip intent detection entirely and go straight to the LLM (general). A phrasing like “show me the latest” with no ERP noun will be treated as general — include an ERP keyword in test phrases. |
| Self-improve Auto-generated intents | The self-improvement loop (triggered on new_erp_feature_request, unknown, or no-handler) auto-creates new_feature_<ts> intents in chatbot_intents.php, handler methods in SpecialFeatureDataHandler, formatter methods in SpecialFeatureFormatter, and REST routes in routes/api/chatbot/special.php. Review the agent's diff before merging — it is a guardrail, not a sandbox. |
| Symlink Never migrate from the chatbot | app/Models is a symlink to intec-erp-v2/app/Models. The ERP owns the schema. Never run migrations from the chatbot, and never edit models here — change them in the ERP. |