Account: ERP-AI-Chatbot
Enter 6-digit code from Google Authenticator
Refresh:

Intent & Reply Guide

v1.0 · 2026-07-30

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.

Visual architecture

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.

The pipeline at a glance

1 User message arrives at POST /api/v1/chatbot/chatChatbotService::processQuery().
2 General-knowledge bypass: isGeneralKnowledgeQuestion() — if the query has no ERP keywords, intent detection is skipped and the query goes straight to the LLM (intent general).
3 Regex fast-path: detectIntentWithRegex() iterates config/intent_patterns.php. First matching group wins (conversational & help intents checked first). Zero LLM calls.
if regex returns "unknown"
4 LLM fallback: LlmIntentDetectionService calls Ollama (qwen3:30b) with a classification prompt. Returns JSON with intent, entities, confidence. A confidence threshold (0.7) gates low-confidence results.
5 Route by bucket: Conversational (isConversationalIntent) → hardcoded markdown / LLM reply.
ERP data query (isErpDataQuery) → step 6.
otherwise → general LLM reply.
ERP data query
6 DataHandler dispatch: 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.
7 Formatter dispatch: 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.
8 Enrich & return: 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.

Intent “schema” — three definition sources

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.

SourceFileRoleLive countKey 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_...', ]
LLM catalog entry shape (config/chatbot_intents.php)
'work_order_status' => [ 'category' => 'work_order', // grouping for reporting 'description' => 'Get status and basic information of a specific work order', 'examples' => ['show me work order 25080011', 'WO 25080011'], // sample trigger phrases 'entities' => ['work_order_number'], // entity keys the handler expects ],

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.

Intents by category (live, from config/chatbot_intents.php)
work_order30
special23
sales_order21
invoice20
purchase_order11
bom9
customer7
employee6
inventory6
delivery5
production5
conversational4
relationship4
search4
financial3
currency3
general2
utility1
quotation1
dashboard1

How to create an intent (step by step)

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.

1
Define the intent in the LLM catalog
Edit 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).
2
Add regex patterns (for the fast path)
Edit 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.
3
Register the intent as an ERP query
Edit 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.
4
Add a handler method
In the matching 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', ...].
5
Add a formatter method
In the matching 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).
6
(Optional) Add a REST route
If the intent should also be callable as a direct REST endpoint, add a route in the matching routes/api/chatbot/<domain>.php file and a controller method in app/Http/Controllers/Api/Chatbot/<Domain>Controller.php.
7
Test
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.
Checklist
[ ] config/chatbot_intents.php entry added [ ] config/intent_patterns.php regex group added [ ] IntentDetectionService::isErpDataQuery() updated [ ] DataHandler::$supportedIntents and handle() updated [ ] Formatter::$identifyingKeys and format() updated [ ] (Optional) REST route + controller method added [ ] Verified via test-intent and a real chat

Intent matching — how a message gets routed

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).

Detection config (live, from config/chatbot.php)
Active mode
llm declared regex-then-LLM in code
LLM confidence threshold
0.7
Intent cache
enabled · TTL 3600s
Default model
qwen3:30b
Multilingual model
qwen3:30b
LLM temperature
0.3
Regex fast-path rules (detectIntentWithRegex)

Iterates config/intent_patterns.php in order. The first group whose any pattern matches the lower-cased query wins. Special pre-processing:

  • Conversational first: greeting, thanks are checked first — but only if the query has no ERP content.
  • Help intent: short-query special logic (isHelpIntent()), e.g. help, menu, what can you do.
  • Part-number intents: detectWorkOrdersByPartIntent() & detectBomIntent() distinguish exact vs partial part numbers → work_orders_by_part vs work_orders_by_part_list, and bom_details vs bom_search.
  • New-feature fallback: isNewFeatureRequest() — if the query has ≥2 ERP keywords but no pattern matched → new_erp_feature_request.
  • Otherwise → unknown, which falls through to the LLM.
LLM fallback (LlmIntentDetectionService)

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:

