dolibarr 25.0.0-alpha
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
29if (!defined('NOTOKENRENEWAL')) {
30 define('NOTOKENRENEWAL', 1);
31}
32if (!defined('NOREQUIREMENU')) {
33 define('NOREQUIREMENU', 1);
34}
35if (!defined('NOREQUIREHTML')) {
36 define('NOREQUIREHTML', 1);
37}
38if (!defined('NOREQUIREAJAX')) {
39 define('NOREQUIREAJAX', 1);
40}
41// The payload is read from the raw php://input body, so the CSRF token cannot be checked by
42// main.inc.php. It is checked explicitly below by aiCheckCsrfToken().
43if (!defined('NOCSRFCHECK')) {
44 define('NOCSRFCHECK', 1);
45}
46
47require '../../main.inc.php';
48require_once DOL_DOCUMENT_ROOT . '/ai/class/mcp.class.php';
49require_once DOL_DOCUMENT_ROOT . '/ai/lib/ai.lib.php';
50require_once DOL_DOCUMENT_ROOT . '/ai/class/llmadapter.class.php';
51require_once DOL_DOCUMENT_ROOT . '/ai/class/privacy_guard.class.php';
52require_once DOL_DOCUMENT_ROOT . '/core/lib/security2.lib.php';
53
54// Security check
55if (!isModEnabled('ai') || !getDolGlobalString('AI_ASSISTANT_ENABLED')) {
56 http_response_code(403);
57 accessforbidden('Module or feature not allowed');
58}
59
60global $db, $user, $conf, $langs;
61
62// Same per-user gate as the Assistant page that calls this endpoint, so a
63// user without 'ai/assistant/use' cannot reach the LLM through direct AJAX.
64if (!$user->hasRight('ai', 'assistant', 'use')) {
66}
67
68// This endpoint sends data to the LLM provider on behalf of the user and can chain tool
69// executions, so it must not be reachable from another site.
70aiCheckCsrfToken('ai/assistant/parse_intent.php');
71
72ob_start();
73top_httphead('application/json');
74
75// Confirmation level: 0=no confirmation, 1=only create/update/delete, 2=all actions
76$askForConfirmation = getDolGlobalInt('AI_ASK_FOR_CONFIRMATION');
77
78// Confidence thresholds
79define('HIGH_CONFIDENCE', 0.8);
80define('MEDIUM_CONFIDENCE', 0.5);
81define('LOW_CONFIDENCE', 0.3);
82
83// Logging variables
84$startTime = microtime(true);
85$rawRequestLog = "";
86$rawResponseLog = "";
87$providerUsed = "offline";
88$errorDetails = "";
89
90$assistantEnabled = getDolGlobalInt('AI_ASSISTANT_ENABLED', 0);
91$serviceKey = getDolGlobalString('AI_API_SERVICE');
92$doRedact = getDolGlobalInt('AI_PRIVACY_REDACTION', 0);
93$timeout = getDolGlobalInt('AI_REQUEST_TIMEOUT', 120);
94
95// Kill switch
96if (!$assistantEnabled) {
97 $response = [
98 "tool" => "respond_to_user",
99 "arguments" => [
100 "message" => "AI assistant service is currently disabled. Please contact your administrator to enable it."
101 ]
102 ];
103 ob_end_clean();
104 echo json_encode($response);
105 exit;
106}
107
108set_time_limit($timeout + 5);
109
110try {
111 // Input
112 $raw_input = file_get_contents('php://input');
113 $data = json_decode($raw_input, true);
114 $query = isset($data['query']) ? trim($data['query']) : '';
115
116 // --- Page context (optional): posted by the chat JS from the value the
117 // printCommonFooter hook emitted on the page being viewed. The POST is
118 // client-controlled, so nothing here is trusted: the element must be on
119 // the whitelist, the object must fetch, and the user must hold the read
120 // permission - otherwise the context is silently dropped. On success a
121 // one-line description is added to the system prompt so the model can
122 // resolve "this invoice" into real tool arguments.
123 $aiPageContextLine = '';
124 $ctxNamesToMask = array();
125 if (!empty($data['context']) && is_array($data['context'])) {
126 $ctxElement = isset($data['context']['element']) ? (string) $data['context']['element'] : '';
127 $ctxId = isset($data['context']['id']) ? (int) $data['context']['id'] : 0;
128 // element => [classfile, classname, label, rights module, rights perm(, rights subperm)]
129 // External modules with their own objects opt in through the
130 // AI_ASSISTANT_CONTEXT_ELEMENTS const: a JSON array of entries
131 // {"element":..,"classfile":"/mymodule/class/x.class.php","classname":..,
132 // "label":..,"rights":["mymodule","myobject","read"]} - same shape,
133 // same validation path (whitelist, fetch, hasRight, entity) as core
134 // elements. Their card pages already emit context automatically via
135 // the global hook; this const is the server-side acceptance half.
136 $ctxMap = array(
137 'facture' => array('/compta/facture/class/facture.class.php', 'Facture', 'customer invoice', 'facture', 'lire'),
138 'invoice_supplier' => array('/fourn/class/fournisseur.facture.class.php', 'FactureFournisseur', 'supplier invoice', 'fournisseur', 'facture', 'lire'),
139 'commande' => array('/commande/class/commande.class.php', 'Commande', 'sales order', 'commande', 'lire'),
140 'order_supplier' => array('/fourn/class/fournisseur.commande.class.php', 'CommandeFournisseur', 'supplier order', 'fournisseur', 'commande', 'lire'),
141 'propal' => array('/comm/propal/class/propal.class.php', 'Propal', 'commercial proposal', 'propal', 'lire'),
142 'supplier_proposal' => array('/supplier_proposal/class/supplier_proposal.class.php', 'SupplierProposal', 'supplier proposal', 'supplier_proposal', 'lire'),
143 'societe' => array('/societe/class/societe.class.php', 'Societe', 'thirdparty', 'societe', 'lire'),
144 'product' => array('/product/class/product.class.php', 'Product', 'product or service', 'produit', 'lire'),
145 'shipping' => array('/expedition/class/expedition.class.php', 'Expedition', 'shipment', 'expedition', 'lire'),
146 'reception' => array('/reception/class/reception.class.php', 'Reception', 'reception', 'reception', 'lire'),
147 'project' => array('/projet/class/project.class.php', 'Project', 'project', 'projet', 'lire'),
148 'project_task' => array('/projet/class/task.class.php', 'Task', 'project task', 'projet', 'lire'),
149 'contrat' => array('/contrat/class/contrat.class.php', 'Contrat', 'contract', 'contrat', 'lire'),
150 'fichinter' => array('/fichinter/class/fichinter.class.php', 'Fichinter', 'intervention', 'ficheinter', 'lire'),
151 'ticket' => array('/ticket/class/ticket.class.php', 'Ticket', 'support ticket', 'ticket', 'read'),
152 'member' => array('/adherents/class/adherent.class.php', 'Adherent', 'member', 'adherent', 'lire'),
153 'expensereport' => array('/expensereport/class/expensereport.class.php', 'ExpenseReport', 'expense report', 'expensereport', 'lire'),
154 'holiday' => array('/holiday/class/holiday.class.php', 'Holiday', 'leave request', 'holiday', 'read'),
155 'don' => array('/don/class/don.class.php', 'Don', 'donation', 'don', 'lire'),
156 'action' => array('/comm/action/class/actioncomm.class.php', 'ActionComm', 'agenda event', 'agenda', 'myactions', 'read'),
157 'bom' => array('/bom/class/bom.class.php', 'BOM', 'bill of materials', 'bom', 'read'),
158 'mo' => array('/mrp/class/mo.class.php', 'Mo', 'manufacturing order', 'mrp', 'read'),
159 'stock' => array('/product/stock/class/entrepot.class.php', 'Entrepot', 'warehouse', 'stock', 'lire'),
160 'contact' => array('/contact/class/contact.class.php', 'Contact', 'contact', 'societe', 'contact', 'lire'),
161 'bank_account' => array('/compta/bank/class/account.class.php', 'Account', 'bank account', 'banque', 'lire'),
162 'category' => array('/categories/class/categorie.class.php', 'Categorie', 'category (tag)', 'categorie', 'lire'),
163 'knowledgerecord' => array('/knowledgemanagement/class/knowledgerecord.class.php', 'KnowledgeRecord', 'knowledge article', 'knowledgemanagement', 'knowledgerecord', 'read'),
164 'recruitmentjobposition' => array('/recruitment/class/recruitmentjobposition.class.php', 'RecruitmentJobPosition', 'job position', 'recruitment', 'recruitmentjobposition', 'read'),
165 // Deliberately absent: user and salary (privacy/SEC precedent
166 // #40313) - personal data cards never feed the prompt.
167 );
168 $ctxExtra = getDolGlobalString('AI_ASSISTANT_CONTEXT_ELEMENTS');
169 if ($ctxExtra) {
170 $extraArr = json_decode($ctxExtra, true);
171 if (is_array($extraArr)) {
172 foreach ($extraArr as $extra) {
173 if (!empty($extra['element']) && !empty($extra['classfile']) && !empty($extra['classname']) && !empty($extra['rights'][0]) && !isset($ctxMap[$extra['element']])) {
174 $ctxMap[(string) $extra['element']] = array(
175 (string) $extra['classfile'],
176 (string) $extra['classname'],
177 (string) ($extra['label'] ?? $extra['element']),
178 (string) $extra['rights'][0],
179 (string) ($extra['rights'][1] ?? 'read'),
180 (string) ($extra['rights'][2] ?? '')
181 );
182 }
183 }
184 }
185 }
186 if ($ctxId > 0 && isset($ctxMap[$ctxElement]) && $user->hasRight($ctxMap[$ctxElement][3], $ctxMap[$ctxElement][4], $ctxMap[$ctxElement][5] ?? '')) {
187 require_once DOL_DOCUMENT_ROOT.$ctxMap[$ctxElement][0];
188 $ctxObj = new $ctxMap[$ctxElement][1]($db);
189 if ($ctxObj->fetch($ctxId) > 0 && (empty($ctxObj->entity) || in_array((int) $ctxObj->entity, explode(',', getEntity($ctxElement))))) {
190 $ctxThirdpartyName = '';
191 if (empty($doRedact) && !empty($ctxObj->socid)) {
192 // The counterparty NAME lets the model use name-based search
193 // tools too; under redaction it is omitted - the ids suffice
194 // and names must not travel to the provider.
195 require_once DOL_DOCUMENT_ROOT.'/societe/class/societe.class.php';
196 $ctxSoc = new Societe($db);
197 if ($ctxSoc->fetch((int) $ctxObj->socid) > 0) {
198 $ctxThirdpartyName = " (".dol_string_nohtmltag($ctxSoc->name).")";
199 }
200 }
201 if (empty($doRedact) && $ctxElement === 'societe' && !empty($ctxObj->name)) {
202 $ctxThirdpartyName = " (".dol_string_nohtmltag((string) $ctxObj->name).")";
203 }
204 // Under redaction, elements whose ref IS a personal/company name
205 // (societe: ref = company name) must not leak it - the privacy
206 // guard is pattern-based and cannot recognize arbitrary names.
207 $ctxNamesToMask[] = (string) $ctxObj->ref;
208 if (!empty($ctxObj->label)) {
209 $ctxNamesToMask[] = (string) $ctxObj->label;
210 }
211 if (!empty($ctxObj->name)) {
212 $ctxNamesToMask[] = (string) $ctxObj->name;
213 }
214 $ctxRefPart = " with ref \"".$ctxObj->ref."\"";
215 if (!empty($doRedact) && in_array($ctxElement, array('societe', 'contact'), true)) {
216 $ctxRefPart = "";
217 }
218 $aiPageContextLine = "The user is currently viewing the ".$ctxMap[$ctxElement][2].$ctxRefPart." (id ".(int) $ctxObj->id.(!empty($ctxObj->socid) ? ", thirdparty id ".(int) $ctxObj->socid.$ctxThirdpartyName : $ctxThirdpartyName).").";
219 $aiPageContextLine .= " When the user says \"this\"/\"it\" or refers to the current document, use these identifiers as tool arguments";
220 if ($ctxElement === 'societe') {
221 $aiPageContextLine .= " - in particular, this thirdparty id is the socid/customer id for any create or search tool";
222 }
223 $aiPageContextLine .= ". NEVER ask the user for ids already given here; pass names/refs the user wrote (products, etc.) directly in the matching ref arguments - tools resolve them.";
224 dol_syslog("AI Pro: page context accepted: ".$ctxElement." #".$ctxId);
225 } else {
226 dol_syslog("AI Pro: page context rejected (fetch/entity): ".$ctxElement." #".$ctxId, LOG_WARNING);
227 }
228 } elseif ($ctxId > 0) {
229 dol_syslog("AI Pro: page context rejected (whitelist/rights): ".$ctxElement." #".$ctxId, LOG_WARNING);
230 } elseif (!empty($data['context']['dashboard']) && is_string($data['context']['dashboard'])) {
231 $dash = dol_string_nohtmltag(dol_substr($data['context']['dashboard'], 0, 60));
232 if (preg_match('/^[a-z0-9 _-]+$/i', $dash)) {
233 $aiPageContextLine = "The user is currently on the ".$dash." dashboard page. Questions about \"here\"/\"this page\" concern that module's data.";
234 dol_syslog("AI Pro: dashboard context accepted: ".$dash);
235 }
236 } elseif (!empty($data['context']['list']) && $ctxElement !== '' && (!empty($data['context']['filters']) || !empty($data['context']['ids']) || !empty($data['context']['selected']))) {
237 // List context: the user's own search inputs on their own list
238 // page, echoed back uninterpreted (sanitized + capped). Nothing is
239 // fetched, so no rights question arises; the model maps these onto
240 // tool arguments and the tools validate as always.
241 $parts = array();
242 $n = 0;
243 foreach ((is_array($data['context']['filters'] ?? null) ? $data['context']['filters'] : array()) as $fk => $fv) {
244 if (!is_string($fv) || !preg_match('/^(search_[a-z0-9_]+|sall|search_all|sortfield|sortorder)$/', (string) $fk)) {
245 continue;
246 }
247 $parts[] = $fk."='".dol_string_nohtmltag(dol_substr($fv, 0, 120))."'";
248 // Search values are names the user typed against real records:
249 // same class of arbitrary string as a thirdparty name.
250 $ctxNamesToMask[] = dol_string_nohtmltag(dol_substr($fv, 0, 120));
251 if (++$n >= 12) {
252 break;
253 }
254 }
255 $idsPart = '';
256 foreach (array('ids' => 100, 'selected' => 25) as $idkey => $cap) {
257 if (!empty($data['context'][$idkey]) && is_array($data['context'][$idkey])) {
258 $clean = array();
259 foreach ($data['context'][$idkey] as $v) {
260 if ((int) $v > 0) {
261 $clean[] = (int) $v;
262 }
263 if (count($clean) >= $cap) {
264 break;
265 }
266 }
267 if (!empty($clean)) {
268 $idsPart .= ($idkey === 'ids' ? " Visible row ids: " : " Checked/selected row ids (act on these when the user says the selected ones): ").implode(',', $clean).".";
269 }
270 }
271 }
272 if (!empty($parts) || $idsPart !== '') {
273 $aiPageContextLine = "The user is currently viewing the \"".preg_replace('/[^a-z0-9_]/', '', $ctxElement)."\" list".(!empty($parts) ? " filtered by: ".implode(', ', $parts) : "").".".$idsPart;
274 $aiPageContextLine .= " To act on \"this list\"/\"these records\"/\"the selected ones\", use these ids or translate the filters into the matching arguments of the list/report tools.";
275 dol_syslog("AI Pro: list context accepted: ".$ctxElement." (".count($parts)." filters".($idsPart !== '' ? ", ids" : "").")");
276 }
277 }
278 }
279
280 // This is to allow easy test of the parse_intent.php by calling the URL with param query=test
281 if (empty($query) && GETPOST('query', 'alphanohtml') == '/tools') {
282 $query = '/tools';
283 }
284
285 if (empty($query)) {
286 ob_end_clean();
287 echo json_encode(["status" => "ok"]);
288 exit;
289 }
290
291 // Extract file attachments sent by the chat (paperclip flow). The JS embeds
292 // cloud-parsed documents as "__FILE_ATTACHMENT__[mime]::<base64>" markers in
293 // the query. They MUST be stripped here, before the privacy/thirdparty
294 // candidate pipeline (which would run regexes over megabytes of base64), and
295 // are handed to the LLM adapter as NATIVE multimodal parts — inlining base64
296 // into the text prompt makes every provider fail or hallucinate.
297 $attachments = array();
298 if (strpos($query, '__FILE_ATTACHMENT__') !== false) {
299 $query = preg_replace_callback(
300 '/__FILE_ATTACHMENT__\[([^\]]*)\]::([A-Za-z0-9+\/=\r\n]+)/',
305 static function (array $m) use (&$attachments) {
306 $attachments[] = array(
307 'mime' => ($m[1] !== '' ? $m[1] : 'application/octet-stream'),
308 'data' => preg_replace('/\s+/', '', $m[2])
309 );
310 return '[attached document]';
311 },
312 $query
313 );
314 $query = trim((string) $query);
315 if ($query === '' || $query === '[attached document]') {
316 $query = 'Analyze the attached document and describe its content.';
317 }
318 }
319
320 // Server-side gate on what the browser sent: MIME allowlist, size caps,
321 // and the privacy-redaction policy (documents cannot be masked, so under
322 // enforced redaction they must not go to a cloud provider at all).
323 $attachmenterror = '';
324 if (!ai_validate_attachments($attachments, $attachmenterror)) {
325 ob_end_clean();
326 echo json_encode(array(
327 "tool" => "respond_to_user",
328 "arguments" => array("message" => $attachmenterror)
329 ));
330 exit;
331 }
332
333 // Privacy (Name Resolution & Masking)
334 $langs->loadLangs(array("main", "bills", "orders", "propal", "supplier_invoice", "supplier_order", "projects", "other"));
335
336 // Translation key of Words we want to block in any language.
337 $blockKeys = [
338 // Objects (Nouns)
339 'Bill',
340 'Invoice',
341 'Order',
342 'Proposal',
343 'Shipment',
344 'Reception',
345 'Contract',
346 'SupplierInvoice',
347 'SupplierOrder',
348 'Project',
349 'Task',
350 'Product',
351 'Service',
352 'Ticket',
353 'Event',
354 'Agenda',
355 'Member',
356 'User',
357 'ThirdParty',
358 'Company',
359 'Contact',
360 // Actions (Verbs/Commands)
361 'Search',
362 'Find',
363 'List',
364 'Show',
365 'Create',
366 'Add',
367 'Modify',
368 'Delete',
369 'Validate',
370 'Send',
371 // Other
372 'Hello',
373 'Test'
374 ];
375
376 // Resolve keys to the actual current language
377 $dynamicStopWords = [];
378 foreach ($blockKeys as $key) {
379 $word = $langs->transnoentities($key);
380 if (!empty($word)) {
381 $dynamicStopWords[] = dol_strtolower($word);
382 }
383 }
384
385 // Add common short English/French/Spanish commands that users often type
386 // regardless of the UI language.
387 $commonCommands = ['show', 'find', 'search', 'list', 'get', 'voir', 'chercher', 'affiche', 'lista', 'buscar'];
388 $dynamicStopWords = array_unique(array_merge($dynamicStopWords, $commonCommands)); // $dynamicStopWords is an array of words
389
390
391 $cleanQuery = preg_replace('/[^\p{L}\p{N}\s\-]/u', '', $query); // Remove special chars from the prompt query
392 $words = preg_split('/\s+/', $cleanQuery, -1, PREG_SPLIT_NO_EMPTY);
393 $count = count($words);
394 $candidates = array();
395
396 // Helper function to validate a phrase without a dictionary.
397 // Returns 0 (not a candidate), 1 (candidate) or 2 (strict candidate, see RULE 2).
398 $isValidPhrase = function (string $phrase) use ($dynamicStopWords): int {
399 $phrase = trim($phrase);
400
401 // RULE 1: Minimum Length
402 // Filter out extremely short words (1-2 chars).
403 // This catches "a", "le", "la", "de", "y", "to", "in", "von", "zu" in almost all languages.
404 if (mb_strlen($phrase) < 3) {
405 return 0;
406 }
407
408 // RULE 2: First Word Check
409 // A phrase starting with a translated keyword ("Invoice Acme") is most
410 // often a verb or an object name read as a company. A single such word
411 // is never a candidate. A longer phrase is kept as a STRICT candidate:
412 // it only resolves when a company carries that whole phrase as its name
413 // (a third party legitimately named "Test Corp" was collateral damage of
414 // the plain rejection - review sonikf on #38356).
415 $parts = explode(' ', $phrase);
416 $firstWord = dol_strtolower($parts[0]);
417
418 if (in_array($firstWord, $dynamicStopWords)) {
419 return count($parts) > 1 ? 2 : 0;
420 }
421
422 return 1;
423 };
424
425 // Fill array $candidates of thirdparty name we may want to work with
426 $strictCandidates = array(); // phrases that must match a whole company name
427 for ($i = 0; $i < $count; $i++) {
428 $phrases = array($words[$i]);
429 if ($i + 1 < $count) {
430 $phrases[] = $words[$i] . ' ' . $words[$i + 1];
431 }
432 if ($i + 2 < $count) {
433 $phrases[] = $words[$i] . ' ' . $words[$i + 1] . ' ' . $words[$i + 2];
434 }
435 foreach ($phrases as $phrase) {
436 $valid = $isValidPhrase($phrase);
437 if ($valid > 0) {
438 $candidates[] = $phrase;
439 if ($valid === 2) {
440 $strictCandidates[$phrase] = true;
441 }
442 }
443 }
444 }
445
446 usort($candidates, function (string $a, string $b): int {
447 return mb_strlen($b) - mb_strlen($a);
448 });
449
450 dol_syslog("parse_intent.php We have candidates into text that may be a thirdparty. List is ".implode(',', $candidates), LOG_DEBUG);
451
452 if (!empty($candidates)) {
453 foreach ($candidates as $phrase) {
454 if (isset($strictCandidates[$phrase])) {
455 // Strict: the whole phrase must be the company name, or the name
456 // must continue with a space ("Test Corp" for "Test Corp SAS"),
457 // the shortest (closest) name first.
458 $sql = "SELECT rowid, nom FROM " . MAIN_DB_PREFIX . "societe WHERE nom = '" . $db->escape($phrase) . "' OR nom LIKE '" . $db->escape($phrase) . " %' ORDER BY LENGTH(nom) LIMIT 1";
459 } else {
460 // We use LIKE '...' to match the start of the company name.
461 $sql = "SELECT rowid, nom FROM " . MAIN_DB_PREFIX . "societe WHERE nom LIKE '" . $db->escape($phrase) . "%' LIMIT 1";
462 }
463
464 $res = $db->query($sql);
465
466 if ($res && $obj = $db->fetch_object($res)) {
467 // Match found. Replace in the original query.
468 $query = preg_replace('/\b' . preg_quote($phrase, '/') . '\b/iu', "socid:" . $obj->rowid, $query);
469
470 break;
471 }
472 }
473 }
474
475 // Token usage of the LLM call, filled after the adapter answered.
476 $usageContext = array();
477
478 // Apply privacy guard if enabled
479 $guard = null;
480 if ($doRedact && class_exists('PrivacyGuard')) {
481 $guard = new PrivacyGuard();
482 $query = $guard->mask($query);
483 // In-context reinforcement, adjacent to the placeholders themselves:
484 // weak models weigh nearby text far more than distant system rules, and
485 // the system-rule variant alone proved insufficient in the field.
486 if (strpos($query, '[[') !== false) {
487 $query .= "\n\n(Note: tokens like [[REF_1]] or [[ADDR_2]] above are privacy-masked real values. Use them verbatim as tool argument values - they are replaced with the real data before execution. Do not refuse the task because of them and do not ask the user to re-provide masked details.)";
488 }
489 }
490
491 // AI Execution
492 $intentJSON = null;
493 $confidence = 0.0;
494 $allToolsSchema = [];
495
496 if ($serviceKey && $serviceKey !== '-1') {
497 $providerUsed = $serviceKey;
498 $mcp = new McpHandler($db, $user, $conf, McpHandler::CTX_ASSISTANT);
499 $mcp->loadTools(); // This fill array ->loadedTools and ->toolsByName from tools found into ai/tools/
500
501 // Two schemas are maintained:
502 // $allToolsSchema — full list including system tools; used ONLY for post-LLM validation.
503 // $llmToolsBase — system tools excluded (is_system=>true filtered out in McpHandler);
504 // This separation guarantees ask_for_confirmation, respond_to_user, etc. are
505 // never visible to the model, preventing the LLM from calling them directly.
506 $allToolsSchema = $mcp->getToolsSchema();
507 $llmToolsBase = $mcp->getToolsSchemaForLLM();
508
509 // Special case we ask debug info
510 if ($query == '/tools') {
511 $s = '----- loadedTools (scan of family tools, not tools)'."\n";
512 $s .= '<pre>' . json_encode($mcp->loadedTools, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . '</pre>';
513 $s .= "\n";
514 $s .= "\n";
515 $s .= '----- toolsByName'."\n";
516 $s .= '<pre>' . json_encode($mcp->toolsByName, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . '</pre>';
517 $s .= "\n";
518 $s .= "\n";
519 $s .= '----- allToolsSchema (non system + system)'."\n";
520 $s .= '<pre>' . json_encode($allToolsSchema, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE) . '</pre>';
521
522 $finalResponse = [
523 "tool" => "respond_to_user",
524 "arguments" => [
525 "message" => $s
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, $usageContext);
531
532 ob_end_clean();
533 echo json_encode($finalResponse);
534 exit;
535 }
536
537 // Detect if query is in a Non-Latin language (Russian, Greek, Chinese, Arabic, etc.)
538 $isComplex = isComplexScript($query);
539
540 $toolsSchema = [];
541
542 if ($isComplex) {
543 // Non-Latin: the classifier matches translated keys (user language
544 // + en_US reference), so try to narrow the schema here too - a Greek
545 // query otherwise always ships all tools, which is the largest
546 // prompt this module can build (documents + full schema overflow
547 // small-context models). No categories detected = full schema, as
548 // before.
549 $detectedCategories = classifyIntentUniversal($query, $langs);
550 if (!empty($detectedCategories)) {
551 dol_syslog("AI Pro: Non-Latin query classified into ".implode(',', $detectedCategories).". Filtering schema.");
552 $toolsSchema = filterToolsProfessional($llmToolsBase, $detectedCategories);
553 } else {
554 dol_syslog("AI Pro: Non-Latin language detected, no category match. Sending full (cleaned) schema.");
555 $toolsSchema = $llmToolsBase;
556 }
557 } else {
558 // Detect in which business family the query is using Hybrid (Translations + Synonyms)
559 $detectedCategories = classifyIntentUniversal($query, $langs);
560
561 // Category filter applied to $llmToolsBase — system tools already excluded
562 $toolsSchema = filterToolsProfessional($llmToolsBase, $detectedCategories);
563
564 dol_syslog("AI Pro: Latin script. Detected: " . json_encode($detectedCategories) . ". Filtered to " . count($toolsSchema) . " tools.");
565 }
566
567 // If we are sending a lot of tools (Non-Latin or Fallback), we strip descriptions.
568 // The 20-tool threshold was likely chosen for GPT-3.5 (4K context window). Modern
569 // LLMs handle the full schema trivially: Gemini 2.5 Flash has a 1M token context,
570 // GPT-4o has 128K, Claude Sonnet has 1M. Compression hurts more than it helps
571 // today because it also truncates tool *descriptions* (down to 3 words), which
572 // breaks tool selection (e.g. "create_other_document" becomes "Create documents
573 // other" -- the LLM then thinks supplier_invoice creation is not available).
574 // We raise the threshold to 100 to effectively disable compression for the
575 // default install (~30 tools), while still leaving a safety net for very large
576 // custom installs that register dozens of additional addMcpTools hooks.
577 $isLargeSchema = count($toolsSchema) > 100;
578 $toolsForLLM = cleanToolSchemaForLLM($toolsSchema, $isLargeSchema);
579
580 // Build System Prompt
581 $basePrompt = getDolGlobalString('AI_INTENT_PROMPT') ?: "You are a professional Dolibarr assistant.";
582
583 $systemRules = "\n\nRules: Respond ONLY JSON and ensure any json string does not contains special chars and are correctly json encoded. Format: {\"tool\":..., \"arguments\":{...}}. ";
584 $systemRules .= "ALWAYS write user-facing text (the message/question/answer argument values) in the SAME LANGUAGE as the user's message. English context notes, tool names, or schemas never change the response language. ";
585 $systemRules .= "When a tool matches the user request, CALL it - never explain limitations instead of acting, and never claim a capability is missing while a matching tool is listed. Only when genuinely NO tool can fulfill the request, use respond_to_user to say the feature is not available. ";
586
587 // If MCP is disabled, we disable all tools
588 if (getDolGlobalString('AI_ASSISTANT_DISABLE_TOOLS')) {
589 $toolsForLLM = array();
590 }
591
592 $systemPrompt = $basePrompt . "\n\n";
593 $systemPrompt .= "Tools:\n" . json_encode($toolsForLLM, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
594 // When redaction is active, the model sees [[TYPE_N]] placeholders where
595 // PII was. Without this rule it refuses tasks needing those values
596 // with it, placeholders travel verbatim through tool arguments and are restored server-side
597 // (unmaskAiResponse on the raw intent JSON) before execution, so the
598 // cloud never sees the data and the task still completes.
599 if ($doRedact) {
600 $systemRules .= " Privacy masking is active: values like [[REF_1]], [[ADDR_2]], [[EMAIL_3]], [[PHONE_4]], [[ZIP_5]] are masked real data. Treat them as valid values: when a tool argument needs such a datum, pass the placeholder exactly as written — it is replaced by the real value before execution. Never refuse a task because values look masked, and never invent replacements for them.";
601 }
602
603 // A bare date is not enough for weaker models: state explicitly that
604 // relative periods are the assistant's job to resolve, not the user's.
605 $systemPrompt .= $systemRules . " Current date: " . date('Y-m-d') . " (" . date('l') . ").";
606 if (!empty($aiPageContextLine)) {
607 // Masked like the query itself: under enforced redaction the ref
608 // becomes a placeholder that is restored server-side in tool
609 // arguments; the numeric ids the tools need stay usable.
610 // Mask once, use for both the system line and the user anchor:
611 // names first (dictionary - arbitrary strings the patterns cannot
612 // see), then the pattern pass for refs, emails, IBANs and the rest.
613 if (!empty($doRedact) && !empty($guard)) {
614 $aiPageContextLine = $guard->mask($guard->maskNames($aiPageContextLine, $ctxNamesToMask));
615 }
616 $systemPrompt .= "\n\nPage context: ".$aiPageContextLine;
617 }
618 $systemPrompt .= " Resolve relative periods yourself from the current date — today, yesterday, this week, this month, last month, this quarter, this year — into explicit YYYY-MM-DD values for date parameters (e.g. this month = first day of the current month to the current date). Never ask the user for dates you can compute.";
619
620 // Get API configuration
621 $servicesList = getListOfAIServices();
622 $apiKey = getDolGlobalString('AI_API_' . strtoupper($serviceKey) . '_KEY');
623
624 if (preg_match('/^crypt:/', $apiKey)) {
625 $apiKey = dolDecrypt($apiKey, $conf->file->instance_unique_id);
626 }
627
628 $defUrl = $servicesList[$serviceKey]['url'] ?? '';
629 $url = getDolGlobalString('AI_API_' . strtoupper($serviceKey) . '_URL') ?: $defUrl;
630 // The model defaults declared in getListOfAIServices() are nested:
631 // $servicesList[$key]['textgeneration'] = ['default' => 'model-name']
632 // Reading 'textgeneration' without ['default'] returns the inner array, which
633 // then fails the (string) type-hint of UniversalLLMAdapter's 4th argument with:
634 // "Argument #4 ($model) must be of type string, array given"
635 //
636 // The admin UI (htdocs/ai/admin/setup.php "Prompt and custom AI models" tab) also
637 // stores the per-function model under AI_API_<SERVICE>_MODEL_TEXT (matching the
638 // convention already used by Ai::generateContent() for the same data). The
639 // previous lookup used AI_API_<SERVICE>_MODEL which is never written by that
640 // form, so the user-configured model was silently ignored.
641 $rawDefault = $servicesList[$serviceKey]['textgeneration'] ?? null;
642 if (is_array($rawDefault)) {
643 $defModel = $rawDefault['default'] ?? 'gpt-4o-mini';
644 } else {
645 $defModel = $rawDefault ?: 'gpt-4o-mini';
646 }
647 $prefix = 'AI_API_' . strtoupper($serviceKey);
648 $model = getDolGlobalString($prefix . '_MODEL_TEXT')
649 ?: getDolGlobalString($prefix . '_MODEL')
650 ?: $defModel;
651 // Defensive: coerce to string if anyone stored an array in this constant
652 if (is_array($model)) {
653 $model = $model['default'] ?? $defModel;
654 }
655 if (!is_string($model) || $model === '') {
656 $model = (string) $defModel;
657 }
658 // Optional per-request model override sent by the chat model picker.
659 // Sanitized to the provider model-id charset; empty/invalid = keep default.
660 if (!empty($data['model']) && is_string($data['model'])) {
661 $reqModel = preg_replace('/[^a-zA-Z0-9._:\/-]/', '', $data['model']);
662 if ($reqModel !== '' && strlen($reqModel) <= 100) {
663 $model = $reqModel;
664 }
665 }
666 $adapterType = $servicesList[$serviceKey]['adapter_type'] ?? 'openai';
667
668
669 // The request.
670 // var_dump($query);
671
672 if (!empty($apiKey)) {
673 $adapter = new UniversalLLMAdapter($adapterType, $apiKey, $url, $model, $timeout);
674
675 dol_syslog("parse_intent.php Call AI API", LOG_DEBUG);
676
677 // In-context page-context reinforcement: weak models ignore context
678 // buried in the system prompt (same lesson as the privacy
679 // placeholders) - a short line adjacent to the query is what
680 // actually works. Masked like everything else under redaction.
681 if (!empty($aiPageContextLine)) {
682 // Keep the user-turn anchor MINIMAL: verbose instructions in
683 // the user message destabilize weaker models (field-observed:
684 // hallucinated tool names appeared with the long form). The
685 // full coaching stays in the system Page-context line above.
686 $aiPageContextShort = strtok($aiPageContextLine, ".").".";
687 $query .= "\n\n(Context: ".$aiPageContextShort.")";
688 }
689
690 // Pinned context turns: past exchanges the user EXPLICITLY selected in
691 // the chat (nothing is carried over by default - context is opt-in, so
692 // its token cost is a visible, deliberate choice). Hard-sanitized here:
693 // roles constrained, embedded attachment payloads stripped (attachments
694 // stay one-shot), per-turn and global caps, privacy redaction applied.
695 $history = array();
696 if (!empty($data['history']) && is_array($data['history'])) {
697 $histBudget = 6000;
698 foreach (array_slice($data['history'], 0, 12) as $turn) {
699 if (!is_array($turn) || empty($turn['text']) || !is_string($turn['text'])) {
700 continue;
701 }
702 $htext = preg_replace('/__FILE_ATTACHMENT__\[[^\]]*\]::[^\s]+/', '[attachment removed]', $turn['text']);
703 $htext = trim((string) $htext);
704 if ($htext === '') {
705 continue;
706 }
707 if (dol_strlen($htext) > 1500) {
708 $htext = dol_substr($htext, 0, 1500).' ...';
709 }
710 if ($guard) {
711 $htext = $guard->mask($htext);
712 }
713 $histBudget -= dol_strlen($htext);
714 if ($histBudget < 0) {
715 break;
716 }
717 $history[] = array('role' => ((($turn['role'] ?? '') === 'assistant') ? 'assistant' : 'user'), 'text' => $htext);
718 }
719 }
720
721 // With past turns in the payload, the model must know what they are
722 // for. Two consecutive user turns (a request whose action the user
723 // cancelled, then a new one) read as "two things to do", and with a
724 // single tool call per answer the model picks the older one - field
725 // case: "set the phone of X" answered by creating "X bis".
726 if (!empty($history)) {
727 $systemPrompt .= "\n\nCONVERSATION CONTEXT: the earlier turns are context only, to resolve references like \"this one\" or \"the second\". The ONLY request to act on is the LAST user message. Never resume, redo or complete an earlier request, even one that looks unanswered or unfinished.";
728 }
729 $rawResponse = $adapter->generate($systemPrompt, $query, 'text', $attachments, $history);
730
731 // $rawResponse should be a json string with format '{"tool":..., "arguments":{text answer}}' but sometimes it is just 'text answer'
732 dol_syslog('rawResponse='.$rawResponse, LOG_DEBUG);
733
734 //var_dump($rawResponse);exit;
735
736 // Capture logs
737 $rawRequestLog = $adapter->lastRequest;
738 $rawResponseLog = $adapter->lastResponse;
739
740 // Token usage for the cost columns of the request log: reported by
741 // the provider inside the response, captured by the adapter.
742 if (!empty($adapter->lastUsage)) {
743 $usageContext = array(
744 'tokens_input' => (int) ($adapter->lastUsage['input'] ?? 0),
745 'tokens_output' => (int) ($adapter->lastUsage['output'] ?? 0),
746 'model' => (string) ($adapter->lastUsage['model'] ?? $model),
747 );
748 }
749
750 // Process response
751 if (is_string($rawResponse) && strpos($rawResponse, 'Error:') === 0) {
752 $errorDetails = $rawResponse;
753 } elseif ($rawResponse) {
754 // Clean JSON response
755 $clean = preg_replace('/```json\s*|\s*```/s', '', $rawResponse);
756 $clean = trim($clean);
757
758 $matches = array();
759 if (preg_match('/^\{.*\}$/s', $clean, $matches)) {
760 $clean = $matches[0];
761 } elseif (preg_match('/\{.*\}/s', $clean, $matches) && strpos($matches[0], '"tool"') !== false) {
762 // The model prefixed its tool call with a sentence ("I first need
763 // to find the third party... {"tool":...}"): the call is the
764 // answer, the sentence is not. Without this the whole text became
765 // a respond_to_user and the tool never ran.
766 $clean = $matches[0];
767 }
768
769 // Unmask the JSON string
770 if ($guard) {
771 $clean = $guard->unmaskAiResponse($clean);
772 }
773
774 // Removed carriage returns and newlines
775 $clean = preg_replace('/[\r\n]/', ' ', $clean);
776
777 // If answer is a json string or not
778 if (strpos($clean, '{') === 0) {
779 // This may be a json string
780 $intentJSON = json_decode($clean, true);
781 // Weak models improvise clarification fields (missing_argument,
782 // reason...) instead of the schema's 'question'; the UI then
783 // renders "undefined". Normalize here so every consumer gets
784 // a question.
785 if (is_array($intentJSON) && ($intentJSON['tool'] ?? '') === 'respond_to_user' && empty($intentJSON['arguments']['message'])) {
786 // Weak models sometimes answer with an empty argument set,
787 // which renders as a blank bubble. Give the user something
788 // actionable instead.
789 $intentJSON['arguments']['message'] = 'I could not produce an answer for this request. Please rephrase or add details.';
790 }
791 if (is_array($intentJSON) && ($intentJSON['tool'] ?? '') === 'ask_for_clarification' && empty($intentJSON['arguments']['question'])) {
792 $a = isset($intentJSON['arguments']) && is_array($intentJSON['arguments']) ? $intentJSON['arguments'] : array();
793 $qparts = array();
794 if (!empty($a['reason'])) {
795 $qparts[] = (string) $a['reason'];
796 }
797 if (!empty($a['missing_argument']) && stripos(implode(' ', $qparts), (string) $a['missing_argument']) === false) {
798 $qparts[] = "Missing: ".(string) $a['missing_argument'];
799 }
800 if (empty($qparts) && !empty($a['message'])) {
801 $qparts[] = (string) $a['message'];
802 }
803 $intentJSON['arguments']['question'] = !empty($qparts) ? implode(' ', $qparts) : 'Could you provide the missing information?';
804 }
805 } else {
806 $intentJSON = [
807 "tool" => "respond_to_user",
808 'arguments' => [
809 "message" => $clean
810 ]
811 ];
812 }
813
814 // Ensure no placeholders remain in the data structure.
815 if ($guard && isset($intentJSON['arguments'])) {
816 $intentJSON['arguments'] = recursiveUnmaskValues($intentJSON['arguments'], $guard);
817 }
818
819 // Validation check: Check if the AI selected a tool that actually exists in our filtered schema.
820 if ($intentJSON && isset($intentJSON['tool'])) {
821 $validToolNames = array_column($allToolsSchema, 'name');
822 if (!in_array($intentJSON['tool'], $validToolNames)) {
823 // Near-miss name: recover only on a single match, same verb, all tokens present.
824 $reqTokens = explode('_', dol_strtolower((string) $intentJSON['tool']));
825 $candidates = array();
826 foreach ($validToolNames as $realName) {
827 if (strpos($realName, $reqTokens[0].'_') !== 0) {
828 continue;
829 }
830 if (!array_diff($reqTokens, explode('_', $realName))) {
831 $candidates[] = $realName;
832 }
833 }
834 if (count($candidates) === 1) {
835 dol_syslog("AI Validation: tool '".$intentJSON['tool']."' recovered to '".$candidates[0]."'.", LOG_INFO);
836 $intentJSON['tool'] = $candidates[0];
837 }
838 }
839 if (!in_array($intentJSON['tool'], $validToolNames)) {
840 dol_syslog("AI Validation: Tool '" . $intentJSON['tool'] . "' not found in filtered schema. Send error message via respond_to_user.", LOG_WARNING);
841
842 // Force the standard response for non-existent functionality
843 $intentJSON = [
844 "tool" => "respond_to_user",
845 "arguments" => [
846 "message" => "I apologize, but the requested functionality is not currently available in the system."
847 ]
848 ];
849 $confidence = 1.0;
850 }
851 }
852
853 // Calculate confidence (only if not manually set to 1.0 above)
854 if ($intentJSON && $confidence === 0.0) {
855 $mappedToolsSchema = array_column($toolsSchema, null, 'name');
856 $confidence = calculateConfidence($intentJSON, $mappedToolsSchema, $rawResponse);
857
858 dol_syslog("parse_intent.php AI Intent: " . json_encode(['query' => $query, 'intent' => $intentJSON, 'confidence' => $confidence]), LOG_DEBUG);
859 }
860 }
861 }
862 }
863
864
865 // Handle no AI Intent
866 if (!$intentJSON || !isset($intentJSON['tool'])) {
867 $message = $langs->transnoentitiesnoconv('AICannotUnderstandRequest');
868 // A provider failure is not a misunderstanding: asking the user to
869 // rephrase when Gemini answers "503 high demand" sends them the wrong
870 // way. Say the service failed, with the provider's own reason, and
871 // for the transient cases (overloaded, rate limited) say to retry.
872 if (strpos($errorDetails, 'Error:') === 0) {
873 $reason = trim(preg_replace('/^Error:\s*(API|cURL #\d+)?\s*/', '', $errorDetails));
874 $reason = dol_trunc(preg_replace('/\s+/', ' ', $reason), 200);
875 if (preg_match('/high demand|overloaded|rate limit|quota|too many requests|try again|timed? ?out|HTTP (429|502|503|504)/i', $errorDetails)) {
876 $message = $langs->transnoentitiesnoconv('AIProviderBusy', $reason);
877 } else {
878 $message = $langs->transnoentitiesnoconv('AIProviderError', $reason);
879 }
880 }
881 $finalResponse = [
882 "tool" => "respond_to_user",
883 "arguments" => [
884 "message" => $message
885 ]
886 ];
887 if (strpos($errorDetails, 'Error:') === 0) {
888 // Lets the chat tell a failed call from a real answer (e.g. keep it
889 // out of the conversation context by default).
890 $finalResponse['status'] = 'error';
891 }
892
893 // Log the failure
894 ai_log_request($db, $user, $query, $finalResponse, $providerUsed, microtime(true) - $startTime, 0.0, $langs->transnoentitiesnoconv('Error'), $errorDetails, $rawRequestLog, $rawResponseLog, $usageContext);
895
896 ob_end_clean();
897 echo json_encode($finalResponse);
898 exit;
899 }
900
901 // Check if confirmation needed
902 $needsConfirmation = false;
903 $toolName = $intentJSON['tool'] ?? '';
904
905 // Normalize the text answer key: some models (e.g. GPT-4o) fill
906 // respond_to_user / reject_general_question under 'response', 'text',
907 // 'answer'... instead of the 'message' key the frontend reads, which
908 // otherwise surfaces as "Empty AI Response".
909 if (in_array($toolName, array('respond_to_user', 'reject_general_question'), true) && isset($intentJSON['arguments']) && is_array($intentJSON['arguments'])) {
910 if (empty($intentJSON['arguments']['message'])) {
911 foreach (array('response', 'text', 'answer', 'content', 'reply', 'output') as $altkey) {
912 if (!empty($intentJSON['arguments'][$altkey])) {
913 $intentJSON['arguments']['message'] = $intentJSON['arguments'][$altkey];
914 break;
915 }
916 }
917 }
918 }
919
920 // ask_for_confirmation is ours, built below around a real tool call; a model
921 // that emits it by itself (it does, on a follow-up question: "do you really
922 // want to update the phone of X?") sends the client a confirmation with
923 // nothing to confirm, which it rejects as malformed. Hand the question to
924 // the user as a plain answer instead: he replies, and the next turn acts.
925 if ($toolName === 'ask_for_confirmation' && empty($intentJSON['arguments']['original_intent'])) {
926 $question = '';
927 foreach (array('message', 'action', 'question', 'text') as $altkey) {
928 if (!empty($intentJSON['arguments'][$altkey]) && is_string($intentJSON['arguments'][$altkey])) {
929 $question = $intentJSON['arguments'][$altkey];
930 break;
931 }
932 }
933 dol_syslog("parse_intent.php model emitted ask_for_confirmation without an action, downgraded to respond_to_user", LOG_WARNING);
934 $toolName = 'respond_to_user';
935 $intentJSON = array('tool' => 'respond_to_user', 'arguments' => array('message' => ($question !== '' ? $question : $langs->transnoentitiesnoconv('AICannotUnderstandRequest'))));
936 }
937
938 // --- Second step: a write was asked, a read was answered -------------------
939 // "Set the phone of X", "delete the draft order of Y": when the object's id
940 // is neither in the message nor in the context, the model answers with a
941 // READ tool (a search). The chat runs one tool per turn, so it would show
942 // the search result and stop, and the user has to ask again with the id.
943 // When the administrator allows it, that read is executed here (rights and
944 // allow-list apply exactly as for a client call), its result goes back to
945 // the model as one extra assistant turn, and the write it then produces
946 // lands in the confirmation gate below like a direct call would.
947 //
948 // SAFETY PROPERTY - ONE STEP, NEVER A LOOP: the second answer is taken only
949 // when it is a write tool of the schema; anything else (another read, a
950 // question, an error) ends the turn on the first answer, unchanged. There
951 // is no third call whatever the model answers.
952 $twoStepLabel = '';
953 // Only on the model path: the shortcut paths above (classifier, page
954 // context...) answer without $adapter / $mcp / $history / $systemPrompt.
955 if (getDolGlobalInt('AI_CHAT_TWO_STEP_WRITE') && is_array($intentJSON) && $toolName !== '' && isset($adapter, $mcp, $history, $systemPrompt, $toolsSchema) && is_object($adapter) && is_object($mcp) && is_array($history) && is_array($toolsSchema)) {
961 $isWriteTool = function ($name, array $args) use ($mcp) {
962 if (preg_match('/(create|update|delete|add|remove|modify|edit|validate|pay|send)/i', $name)) {
963 return true;
964 }
965 $inst = $mcp->toolsByName[$name] ?? null;
966 if (is_object($inst) && method_exists($inst, 'writeConfirmationPreview')) {
967 return ((string) $inst->writeConfirmationPreview($name, $args)) !== McpTool::NO_WRITE;
968 }
969 return false;
970 };
971 $readArgs = (isset($intentJSON['arguments']) && is_array($intentJSON['arguments'])) ? $intentJSON['arguments'] : array();
972 $systemTools = array('respond_to_user', 'reject_general_question', 'ask_for_clarification', 'ask_for_confirmation', 'navigate_to_page');
973 if (aiQueryAsksForWrite($query, $langs) && !in_array($toolName, $systemTools, true) && !$isWriteTool($toolName, $readArgs)) {
974 $readResult = $mcp->executeTool($toolName, $readArgs);
975 if (is_array($readResult) && !isset($readResult['error']) && (($readResult['resultType'] ?? '') !== 'input_required')) {
976 // The first call is a step of its own in the log (tokens included).
977 ai_log_request($db, $user, $query, $intentJSON, $providerUsed, microtime(true) - $startTime, $confidence, 'Step1', $errorDetails, $rawRequestLog, $rawResponseLog, $usageContext);
978
979 // Compact result, capped like a pinned tool result, and passed
980 // through the privacy guard exactly like the pinned history is.
981 $snippet = (string) json_encode($readResult, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
982 if (dol_strlen($snippet) > 1500) {
983 $snippet = dol_substr($snippet, 0, 1500).' ...';
984 }
985 $snippet = '['.$toolName.' result] '.$snippet;
986 if ($guard) {
987 $snippet = $guard->mask($snippet);
988 }
989 $history2 = $history;
990 $history2[] = array('role' => 'assistant', 'text' => $snippet);
991 $systemPrompt2 = $systemPrompt."\n\nSTEP 2: the read tool ".$toolName." was already executed for you; its result is the last assistant turn. Now perform the WRITE the user asked for in the last user message, with the ids found in that result. If the result does not identify one object with certainty, or the write cannot be done, answer with respond_to_user and say why. Do not call a read tool again.";
992
993 $rawResponse2 = $adapter->generate($systemPrompt2, $query, 'text', $attachments, $history2);
994 $rawRequestLog = $adapter->lastRequest;
995 $rawResponseLog = $adapter->lastResponse;
996 if (!empty($adapter->lastUsage)) {
997 $usageContext = array(
998 'tokens_input' => (int) ($adapter->lastUsage['input'] ?? 0),
999 'tokens_output' => (int) ($adapter->lastUsage['output'] ?? 0),
1000 'model' => (string) ($adapter->lastUsage['model'] ?? (isset($model) ? $model : '')),
1001 );
1002 }
1003
1004 $intent2 = null;
1005 if (is_string($rawResponse2) && strpos($rawResponse2, 'Error:') !== 0) {
1006 $clean2 = trim((string) preg_replace('/```json\s*|\s*```/s', '', $rawResponse2));
1007 $m2 = array();
1008 if (preg_match('/\{.*\}/s', $clean2, $m2)) {
1009 $clean2 = $m2[0];
1010 }
1011 if ($guard) {
1012 $clean2 = $guard->unmaskAiResponse($clean2);
1013 }
1014 $intent2 = json_decode((string) preg_replace('/[\r\n]/', ' ', $clean2), true);
1015 if (is_array($intent2) && $guard && isset($intent2['arguments'])) {
1016 $intent2['arguments'] = recursiveUnmaskValues($intent2['arguments'], $guard);
1017 }
1018 }
1019 $args2 = (is_array($intent2) && isset($intent2['arguments']) && is_array($intent2['arguments'])) ? $intent2['arguments'] : array();
1020 if (is_array($intent2) && !empty($intent2['tool']) && is_string($intent2['tool']) && in_array($intent2['tool'], array_column($allToolsSchema, 'name'), true) && $isWriteTool($intent2['tool'], $args2)) {
1021 dol_syslog("parse_intent.php two-step: read ".$toolName." then write ".$intent2['tool'], LOG_INFO);
1022 $intentJSON = $intent2;
1023 $toolName = $intent2['tool'];
1024 $confidence = calculateConfidence($intentJSON, array_column($toolsSchema, null, 'name'), (string) $rawResponse2);
1025 // The preview must name the object the read resolved, not an id.
1026 $twoStepLabel = aiLabelOfResolvedObject($readResult, $args2);
1027 } else {
1028 dol_syslog("parse_intent.php two-step: second answer is not a write, the turn ends on the read ".$toolName, LOG_INFO);
1029 }
1030 }
1031 }
1032 }
1033
1034 if ($askForConfirmation > 0) {
1035 $isModifyOperation = preg_match('/(create|update|delete|add|remove|modify|edit)/i', $toolName);
1036
1037 if ($askForConfirmation == 1 && $isModifyOperation) {
1038 $needsConfirmation = true;
1039 } elseif ($askForConfirmation == 2) {
1040 $needsConfirmation = true;
1041 }
1042 }
1043
1044 // Handle confirmation
1045 if ($needsConfirmation) {
1046 $allToolsMap = !empty($allToolsSchema)
1047 ? array_column($allToolsSchema, null, 'name')
1048 : [];
1049 $toolDescription = $allToolsMap[$toolName]['description'] ?? 'No description available';
1050 $arguments = $intentJSON['arguments'] ?? [];
1051
1052 $details = formatArgumentsForDisplay($arguments);
1053 $action = extractActionFromTool($toolName);
1054
1055 // A write tool describes its own effect in a sentence, which is what the
1056 // user has to act on: prefer it over the raw argument dump, and keep the
1057 // dump underneath for the detail.
1058 $toolInstance = $mcp->toolsByName[$toolName] ?? null;
1059 if (is_object($toolInstance) && method_exists($toolInstance, 'writeConfirmationPreview')) {
1060 $preview = (string) $toolInstance->writeConfirmationPreview($toolName, $arguments);
1061 if ($preview !== McpTool::NO_WRITE) {
1062 $action = $preview;
1063 }
1064 }
1065 if (!empty($twoStepLabel)) {
1066 // The id came from a read the user never saw: say which object it is.
1067 $action .= ' - '.$twoStepLabel;
1068 }
1069
1070 $confirmationResponse = [
1071 "tool" => "ask_for_confirmation",
1072 "arguments" => [
1073 "action" => $action,
1074 "details" => $details,
1075 "original_intent" => $intentJSON
1076 ]
1077 ];
1078
1079 // Log the confirmation request
1080 ai_log_request($db, $user, $query, $confirmationResponse, $providerUsed, microtime(true) - $startTime, $confidence, $langs->transnoentitiesnoconv("Confirm"), $errorDetails, $rawRequestLog, $rawResponseLog, $usageContext);
1081
1082 ob_end_clean();
1083 echo json_encode($confirmationResponse);
1084 exit;
1085 }
1086
1087 // Handle low confidence
1088 if ($confidence < LOW_CONFIDENCE) {
1089 $finalResponse = [
1090 "tool" => "respond_to_user",
1091 "arguments" => [
1092 "message" => "I'm not confident about understanding your request. Please try rephrasing it with more specific details."
1093 ]
1094 ];
1095
1096 // Log the low confidence response
1097 ai_log_request($db, $user, $query, $finalResponse, $providerUsed, microtime(true) - $startTime, $confidence, 'low_confidence', $errorDetails, $rawRequestLog, $rawResponseLog, $usageContext);
1098
1099 ob_end_clean();
1100 echo json_encode($finalResponse);
1101 exit;
1102 }
1103
1104 // Add confidence note
1105 if ($confidence < MEDIUM_CONFIDENCE && isset($intentJSON['arguments'])) {
1106 $intentJSON['arguments']['_confidence_note'] = "I'm moderately confident about this interpretation. Please verify the results.";
1107 }
1108
1109 // Success!
1110 $finalResponse = $intentJSON;
1111 $execTime = microtime(true) - $startTime;
1112 ai_log_request($db, $user, $query, $finalResponse, $providerUsed, $execTime, $confidence, $langs->transnoentitiesnoconv("Success"), $errorDetails, $rawRequestLog, $rawResponseLog, $usageContext);
1113
1114 ob_end_clean();
1115 echo json_encode($finalResponse);
1116} catch (Throwable $e) {
1117 $friendlyResponse = [
1118 "tool" => "respond_to_user",
1119 "arguments" => [
1120 "message" => "I'm experiencing technical difficulties. Please try again later or contact your administrator."
1121 ]
1122 ];
1123
1124 $realErrorForLog = "PHP Exception: " . $e->getMessage() . " in " . $e->getFile() . " on line " . $e->getLine();
1125
1126 dol_syslog("AI Critical Error: " . $realErrorForLog, LOG_ERR);
1127
1128 if (function_exists('ai_log_request') && is_object($db)) {
1130 $db,
1131 $user,
1132 $query ?? 'unknown',
1133 $friendlyResponse,
1134 $providerUsed,
1135 microtime(true) - $startTime,
1136 0.0,
1137 'error',
1138 $realErrorForLog,
1139 $rawRequestLog ?? '',
1140 $rawResponseLog ?? '',
1141 $usageContext ?? array()
1142 );
1143 }
1144
1145 ob_end_clean();
1146 echo json_encode($friendlyResponse);
1147}
1148
1149
1159function aiQueryAsksForWrite($query, $langs)
1160{
1161 $verbs = array();
1162 foreach (array('Create', 'Add', 'Modify', 'Update', 'Delete', 'Remove', 'Validate', 'Send') as $key) {
1163 $word = dol_strtolower((string) $langs->transnoentities($key));
1164 if ($word !== '' && $word !== dol_strtolower($key)) {
1165 $verbs[] = $word;
1166 }
1167 }
1168 $verbs = array_merge($verbs, array(
1169 'create', 'add', 'modify', 'update', 'delete', 'remove', 'validate', 'send', 'change', 'set', 'edit', 'rename', 'close', 'cancel',
1170 'crée', 'cree', 'créer', 'creer', 'ajoute', 'ajouter', 'modifie', 'modifier', 'mets', 'met', 'mettre', 'change', 'changer', 'passe', 'passer',
1171 'supprime', 'supprimer', 'efface', 'effacer', 'retire', 'retirer', 'valide', 'valider', 'envoie', 'envoyer', 'enregistre', 'enregistrer', 'renomme', 'renommer', 'clôture', 'cloture', 'annule', 'annuler'
1172 ));
1173 $escaped = array();
1174 foreach (array_unique(array_filter($verbs)) as $verb) {
1175 $escaped[] = preg_quote($verb, '/');
1176 }
1177 $pattern = '/(^|[^\p{L}])('.implode('|', $escaped).')([^\p{L}]|$)/iu';
1178 return (bool) preg_match($pattern, dol_strtolower((string) $query));
1179}
1180
1189function aiLabelOfResolvedObject(array $readResult, array $writeArgs)
1190{
1191 $ids = array();
1192 foreach ($writeArgs as $k => $v) {
1193 if ((is_int($v) || (is_string($v) && ctype_digit($v))) && preg_match('/(^id$|_id$|^socid$|^fk_)/', (string) $k)) {
1194 $ids[] = (int) $v;
1195 }
1196 }
1197 if (empty($ids)) {
1198 return '';
1199 }
1200 $rows = $readResult;
1201 if (isset($rows['data']) && is_array($rows['data'])) {
1202 $rows = $rows['data'];
1203 }
1204 if (isset($rows['id']) || isset($rows['rowid'])) {
1205 $rows = array($rows);
1206 }
1207 foreach ($rows as $row) {
1208 if (!is_array($row)) {
1209 continue;
1210 }
1211 $rowid = (int) ($row['id'] ?? $row['rowid'] ?? 0);
1212 if ($rowid > 0 && in_array($rowid, $ids, true)) {
1213 foreach (array('name', 'nom', 'ref', 'label', 'subject', 'title', 'login') as $key) {
1214 if (!empty($row[$key]) && is_string($row[$key])) {
1215 return dol_trunc($row[$key], 80);
1216 }
1217 }
1218 }
1219 }
1220 return '';
1221}
1222
1241function recursiveUnmaskValues($data, ?PrivacyGuard $guard)
1242{
1243 if ($guard === null) {
1244 return $data;
1245 }
1246
1247 if (is_array($data)) {
1248 return array_map(
1253 function ($item) use ($guard) {
1254 return recursiveUnmaskValues($item, $guard);
1255 },
1256 $data
1257 );
1258 }
1259
1260 if (is_string($data)) {
1261 return $guard->unmask($data);
1262 }
1263
1264 return $data;
1265}
1266
1279function isComplexScript(string $text)
1280{
1281 // CJK (Chinese, Japanese, Korean)
1282 if (preg_match('/\p{Han}|\p{Hiragana}|\p{Katakana}|\p{Hangul}/u', $text)) {
1283 return true;
1284 }
1285
1286 // Cyrillic (Russian, Ukrainian, Bulgarian, Serbian)
1287 if (preg_match('/\p{Cyrillic}/u', $text)) {
1288 return true;
1289 }
1290
1291 // Greek
1292 if (preg_match('/\p{Greek}/u', $text)) {
1293 return true;
1294 }
1295
1296 // Arabic
1297 if (preg_match('/\p{Arabic}/u', $text)) {
1298 return true;
1299 }
1300
1301 // Hebrew
1302 if (preg_match('/\p{Hebrew}/u', $text)) {
1303 return true;
1304 }
1305
1306 // Thai
1307 if (preg_match('/\p{Thai}/u', $text)) {
1308 return true;
1309 }
1310
1311 return false;
1312}
1313
1328function aiNormalizeForMatch($word, $asStem = false)
1329{
1330 $w = dol_strtolower(trim($word), 'UTF-8');
1331 if (class_exists('Normalizer')) {
1332 $decomposed = Normalizer::normalize($w, Normalizer::FORM_D);
1333 if ($decomposed !== false) {
1334 $w = (string) preg_replace('/\p{Mn}+/u', '', $decomposed);
1335 }
1336 }
1337 if ($asStem) {
1338 $len = dol_strlen($w);
1339 if ($len >= 6) {
1340 $w = dol_substr($w, 0, $len - 2); // drop the inflected tail
1341 } elseif ($len == 5) {
1342 $w = dol_substr($w, 0, 4);
1343 } elseif ($len < 3) {
1344 return ''; // too short to stem: matching it would be noise
1345 }
1346 }
1347 return $w;
1348}
1349
1372function classifyIntentUniversal(string $query, Translate $langs)
1373{
1374 $isLatin = !isComplexScript($query);
1375 $searchQuery = $isLatin ? strtolower(dol_string_unaccent($query)) : $query;
1376
1377 $langs->loadLangs(array("main", "bills", "orders", "propal", "companies", "products", "projects", "dict", "sendings", "receptions", "ticket", "members", "agenda", "interventions"));
1378
1379 // Vocabulary rule: every object family whose tools exist must light up the
1380 // categories those tools carry (see ApiBridge::ENDPOINT_CATEGORIES), or the
1381 // prompt filter drops them and the model claims the feature does not exist
1382 // (that is how receptions were lost before). Families WITHOUT any bridged
1383 // tool (bank accounts, donations, holidays) are deliberately absent: their
1384 // words would activate categories that hold no matching tool and only
1385 // narrow the prompt wrongly - add the endpoint first, the vocabulary second.
1386 $intentMap = [
1387 'billing' => [
1388 // Member/Subscription: members and subscriptions tools are
1389 // categorized ['thirdparty', 'billing'].
1390 'keys' => ['Bill', 'Invoice', 'Payment', 'Cheque', 'VAT', 'BillStatusUnpaid', 'BillStatusPaid', 'BillStatusDraft', 'Member', 'Subscription'],
1391 'synonyms' => ['paid', 'unpaid', 'pay', 'money', 'cost', 'amount', 'overdue', 'member', 'membership', 'subscription', 'cotisation', 'adhesion']
1392 ],
1393 'commercial' => [
1394 // 'Reception' and 'Shipment' matter: create_other_document (the tool
1395 // that creates receptions/shipments) is categorized 'commercial', so a
1396 // query like "create a reception from this delivery note" must light
1397 // this category up or the creation tool is filtered out of the prompt
1398 // and the model honestly answers it cannot create receptions.
1399 // Intervention: interventions tools are ['project', 'commercial'].
1400 'keys' => ['Order', 'Proposal', 'Quote', 'SupplierOrder', 'OrderStatusDraft', 'Reception', 'Shipment', 'Delivery', 'Intervention'],
1401 'synonyms' => ['sale', 'buy', 'purchase', 'contract', 'shipping', 'quote', 'reception', 'shipment', 'delivery', 'receive', 'intervention']
1402 ],
1403 'thirdparty' => [
1404 // Ticket and agenda-event tools are ['thirdparty', 'project'];
1405 // members/subscriptions are ['thirdparty', 'billing']; the
1406 // categories endpoint is ['thirdparty', 'stock'] (its 'Category'
1407 // UI key translates to 'Tag/category' - unusable as a keyword,
1408 // hence plain synonyms).
1409 'keys' => ['ThirdParty', 'Customer', 'Supplier', 'Contact', 'Company', 'Ticket', 'Member', 'Subscription', 'Event', 'Agenda'],
1410 'synonyms' => ['client', 'partner', 'address', 'phone', 'vendor', 'ticket', 'support', 'incident', 'member', 'adherent', 'membership', 'meeting', 'appointment', 'rdv', 'category', 'categorie', 'tag']
1411 ],
1412 'stock' => [
1413 'keys' => ['Product', 'Service', 'Stock', 'Warehouse'],
1414 'synonyms' => ['item', 'inventory', 'sku', 'location', 'qty', 'warehouse', 'category', 'categorie', 'tag']
1415 ],
1416 'project' => [
1417 'keys' => ['Project', 'Task', 'Ticket', 'Event', 'Agenda', 'Intervention'],
1418 'synonyms' => ['task', 'team', 'deadline', 'planning', 'milestone', 'ticket', 'event', 'meeting', 'appointment', 'rdv', 'intervention']
1419 ],
1420 'reporting' => [
1421 'keys' => ['Report', 'Statistics', 'Turnover', 'Revenue', 'Income'],
1422 'synonyms' => ['report', 'statistics', 'analytics', 'chart', 'total', 'turnover']
1423 ]
1424 ];
1425
1426 // en_US reference translator (loaded once; Translate caches files)
1427 static $langsEnUs = null;
1428 if ($langsEnUs === null) {
1429 global $conf;
1430 $langsEnUs = new Translate('', $conf);
1431 $langsEnUs->setDefaultLang('en_US');
1432 $langsEnUs->loadLangs(array('main', 'bills', 'companies', 'products', 'projects', 'orders', 'propal', 'stocks', 'other', 'ticket', 'members', 'agenda', 'interventions'));
1433 }
1434
1435 $detectedCategories = [];
1436 foreach ($intentMap as $category => $data) {
1437 $keywords = [];
1438 foreach ($data['keys'] as $key) {
1439 // Matched in the user's language AND in the en_US reference: keys
1440 // translate to the UI language only, but users routinely type
1441 // English terms on non-English installs. One mechanism, no
1442 // separate synonym lists to translate.
1443 foreach (array($langs->transnoentities($key), $langsEnUs->transnoentities($key)) as $trans) {
1444 if ($trans === '' || ($trans === $key && preg_match('/[A-Z]/', dol_substr($key, 1, 200)))) {
1445 // Untranslated composite key (e.g. 'BillStatusUnpaid'):
1446 // matching it would be noise. A plain word equal to its
1447 // key ('Customer' in en_US) is a real keyword - keep it.
1448 continue;
1449 }
1450 if ($isLatin) {
1451 $trans = strtolower(dol_string_unaccent($trans));
1452 }
1453 $keywords[] = $trans;
1454 }
1455 }
1456 $keywords = array_unique($keywords);
1457
1458 // Hardcoded English colloquialisms (words no UI key carries) - words no
1459 // UI key carries; translated vocabulary already arrives through the
1460 // dual-language keys above.
1461 foreach ($data['synonyms'] as $syn) {
1462 $keywords[] = $isLatin ? strtolower(dol_string_unaccent($syn)) : $syn;
1463 }
1464 $keywords = array_unique($keywords);
1465
1466 $normalizedQuery = aiNormalizeForMatch($searchQuery, false);
1467 foreach ($keywords as $word) {
1468 if (empty($word)) {
1469 continue;
1470 }
1471 if ($isLatin) {
1472 if (preg_match('/\b' . preg_quote($word, '/') . 's?\b/u', $searchQuery)) {
1473 $detectedCategories[] = $category;
1474 break;
1475 }
1476 } else {
1477 // Inflection-tolerant: stem the keyword, normalize the query,
1478 // then substring-match. Natural-word translations work as-is.
1479 $stem = aiNormalizeForMatch($word, true);
1480 $needleStem = $stem;
1481 if ($needleStem !== '' && mb_strpos($normalizedQuery, $needleStem) !== false) {
1482 $detectedCategories[] = $category;
1483 break;
1484 }
1485 }
1486 }
1487 }
1488 return $detectedCategories;
1489}
1490
1511function filterToolsProfessional(array $allTools, array $activeCategories)
1512{
1513 if (empty($activeCategories) || (count($activeCategories) === 1 && $activeCategories[0] === 'global')) {
1514 return $allTools;
1515 }
1516
1517 $targetCategories = array_merge(['global'], $activeCategories);
1518 $filtered = [];
1519
1520 foreach ($allTools as $tool) {
1521 $toolCats = $tool['categories'] ?? ['global'];
1522 if (count(array_intersect($toolCats, $targetCategories)) > 0) {
1523 if (count($activeCategories) > 0 && $toolCats === ['global']) {
1524 continue;
1525 }
1526 $filtered[] = $tool;
1527 }
1528 }
1529
1530 if (count($filtered) < 3) {
1531 dol_syslog("AI Filter: Too few tools (" . count($filtered) . "). Reverting to full schema.", LOG_WARNING);
1532 return $allTools;
1533 }
1534
1535 return $filtered;
1536}
1537
1546function cleanToolSchemaForLLM(array $tools, bool $isLargeSchema = false)
1547{
1548 $cleaned = [];
1549
1550 foreach ($tools as $tool) {
1551 // Tool descriptions are how the LLM selects the right tool -- never truncate
1552 // them, even when the schema is large. Truncating to 3 words ("Create documents
1553 // other", "Add a single") breaks tool selection. If the schema really is too
1554 // big for the chosen model, the right answer is to filter the toolset before
1555 // it reaches the LLM (which is what filterToolsProfessional() already does
1556 // upstream of this function), not to mutilate each tool's description.
1557 // Parameter-level compression (stripping defaults, descriptions of optional
1558 // fields, etc.) remains gated on $isLargeSchema below.
1559 $desc = $tool['description'];
1560
1561 // Get parameters
1562 $toolParams = $tool['parameters'] ?? $tool['inputSchema'] ?? [];
1563
1564 if ($isLargeSchema && isset($toolParams['properties']) && is_array($toolParams['properties'])) {
1565 $requiredList = $toolParams['required'] ?? [];
1566 $newProperties = [];
1567
1568 foreach ($toolParams['properties'] as $propKey => $propData) {
1569 $isRequired = in_array($propKey, $requiredList);
1570
1571 // -----------------------------------------------------------
1572 // Remove Optional Parameters with Defaults
1573 // -----------------------------------------------------------
1574 // If a parameter is optional and has a default value defined in
1575 // the schema, we assume the backend will handle it. We remove it
1576 // from the prompt entirely. This saves massive amounts of tokens
1577 // on list/search functions (limit, sortorder, sqlfilters, etc).
1578 // -----------------------------------------------------------
1579 if (!$isRequired && isset($propData['default'])) {
1580 continue;
1581 }
1582
1583 // Remove 'type' for string (LLM default), Keep others (int/bool/arr)
1584 if (isset($propData['type']) && $propData['type'] === 'string') {
1585 unset($propData['type']);
1586 }
1587
1588 // Handle Descriptions
1589 // Remove descriptions entirely. Rely on the key name (e.g. 'email', 'qty').
1590 // Exception: Keep 1 word if it's a required parameter with a confusing name.
1591 if (isset($propData['description'])) {
1592 unset($propData['description']);
1593 // If we want to keep a tiny hint for required params, uncomment below:
1594 // if ($isRequired) {
1595 // $propData['description'] = explode(' ', trim($propData['description']))[0];
1596 // }
1597 }
1598
1599 // Collapse Complex Objects
1600 // If a parameter is a deep object (like a complex filter), replace the
1601 // recursive properties definition with a generic string to save tokens.
1602 if (isset($propData['type']) && $propData['type'] === 'object' && isset($propData['properties'])) {
1603 unset($propData['properties']);
1604 unset($propData['required']);
1605 $propData['description'] = "JSON object"; // Minimal hint
1606 }
1607
1608 $newProperties[$propKey] = $propData;
1609 }
1610
1611 $toolParams['properties'] = $newProperties;
1612
1613 // Clean up root metadata
1614 unset($toolParams['type']);
1615 unset($toolParams['additionalProperties']);
1616 }
1617
1618 $cleaned[] = [
1619 'name' => $tool['name'],
1620 'description' => $desc,
1621 'parameters' => $toolParams
1622 ];
1623 }
1624
1625 return $cleaned;
1626}
1627
1640function calculateConfidence($intentJSON, $toolsSchema, $rawResponse)
1641{
1642 $confidence = 0.0;
1643 $factors = [];
1644
1645 // Factor 1: JSON parsing success (Weight: 40%)
1646 // If we are here, the JSON generally parsed, but we check if the structure is valid.
1647 $factors['parse_success'] = 0.4;
1648
1649 // Factor 2: Response completeness (Weight: 30%)
1650 // Check if we have a tool name and some arguments.
1651 $hasRequiredFields = !empty($intentJSON['tool']) && !empty($intentJSON['arguments']);
1652 $factors['completeness'] = $hasRequiredFields ? 0.3 : 0.0;
1653
1654 // Factor 3: Schema validation (Weight: 20%)
1655 $isValidSchema = false;
1656
1657 // Ensure the tool exists in our known schema
1658 if (isset($intentJSON['tool']) && isset($toolsSchema[$intentJSON['tool']])) {
1659 // Support both 'parameters' and 'inputSchema'
1660 $schema = $toolsSchema[$intentJSON['tool']]['parameters']
1661 ?? $toolsSchema[$intentJSON['tool']]['inputSchema']
1662 ?? [];
1663
1664 // Extract parameters provided by the AI
1665 $providedParams = array_keys($intentJSON['arguments'] ?? []);
1666
1667 // Standard JSON Schema structure uses 'properties' to list params and 'required' to list mandatory ones.
1668 $properties = $schema['properties'] ?? [];
1669 $requiredList = $schema['required'] ?? [];
1670
1671 $missingParams = [];
1672
1673 // Iterate through the schema properties to check required fields
1674 foreach ($properties as $paramKey => $paramDetails) {
1675 // Check if this specific parameter is marked as required in the schema
1676 if (in_array($paramKey, $requiredList)) {
1677 // If it is required but not in the AI's provided arguments, it's missing.
1678 if (!in_array($paramKey, $providedParams)) {
1679 $missingParams[] = $paramKey;
1680 }
1681 }
1682 }
1683
1684 // If no required parameters are missing, schema validation passes.
1685 $isValidSchema = empty($missingParams);
1686 }
1687
1688 $factors['schema_validation'] = $isValidSchema ? 0.2 : 0.0;
1689
1690 // Factor 4: Response quality (Weight: 10%)
1691 $qualityScore = 0.0;
1692 if (is_string($rawResponse)) {
1693 // Check for error indicators in the raw text (e.g., "I'm sorry", "Error")
1694 if (!preg_match('/error|fail|unable|cannot|sorry/i', $rawResponse)) {
1695 $qualityScore += 0.05;
1696 }
1697
1698 // Verify the tool actually exists in our registry (double check)
1699 if (isset($intentJSON['tool']) && isset($toolsSchema[$intentJSON['tool']])) {
1700 $qualityScore += 0.05;
1701 }
1702 }
1703 $factors['response_quality'] = $qualityScore;
1704
1705 // Calculate Total Confidence
1706 $confidence = array_sum($factors);
1707
1708 // Ensure confidence stays within bounds [0, 1]
1709 return max(0.0, min(1.0, $confidence));
1710}
1711
1718function formatArgumentsForDisplay($arguments)
1719{
1720 $formattedArgs = [];
1721 foreach ($arguments as $key => $value) {
1722 if (is_array($value)) {
1723 $formattedArgs[] = "- {$key}: " . (empty($value) ? "(empty)" : json_encode($value, JSON_PRETTY_PRINT));
1724 } else {
1725 $formattedArgs[] = "- {$key}: {$value}";
1726 }
1727 }
1728 return implode("\n", $formattedArgs);
1729}
1730
1737function extractActionFromTool($toolName)
1738{
1739 if (preg_match('/^(create|update|delete|list|show|find|search|get|view|validate|send)/i', $toolName, $matches)) {
1740 return strtolower($matches[1]);
1741 }
1742 return 'perform this action';
1743}
getListOfAIServices()
Get list of available ai services.
Definition ai.lib.php:69
ai_validate_attachments(array $attachments, &$error)
Validate chat attachments before they reach any LLM provider.
Definition ai.lib.php:317
ai_log_request($db, $user, $query, array $response, $provider, float $time, float $confidence, $status, $error='', $rawReq='', $rawRes='', array $context=array(), &$logId=null)
Log AI Request with Raw Payloads.
Definition ai.lib.php:416
aiCheckCsrfToken($context='')
Check the anti-CSRF token of a request sent to one of the AI Assistant endpoints.
Definition ai.lib.php:1065
Class to handle MCP (Model Context Protocol).
Definition mcp.class.php:40
const NO_WRITE
Returned by writeConfirmationPreview() when the tool writes nothing.
Class to manage privacy data masking and unmasking.
Class to manage third parties objects (customers, suppliers, prospects...)
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:
dol_strtolower($string, $encoding="UTF-8")
Convert a string to lower.
dol_string_nohtmltag($stringtoclean, $removelinefeed=1, $pagecodeto='UTF-8', $strip_tags=0, $removedoublespaces=1)
Clean a string from all HTML tags and entities.
dol_strlen($string, $stringencoding='UTF-8')
Make a strlen call.
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.
GETPOST($paramname, $check='alphanohtml', $method=0, $filter=null, $options=null, $noreplace=0, $nodefault=0)
Return value of a param into GET or POST supervariable.
dol_substr($string, $start, $length=null, $stringencoding='', $trunconbytes=0)
Make a substring.
dol_trunc($string, $size=40, $trunc='right', $stringencoding='UTF-8', $nodot=0, $display=0)
Truncate a string to a particular length adding '...' if string larger than length.
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.
getEntity($element, $shared=1, $currentobject=null)
Get list of entity id to use.
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.
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.
catch(Throwable $e) aiQueryAsksForWrite($query, $langs)
Does the user's message ask for a write (create / update / delete / ...)? Translated keys of the curr...
aiNormalizeForMatch($word, $asStem=false)
Normalize a keyword or query for inflection-tolerant non-Latin matching.
aiLabelOfResolvedObject(array $readResult, array $writeArgs)
Name of the object a write targets, taken from the read result that produced its id (so the confirmat...
filterToolsProfessional(array $allTools, array $activeCategories)
Filter a list of tools based on active intent categories.
recursiveUnmaskValues($data, ?PrivacyGuard $guard)
Recursively unmask values in a dataset.
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:492
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.