dolibarr 24.0.0-beta
ai.class.php
Go to the documentation of this file.
1<?php
2/* Copyright (C) 2024 Laurent Destailleur <eldy@users.sourceforge.net>
3 * Copyright (C) 2024 Frédéric France <frederic.france@free.fr>
4 * Copyright (C) 2024 MDW <mdeweerd@users.noreply.github.com>
5 *
6 * This program is free software; you can redistribute it and/or modify
7 * it under the terms of the GNU General Public License as published by
8 * the Free Software Foundation; either version 3 of the License, or
9 * (at your option) any later version.
10 *
11 * This program is distributed in the hope that it will be useful,
12 * but WITHOUT ANY WARRANTY; without even the implied warranty of
13 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 * GNU General Public License for more details.
15 *
16 * You should have received a copy of the GNU General Public License
17 * along with this program. If not, see <https://www.gnu.org/licenses/>.
18 * or see https://www.gnu.org/
19 */
20
27require_once DOL_DOCUMENT_ROOT."/core/lib/admin.lib.php";
28require_once DOL_DOCUMENT_ROOT.'/core/lib/geturl.lib.php';
29require_once DOL_DOCUMENT_ROOT."/ai/lib/ai.lib.php";
30
31
35class Ai
36{
40 protected $db;
41
45 private $apiService;
46
50 private $apiKey;
51
55 private $apiEndpoint;
56
57 const AI_DEFAULT_PROMPT_FOR_EMAIL = 'You are an email editor. Return only the content of the message. Do not add explanation.'; // Note: This instruction will also be completed by generateContent() to manage text versus HTML content.
58 const AI_DEFAULT_PROMPT_FOR_WEBPAGE = 'You are a website editor. Return all HTML content inside a section tag. Do not add explanation.';
59 const AI_DEFAULT_PROMPT_FOR_TEXT_TRANSLATION = 'You are a translator, answer with one and only one translation with no comment and explanation.';
60 const AI_DEFAULT_PROMPT_FOR_TEXT_SUMMARIZE = 'You are a writer, make the answer in the same language than the original text to summarize.';
61 const AI_DEFAULT_PROMPT_FOR_TEXT_SPELLCHECKER = 'You are a proofreader, write your response in the same language as the original text in order to correct spelling and grammar errors. If there is carriage return or line feed in original message, keep them. Keep also any HTML or markdown formatting without adding one, just fix spelling and grammar errors. Answer with the corrected text and only the corrected text with no comment and explanation.';
62 const AI_DEFAULT_PROMPT_FOR_TEXT_REPHRASER = 'You are a writer, write your response in the same language as the original text to rephrase. Give only one answer with no comment and explanation. If there is carriage return or line feed in original message, keep them. Keep also any HTML or markdown formatting without adding one.';
63 const AI_DEFAULT_PROMPT_FOR_EXTRAFIELD_FILLER = 'Give only one answer with no comment and explanation, I want the text to be ready to copy and paste.';
64 const AI_DEFAULT_PROMPT_FOR_DOC_PARSING = 'You are an assistant to analyze documents. Return your answer with a JSON string and only a JSON string, do not add any other comment.';
65
66
73 public function __construct($db)
74 {
75 $this->db = $db;
76
77 // Get API key according to enabled AI
78 $this->apiService = getDolGlobalString('AI_API_SERVICE', 'chatgpt');
79 $this->apiKey = getDolGlobalString('AI_API_'.strtoupper($this->apiService).'_KEY');
80 }
81
87 public function getApiService()
88 {
89 return $this->apiService;
90 }
91
103 public function generateContent($instructions, $model = 'auto', $function = 'textgeneration', $format = '', $moreheaders = array(), $moreendpoint = '')
104 {
105 global $dolibarr_main_data_root;
106
107 $arrayofai = getListOfAIServices();
108
109 // TODO Can store the need for a key into array returned by getListOfAIServices()
110 if (empty($this->apiKey) && in_array($this->apiService, array('chatgpt', 'groq', 'mistral'))) {
111 return array('error' => true, 'message' => 'API key is not defined for the AI enabled service ('.$this->apiService.')');
112 }
113
114 // $this->apiEndpoint is already set here only if it was previously forced.
115
116 if (empty($this->apiEndpoint) && $this->apiService == 'custom' && !getDolGlobalString('AI_API_CUSTOM_URL')) {
117 return array('error' => true, 'message' => 'API URL is not defined for the AI enabled service ('.$this->apiService.')');
118 }
119
120 // In most cases, it is empty and we must get it from $function and $this->apiService
121 if (empty($this->apiEndpoint)) {
122 // Return the endpoint from $this->apiService.
123 if ($function == 'imagegeneration') {
124 $this->apiEndpoint = getDolGlobalString('AI_API_'.strtoupper($this->apiService).'_URL', $arrayofai[$this->apiService]['url']);
125 $this->apiEndpoint .= (preg_match('/\/$/', $this->apiEndpoint) ? '' : '/').'images/generations';
126 } elseif ($function == 'audiogeneration') {
127 $this->apiEndpoint = getDolGlobalString('AI_API_'.strtoupper($this->apiService).'_URL', $arrayofai[$this->apiService]['url']);
128 $this->apiEndpoint .= (preg_match('/\/$/', $this->apiEndpoint) ? '' : '/').'audio/speech';
129 } elseif ($function == 'transcription') {
130 $this->apiEndpoint = getDolGlobalString('AI_API_'.strtoupper($this->apiService).'_URL', $arrayofai[$this->apiService]['url']);
131 $this->apiEndpoint .= (preg_match('/\/$/', $this->apiEndpoint) ? '' : '/').'transcriptions';
132 } elseif ($function == 'file') {
133 $this->apiEndpoint = getDolGlobalString('AI_API_'.strtoupper($this->apiService).'_URL', $arrayofai[$this->apiService]['url']);
134 $this->apiEndpoint .= (preg_match('/\/$/', $this->apiEndpoint) ? '' : '/').'files';
135 } elseif ($function == 'assistant') {
136 $this->apiEndpoint = getDolGlobalString('AI_API_'.strtoupper($this->apiService).'_URL', $arrayofai[$this->apiService]['url']);
137 $this->apiEndpoint .= (preg_match('/\/$/', $this->apiEndpoint) ? '' : '/').'assistans';
138 } elseif ($function == 'thread') {
139 $this->apiEndpoint = getDolGlobalString('AI_API_'.strtoupper($this->apiService).'_URL', $arrayofai[$this->apiService]['url']);
140 $this->apiEndpoint .= (preg_match('/\/$/', $this->apiEndpoint) ? '' : '/').'threads';
141 } else { // if $function == 'docparsing', 'text...', ...
142 $this->apiEndpoint = getDolGlobalString('AI_API_'.strtoupper($this->apiService).'_URL', $arrayofai[$this->apiService]['url']);
143 if ($this->apiService == 'google') {
144 // Google Gemini native API: the /models/<model>:generateContent suffix is
145 // appended later (once $model has been resolved). The OpenAI-style
146 // /chat/completions does not exist on the native Gemini endpoint.
147 $this->apiEndpoint = rtrim($this->apiEndpoint, '/');
148 } else {
149 $this->apiEndpoint .= (preg_match('/\/$/', $this->apiEndpoint) ? '' : '/').'chat/completions';
150 }
151 }
152 }
153 if ($moreendpoint) {
154 $this->apiEndpoint .= '/'.$moreendpoint;
155 }
156
157
158 // $model may be undefined or 'auto'.
159 // If this is the case, we must get it from $function and $this->apiService
160 if (empty($model) || $model == 'auto') {
161 // Return the model from $this->apiService.
162 if (in_array($function, array('file', 'assistant', 'thread'))) {
163 $model = '';
164 } elseif ($function == 'imagegeneration') {
165 $model = getDolGlobalString('AI_API_'.strtoupper($this->apiService).'_MODEL_IMAGE', $arrayofai[$this->apiService][$function]['default']);
166 } elseif ($function == 'audiogeneration') {
167 $model = getDolGlobalString('AI_API_'.strtoupper($this->apiService).'_MODEL_AUDIO', $arrayofai[$this->apiService][$function]['default']);
168 } elseif ($function == 'transcription') {
169 $model = getDolGlobalString('AI_API_'.strtoupper($this->apiService).'_MODEL_TRANSCRIPT', $arrayofai[$this->apiService][$function]['default']);
170 } elseif ($function == 'translation') {
171 $model = getDolGlobalString('AI_API_'.strtoupper($this->apiService).'_MODEL_TRANSLATE', $arrayofai[$this->apiService][$function]['default']);
172 } elseif ($function == 'docparsing') {
173 $model = getDolGlobalString('AI_API_'.strtoupper($this->apiService).'_MODEL_DOCPARSING', $arrayofai[$this->apiService][$function]['default']);
174 } else {
175 // else 'textgenerationemail', 'textgenerationwebpage', 'textgeneration', 'texttranslation', 'textsummarize', 'textrephraser', 'textspellchecker', ...
176 $model = getDolGlobalString('AI_API_'.strtoupper($this->apiService).'_MODEL_TEXT', $arrayofai[$this->apiService]['textgeneration']['default']);
177 }
178 }
179
180 // Google Gemini: append /models/<model>:generateContent now that $model is resolved.
181 if ($this->apiService == 'google' && !in_array($function, array('file', 'assistant', 'thread'))
182 && strpos($this->apiEndpoint, ':generateContent') === false) {
183 $this->apiEndpoint .= '/models/'.rawurlencode($model).':generateContent';
184 }
185
186 dol_syslog("Call API for apiKey=".substr($this->apiKey, 0, 5).'***********, apiEndpoint='.$this->apiEndpoint.", model=".$model.", format=".$format);
187 if (getDolGlobalString("AI_DEBUG")) {
188 if (@is_writable($dolibarr_main_data_root)) { // Avoid fatal error on fopen with open_basedir
189 $outputfile = $dolibarr_main_data_root."/dolibarr_ai.log";
190 $fp = fopen($outputfile, "w"); // overwrite
191
192 if ($fp) {
193 fwrite($fp, "Call API for apiKey=".substr($this->apiKey, 0, 5).'***********, apiEndpoint='.$this->apiEndpoint.", model=".$model.", format=".$format."\n");
194 fclose($fp);
195 dolChmod($outputfile);
196 }
197 }
198 }
199
200 $response = null;
201
202 try {
203 if (empty($this->apiEndpoint)) {
204 throw new Exception('The AI service '.$this->apiService.' is not yet supported for the type of request '.$function);
205 }
206
207 $configurationsJson = getDolGlobalString('AI_CONFIGURATIONS_PROMPT');
208 $configurations = json_decode($configurationsJson, true);
209
210 $prePrompt = '';
211 $postPrompt = '';
212
213 if (isset($configurations[$function])) {
214 if (isset($configurations[$function]['prePrompt'])) {
215 $prePrompt = $configurations[$function]['prePrompt'];
216 }
217
218 if (isset($configurations[$function]['postPrompt'])) {
219 $postPrompt = $configurations[$function]['postPrompt'];
220 }
221 }
222 //var_dump($prePrompt);
223
224 // Get the default value of prePrompt if not defined
225 if (empty($prePrompt) && $function == 'textgenerationemail') {
226 $prePrompt = self::AI_DEFAULT_PROMPT_FOR_EMAIL;
227 if ($format === 'html') {
228 $prePrompt .= ' Return all HTML content inside a section tag';
229 } else {
230 $prePrompt .= ' Return content in UTF8 text. Use Linux carriage return if you need to split a line. Do not include any HTML tag neither HTML entities.';
231 }
232 }
233 if (empty($prePrompt) && $function == 'textgenerationwebpage') {
234 $prePrompt = self::AI_DEFAULT_PROMPT_FOR_WEBPAGE;
235 }
236 if (empty($prePrompt) && $function == 'textgenerationextrafield') {
237 $prePrompt = self::AI_DEFAULT_PROMPT_FOR_EXTRAFIELD_FILLER;
238 }
239 if (empty($prePrompt) && $function == 'texttranslation') {
240 $prePrompt = self::AI_DEFAULT_PROMPT_FOR_TEXT_TRANSLATION;
241 }
242 if (empty($prePrompt) && $function == 'textsummarize') {
243 $prePrompt = self::AI_DEFAULT_PROMPT_FOR_TEXT_SUMMARIZE;
244 }
245 if (empty($prePrompt) && $function == 'textrephraser') {
246 $prePrompt = self::AI_DEFAULT_PROMPT_FOR_TEXT_REPHRASER;
247 }
248 if (empty($prePrompt) && $function == 'textspellchecker') {
249 $prePrompt = self::AI_DEFAULT_PROMPT_FOR_TEXT_SPELLCHECKER;
250 }
251 if (empty($prePrompt) && $function == 'docparsing') {
252 $prePrompt = self::AI_DEFAULT_PROMPT_FOR_DOC_PARSING;
253 }
254
255 if (is_array($instructions)) {
256 $arrayforpayload = $instructions;
257 $fullInstructions = '';
258 } else {
259 $fullInstructions = $instructions.($postPrompt ? (preg_match('/[\.\!\?]$/', $instructions) ? '' : '.').' '.$postPrompt : '');
260
261 // Set payload string
262 /*{
263 "messages": [
264 {
265 "content": "You are a helpful assistant.",
266 "role": "system"
267 },
268 {
269 "content": "Hello!",
270 "role": "user"
271 }
272 ],
273 "model": "tinyllama-1.1b",
274 "stream": true,
275 "max_tokens": 2048,
276 "stop": [
277 "hello"
278 ],
279 "frequency_penalty": 0,
280 "presence_penalty": 0,
281 "temperature": 0.7,
282 "top_p": 0.95
283 }*/
284
285 // Add a system message
286 $addDateTimeContext = false;
287 if ($addDateTimeContext) { // @phpstan-ignore-line
288 $prePrompt = ($prePrompt ? $prePrompt.(preg_match('/[\.\!\?]$/', $prePrompt) ? '' : '.').' ' : '').'Today we are '.dol_print_date(dol_now(), 'dayhourtext');
289 }
290
291 if ($this->apiService == 'google') {
292 // Google Gemini native payload format (different from OpenAI's "messages").
293 $arrayforpayload = array(
294 'contents' => array(
295 array('role' => 'user', 'parts' => array(array('text' => $fullInstructions)))
296 )
297 );
298 if ($prePrompt) {
299 $arrayforpayload['system_instruction'] = array(
300 'parts' => array(array('text' => $prePrompt))
301 );
302 }
303 } else {
304 // OpenAI-compatible payload format (chatgpt, mistral, groq, anthropic-compat, custom, ...)
305 $arrayforpayload = array(
306 'messages' => array(array('role' => 'user', 'content' => $fullInstructions)),
307 'model' => $model,
308 );
309 if ($prePrompt) {
310 $arrayforpayload['messages'][] = array('role' => 'system', 'content' => $prePrompt);
311 }
312 }
313 }
314
315 /*
316 $arrayforpayload['temperature'] = 0.7;
317 $arrayforpayload['max_tokens'] = -1;
318 $arrayforpayload['stream'] = false;
319 */
320
321 if ($function == 'thread') {
322 $payload = $instructions;
323 } else {
324 $payload = json_encode($arrayforpayload);
325 }
326
327 if ($this->apiService == 'google') {
328 // Google Gemini uses the x-goog-api-key header (Bearer is not accepted by the native API).
329 $headers = array(
330 'x-goog-api-key: ' . $this->apiKey,
331 );
332 } else {
333 $headers = array(
334 'Authorization: Bearer ' . $this->apiKey,
335 );
336 }
337 if ($function != 'file') {
338 $headers[] = 'Content-Type: application/json';
339 }
340 if (!empty($moreheaders)) {
341 foreach ($moreheaders as $morekey => $moreval) {
342 $headers[] = $morekey.': '.$moreval;
343 }
344 }
345
346 if (getDolGlobalString("AI_DEBUG")) {
347 if (@is_writable($dolibarr_main_data_root)) { // Avoid fatal error on fopen with open_basedir
348 $outputfile = $dolibarr_main_data_root."/dolibarr_ai.log";
349 $fp = fopen($outputfile, "a");
350
351 if ($fp) {
352 if ($function == 'docparsing') {
353 fwrite($fp, "Call endpoint ".$this->apiEndpoint." with POST and the following file to upload:\n");
354 fwrite($fp, $instructions."\n");
355 } else {
356 fwrite($fp, "Call endpoint ".$this->apiEndpoint." with POST and the following message:\n");
357 fwrite($fp, $fullInstructions."\n");
358 fwrite($fp, "And prepompt:\n");
359 fwrite($fp, $prePrompt."\n");
360 }
361 fwrite($fp, "HTTP Header\n");
362 fwrite($fp, var_export($headers, true)."\n");
363 fwrite($fp, "Payload\n");
364 fwrite($fp, var_export($payload, true)."\n");
365
366 fclose($fp);
367 dolChmod($outputfile);
368 }
369 }
370 }
371
372 // By default, we accept only external endpoints ($dolibarr_ai_allow_local_endpoints is not set).
373 // To allow local endpoints, we must set $dolibarr_ai_allow_local_endpoints to 1 or 2 in conf.php.
374 global $dolibarr_ai_allow_local_endpoints;
375 $localurl = $dolibarr_ai_allow_local_endpoints ?? 0;
376
377 $response = getURLContent($this->apiEndpoint, 'POST', $payload, 1, $headers, array('http', 'https'), $localurl);
378
379 if (empty($response['http_code'])) {
380 throw new Exception('API request failed. No http received');
381 }
382 if (!empty($response['http_code']) && $response['http_code'] != 200) {
383 if (in_array($response['http_code'], array(400, 401, 403, 429)) && !empty($response['content'])) {
384 $tmp = json_decode($response['content'], true);
385 if (!empty($tmp['message'])) {
386 return array(
387 'error' => true,
388 'message' => $tmp['message'],
389 'code' => (empty($response['http_code']) ? 0 : $response['http_code']),
390 'curl_error_no' => (empty($response['curl_error_no']) ? 0 : $response['curl_error_no']),
391 'format' => $format,
392 'service' => $this->apiService,
393 'function' => $function
394 );
395 }
396 }
397 throw new Exception('API request on AI endpoint '.$this->apiEndpoint.' failed with status code '.$response['http_code']);
398 }
399
400 if (getDolGlobalString("AI_DEBUG")) {
401 if (@is_writable($dolibarr_main_data_root)) { // Avoid fatal error on fopen with open_basedir
402 $outputfile = $dolibarr_main_data_root."/dolibarr_ai.log";
403 $fp = fopen($outputfile, "a");
404
405 if ($fp) {
406 fwrite($fp, "Answer\n");
407 fwrite($fp, var_export((empty($response['content']) ? 'No content result' : $response['content']), true)."\n");
408
409 fclose($fp);
410 dolChmod($outputfile);
411 }
412 }
413 }
414
415
416 // Decode JSON response
417 $decodedResponse = json_decode($response['content'], true);
418
419 // Extraction content
420 if (!empty($decodedResponse['error'])) {
421 if (is_scalar($decodedResponse['error'])) {
422 $generatedContent = $decodedResponse['error'];
423 } else {
424 $generatedContent = var_export($decodedResponse['error'], true);
425 }
426 } elseif ($this->apiService == 'google') {
427 // Google Gemini response shape: candidates[0].content.parts[*].text
428 // (parts is an array because Gemini can return mixed-modality output;
429 // we concatenate the textual parts.)
430 $generatedContent = '';
431 if (!empty($decodedResponse['candidates'][0]['content']['parts'])) {
432 foreach ($decodedResponse['candidates'][0]['content']['parts'] as $part) {
433 if (isset($part['text'])) {
434 $generatedContent .= $part['text'];
435 }
436 }
437 }
438 } else {
439 $generatedContent = $decodedResponse['choices'][0]['message']['content'];
440 }
441 dol_syslog("ai->generatedContent returned: ".dol_trunc($generatedContent, 50));
442
443 // If content is not HTML, we convert it into HTML
444 if ($format == 'html') {
445 if (!dol_textishtml($generatedContent)) {
446 dol_syslog("Result was detected as not HTML so we convert it into HTML.");
447 $generatedContent = dol_nl2br($generatedContent);
448 } else {
449 dol_syslog("Result was detected as already HTML. Do nothing.");
450 }
451
452 // TODO If content is for website module, we must
453 // - clan html header, keep body only and remove ``` ticks added by AI
454 // - add tags <section contenEditable="true"> </section>
455 }
456
457 return $generatedContent;
458 } catch (Exception $e) {
459 $errormessage = $e->getMessage();
460 $errormessagelog = $e->getMessage();
461 if (!empty($response['content'])) {
462 $decodedResponse = json_decode($response['content'], true);
463 $errormessagelog .= ' - '.$response['content'];
464
465 if (!empty($decodedResponse['error']['message'])) {
466 // With OpenAI, error is into an object error into the content
467 $errormessage .= ' - '.$decodedResponse['error']['message'];
468 } else {
469 $errormessage .= ' - '.$response['content'];
470 }
471 }
472
473 if (getDolGlobalString("AI_DEBUG")) {
474 if (@is_writable($dolibarr_main_data_root)) { // Avoid fatal error on fopen with open_basedir
475 $outputfile = $dolibarr_main_data_root."/dolibarr_ai.log";
476 $fp = fopen($outputfile, "a");
477
478 if ($fp) {
479 fwrite($fp, "Error: ".$errormessagelog."\n");
480
481 fclose($fp);
482 dolChmod($outputfile);
483 }
484 }
485 }
486
487 return array(
488 'error' => true,
489 'message' => $errormessage,
490 'code' => (empty($response['http_code']) ? 0 : $response['http_code']),
491 'curl_error_no' => (empty($response['curl_error_no']) ? 0 : $response['curl_error_no']),
492 'format' => $format,
493 'service' => $this->apiService,
494 'function' => $function
495 );
496 }
497 }
498}
getListOfAIServices()
Get list of available ai services.
Definition ai.lib.php:68
Class for AI feature.
Definition ai.class.php:36
getApiService()
get API Service
Definition ai.class.php:87
generateContent($instructions, $model='auto', $function='textgeneration', $format='', $moreheaders=array(), $moreendpoint='')
Generate the response of an AI prompt.
Definition ai.class.php:103
__construct($db)
Constructor.
Definition ai.class.php:73
dol_now($mode='gmt')
Return date for now.
dol_nl2br($stringtoencode, $nl2brmode=0, $forxml=false)
Replace CRLF in string with a HTML BR tag.
dolChmod($filepath, $newmask='')
Change mod of a file.
dol_textishtml($msg, $option=0)
Return if a text is a html content.
dol_print_date($time, $format='', $tzoutput='auto', $outputlangs=null, $encodetooutput=false, $decorate=0)
Output date in a string format according to outputlangs (or langs if not defined).
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.
dol_syslog($message, $level=LOG_INFO, $ident=0, $suffixinfilename='', $restricttologhandler='', $logcontext=null)
Write log message into outputs.
getURLContent($url, $postorget='GET', $param='', $followlocation=1, $addheaders=array(), $allowedschemes=array('http', 'https'), $localurl=0, $ssl_verifypeer=-1, $timeoutconnect=0, $timeoutresponse=0, $otherCurlOptions=array(), $morelogsuffix='')
Function to get a content from an URL (use proxy if proxy defined).