FactorWeightWhat it scores
Entity specificity25%exact vs partial vs ambiguous entity
Action keywords20%list / search / details / status
Quantifiers15%limit, all, latest, first
Temporal context15%today / this week / date range
Relationship indicators10%for / by / from / with
Domain context10%work_order / sales_order / inventory / ...
Conversation history5%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 types the LLM extracts (and normalizes)
Entity keyMeaning / format
work_order_number8-digit number starting with year (e.g., 25080011)
sales_order_number8-digit number starting with year (e.g., 25010001)
purchase_order_number8-digit number starting with year (e.g., 25110530)
invoice_number8-digit number starting with 11 or 12 (e.g., 11250001)
part_numberPart/BOM codes — letters, numbers, dashes, dots, slashes (e.g., 714-249101, 4022.656.6685, BS-M5-2)
customer_nameCompany or customer name (e.g., CELESTICA ELECTRONICS (M) SDN BHD)
supplier_nameVendor or supplier name
customer_po_numberCustomer's PO reference
trip_numberTrip ID with T prefix (e.g., T25001)
start_dateDate in YYYY-MM-DD or relative ("this week", "last month")
end_dateDate in YYYY-MM-DD or relative
intervaltoday, tomorrow, this_week, next_week, last_week, this_month, ...
week_numberWeek number (1-52)
yearYear (e.g., 2025)
currency_code3-letter currency code (USD, MYR, EUR)
location_nameWarehouse / store location name
employee_nameEmployee name for production queries
limitNumber of records to return ("list 20", "top 10", "last 50")
show_allBoolean true if user says "all" or "show all" (no limit)
process_nameManufacturing process name (e.g., BENDING, WELDING)
staff_idStaff / employee ID (4-6 digits, e.g., 70000)
positionJob position / department (engineer, operator, technician)
station_idStation 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.

Reply “schema” — payload + formatter (no DB)

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.

The payload / formatter contract
ElementWhereRule
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.
Handler → formatter dispatch (live, scanned from app/Services/ChatbotServices/)

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 only related DB table — chatbot_query_logs

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.)

Reply format guide — markdown helpers & examples

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).

