dolibarr 24.0.0-beta
parse_intent.php
Go to the documentation of this file.
1<?php
2/* Copyright (C) 2026 Laurent Destailleur <eldy@users.sourceforge.net>
3 * Copyright (C) 2026 Nick Fragoulis
4 * Copyright (C) 2026 Jose Martinez <jose.martinez@pichinov.com>
5 * Copyright (C) 2026 Anthony Damhet <a.damhet@progiseize.fr>
6 *
7 * This program is free software; you can redistribute it and/or modify
8 * it under the terms of the GNU General Public License as published by
9 * the Free Software Foundation; either version 3 of the License, or
10 * (at your option) any later version.
11 *
12 * This program is distributed in the hope that it will be useful,
13 * but WITHOUT ANY WARRANTY, without even the implied warranty of
14 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15 * GNU General Public License for more details.
16 *
17 * You should have received a copy of the GNU General Public License
18 * along with this program. If not, see <https://www.gnu.org/licenses/>.
19 * or see https://www.gnu.org/
20 */
21
28if (!defined('NOTOKENRENEWAL')) {
29 define('NOTOKENRENEWAL', 1);
30}
31if (!defined('NOREQUIREMENU')) {
32 define('NOREQUIREMENU', 1);
33}
34if (!defined('NOREQUIREHTML')) {
35 define('NOREQUIREHTML', 1);
36}
37if (!defined('NOREQUIREAJAX')) {
38 define('NOREQUIREAJAX', 1);
39}
40if (!defined('NOCSRFCHECK')) { // TODO Enable the CSRF check
41 define('NOCSRFCHECK', 1);
42}
43
44require '../../main.inc.php';
45require_once DOL_DOCUMENT_ROOT . '/ai/class/mcp.class.php';
46require_once DOL_DOCUMENT_ROOT . '/ai/lib/ai.lib.php';
47require_once DOL_DOCUMENT_ROOT . '/ai/class/llmadapter.class.php';
48require_once DOL_DOCUMENT_ROOT . '/core/lib/functions.lib.php';
49require_once DOL_DOCUMENT_ROOT . '/ai/class/privacy_guard.class.php';
50require_once DOL_DOCUMENT_ROOT . '/core/lib/security2.lib.php';
51
52// Security check
53if (!isModEnabled('ai') || !getDolGlobalString('AI_ASSISTANT_ENABLED')) {
54 http_response_code(403);
55 accessforbidden('Module or feature not allowed');
56}
57
58global $db, $user, $conf, $langs;
59
60// Same per-user gate as the Assistant page that calls this endpoint, so a
61// user without 'ai/assistant/use' cannot reach the LLM through direct AJAX.
62if (!$user->hasRight('ai', 'assistant', 'use')) {
64}
65
66ob_start();
67top_httphead('application/json');
68
69// Confirmation level: 0=no confirmation, 1=only create/update/delete, 2=all actions
70$askForConfirmation = getDolGlobalInt('AI_ASK_FOR_CONFIRMATION');
71
72// Confidence thresholds
73define('HIGH_CONFIDENCE', 0.8);
74define('MEDIUM_CONFIDENCE', 0.5);
75define('LOW_CONFIDENCE', 0.3);
76
77// Logging variables
78$startTime = microtime(true);
79$rawRequestLog = "";
80$rawResponseLog = "";
81$providerUsed = "offline";
82$errorDetails = "";
83
84$assistantEnabled = getDolGlobalInt('AI_ASSISTANT_ENABLED', 0);
85$serviceKey = getDolGlobalString('AI_API_SERVICE');
86$doRedact = getDolGlobalInt('AI_PRIVACY_REDACTION', 0);
87$timeout = getDolGlobalInt('AI_REQUEST_TIMEOUT', 120);
88
89// Kill switch
90if (!$assistantEnabled) {
91 $response = [
92 "tool" => "respond_to_user",
93 "arguments" => [
94 "message" => "AI assistant service is currently disabled. Please contact your administrator to enable it."
95 ]
96 ];
97 ob_end_clean();
98 echo json_encode($response);
99 exit;
100}
101
102set_time_limit($timeout + 5);
103
104try {
105 // Input
106 $raw_input = file_get_contents('php://input');
107 $data = json_decode($raw_input, true);
108 $query = isset($data['query']) ? trim($data['query']) : '';
109
110 if (empty($query)) {
111 ob_end_clean();
112 echo json_encode(["status" => "ok"]);
113 exit;
114 }
115
116 // Privacy (Name Resolution & Masking)
117 $langs->loadLangs(array("main", "bills", "orders", "propal", "supplier_invoice", "supplier_order", "projects", "other"));
118
119 // Translation key of Words we want to block in any language.
120 $blockKeys = [
121 // Objects (Nouns)
122 'Bill',
123 'Invoice',
124 'Order',
125 'Proposal',
126 'Shipment',
127 'Reception',
128 'Contract',
129 'SupplierInvoice',
130 'SupplierOrder',
131 'Project',
132 'Task',
133 'Product',
134 'Service',
135 'Ticket',
136 'Event',
137 'Agenda',
138 'Member',
139 'User',
140 'ThirdParty',
141 'Company',
142 'Contact',
143 // Actions (Verbs/Commands)
144 'Search',
145 'Find',
146 'List',
147 'Show',
148 'Create',
149 'Add',
150 'Modify',
151 'Delete',
152 'Validate',
153 'Send',
154 // Other
155 'Hello',
156 'Test'
157 ];
158
159 // Resolve keys to the actual current language
160 $dynamicStopWords = [];
161 foreach ($blockKeys as $key) {
162 $word = $langs->transnoentities($key);
163 if (!empty($word)) {
164 $dynamicStopWords[] = mb_strtolower($word);
165 }
166 }
167
168 // Add common short English/French/Spanish commands that users often type
169 // regardless of the UI language.
170 $commonCommands = ['show', 'find', 'search', 'list', 'get', 'voir', 'chercher', 'affiche', 'lista', 'buscar'];
171 $dynamicStopWords = array_unique(array_merge($dynamicStopWords, $commonCommands)); // $dynamicStopWords is an array of words
172
173
174 $cleanQuery = preg_replace('/[^\p{L}\p{N}\s\-]/u', '', $query); // Remove special chars from the prompt query
175 $words = preg_split('/\s+/', $cleanQuery, -1, PREG_SPLIT_NO_EMPTY);
176 $count = count($words);
177 $candidates = array();
178
179 // Helper function to validate a phrase without a dictionary
180 $isValidPhrase = function (string $phrase) use ($dynamicStopWords): bool {
181 $phrase = trim($phrase);
182
183 // RULE 1: Minimum Length
184 // Filter out extremely short words (1-2 chars).
185 // This catches "a", "le", "la", "de", "y", "to", "in", "von", "zu" in almost all languages.
186 if (mb_strlen($phrase) < 3) {
187 return false;
188 }
189
190 // RULE 2: First Word Check
191 // If the phrase starts with a translated keyword (e.g. "Invoice Acme"), skip it.
192 $parts = explode(' ', $phrase);
193 $firstWord = mb_strtolower($parts[0]);
194
195 if (in_array($firstWord, $dynamicStopWords)) {
196 return false;
197 }
198
199 return true;
200 };
201
202 // Fill array $candidates of thirdparty name we may want to work with
203 for ($i = 0; $i < $count; $i++) {
204 // Single Word
205 if ($isValidPhrase($words[$i])) {
206 $candidates[] = $words[$i];
207 }
208
209 if ($i + 1 < $count) {
210 $phrase = $words[$i] . ' ' . $words[$i + 1];
211 if ($isValidPhrase($phrase)) {
212 $candidates[] = $phrase;
213 }
214 }
215
216 if ($i + 2 < $count) {
217 $phrase = $words[$i] . ' ' . $words[$i + 1] . ' ' . $words[$i + 2];
218 if ($isValidPhrase($phrase)) {
219 $candidates[] = $phrase;
220 }
221 }
222 }
223
224 usort($candidates, function (string $a, string $b): int {
225 return mb_strlen($b) - mb_strlen($a);
226 });
227
228 dol_syslog("parse_intent.php We have candidates into text that may be a thirdparty. List is ".implode(',', $candidates), LOG_DEBUG);
229
230 if (!empty($candidates)) {
231 foreach ($candidates as $phrase) {
232 // We use LIKE '...' to match the start of the company name.
233 $sql = "SELECT rowid, nom FROM " . MAIN_DB_PREFIX . "societe WHERE nom LIKE '" . $db->escape($phrase) . "%' LIMIT 1";
234
235 $res = $db->query($sql);
236
237 if ($res && $obj = $db->fetch_object($res)) {
238 // Match found. Replace in the original query.
239 $query = preg_replace('/\b' . preg_quote($phrase, '/') . '\b/iu', "socid:" . $obj->rowid, $query);
240
241 break;
242 }
243 }
244 }
245
246 // Apply privacy guard if enabled
247 $guard = null;
248 if ($doRedact && class_exists('PrivacyGuard')) {
249 $guard = new PrivacyGuard();
250 $query = $guard->mask($query);
251 }
252
253 // AI Execution
254 $intentJSON = null;
255 $confidence = 0.0;
256 $allToolsSchema = [];
257
258 if ($serviceKey && $serviceKey !== '-1') {
259 $providerUsed = $serviceKey;
260 $mcp = new McpHandler($db, $user, $conf, McpHandler::CTX_ASSISTANT);
261
262 // Two schemas are maintained:
263 // $allToolsSchema — full list including system tools; used ONLY for post-LLM validation.
264 // $llmToolsBase — system tools excluded (is_system=>true filtered out in McpHandler);
265 // used for category filtering and as the LLM tool list.
266 // This separation guarantees ask_for_confirmation, respond_to_user, etc. are
267 // never visible to the model, preventing the LLM from calling them directly.
268 $allToolsSchema = $mcp->getToolsSchema();
269 $llmToolsBase = $mcp->getToolsSchemaForLLM();
270
271 // Detect if query is in a Non-Latin language (Russian, Greek, Chinese, Arabic, etc.)
272 $isComplex = isComplexScript($query);
273
274 $toolsSchema = [];
275
276 if ($isComplex) {
277 // Non-Latin: send full LLM-safe schema (system tools already excluded)
278 dol_syslog("AI Pro: Non-Latin language detected. Sending full (cleaned) schema.");
279 $toolsSchema = $llmToolsBase;
280 } else {
281 // Detect in which business family the query is using Hybrid (Translations + Synonyms)
282 $detectedCategories = classifyIntentUniversal($query, $langs);
283
284 // Category filter applied to $llmToolsBase — system tools already excluded
285 $toolsSchema = filterToolsProfessional($llmToolsBase, $detectedCategories);
286
287 dol_syslog("AI Pro: Latin script. Detected: " . json_encode($detectedCategories) . ". Filtered to " . count($toolsSchema) . " tools.");
288 }
289
290 // If we are sending a lot of tools (Non-Latin or Fallback), we strip descriptions.
291 // The 20-tool threshold was likely chosen for GPT-3.5 (4K context window). Modern
292 // LLMs handle the full schema trivially: Gemini 2.5 Flash has a 1M token context,
293 // GPT-4o has 128K, Claude Sonnet has 1M. Compression hurts more than it helps
294 // today because it also truncates tool *descriptions* (down to 3 words), which
295 // breaks tool selection (e.g. "create_other_document" becomes "Create documents
296 // other" -- the LLM then thinks supplier_invoice creation is not available).
297 // We raise the threshold to 100 to effectively disable compression for the
298 // default install (~30 tools), while still leaving a safety net for very large
299 // custom installs that register dozens of additional addMcpTools hooks.
300 $isLargeSchema = count($toolsSchema) > 100;
301 $toolsForLLM = cleanToolSchemaForLLM($toolsSchema, $isLargeSchema);
302
303 // Build System Prompt
304 $basePrompt = getDolGlobalString('AI_INTENT_PROMPT') ?: "You are a professional Dolibarr assistant.";
305
306 $systemRules = "\n\nRules: Respond ONLY JSON and ensure any json string does not contains special chars and are correctly json encoded. Format: {\"tool\":..., \"arguments\":{...}}. ";
307 $systemRules .= "IMPORTANT: If the user asks for functionality that is NOT available in the list of Tools above, you MUST use the tool 'respond_to_user' to inform them that the specific feature is not available.";
308
309 // If MCP is disabled, we disable all tools
310 if (getDolGlobalString('AI_ASSISTANT_DISABLE_TOOLS')) {
311 $toolsForLLM = array();
312 }
313
314 $systemPrompt = $basePrompt . "\n\n";
315 $systemPrompt .= "Tools:\n" . json_encode($toolsForLLM, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
316 $systemPrompt .= $systemRules . " Date: " . date('Y-m-d');
317
318 // Get API configuration
319 $servicesList = getListOfAIServices();
320 $apiKey = getDolGlobalString('AI_API_' . strtoupper($serviceKey) . '_KEY');
321
322 if (preg_match('/^crypt:/', $apiKey)) {
323 $apiKey = dolDecrypt($apiKey, $conf->file->instance_unique_id);
324 }
325
326 $defUrl = $servicesList[$serviceKey]['url'] ?? '';
327 $url = getDolGlobalString('AI_API_' . strtoupper($serviceKey) . '_URL') ?: $defUrl;
328 // The model defaults declared in getListOfAIServices() are nested:
329 // $servicesList[$key]['textgeneration'] = ['default' => 'model-name']
330 // Reading 'textgeneration' without ['default'] returns the inner array, which
331 // then fails the (string) type-hint of UniversalLLMAdapter's 4th argument with:
332 // "Argument #4 ($model) must be of type string, array given"
333 //
334 // The admin UI (htdocs/ai/admin/setup.php "Prompt and custom AI models" tab) also
335 // stores the per-function model under AI_API_<SERVICE>_MODEL_TEXT (matching the
336 // convention already used by Ai::generateContent() for the same data). The
337 // previous lookup used AI_API_<SERVICE>_MODEL which is never written by that
338 // form, so the user-configured model was silently ignored.
339 $rawDefault = $servicesList[$serviceKey]['textgeneration'] ?? null;
340 if (is_array($rawDefault)) {
341 $defModel = $rawDefault['default'] ?? 'gpt-4o-mini';
342 } else {
343 $defModel = $rawDefault ?: 'gpt-4o-mini';
344 }
345 $prefix = 'AI_API_' . strtoupper($serviceKey);
346 $model = getDolGlobalString($prefix . '_MODEL_TEXT')
347 ?: getDolGlobalString($prefix . '_MODEL')
348 ?: $defModel;
349 // Defensive: coerce to string if anyone stored an array in this constant
350 if (is_array($model)) {
351 $model = $model['default'] ?? $defModel;
352 }
353 if (!is_string($model) || $model === '') {
354 $model = (string) $defModel;
355 }
356 $adapterType = $servicesList[$serviceKey]['adapter_type'] ?? 'openai';
357
358
359 // The request.
360 // var_dump($query);
361
362 if (!empty($apiKey)) {
363 $adapter = new UniversalLLMAdapter($adapterType, $apiKey, $url, $model, $timeout);
364
365 dol_syslog("parse_intent.php Call AI API", LOG_DEBUG);
366
367 $rawResponse = $adapter->generate($systemPrompt, $query);
368
369 // $rawResponse should be a json string with format '{"tool":..., "arguments":{text answer}}' but sometimes it is just 'text answer'
370 dol_syslog('rawResponse='.$rawResponse, LOG_DEBUG);
371
372 //var_dump($rawResponse);exit;
373
374 // Capture logs
375 $rawRequestLog = $adapter->lastRequest;
376 $rawResponseLog = $adapter->lastResponse;
377
378 // Process response
379 if (is_string($rawResponse) && strpos($rawResponse, 'Error:') === 0) {
380 $errorDetails = $rawResponse;
381 } elseif ($rawResponse) {
382 // Clean JSON response
383 $clean = preg_replace('/```json\s*|\s*```/s', '', $rawResponse);
384 $clean = trim($clean);
385
386 $matches = array();
387 if (preg_match('/^\{.*\}$/s', $clean, $matches)) {
388 $clean = $matches[0];
389 }
390
391 // Unmask the JSON string
392 if ($guard) {
393 $clean = $guard->unmaskAiResponse($clean);
394 }
395
396 // Removed carriage returns and newlines
397 $clean = preg_replace('/[\r\n]/', ' ', $clean);
398
399 // If answer is a json string or not
400 if (strpos($clean, '{') === 0) {
401 // This may be a json string
402 $intentJSON = json_decode($clean, true);
403 } else {
404 $intentJSON = [
405 "tool" => "respond_to_user",
406 'arguments' => [
407 "message" => $clean
408 ]
409 ];
410 }
411
412 // Ensure no placeholders remain in the data structure.
413 if ($guard && isset($intentJSON['arguments'])) {
414 $intentJSON['arguments'] = recursiveUnmaskValues($intentJSON['arguments'], $guard);
415 }
416
417 // Validation check: Check if the AI selected a tool that actually exists in our filtered schema.
418 if ($intentJSON && isset($intentJSON['tool'])) {
419 $validToolNames = array_column($allToolsSchema, 'name');
420 if (!in_array($intentJSON['tool'], $validToolNames)) {
421 dol_syslog("AI Validation: Tool '" . $intentJSON['tool'] . "' not found in filtered schema. Send error message via respond_to_user.", LOG_WARNING);
422
423 // Force the standard response for non-existent functionality
424 $intentJSON = [
425 "tool" => "respond_to_user",
426 "arguments" => [
427 "message" => "I apologize, but the requested functionality is not currently available in the system."
428 ]
429 ];
430 $confidence = 1.0;
431 }
432 }
433
434 // Calculate confidence (only if not manually set to 1.0 above)
435 if ($intentJSON && $confidence === 0.0) {
436 $mappedToolsSchema = array_column($toolsSchema, null, 'name');
437 $confidence = calculateConfidence($intentJSON, $mappedToolsSchema, $rawResponse);
438
439 dol_syslog("parse_intent.php AI Intent: " . json_encode(['query' => $query, 'intent' => $intentJSON, 'confidence' => $confidence]), LOG_DEBUG);
440 }
441 }
442 }
443 }
444
445
446 // Handle no AI Intent
447 if (!$intentJSON || !isset($intentJSON['tool'])) {
448 $finalResponse = [
449 "tool" => "respond_to_user",
450 "arguments" => [
451 "message" => "I'm having trouble understanding your request. Please try rephrasing it differently. If the problem persists, please contact your administrator to check the AI connection status."
452 ]
453 ];
454
455 // Log the failure
456 ai_log_request($db, $user, $query, $finalResponse, $providerUsed, microtime(true) - $startTime, 0.0, $langs->transnoentitiesnoconv('Error'), $errorDetails, $rawRequestLog, $rawResponseLog);
457
458 ob_end_clean();
459 echo json_encode($finalResponse);
460 exit;
461 }
462
463 // Check if confirmation needed
464 $needsConfirmation = false;
465 $toolName = $intentJSON['tool'] ?? '';
466
467 // Normalize the text answer key: some models (e.g. GPT-4o) fill
468 // respond_to_user / reject_general_question under 'response', 'text',
469 // 'answer'... instead of the 'message' key the frontend reads, which
470 // otherwise surfaces as "Empty AI Response".
471 if (in_array($toolName, array('respond_to_user', 'reject_general_question'), true) && isset($intentJSON['arguments']) && is_array($intentJSON['arguments'])) {
472 if (empty($intentJSON['arguments']['message'])) {
473 foreach (array('response', 'text', 'answer', 'content', 'reply', 'output') as $altkey) {
474 if (!empty($intentJSON['arguments'][$altkey])) {
475 $intentJSON['arguments']['message'] = $intentJSON['arguments'][$altkey];
476 break;
477 }
478 }
479 }
480 }
481
482 if ($askForConfirmation > 0) {
483 $isModifyOperation = preg_match('/(create|update|delete|add|remove|modify|edit)/i', $toolName);
484
485 if ($askForConfirmation == 1 && $isModifyOperation) {
486 $needsConfirmation = true;
487 } elseif ($askForConfirmation == 2) {
488 $needsConfirmation = true;
489 }
490 }
491
492 // Handle confirmation
493 if ($needsConfirmation) {
494 $allToolsMap = !empty($allToolsSchema)
495 ? array_column($allToolsSchema, null, 'name')
496 : [];
497 $toolDescription = $allToolsMap[$toolName]['description'] ?? 'No description available';
498 $arguments = $intentJSON['arguments'] ?? [];
499
500 $details = formatArgumentsForDisplay($arguments);
501 $action = extractActionFromTool($toolName);
502
503 $confirmationResponse = [
504 "tool" => "ask_for_confirmation",
505 "arguments" => [
506 "action" => $action,
507 "details" => $details,
508 "original_intent" => $intentJSON
509 ]
510 ];
511
512 // Log the confirmation request
513 ai_log_request($db, $user, $query, $confirmationResponse, $providerUsed, microtime(true) - $startTime, $confidence, $langs->transnoentitiesnoconv("Confirm"), $errorDetails, $rawRequestLog, $rawResponseLog);
514
515 ob_end_clean();
516 echo json_encode($confirmationResponse);
517 exit;
518 }
519
520 // Handle low confidence
521 if ($confidence < LOW_CONFIDENCE) {
522 $finalResponse = [
523 "tool" => "respond_to_user",
524 "arguments" => [
525 "message" => "I'm not confident about understanding your request. Please try rephrasing it with more specific details."
526 ]
527 ];
528
529 // Log the low confidence response
530 ai_log_request($db, $user, $query, $finalResponse, $providerUsed, microtime(true) - $startTime, $confidence, 'low_confidence', $errorDetails, $rawRequestLog, $rawResponseLog);
531
532 ob_end_clean();
533 echo json_encode($finalResponse);
534 exit;
535 }
536
537 // Add confidence note
538 if ($confidence < MEDIUM_CONFIDENCE && isset($intentJSON['arguments'])) {
539 $intentJSON['arguments']['_confidence_note'] = "I'm moderately confident about this interpretation. Please verify the results.";
540 }
541
542 // Success!
543 $finalResponse = $intentJSON;
544 $execTime = microtime(true) - $startTime;
545 ai_log_request($db, $user, $query, $finalResponse, $providerUsed, $execTime, $confidence, $langs->transnoentitiesnoconv("Success"), $errorDetails, $rawRequestLog, $rawResponseLog);
546
547 ob_end_clean();
548 echo json_encode($finalResponse);
549} catch (Throwable $e) {
550 $friendlyResponse = [
551 "tool" => "respond_to_user",
552 "arguments" => [
553 "message" => "I'm experiencing technical difficulties. Please try again later or contact your administrator."
554 ]
555 ];
556
557 $realErrorForLog = "PHP Exception: " . $e->getMessage() . " in " . $e->getFile() . " on line " . $e->getLine();
558
559 dol_syslog("AI Critical Error: " . $realErrorForLog, LOG_ERR);
560
561 if (function_exists('ai_log_request') && is_object($db)) {
563 $db,
564 $user,
565 $query ?? 'unknown',
566 $friendlyResponse,
567 $providerUsed,
568 microtime(true) - $startTime,
569 0.0,
570 'error',
571 $realErrorForLog,
572 $rawRequestLog ?? '',
573 $rawResponseLog ?? ''
574 );
575 }
576
577 ob_end_clean();
578 echo json_encode($friendlyResponse);
579}
580
581
600function recursiveUnmaskValues($data, ?PrivacyGuard $guard)
601{
602 if ($guard === null) {
603 return $data;
604 }
605
606 if (is_array($data)) {
607 return array_map(
612 function ($item) use ($guard) {
613 return recursiveUnmaskValues($item, $guard);
614 },
615 $data
616 );
617 }
618
619 if (is_string($data)) {
620 return $guard->unmask($data);
621 }
622
623 return $data;
624}
625
638function isComplexScript(string $text)
639{
640 // CJK (Chinese, Japanese, Korean)
641 if (preg_match('/\p{Han}|\p{Hiragana}|\p{Katakana}|\p{Hangul}/u', $text)) {
642 return true;
643 }
644
645 // Cyrillic (Russian, Ukrainian, Bulgarian, Serbian)
646 if (preg_match('/\p{Cyrillic}/u', $text)) {
647 return true;
648 }
649
650 // Greek
651 if (preg_match('/\p{Greek}/u', $text)) {
652 return true;
653 }
654
655 // Arabic
656 if (preg_match('/\p{Arabic}/u', $text)) {
657 return true;
658 }
659
660 // Hebrew
661 if (preg_match('/\p{Hebrew}/u', $text)) {
662 return true;
663 }
664
665 // Thai
666 if (preg_match('/\p{Thai}/u', $text)) {
667 return true;
668 }
669
670 return false;
671}
672
695function classifyIntentUniversal(string $query, Translate $langs)
696{
697 $isLatin = !isComplexScript($query);
698 $searchQuery = $isLatin ? strtolower(dol_string_unaccent($query)) : $query;
699
700 $langs->loadLangs(array("main", "bills", "orders", "propal", "companies", "products", "projects", "dict"));
701
702 $intentMap = [
703 'billing' => [
704 'keys' => ['Bill', 'Invoice', 'Payment', 'Cheque', 'VAT', 'BillStatusUnpaid', 'BillStatusPaid', 'BillStatusDraft'],
705 'synonyms' => ['paid', 'unpaid', 'pay', 'money', 'cost', 'amount']
706 ],
707 'commercial' => [
708 'keys' => ['Order', 'Proposal', 'Quote', 'SupplierOrder', 'OrderStatusDraft'],
709 'synonyms' => ['sale', 'buy', 'purchase', 'contract', 'shipping']
710 ],
711 'thirdparty' => [
712 'keys' => ['ThirdParty', 'Customer', 'Supplier', 'Contact', 'Company'],
713 'synonyms' => ['client', 'partner', 'address', 'phone']
714 ],
715 'stock' => [
716 'keys' => ['Product', 'Service', 'Stock', 'Warehouse'],
717 'synonyms' => ['item', 'inventory', 'sku', 'location', 'qty']
718 ],
719 'project' => [
720 'keys' => ['Project', 'Task'],
721 'synonyms' => ['milestone', 'gantt', 'team']
722 ],
723 'reporting' => [
724 'keys' => ['Report', 'Statistics', 'Turnover', 'Revenue', 'Income'],
725 'synonyms' => ['graph', 'chart', 'analytics', 'dashboard', 'kpi']
726 ]
727 ];
728
729 $detectedCategories = [];
730 foreach ($intentMap as $category => $data) {
731 $keywords = [];
732 foreach ($data['keys'] as $key) {
733 $trans = $langs->trans($key);
734 if ($isLatin) {
735 $trans = strtolower(dol_string_unaccent($trans));
736 }
737 $keywords[] = $trans;
738 if (!$isLatin && $key !== $trans) {
739 $keywords[] = strtolower($key);
740 }
741 }
742 if ($isLatin) {
743 foreach ($data['synonyms'] as $syn) {
744 $keywords[] = dol_string_unaccent($syn);
745 }
746 }
747 foreach ($keywords as $word) {
748 if (empty($word)) {
749 continue;
750 }
751 if ($isLatin) {
752 if (preg_match('/\b' . preg_quote($word, '/') . 's?\b/u', $searchQuery)) {
753 $detectedCategories[] = $category;
754 break;
755 }
756 } else {
757 if (mb_stripos($searchQuery, $word) !== false) {
758 $detectedCategories[] = $category;
759 break;
760 }
761 }
762 }
763 }
764 return $detectedCategories;
765}
766
787function filterToolsProfessional(array $allTools, array $activeCategories)
788{
789 if (empty($activeCategories) || (count($activeCategories) === 1 && $activeCategories[0] === 'global')) {
790 return $allTools;
791 }
792
793 $targetCategories = array_merge(['global'], $activeCategories);
794 $filtered = [];
795
796 foreach ($allTools as $tool) {
797 $toolCats = $tool['categories'] ?? ['global'];
798 if (count(array_intersect($toolCats, $targetCategories)) > 0) {
799 if (count($activeCategories) > 0 && $toolCats === ['global']) {
800 continue;
801 }
802 $filtered[] = $tool;
803 }
804 }
805
806 if (count($filtered) < 3) {
807 dol_syslog("AI Filter: Too few tools (" . count($filtered) . "). Reverting to full schema.", LOG_WARNING);
808 return $allTools;
809 }
810
811 return $filtered;
812}
813
822function cleanToolSchemaForLLM(array $tools, bool $isLargeSchema = false)
823{
824 $cleaned = [];
825
826 foreach ($tools as $tool) {
827 // Tool descriptions are how the LLM selects the right tool -- never truncate
828 // them, even when the schema is large. Truncating to 3 words ("Create documents
829 // other", "Add a single") breaks tool selection. If the schema really is too
830 // big for the chosen model, the right answer is to filter the toolset before
831 // it reaches the LLM (which is what filterToolsProfessional() already does
832 // upstream of this function), not to mutilate each tool's description.
833 // Parameter-level compression (stripping defaults, descriptions of optional
834 // fields, etc.) remains gated on $isLargeSchema below.
835 $desc = $tool['description'];
836
837 // Get parameters
838 $toolParams = $tool['parameters'] ?? $tool['inputSchema'] ?? [];
839
840 if ($isLargeSchema && isset($toolParams['properties']) && is_array($toolParams['properties'])) {
841 $requiredList = $toolParams['required'] ?? [];
842 $newProperties = [];
843
844 foreach ($toolParams['properties'] as $propKey => $propData) {
845 $isRequired = in_array($propKey, $requiredList);
846
847 // -----------------------------------------------------------
848 // Remove Optional Parameters with Defaults
849 // -----------------------------------------------------------
850 // If a parameter is optional and has a default value defined in
851 // the schema, we assume the backend will handle it. We remove it
852 // from the prompt entirely. This saves massive amounts of tokens
853 // on list/search functions (limit, sortorder, sqlfilters, etc).
854 // -----------------------------------------------------------
855 if (!$isRequired && isset($propData['default'])) {
856 continue;
857 }
858
859 // Remove 'type' for string (LLM default), Keep others (int/bool/arr)
860 if (isset($propData['type']) && $propData['type'] === 'string') {
861 unset($propData['type']);
862 }
863
864 // Handle Descriptions
865 // Remove descriptions entirely. Rely on the key name (e.g. 'email', 'qty').
866 // Exception: Keep 1 word if it's a required parameter with a confusing name.
867 if (isset($propData['description'])) {
868 unset($propData['description']);
869 // If we want to keep a tiny hint for required params, uncomment below:
870 // if ($isRequired) {
871 // $propData['description'] = explode(' ', trim($propData['description']))[0];
872 // }
873 }
874
875 // Collapse Complex Objects
876 // If a parameter is a deep object (like a complex filter), replace the
877 // recursive properties definition with a generic string to save tokens.
878 if (isset($propData['type']) && $propData['type'] === 'object' && isset($propData['properties'])) {
879 unset($propData['properties']);
880 unset($propData['required']);
881 $propData['description'] = "JSON object"; // Minimal hint
882 }
883
884 $newProperties[$propKey] = $propData;
885 }
886
887 $toolParams['properties'] = $newProperties;
888
889 // Clean up root metadata
890 unset($toolParams['type']);
891 unset($toolParams['additionalProperties']);
892 }
893
894 $cleaned[] = [
895 'name' => $tool['name'],
896 'description' => $desc,
897 'parameters' => $toolParams
898 ];
899 }
900
901 return $cleaned;
902}
903
916function calculateConfidence($intentJSON, $toolsSchema, $rawResponse)
917{
918 $confidence = 0.0;
919 $factors = [];
920
921 // Factor 1: JSON parsing success (Weight: 40%)
922 // If we are here, the JSON generally parsed, but we check if the structure is valid.
923 $factors['parse_success'] = 0.4;
924
925 // Factor 2: Response completeness (Weight: 30%)
926 // Check if we have a tool name and some arguments.
927 $hasRequiredFields = !empty($intentJSON['tool']) && !empty($intentJSON['arguments']);
928 $factors['completeness'] = $hasRequiredFields ? 0.3 : 0.0;
929
930 // Factor 3: Schema validation (Weight: 20%)
931 $isValidSchema = false;
932
933 // Ensure the tool exists in our known schema
934 if (isset($intentJSON['tool']) && isset($toolsSchema[$intentJSON['tool']])) {
935 // Support both 'parameters' and 'inputSchema'
936 $schema = $toolsSchema[$intentJSON['tool']]['parameters']
937 ?? $toolsSchema[$intentJSON['tool']]['inputSchema']
938 ?? [];
939
940 // Extract parameters provided by the AI
941 $providedParams = array_keys($intentJSON['arguments'] ?? []);
942
943 // Standard JSON Schema structure uses 'properties' to list params and 'required' to list mandatory ones.
944 $properties = $schema['properties'] ?? [];
945 $requiredList = $schema['required'] ?? [];
946
947 $missingParams = [];
948
949 // Iterate through the schema properties to check required fields
950 foreach ($properties as $paramKey => $paramDetails) {
951 // Check if this specific parameter is marked as required in the schema
952 if (in_array($paramKey, $requiredList)) {
953 // If it is required but not in the AI's provided arguments, it's missing.
954 if (!in_array($paramKey, $providedParams)) {
955 $missingParams[] = $paramKey;
956 }
957 }
958 }
959
960 // If no required parameters are missing, schema validation passes.
961 $isValidSchema = empty($missingParams);
962 }
963
964 $factors['schema_validation'] = $isValidSchema ? 0.2 : 0.0;
965
966 // Factor 4: Response quality (Weight: 10%)
967 $qualityScore = 0.0;
968 if (is_string($rawResponse)) {
969 // Check for error indicators in the raw text (e.g., "I'm sorry", "Error")
970 if (!preg_match('/error|fail|unable|cannot|sorry/i', $rawResponse)) {
971 $qualityScore += 0.05;
972 }
973
974 // Verify the tool actually exists in our registry (double check)
975 if (isset($intentJSON['tool']) && isset($toolsSchema[$intentJSON['tool']])) {
976 $qualityScore += 0.05;
977 }
978 }
979 $factors['response_quality'] = $qualityScore;
980
981 // Calculate Total Confidence
982 $confidence = array_sum($factors);
983
984 // Ensure confidence stays within bounds [0, 1]
985 return max(0.0, min(1.0, $confidence));
986}
987
994function formatArgumentsForDisplay($arguments)
995{
996 $formattedArgs = [];
997 foreach ($arguments as $key => $value) {
998 if (is_array($value)) {
999 $formattedArgs[] = "- {$key}: " . (empty($value) ? "(empty)" : json_encode($value, JSON_PRETTY_PRINT));
1000 } else {
1001 $formattedArgs[] = "- {$key}: {$value}";
1002 }
1003 }
1004 return implode("\n", $formattedArgs);
1005}
1006
1013function extractActionFromTool($toolName)
1014{
1015 if (preg_match('/^(create|update|delete|list|show|find|search|get|view|validate|send)/i', $toolName, $matches)) {
1016 return strtolower($matches[1]);
1017 }
1018 return 'perform this action';
1019}
getListOfAIServices()
Get list of available ai services.
Definition ai.lib.php:68
ai_log_request($db, $user, $query, array $response, $provider, float $time, float $confidence, $status, $error='', $rawReq='', $rawRes='')
Log AI Request with Raw Payloads.
Definition ai.lib.php:319
Class to handle MCP (Model Context Protocol).
Definition mcp.class.php:39
Class to manage privacy data masking and unmasking.
Class to manage translations.
if(!isModEnabled('ai')||!getDolGlobalString('AI_ASSISTANT_ENABLED')) global $conf
The main.inc.php has been included so the following variable are now defined:
if(!isModEnabled('ai')||!getDolGlobalString('AI_ASSISTANT_ENABLED')) global $db
API class for accounts.
getDolGlobalInt($key, $default=0)
Return a Dolibarr global constant int value.
dol_string_unaccent($str)
Clean a string from all accent characters to be used as ref, login or by dol_sanitizeFileName.
getDolGlobalString($key, $default='')
Return a Dolibarr global constant string value.
isModEnabled($module)
Is Dolibarr module enabled.
dol_syslog($message, $level=LOG_INFO, $ident=0, $suffixinfilename='', $restricttologhandler='', $logcontext=null)
Write log message into outputs.
print $langs trans("Show") . '< td style="' . $timeColor . '" align="center"> s</td > badge status0 badge status4 badge status3 Error badge status8< td align="center">< span class="badge ' . $badge . '"></span ></td >< td align="center">< a href="#" class="button button-small" onclick="openLogModal(this)" data-req="' . dol_escape_htmltag($reqSafe) . '" data-res="' . dol_escape_htmltag($resSafe) . '" data-err="' . dol_escape_htmltag($errSafe) . '">< span class="fa fa-search-plus"></span ></a ></td ></tr >< tr >< td colspan="' . $colspan . '" class="opacitymedium"></td ></tr ></table ></div ></form > logModal none logModal none s a JSON string
buildzip.php
if(!defined( 'NOREQUIREMENU')) if(!empty(GETPOST('seteventmessages', 'alpha'))) if(!function_exists("llxHeader")) top_httphead($contenttype='text/html', $forcenocache=0)
Show HTTP header.
extractActionFromTool($toolName)
Extract action from tool name.
formatArgumentsForDisplay($arguments)
Format arguments for display in confirmation.
catch(Throwable $e) recursiveUnmaskValues($data, ?PrivacyGuard $guard)
Recursively unmask values in a dataset.
isComplexScript(string $text)
Detects if the query uses Non-Latin Scripts.
calculateConfidence($intentJSON, $toolsSchema, $rawResponse)
Calculate confidence score based on multiple factors.
classifyIntentUniversal(string $query, Translate $langs)
Detect intent categories from a user query.
filterToolsProfessional(array $allTools, array $activeCategories)
Filter a list of tools based on active intent categories.
cleanToolSchemaForLLM(array $tools, bool $isLargeSchema=false)
Compresses tool schema by removing optional parameters with defaults and stripping descriptions,...
print $langs trans('Date')." left Ref Label right Qty right Price right TotalHT right TotalTTC right right right right right right right right right centpercent right TotalHT right n right VAT right n right TotalVAT right n No sujeto a RE IRPF right TotalLT1 right n right TotalLT2 right n right TotalTTC right n takeposcustomercurrency takeposcustomercurrency takeposcustomercurrency takeposcustomercurrency right TotalTTC takeposcustomercurrency right takeposcustomercurrency n right Paid right PaymentTypeShortLIQ right SELECT p pos_change as p datep as date
Definition receipt.php:487
accessforbidden($message='', $printheader=1, $printfooter=1, $showonlymessage=0, $params=null)
Show a message to say access is forbidden and stop program.
dolDecrypt($chain, $key='', $patterntotest='')
Decode a string with a symmetric encryption.