* WIP AI design - Chat Interface - French Translation * AI Home Screen Added a home page for the AI assistant, with pre-recorded prompts * Fix missing user * Add copyright * Fix working tools and alignment
1019 lines
34 KiB
PHP
1019 lines
34 KiB
PHP
<?php
|
|
/* Copyright (C) 2026 Laurent Destailleur <eldy@users.sourceforge.net>
|
|
* Copyright (C) 2026 Nick Fragoulis
|
|
* Copyright (C) 2026 Jose Martinez <jose.martinez@pichinov.com>
|
|
* Copyright (C) 2026 Anthony Damhet <a.damhet@progiseize.fr>
|
|
*
|
|
* This program is free software; you can redistribute it and/or modify
|
|
* it under the terms of the GNU General Public License as published by
|
|
* the Free Software Foundation; either version 3 of the License, or
|
|
* (at your option) any later version.
|
|
*
|
|
* This program is distributed in the hope that it will be useful,
|
|
* but WITHOUT ANY WARRANTY, without even the implied warranty of
|
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
* GNU General Public License for more details.
|
|
*
|
|
* You should have received a copy of the GNU General Public License
|
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
* or see https://www.gnu.org/
|
|
*/
|
|
|
|
/**
|
|
* \file htdocs/ai/assistant/parse_intent.php
|
|
* \ingroup ai
|
|
* \brief File to handle MCP (Model Context Protocol) Intent Parsing
|
|
*/
|
|
|
|
if (!defined('NOTOKENRENEWAL')) {
|
|
define('NOTOKENRENEWAL', 1);
|
|
}
|
|
if (!defined('NOREQUIREMENU')) {
|
|
define('NOREQUIREMENU', 1);
|
|
}
|
|
if (!defined('NOREQUIREHTML')) {
|
|
define('NOREQUIREHTML', 1);
|
|
}
|
|
if (!defined('NOREQUIREAJAX')) {
|
|
define('NOREQUIREAJAX', 1);
|
|
}
|
|
if (!defined('NOCSRFCHECK')) { // TODO Enable the CSRF check
|
|
define('NOCSRFCHECK', 1);
|
|
}
|
|
|
|
require '../../main.inc.php';
|
|
require_once DOL_DOCUMENT_ROOT . '/ai/class/mcp.class.php';
|
|
require_once DOL_DOCUMENT_ROOT . '/ai/lib/ai.lib.php';
|
|
require_once DOL_DOCUMENT_ROOT . '/ai/class/llmadapter.class.php';
|
|
require_once DOL_DOCUMENT_ROOT . '/core/lib/functions.lib.php';
|
|
require_once DOL_DOCUMENT_ROOT . '/ai/class/privacy_guard.class.php';
|
|
require_once DOL_DOCUMENT_ROOT . '/core/lib/security2.lib.php';
|
|
|
|
// Security check
|
|
if (!isModEnabled('ai') || !getDolGlobalString('AI_ASSISTANT_ENABLED')) {
|
|
http_response_code(403);
|
|
accessforbidden('Module or feature not allowed');
|
|
}
|
|
|
|
global $db, $user, $conf, $langs;
|
|
|
|
// Same per-user gate as the Assistant page that calls this endpoint, so a
|
|
// user without 'ai/assistant/use' cannot reach the LLM through direct AJAX.
|
|
if (!$user->hasRight('ai', 'assistant', 'use')) {
|
|
accessforbidden();
|
|
}
|
|
|
|
ob_start();
|
|
top_httphead('application/json');
|
|
|
|
// Confirmation level: 0=no confirmation, 1=only create/update/delete, 2=all actions
|
|
$askForConfirmation = getDolGlobalInt('AI_ASK_FOR_CONFIRMATION');
|
|
|
|
// Confidence thresholds
|
|
define('HIGH_CONFIDENCE', 0.8);
|
|
define('MEDIUM_CONFIDENCE', 0.5);
|
|
define('LOW_CONFIDENCE', 0.3);
|
|
|
|
// Logging variables
|
|
$startTime = microtime(true);
|
|
$rawRequestLog = "";
|
|
$rawResponseLog = "";
|
|
$providerUsed = "offline";
|
|
$errorDetails = "";
|
|
|
|
$assistantEnabled = getDolGlobalInt('AI_ASSISTANT_ENABLED', 0);
|
|
$serviceKey = getDolGlobalString('AI_API_SERVICE');
|
|
$doRedact = getDolGlobalInt('AI_PRIVACY_REDACTION', 0);
|
|
$timeout = getDolGlobalInt('AI_REQUEST_TIMEOUT', 120);
|
|
|
|
// Kill switch
|
|
if (!$assistantEnabled) {
|
|
$response = [
|
|
"tool" => "respond_to_user",
|
|
"arguments" => [
|
|
"message" => "AI assistant service is currently disabled. Please contact your administrator to enable it."
|
|
]
|
|
];
|
|
ob_end_clean();
|
|
echo json_encode($response);
|
|
exit;
|
|
}
|
|
|
|
set_time_limit($timeout + 5);
|
|
|
|
try {
|
|
// Input
|
|
$raw_input = file_get_contents('php://input');
|
|
$data = json_decode($raw_input, true);
|
|
$query = isset($data['query']) ? trim($data['query']) : '';
|
|
|
|
if (empty($query)) {
|
|
ob_end_clean();
|
|
echo json_encode(["status" => "ok"]);
|
|
exit;
|
|
}
|
|
|
|
// Privacy (Name Resolution & Masking)
|
|
$langs->loadLangs(array("main", "bills", "orders", "propal", "supplier_invoice", "supplier_order", "projects", "other"));
|
|
|
|
// Translation key of Words we want to block in any language.
|
|
$blockKeys = [
|
|
// Objects (Nouns)
|
|
'Bill',
|
|
'Invoice',
|
|
'Order',
|
|
'Proposal',
|
|
'Shipment',
|
|
'Reception',
|
|
'Contract',
|
|
'SupplierInvoice',
|
|
'SupplierOrder',
|
|
'Project',
|
|
'Task',
|
|
'Product',
|
|
'Service',
|
|
'Ticket',
|
|
'Event',
|
|
'Agenda',
|
|
'Member',
|
|
'User',
|
|
'ThirdParty',
|
|
'Company',
|
|
'Contact',
|
|
// Actions (Verbs/Commands)
|
|
'Search',
|
|
'Find',
|
|
'List',
|
|
'Show',
|
|
'Create',
|
|
'Add',
|
|
'Modify',
|
|
'Delete',
|
|
'Validate',
|
|
'Send',
|
|
// Other
|
|
'Hello',
|
|
'Test'
|
|
];
|
|
|
|
// Resolve keys to the actual current language
|
|
$dynamicStopWords = [];
|
|
foreach ($blockKeys as $key) {
|
|
$word = $langs->transnoentities($key);
|
|
if (!empty($word)) {
|
|
$dynamicStopWords[] = mb_strtolower($word);
|
|
}
|
|
}
|
|
|
|
// Add common short English/French/Spanish commands that users often type
|
|
// regardless of the UI language.
|
|
$commonCommands = ['show', 'find', 'search', 'list', 'get', 'voir', 'chercher', 'affiche', 'lista', 'buscar'];
|
|
$dynamicStopWords = array_unique(array_merge($dynamicStopWords, $commonCommands)); // $dynamicStopWords is an array of words
|
|
|
|
|
|
$cleanQuery = preg_replace('/[^\p{L}\p{N}\s\-]/u', '', $query); // Remove special chars from the prompt query
|
|
$words = preg_split('/\s+/', $cleanQuery, -1, PREG_SPLIT_NO_EMPTY);
|
|
$count = count($words);
|
|
$candidates = array();
|
|
|
|
// Helper function to validate a phrase without a dictionary
|
|
$isValidPhrase = function (string $phrase) use ($dynamicStopWords): bool {
|
|
$phrase = trim($phrase);
|
|
|
|
// RULE 1: Minimum Length
|
|
// Filter out extremely short words (1-2 chars).
|
|
// This catches "a", "le", "la", "de", "y", "to", "in", "von", "zu" in almost all languages.
|
|
if (mb_strlen($phrase) < 3) {
|
|
return false;
|
|
}
|
|
|
|
// RULE 2: First Word Check
|
|
// If the phrase starts with a translated keyword (e.g. "Invoice Acme"), skip it.
|
|
$parts = explode(' ', $phrase);
|
|
$firstWord = mb_strtolower($parts[0]);
|
|
|
|
if (in_array($firstWord, $dynamicStopWords)) {
|
|
return false;
|
|
}
|
|
|
|
return true;
|
|
};
|
|
|
|
// Fill array $candidates of thirdparty name we may want to work with
|
|
for ($i = 0; $i < $count; $i++) {
|
|
// Single Word
|
|
if ($isValidPhrase($words[$i])) {
|
|
$candidates[] = $words[$i];
|
|
}
|
|
|
|
if ($i + 1 < $count) {
|
|
$phrase = $words[$i] . ' ' . $words[$i + 1];
|
|
if ($isValidPhrase($phrase)) {
|
|
$candidates[] = $phrase;
|
|
}
|
|
}
|
|
|
|
if ($i + 2 < $count) {
|
|
$phrase = $words[$i] . ' ' . $words[$i + 1] . ' ' . $words[$i + 2];
|
|
if ($isValidPhrase($phrase)) {
|
|
$candidates[] = $phrase;
|
|
}
|
|
}
|
|
}
|
|
|
|
usort($candidates, function (string $a, string $b): int {
|
|
return mb_strlen($b) - mb_strlen($a);
|
|
});
|
|
|
|
dol_syslog("parse_intent.php We have candidates into text that may be a thirdparty. List is ".implode(',', $candidates), LOG_DEBUG);
|
|
|
|
if (!empty($candidates)) {
|
|
foreach ($candidates as $phrase) {
|
|
// We use LIKE '...' to match the start of the company name.
|
|
$sql = "SELECT rowid, nom FROM " . MAIN_DB_PREFIX . "societe WHERE nom LIKE '" . $db->escape($phrase) . "%' LIMIT 1";
|
|
|
|
$res = $db->query($sql);
|
|
|
|
if ($res && $obj = $db->fetch_object($res)) {
|
|
// Match found. Replace in the original query.
|
|
$query = preg_replace('/\b' . preg_quote($phrase, '/') . '\b/iu', "socid:" . $obj->rowid, $query);
|
|
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Apply privacy guard if enabled
|
|
$guard = null;
|
|
if ($doRedact && class_exists('PrivacyGuard')) {
|
|
$guard = new PrivacyGuard();
|
|
$query = $guard->mask($query);
|
|
}
|
|
|
|
// AI Execution
|
|
$intentJSON = null;
|
|
$confidence = 0.0;
|
|
$allToolsSchema = [];
|
|
|
|
if ($serviceKey && $serviceKey !== '-1') {
|
|
$providerUsed = $serviceKey;
|
|
$mcp = new McpHandler($db, $user, $conf, McpHandler::CTX_ASSISTANT);
|
|
|
|
// Two schemas are maintained:
|
|
// $allToolsSchema — full list including system tools; used ONLY for post-LLM validation.
|
|
// $llmToolsBase — system tools excluded (is_system=>true filtered out in McpHandler);
|
|
// used for category filtering and as the LLM tool list.
|
|
// This separation guarantees ask_for_confirmation, respond_to_user, etc. are
|
|
// never visible to the model, preventing the LLM from calling them directly.
|
|
$allToolsSchema = $mcp->getToolsSchema();
|
|
$llmToolsBase = $mcp->getToolsSchemaForLLM();
|
|
|
|
// Detect if query is in a Non-Latin language (Russian, Greek, Chinese, Arabic, etc.)
|
|
$isComplex = isComplexScript($query);
|
|
|
|
$toolsSchema = [];
|
|
|
|
if ($isComplex) {
|
|
// Non-Latin: send full LLM-safe schema (system tools already excluded)
|
|
dol_syslog("AI Pro: Non-Latin language detected. Sending full (cleaned) schema.");
|
|
$toolsSchema = $llmToolsBase;
|
|
} else {
|
|
// Detect in which business family the query is using Hybrid (Translations + Synonyms)
|
|
$detectedCategories = classifyIntentUniversal($query, $langs);
|
|
|
|
// Category filter applied to $llmToolsBase — system tools already excluded
|
|
$toolsSchema = filterToolsProfessional($llmToolsBase, $detectedCategories);
|
|
|
|
dol_syslog("AI Pro: Latin script. Detected: " . json_encode($detectedCategories) . ". Filtered to " . count($toolsSchema) . " tools.");
|
|
}
|
|
|
|
// If we are sending a lot of tools (Non-Latin or Fallback), we strip descriptions.
|
|
// The 20-tool threshold was likely chosen for GPT-3.5 (4K context window). Modern
|
|
// LLMs handle the full schema trivially: Gemini 2.5 Flash has a 1M token context,
|
|
// GPT-4o has 128K, Claude Sonnet has 1M. Compression hurts more than it helps
|
|
// today because it also truncates tool *descriptions* (down to 3 words), which
|
|
// breaks tool selection (e.g. "create_other_document" becomes "Create documents
|
|
// other" -- the LLM then thinks supplier_invoice creation is not available).
|
|
// We raise the threshold to 100 to effectively disable compression for the
|
|
// default install (~30 tools), while still leaving a safety net for very large
|
|
// custom installs that register dozens of additional addMcpTools hooks.
|
|
$isLargeSchema = count($toolsSchema) > 100;
|
|
$toolsForLLM = cleanToolSchemaForLLM($toolsSchema, $isLargeSchema);
|
|
|
|
// Build System Prompt
|
|
$basePrompt = getDolGlobalString('AI_INTENT_PROMPT') ?: "You are a professional Dolibarr assistant.";
|
|
|
|
$systemRules = "\n\nRules: Respond ONLY JSON and ensure any json string does not contains special chars and are correctly json encoded. Format: {\"tool\":..., \"arguments\":{...}}. ";
|
|
$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.";
|
|
|
|
// If MCP is disabled, we disable all tools
|
|
if (getDolGlobalString('AI_ASSISTANT_DISABLE_TOOLS')) {
|
|
$toolsForLLM = array();
|
|
}
|
|
|
|
$systemPrompt = $basePrompt . "\n\n";
|
|
$systemPrompt .= "Tools:\n" . json_encode($toolsForLLM, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
|
$systemPrompt .= $systemRules . " Date: " . date('Y-m-d');
|
|
|
|
// Get API configuration
|
|
$servicesList = getListOfAIServices();
|
|
$apiKey = getDolGlobalString('AI_API_' . strtoupper($serviceKey) . '_KEY');
|
|
|
|
if (preg_match('/^crypt:/', $apiKey)) {
|
|
$apiKey = dolDecrypt($apiKey, $conf->file->instance_unique_id);
|
|
}
|
|
|
|
$defUrl = $servicesList[$serviceKey]['url'] ?? '';
|
|
$url = getDolGlobalString('AI_API_' . strtoupper($serviceKey) . '_URL') ?: $defUrl;
|
|
// The model defaults declared in getListOfAIServices() are nested:
|
|
// $servicesList[$key]['textgeneration'] = ['default' => 'model-name']
|
|
// Reading 'textgeneration' without ['default'] returns the inner array, which
|
|
// then fails the (string) type-hint of UniversalLLMAdapter's 4th argument with:
|
|
// "Argument #4 ($model) must be of type string, array given"
|
|
//
|
|
// The admin UI (htdocs/ai/admin/setup.php "Prompt and custom AI models" tab) also
|
|
// stores the per-function model under AI_API_<SERVICE>_MODEL_TEXT (matching the
|
|
// convention already used by Ai::generateContent() for the same data). The
|
|
// previous lookup used AI_API_<SERVICE>_MODEL which is never written by that
|
|
// form, so the user-configured model was silently ignored.
|
|
$rawDefault = $servicesList[$serviceKey]['textgeneration'] ?? null;
|
|
if (is_array($rawDefault)) {
|
|
$defModel = $rawDefault['default'] ?? 'gpt-4o-mini';
|
|
} else {
|
|
$defModel = $rawDefault ?: 'gpt-4o-mini';
|
|
}
|
|
$prefix = 'AI_API_' . strtoupper($serviceKey);
|
|
$model = getDolGlobalString($prefix . '_MODEL_TEXT')
|
|
?: getDolGlobalString($prefix . '_MODEL')
|
|
?: $defModel;
|
|
// Defensive: coerce to string if anyone stored an array in this constant
|
|
if (is_array($model)) {
|
|
$model = $model['default'] ?? $defModel;
|
|
}
|
|
if (!is_string($model) || $model === '') {
|
|
$model = (string) $defModel;
|
|
}
|
|
$adapterType = $servicesList[$serviceKey]['adapter_type'] ?? 'openai';
|
|
|
|
|
|
// The request.
|
|
// var_dump($query);
|
|
|
|
if (!empty($apiKey)) {
|
|
$adapter = new UniversalLLMAdapter($adapterType, $apiKey, $url, $model, $timeout);
|
|
|
|
dol_syslog("parse_intent.php Call AI API", LOG_DEBUG);
|
|
|
|
$rawResponse = $adapter->generate($systemPrompt, $query);
|
|
|
|
// $rawResponse should be a json string with format '{"tool":..., "arguments":{text answer}}' but sometimes it is just 'text answer'
|
|
dol_syslog('rawResponse='.$rawResponse, LOG_DEBUG);
|
|
|
|
//var_dump($rawResponse);exit;
|
|
|
|
// Capture logs
|
|
$rawRequestLog = $adapter->lastRequest;
|
|
$rawResponseLog = $adapter->lastResponse;
|
|
|
|
// Process response
|
|
if (is_string($rawResponse) && strpos($rawResponse, 'Error:') === 0) {
|
|
$errorDetails = $rawResponse;
|
|
} elseif ($rawResponse) {
|
|
// Clean JSON response
|
|
$clean = preg_replace('/```json\s*|\s*```/s', '', $rawResponse);
|
|
$clean = trim($clean);
|
|
|
|
$matches = array();
|
|
if (preg_match('/^\{.*\}$/s', $clean, $matches)) {
|
|
$clean = $matches[0];
|
|
}
|
|
|
|
// Unmask the JSON string
|
|
if ($guard) {
|
|
$clean = $guard->unmaskAiResponse($clean);
|
|
}
|
|
|
|
// Removed carriage returns and newlines
|
|
$clean = preg_replace('/[\r\n]/', ' ', $clean);
|
|
|
|
// If answer is a json string or not
|
|
if (strpos($clean, '{') === 0) {
|
|
// This may be a json string
|
|
$intentJSON = json_decode($clean, true);
|
|
} else {
|
|
$intentJSON = [
|
|
"tool" => "respond_to_user",
|
|
'arguments' => [
|
|
"message" => $clean
|
|
]
|
|
];
|
|
}
|
|
|
|
// Ensure no placeholders remain in the data structure.
|
|
if ($guard && isset($intentJSON['arguments'])) {
|
|
$intentJSON['arguments'] = recursiveUnmaskValues($intentJSON['arguments'], $guard);
|
|
}
|
|
|
|
// Validation check: Check if the AI selected a tool that actually exists in our filtered schema.
|
|
if ($intentJSON && isset($intentJSON['tool'])) {
|
|
$validToolNames = array_column($allToolsSchema, 'name');
|
|
if (!in_array($intentJSON['tool'], $validToolNames)) {
|
|
dol_syslog("AI Validation: Tool '" . $intentJSON['tool'] . "' not found in filtered schema. Send error message via respond_to_user.", LOG_WARNING);
|
|
|
|
// Force the standard response for non-existent functionality
|
|
$intentJSON = [
|
|
"tool" => "respond_to_user",
|
|
"arguments" => [
|
|
"message" => "I apologize, but the requested functionality is not currently available in the system."
|
|
]
|
|
];
|
|
$confidence = 1.0;
|
|
}
|
|
}
|
|
|
|
// Calculate confidence (only if not manually set to 1.0 above)
|
|
if ($intentJSON && $confidence === 0.0) {
|
|
$mappedToolsSchema = array_column($toolsSchema, null, 'name');
|
|
$confidence = calculateConfidence($intentJSON, $mappedToolsSchema, $rawResponse);
|
|
|
|
dol_syslog("parse_intent.php AI Intent: " . json_encode(['query' => $query, 'intent' => $intentJSON, 'confidence' => $confidence]), LOG_DEBUG);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
// Handle no AI Intent
|
|
if (!$intentJSON || !isset($intentJSON['tool'])) {
|
|
$finalResponse = [
|
|
"tool" => "respond_to_user",
|
|
"arguments" => [
|
|
"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."
|
|
]
|
|
];
|
|
|
|
// Log the failure
|
|
ai_log_request($db, $user, $query, $finalResponse, $providerUsed, microtime(true) - $startTime, 0.0, $langs->transnoentitiesnoconv('Error'), $errorDetails, $rawRequestLog, $rawResponseLog);
|
|
|
|
ob_end_clean();
|
|
echo json_encode($finalResponse);
|
|
exit;
|
|
}
|
|
|
|
// Check if confirmation needed
|
|
$needsConfirmation = false;
|
|
$toolName = $intentJSON['tool'] ?? '';
|
|
|
|
// Normalize the text answer key: some models (e.g. GPT-4o) fill
|
|
// respond_to_user / reject_general_question under 'response', 'text',
|
|
// 'answer'... instead of the 'message' key the frontend reads, which
|
|
// otherwise surfaces as "Empty AI Response".
|
|
if (in_array($toolName, array('respond_to_user', 'reject_general_question'), true) && isset($intentJSON['arguments']) && is_array($intentJSON['arguments'])) {
|
|
if (empty($intentJSON['arguments']['message'])) {
|
|
foreach (array('response', 'text', 'answer', 'content', 'reply', 'output') as $altkey) {
|
|
if (!empty($intentJSON['arguments'][$altkey])) {
|
|
$intentJSON['arguments']['message'] = $intentJSON['arguments'][$altkey];
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if ($askForConfirmation > 0) {
|
|
$isModifyOperation = preg_match('/(create|update|delete|add|remove|modify|edit)/i', $toolName);
|
|
|
|
if ($askForConfirmation == 1 && $isModifyOperation) {
|
|
$needsConfirmation = true;
|
|
} elseif ($askForConfirmation == 2) {
|
|
$needsConfirmation = true;
|
|
}
|
|
}
|
|
|
|
// Handle confirmation
|
|
if ($needsConfirmation) {
|
|
$allToolsMap = !empty($allToolsSchema)
|
|
? array_column($allToolsSchema, null, 'name')
|
|
: [];
|
|
$toolDescription = $allToolsMap[$toolName]['description'] ?? 'No description available';
|
|
$arguments = $intentJSON['arguments'] ?? [];
|
|
|
|
$details = formatArgumentsForDisplay($arguments);
|
|
$action = extractActionFromTool($toolName);
|
|
|
|
$confirmationResponse = [
|
|
"tool" => "ask_for_confirmation",
|
|
"arguments" => [
|
|
"action" => $action,
|
|
"details" => $details,
|
|
"original_intent" => $intentJSON
|
|
]
|
|
];
|
|
|
|
// Log the confirmation request
|
|
ai_log_request($db, $user, $query, $confirmationResponse, $providerUsed, microtime(true) - $startTime, $confidence, $langs->transnoentitiesnoconv("Confirm"), $errorDetails, $rawRequestLog, $rawResponseLog);
|
|
|
|
ob_end_clean();
|
|
echo json_encode($confirmationResponse);
|
|
exit;
|
|
}
|
|
|
|
// Handle low confidence
|
|
if ($confidence < LOW_CONFIDENCE) {
|
|
$finalResponse = [
|
|
"tool" => "respond_to_user",
|
|
"arguments" => [
|
|
"message" => "I'm not confident about understanding your request. Please try rephrasing it with more specific details."
|
|
]
|
|
];
|
|
|
|
// Log the low confidence response
|
|
ai_log_request($db, $user, $query, $finalResponse, $providerUsed, microtime(true) - $startTime, $confidence, 'low_confidence', $errorDetails, $rawRequestLog, $rawResponseLog);
|
|
|
|
ob_end_clean();
|
|
echo json_encode($finalResponse);
|
|
exit;
|
|
}
|
|
|
|
// Add confidence note
|
|
if ($confidence < MEDIUM_CONFIDENCE && isset($intentJSON['arguments'])) {
|
|
$intentJSON['arguments']['_confidence_note'] = "I'm moderately confident about this interpretation. Please verify the results.";
|
|
}
|
|
|
|
// Success!
|
|
$finalResponse = $intentJSON;
|
|
$execTime = microtime(true) - $startTime;
|
|
ai_log_request($db, $user, $query, $finalResponse, $providerUsed, $execTime, $confidence, $langs->transnoentitiesnoconv("Success"), $errorDetails, $rawRequestLog, $rawResponseLog);
|
|
|
|
ob_end_clean();
|
|
echo json_encode($finalResponse);
|
|
} catch (Throwable $e) {
|
|
$friendlyResponse = [
|
|
"tool" => "respond_to_user",
|
|
"arguments" => [
|
|
"message" => "I'm experiencing technical difficulties. Please try again later or contact your administrator."
|
|
]
|
|
];
|
|
|
|
$realErrorForLog = "PHP Exception: " . $e->getMessage() . " in " . $e->getFile() . " on line " . $e->getLine();
|
|
|
|
dol_syslog("AI Critical Error: " . $realErrorForLog, LOG_ERR);
|
|
|
|
if (function_exists('ai_log_request') && is_object($db)) {
|
|
ai_log_request(
|
|
$db,
|
|
$user,
|
|
$query ?? 'unknown',
|
|
$friendlyResponse,
|
|
$providerUsed,
|
|
microtime(true) - $startTime,
|
|
0.0,
|
|
'error',
|
|
$realErrorForLog,
|
|
$rawRequestLog ?? '',
|
|
$rawResponseLog ?? ''
|
|
);
|
|
}
|
|
|
|
ob_end_clean();
|
|
echo json_encode($friendlyResponse);
|
|
}
|
|
|
|
|
|
/**
|
|
* Recursively unmask values in a dataset.
|
|
*
|
|
* This helper walks through an array structure and applies the appropriate
|
|
* unmasking method on all string values. It ensures that any masked or
|
|
* placeholder data is restored before being used in actual tool execution.
|
|
*
|
|
* Supported guard methods:
|
|
* - unmask(string $value): string
|
|
* - unmaskAiResponse(string $value): string
|
|
*
|
|
* If both methods exist, `unmask()` takes precedence.
|
|
*
|
|
* @param mixed $data The input data (array, string, or scalar) to process.
|
|
* @param PrivacyGuard|null $guard An object providing unmasking methods.
|
|
*
|
|
* @return mixed The data with all string values unmasked.
|
|
*/
|
|
function recursiveUnmaskValues($data, ?PrivacyGuard $guard)
|
|
{
|
|
if ($guard === null) {
|
|
return $data;
|
|
}
|
|
|
|
if (is_array($data)) {
|
|
return array_map(
|
|
/**
|
|
* @param mixed $item
|
|
* @return mixed
|
|
*/
|
|
function ($item) use ($guard) {
|
|
return recursiveUnmaskValues($item, $guard);
|
|
},
|
|
$data
|
|
);
|
|
}
|
|
|
|
if (is_string($data)) {
|
|
return $guard->unmask($data);
|
|
}
|
|
|
|
return $data;
|
|
}
|
|
|
|
/**
|
|
* Detects if the query uses Non-Latin Scripts.
|
|
*
|
|
* Supports all Dolibarr Core Non-Latin languages:
|
|
* - CJK (Chinese, Japanese, Korean)
|
|
* - Cyrillic (Russian, Ukrainian, Serbian, Bulgarian)
|
|
* - Greek, Arabic, Hebrew, Thai
|
|
*
|
|
*
|
|
* @param string $text The input text to be checked.
|
|
* @return bool True if the text contains complex scripts, false otherwise.
|
|
*/
|
|
function isComplexScript(string $text)
|
|
{
|
|
// CJK (Chinese, Japanese, Korean)
|
|
if (preg_match('/\p{Han}|\p{Hiragana}|\p{Katakana}|\p{Hangul}/u', $text)) {
|
|
return true;
|
|
}
|
|
|
|
// Cyrillic (Russian, Ukrainian, Bulgarian, Serbian)
|
|
if (preg_match('/\p{Cyrillic}/u', $text)) {
|
|
return true;
|
|
}
|
|
|
|
// Greek
|
|
if (preg_match('/\p{Greek}/u', $text)) {
|
|
return true;
|
|
}
|
|
|
|
// Arabic
|
|
if (preg_match('/\p{Arabic}/u', $text)) {
|
|
return true;
|
|
}
|
|
|
|
// Hebrew
|
|
if (preg_match('/\p{Hebrew}/u', $text)) {
|
|
return true;
|
|
}
|
|
|
|
// Thai
|
|
if (preg_match('/\p{Thai}/u', $text)) {
|
|
return true;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Detect intent categories from a user query.
|
|
*
|
|
* This function analyzes a natural language query and attempts to classify it
|
|
* into one or more predefined intent categories (e.g., billing, commercial,
|
|
* thirdparty, stock, project, reporting).
|
|
*
|
|
* It leverages Dolibarr translations (`$langs->trans()`) to match localized
|
|
* keywords, and applies additional synonym matching for Latin-based queries.
|
|
* For non-Latin scripts, it performs a simpler substring search.
|
|
*
|
|
* Matching strategy:
|
|
* - Latin queries: normalized (lowercase + unaccent) and matched using regex word boundaries.
|
|
* - Non-Latin queries: matched using case-insensitive substring search.
|
|
*
|
|
* Each category is detected if at least one keyword or synonym matches.
|
|
*
|
|
* @param string $query The user input query to analyze.
|
|
* @param Translate $langs The Dolibarr translation object used to resolve localized keywords.
|
|
*
|
|
* @return string[] Array of detected intent categories (e.g., ['billing', 'stock']).
|
|
*/
|
|
function classifyIntentUniversal(string $query, Translate $langs)
|
|
{
|
|
$isLatin = !isComplexScript($query);
|
|
$searchQuery = $isLatin ? strtolower(dol_string_unaccent($query)) : $query;
|
|
|
|
$langs->loadLangs(array("main", "bills", "orders", "propal", "companies", "products", "projects", "dict"));
|
|
|
|
$intentMap = [
|
|
'billing' => [
|
|
'keys' => ['Bill', 'Invoice', 'Payment', 'Cheque', 'VAT', 'BillStatusUnpaid', 'BillStatusPaid', 'BillStatusDraft'],
|
|
'synonyms' => ['paid', 'unpaid', 'pay', 'money', 'cost', 'amount']
|
|
],
|
|
'commercial' => [
|
|
'keys' => ['Order', 'Proposal', 'Quote', 'SupplierOrder', 'OrderStatusDraft'],
|
|
'synonyms' => ['sale', 'buy', 'purchase', 'contract', 'shipping']
|
|
],
|
|
'thirdparty' => [
|
|
'keys' => ['ThirdParty', 'Customer', 'Supplier', 'Contact', 'Company'],
|
|
'synonyms' => ['client', 'partner', 'address', 'phone']
|
|
],
|
|
'stock' => [
|
|
'keys' => ['Product', 'Service', 'Stock', 'Warehouse'],
|
|
'synonyms' => ['item', 'inventory', 'sku', 'location', 'qty']
|
|
],
|
|
'project' => [
|
|
'keys' => ['Project', 'Task'],
|
|
'synonyms' => ['milestone', 'gantt', 'team']
|
|
],
|
|
'reporting' => [
|
|
'keys' => ['Report', 'Statistics', 'Turnover', 'Revenue', 'Income'],
|
|
'synonyms' => ['graph', 'chart', 'analytics', 'dashboard', 'kpi']
|
|
]
|
|
];
|
|
|
|
$detectedCategories = [];
|
|
foreach ($intentMap as $category => $data) {
|
|
$keywords = [];
|
|
foreach ($data['keys'] as $key) {
|
|
$trans = $langs->trans($key);
|
|
if ($isLatin) {
|
|
$trans = strtolower(dol_string_unaccent($trans));
|
|
}
|
|
$keywords[] = $trans;
|
|
if (!$isLatin && $key !== $trans) {
|
|
$keywords[] = strtolower($key);
|
|
}
|
|
}
|
|
if ($isLatin) {
|
|
foreach ($data['synonyms'] as $syn) {
|
|
$keywords[] = dol_string_unaccent($syn);
|
|
}
|
|
}
|
|
foreach ($keywords as $word) {
|
|
if (empty($word)) {
|
|
continue;
|
|
}
|
|
if ($isLatin) {
|
|
if (preg_match('/\b' . preg_quote($word, '/') . 's?\b/u', $searchQuery)) {
|
|
$detectedCategories[] = $category;
|
|
break;
|
|
}
|
|
} else {
|
|
if (mb_stripos($searchQuery, $word) !== false) {
|
|
$detectedCategories[] = $category;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
return $detectedCategories;
|
|
}
|
|
|
|
/**
|
|
* Filter a list of tools based on active intent categories.
|
|
*
|
|
* This function narrows down the available tools by matching their assigned
|
|
* categories against the detected intent categories. Tools tagged as "global"
|
|
* are always considered, but may be excluded when more specific categories
|
|
* are active to avoid overly generic matches.
|
|
*
|
|
* Behavior:
|
|
* - If no categories are provided, or only "global" is present, all tools are returned.
|
|
* - Tools are included if they share at least one category with the target categories.
|
|
* - Tools with only the "global" category are excluded when specific categories are active.
|
|
* - If filtering results in fewer than 3 tools, the full tool list is returned as a fallback.
|
|
*
|
|
*
|
|
* @param array<int,array<string,mixed>> $allTools List of all available tools.
|
|
* @param string[] $activeCategories Detected intent categories (e.g., ['billing', 'stock']).
|
|
*
|
|
* @return array<int,array<string,mixed>> Filtered list of tools matching the active categories.
|
|
*/
|
|
function filterToolsProfessional(array $allTools, array $activeCategories)
|
|
{
|
|
if (empty($activeCategories) || (count($activeCategories) === 1 && $activeCategories[0] === 'global')) {
|
|
return $allTools;
|
|
}
|
|
|
|
$targetCategories = array_merge(['global'], $activeCategories);
|
|
$filtered = [];
|
|
|
|
foreach ($allTools as $tool) {
|
|
$toolCats = $tool['categories'] ?? ['global'];
|
|
if (count(array_intersect($toolCats, $targetCategories)) > 0) {
|
|
if (count($activeCategories) > 0 && $toolCats === ['global']) {
|
|
continue;
|
|
}
|
|
$filtered[] = $tool;
|
|
}
|
|
}
|
|
|
|
if (count($filtered) < 3) {
|
|
dol_syslog("AI Filter: Too few tools (" . count($filtered) . "). Reverting to full schema.", LOG_WARNING);
|
|
return $allTools;
|
|
}
|
|
|
|
return $filtered;
|
|
}
|
|
|
|
/**
|
|
* Compresses tool schema by removing optional parameters with defaults
|
|
* and stripping descriptions, relying on LLM inference of variable names.
|
|
*
|
|
* @param array<int, array<string, mixed>> $tools Array of tool definitions.
|
|
* @param bool $isLargeSchema True if compression is needed.
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
function cleanToolSchemaForLLM(array $tools, bool $isLargeSchema = false)
|
|
{
|
|
$cleaned = [];
|
|
|
|
foreach ($tools as $tool) {
|
|
// Tool descriptions are how the LLM selects the right tool -- never truncate
|
|
// them, even when the schema is large. Truncating to 3 words ("Create documents
|
|
// other", "Add a single") breaks tool selection. If the schema really is too
|
|
// big for the chosen model, the right answer is to filter the toolset before
|
|
// it reaches the LLM (which is what filterToolsProfessional() already does
|
|
// upstream of this function), not to mutilate each tool's description.
|
|
// Parameter-level compression (stripping defaults, descriptions of optional
|
|
// fields, etc.) remains gated on $isLargeSchema below.
|
|
$desc = $tool['description'];
|
|
|
|
// Get parameters
|
|
$toolParams = $tool['parameters'] ?? $tool['inputSchema'] ?? [];
|
|
|
|
if ($isLargeSchema && isset($toolParams['properties']) && is_array($toolParams['properties'])) {
|
|
$requiredList = $toolParams['required'] ?? [];
|
|
$newProperties = [];
|
|
|
|
foreach ($toolParams['properties'] as $propKey => $propData) {
|
|
$isRequired = in_array($propKey, $requiredList);
|
|
|
|
// -----------------------------------------------------------
|
|
// Remove Optional Parameters with Defaults
|
|
// -----------------------------------------------------------
|
|
// If a parameter is optional and has a default value defined in
|
|
// the schema, we assume the backend will handle it. We remove it
|
|
// from the prompt entirely. This saves massive amounts of tokens
|
|
// on list/search functions (limit, sortorder, sqlfilters, etc).
|
|
// -----------------------------------------------------------
|
|
if (!$isRequired && isset($propData['default'])) {
|
|
continue;
|
|
}
|
|
|
|
// Remove 'type' for string (LLM default), Keep others (int/bool/arr)
|
|
if (isset($propData['type']) && $propData['type'] === 'string') {
|
|
unset($propData['type']);
|
|
}
|
|
|
|
// Handle Descriptions
|
|
// Remove descriptions entirely. Rely on the key name (e.g. 'email', 'qty').
|
|
// Exception: Keep 1 word if it's a required parameter with a confusing name.
|
|
if (isset($propData['description'])) {
|
|
unset($propData['description']);
|
|
// If we want to keep a tiny hint for required params, uncomment below:
|
|
// if ($isRequired) {
|
|
// $propData['description'] = explode(' ', trim($propData['description']))[0];
|
|
// }
|
|
}
|
|
|
|
// Collapse Complex Objects
|
|
// If a parameter is a deep object (like a complex filter), replace the
|
|
// recursive properties definition with a generic string to save tokens.
|
|
if (isset($propData['type']) && $propData['type'] === 'object' && isset($propData['properties'])) {
|
|
unset($propData['properties']);
|
|
unset($propData['required']);
|
|
$propData['description'] = "JSON object"; // Minimal hint
|
|
}
|
|
|
|
$newProperties[$propKey] = $propData;
|
|
}
|
|
|
|
$toolParams['properties'] = $newProperties;
|
|
|
|
// Clean up root metadata
|
|
unset($toolParams['type']);
|
|
unset($toolParams['additionalProperties']);
|
|
}
|
|
|
|
$cleaned[] = [
|
|
'name' => $tool['name'],
|
|
'description' => $desc,
|
|
'parameters' => $toolParams
|
|
];
|
|
}
|
|
|
|
return $cleaned;
|
|
}
|
|
|
|
/**
|
|
* Calculate confidence score based on multiple factors.
|
|
*
|
|
* This function analyzes the AI's response to determine if the intent was
|
|
* parsed correctly and if all required arguments were provided according to
|
|
* the tool's schema.
|
|
*
|
|
* @param array<string, mixed> $intentJSON The parsed intent (Keys: 'tool', 'arguments').
|
|
* @param array<string, array<string, mixed>> $toolsSchema Available tools schema (Key=ToolName, Value=ToolDefinition).
|
|
* @param string $rawResponse Raw response string from the AI provider.
|
|
* @return float Confidence score between 0.0 and 1.0.
|
|
*/
|
|
function calculateConfidence($intentJSON, $toolsSchema, $rawResponse)
|
|
{
|
|
$confidence = 0.0;
|
|
$factors = [];
|
|
|
|
// Factor 1: JSON parsing success (Weight: 40%)
|
|
// If we are here, the JSON generally parsed, but we check if the structure is valid.
|
|
$factors['parse_success'] = 0.4;
|
|
|
|
// Factor 2: Response completeness (Weight: 30%)
|
|
// Check if we have a tool name and some arguments.
|
|
$hasRequiredFields = !empty($intentJSON['tool']) && !empty($intentJSON['arguments']);
|
|
$factors['completeness'] = $hasRequiredFields ? 0.3 : 0.0;
|
|
|
|
// Factor 3: Schema validation (Weight: 20%)
|
|
$isValidSchema = false;
|
|
|
|
// Ensure the tool exists in our known schema
|
|
if (isset($intentJSON['tool']) && isset($toolsSchema[$intentJSON['tool']])) {
|
|
// Support both 'parameters' and 'inputSchema'
|
|
$schema = $toolsSchema[$intentJSON['tool']]['parameters']
|
|
?? $toolsSchema[$intentJSON['tool']]['inputSchema']
|
|
?? [];
|
|
|
|
// Extract parameters provided by the AI
|
|
$providedParams = array_keys($intentJSON['arguments'] ?? []);
|
|
|
|
// Standard JSON Schema structure uses 'properties' to list params and 'required' to list mandatory ones.
|
|
$properties = $schema['properties'] ?? [];
|
|
$requiredList = $schema['required'] ?? [];
|
|
|
|
$missingParams = [];
|
|
|
|
// Iterate through the schema properties to check required fields
|
|
foreach ($properties as $paramKey => $paramDetails) {
|
|
// Check if this specific parameter is marked as required in the schema
|
|
if (in_array($paramKey, $requiredList)) {
|
|
// If it is required but not in the AI's provided arguments, it's missing.
|
|
if (!in_array($paramKey, $providedParams)) {
|
|
$missingParams[] = $paramKey;
|
|
}
|
|
}
|
|
}
|
|
|
|
// If no required parameters are missing, schema validation passes.
|
|
$isValidSchema = empty($missingParams);
|
|
}
|
|
|
|
$factors['schema_validation'] = $isValidSchema ? 0.2 : 0.0;
|
|
|
|
// Factor 4: Response quality (Weight: 10%)
|
|
$qualityScore = 0.0;
|
|
if (is_string($rawResponse)) {
|
|
// Check for error indicators in the raw text (e.g., "I'm sorry", "Error")
|
|
if (!preg_match('/error|fail|unable|cannot|sorry/i', $rawResponse)) {
|
|
$qualityScore += 0.05;
|
|
}
|
|
|
|
// Verify the tool actually exists in our registry (double check)
|
|
if (isset($intentJSON['tool']) && isset($toolsSchema[$intentJSON['tool']])) {
|
|
$qualityScore += 0.05;
|
|
}
|
|
}
|
|
$factors['response_quality'] = $qualityScore;
|
|
|
|
// Calculate Total Confidence
|
|
$confidence = array_sum($factors);
|
|
|
|
// Ensure confidence stays within bounds [0, 1]
|
|
return max(0.0, min(1.0, $confidence));
|
|
}
|
|
|
|
/**
|
|
* Format arguments for display in confirmation
|
|
*
|
|
* @param array<string, mixed> $arguments The arguments to format (Key=ParamName, Value=Value)
|
|
* @return string Formatted arguments string
|
|
*/
|
|
function formatArgumentsForDisplay($arguments)
|
|
{
|
|
$formattedArgs = [];
|
|
foreach ($arguments as $key => $value) {
|
|
if (is_array($value)) {
|
|
$formattedArgs[] = "- {$key}: " . (empty($value) ? "(empty)" : json_encode($value, JSON_PRETTY_PRINT));
|
|
} else {
|
|
$formattedArgs[] = "- {$key}: {$value}";
|
|
}
|
|
}
|
|
return implode("\n", $formattedArgs);
|
|
}
|
|
|
|
/**
|
|
* Extract action from tool name
|
|
*
|
|
* @param string $toolName The tool name
|
|
* @return string The extracted action
|
|
*/
|
|
function extractActionFromTool($toolName)
|
|
{
|
|
if (preg_match('/^(create|update|delete|list|show|find|search|get|view|validate|send)/i', $toolName, $matches)) {
|
|
return strtolower($matches[1]);
|
|
}
|
|
return 'perform this action';
|
|
}
|