BaseFormatter helpers (app/Services/ChatbotServices/Formatters/BaseFormatter.php)
HelperProducesNote
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)![alt](url)Embeds an image; skips the placeholder no-image-icon.png.
addBomImage(&$output, $data, $pn)![BOM: pn](url)Adds a BOM/product image from data['image_url'] / bom.image_url / product.image_url.
formatCurrency($value, $currency='MYR')MYR 1,234.56Currency + number_format with 2 decimals.
formatNumber($value, $decimals=2)1,234.56number_format with commas.
formatDate($date, 'Y-m-d')2026-07-30Formats a date; "N/A" if empty.
truncate($text, $max=30)truncated...Truncates with a "..." suffix.
finishOutput($output)joined stringimplode("\n", \$output) — final markdown string.
Example 1 — key/value detail reply
$output = $this->startOutput("Work Order: 25080011"); $this->addLine($output, 'Status', 'In Progress'); $this->addLine($output, 'Part Number', '714-249101'); $this->addLine($output, 'Quantity', $this->formatNumber(100, 0)); $this->addSection($output, 'Schedule'); $this->addLine($output, 'Due Date', $this->formatDate('2026-08-15')); return $this->finishOutput($output);
## Work Order: 25080011 **Status:** In Progress **Part Number:** 714-249101 **Quantity:** 100 ### Schedule **Due Date:** 2026-08-15
Example 2 — markdown table reply
$output = $this->startOutput("Today's Shipments"); [$header, $sep] = $this->tableHeader(['Trip', 'Customer', 'Items']); $output[] = $header; $output[] = $sep; foreach ($trips as $t) { $output[] = $this->tableRow([$t['trip_no'], $t['customer'], $t['items']]); } return $this->finishOutput($output);
## Today's Shipments | Trip | Customer | Items | |---|---|---| | T25001 | Acme Sdn Bhd | 12 | | T25002 | Beta Corp | 3 |
Example 3 — image embed (BOM/product photo)
$this->addBomImage($output, $data, $partNumber); // equivalent to: $this->addImage($output, $data['image_url'] ?? $data['bom']['image_url'] ?? null, "BOM: {$partNumber}");
![BOM: 714-249101](https://chatbot.inteceng.com.my/my/storage/bom/714-249101.png)
Example 4 — export buttons & galleries (added by ChatbotService, not the formatter)

For exportable intents, ChatbotService::buildResponse() appends Excel/Word download links to the markdown and adds an actions[] array to the JSON response:

--- 📊 **[Export to Excel](http://chatbot.inteceng.com.my:8001/api/v1/chatbot/export/export_xxx)** | 📄 **[Executive Report (Word)](http://chatbot.inteceng.com.my:8001/api/v1/chatbot/export-word/export_xxx)**

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.

Example 5 — conversational replies (hardcoded in ChatbotService)

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.

Example 6 — pre-formatted templates with {placeholder} syntax (config/chatbot.php → templates)

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 keyPlaceholdersPreview
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.

End-to-end example — adding work_order_status

A worked example of one intent + reply, start to finish. The snippets below are the actual live definitions read from the codebase by the controller.

Step 1 — LLM catalog entry (config/chatbot_intents.php)
'work_order_status' => [ 'category' => 'work_order', 'description' => 'Get status and basic information of a specific work order', 'examples' => ['show me work order 25080011', 'what is the status of WO 25090001', 'work order details for 25080011', 'check WO status 25080011', 'WO 25080011', 'status of work order 25080011', ], 'entities' => ['work_order_number', ], ],
Step 2 — regex group (config/intent_patterns.php)
'work_order_status' => [ /\b(wo|work\s*order)\s*(\d{8})/i, /\b(show|get|check)\s*(wo|work\s*order)\s*(\d{8})/i, /\bstatus\s*(of\s*)?(wo|work\s*order)\s*(\d{8})/i, /\b(wo|work\s*order)\s*status\s*(\d{8})/i, /\b(wo|work\s*order)\s*(\d{4}-\d{4,5}-\d{2})/i, /\b(show|get|check|list)\s*(wo|work\s*order)\s*(\d{4}-\d{4,5}-\d{2})/i, /\bstatus\s*(of\s*)?(wo|work\s*order)\s*(\d{4}-\d{4,5}-\d{2})/i, /\b(wo|work\s*order)\s*status\s*(\d{4}-\d{4,5}-\d{2})/i, /\b(list|show|get|check)\s*(wo|work\s*order)\s*([0-9]{3,5}-[0-9]{3,6}-[0-9]{2,3})/i, /\b(wo|work\s*order)\s*([0-9]{3,5}-[0-9]{3,6}-[0-9]{2,3})/i, ],
Step 3 — register as an ERP query (IntentDetectionService::isErpDataQuery)
$erpIntents = [ // ... existing entries ... 'work_order_status', // <-- add the intent here // ... ];
Step 4 — handler (WorkOrderDataHandler)
// 1) add to the supportedIntents array protected array $supportedIntents = [ 'work_order_details', 'work_order_status', // <-- here // ... ]; // 2) add a branch in handle() public function handle(string $intent, array $entities, ?string $userQuery = null): ?array { switch ($intent) { case 'work_order_status': return $this->getWorkOrderDetails($entities); // returns ['type' => '...', 'work_order' => [...]] // ... } }
Step 5 — formatter (WorkOrderFormatter)
// 1) claim the payload by its shape protected array $identifyingKeys = [ ['work_order', 'product'], // nested structure from controller // ... ]; // 2) render it in format() public function format(array $data): string { if (isset($data['work_order']) && isset($data['product'])) { return $this->formatWorkOrderDetailsNested($data); } // ... } // 3) build the markdown with BaseFormatter helpers protected function formatWorkOrderDetailsNested(array $data): string { $output = $this->startOutput("Work Order: {$woNumber}"); $this->addBomImage($output, $data, $partNumber); $this->addLine($output, 'Status', $status); $this->addSection($output, 'Schedule'); $this->addLine($output, 'Due Date', $this->formatDate($wo['due_date'] ?? null)); return $this->finishOutput($output); }
Step 7 — test
# Test intent detection directly curl -X POST http://chatbot.inteceng.com.my:8001/api/v1/chatbot/test-intent \ -H 'Content-Type: application/json' \ -d '{"query":"what is the status of WO 25080011"}' # Test the full pipeline curl -X POST http://chatbot.inteceng.com.my:8001/api/v1/chatbot/chat \ -H 'Content-Type: application/json' \ -d '{"message":"WO 25080011 status","client":"web"}'

Expected: regex matches work_order_statusWorkOrderDataHandler fetches the WO → WorkOrderFormatter renders the markdown detail card (with BOM image if available).

Gotchas & tips

IssueDetail & 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.