dolibarr 23.0.3
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 all HTML content inside a section tag. Do not add explanation.';
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_REPHRASER = 'You are a writer, give only one answer with no comment and explanation and give the answer in the same language than the original text to rephrase.';
62 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.';
63
70 public function __construct($db)
71 {
72 $this->db = $db;
73
74 // Get API key according to enabled AI
75 $this->apiService = getDolGlobalString('AI_API_SERVICE', 'chatgpt');
76 $this->apiKey = getDolGlobalString('AI_API_'.strtoupper($this->apiService).'_KEY');
77 }
78
88 public function generateContent($instructions, $model = 'auto', $function = 'textgeneration', $format = '')
89 {
90 global $dolibarr_main_data_root;
91
92 $arrayofai = getListOfAIServices();
93
94 // TODO Can store the need for a key into array returned by getListOfAIServices()
95 if (empty($this->apiKey) && in_array($this->apiService, array('chatgpt', 'groq', 'mistral'))) {
96 return array('error' => true, 'message' => 'API key is not defined for the AI enabled service ('.$this->apiService.')');
97 }
98
99 // $this->apiEndpoint is already set here only if it was previously forced.
100
101 if (empty($this->apiEndpoint) && $this->apiService == 'custom' && !getDolGlobalString('AI_API_CUSTOM_URL')) {
102 return array('error' => true, 'message' => 'API URL is not defined for the AI enabled service ('.$this->apiService.')');
103 }
104
105 // In most cases, it is empty and we must get it from $function and $this->apiService
106 if (empty($this->apiEndpoint)) {
107 // Return the endpoint from $this->apiService.
108 if ($function == 'imagegeneration') {
109 $this->apiEndpoint = getDolGlobalString('AI_API_'.strtoupper($this->apiService).'_URL', $arrayofai[$this->apiService]['url']);
110 $this->apiEndpoint .= (preg_match('/\/$/', $this->apiEndpoint) ? '' : '/').'images/generations';
111 } elseif ($function == 'audiogeneration') {
112 $this->apiEndpoint = getDolGlobalString('AI_API_'.strtoupper($this->apiService).'_URL', $arrayofai[$this->apiService]['url']);
113 $this->apiEndpoint .= (preg_match('/\/$/', $this->apiEndpoint) ? '' : '/').'audio/speech';
114 } elseif ($function == 'transcription') {
115 $this->apiEndpoint = getDolGlobalString('AI_API_'.strtoupper($this->apiService).'_URL', $arrayofai[$this->apiService]['url']);
116 $this->apiEndpoint .= (preg_match('/\/$/', $this->apiEndpoint) ? '' : '/').'transcriptions';
117 } else {
118 $this->apiEndpoint = getDolGlobalString('AI_API_'.strtoupper($this->apiService).'_URL', $arrayofai[$this->apiService]['url']);
119 $this->apiEndpoint .= (preg_match('/\/$/', $this->apiEndpoint) ? '' : '/').'chat/completions';
120 }
121 }
122
123 // $model may be undefined or 'auto'.
124 // If this is the case, we must get it from $function and $this->apiService
125 if (empty($model) || $model == 'auto') {
126 // Return the model from $this->apiService.
127 if ($function == 'imagegeneration') {
128 $model = getDolGlobalString('AI_API_'.strtoupper($this->apiService).'_MODEL_IMAGE', $arrayofai[$this->apiService][$function]);
129 } elseif ($function == 'audiogeneration') {
130 $model = getDolGlobalString('AI_API_'.strtoupper($this->apiService).'_MODEL_AUDIO', $arrayofai[$this->apiService][$function]);
131 } elseif ($function == 'transcription') {
132 $model = getDolGlobalString('AI_API_'.strtoupper($this->apiService).'_MODEL_TRANSCRIPT', $arrayofai[$this->apiService][$function]);
133 } elseif ($function == 'translation') {
134 $model = getDolGlobalString('AI_API_'.strtoupper($this->apiService).'_MODEL_TRANSLATE', $arrayofai[$this->apiService][$function]);
135 } else {
136 // else 'textgenerationemail', 'textgenerationwebpage', 'textgeneration', 'texttranslation', 'textsummarize'
137 $model = getDolGlobalString('AI_API_'.strtoupper($this->apiService).'_MODEL_TEXT', $arrayofai[$this->apiService]['textgeneration']);
138 }
139 }
140
141 dol_syslog("Call API for apiKey=".substr($this->apiKey, 0, 5).'***********, apiEndpoint='.$this->apiEndpoint.", model=".$model);
142
143 $response = null;
144
145 try {
146 if (empty($this->apiEndpoint)) {
147 throw new Exception('The AI service '.$this->apiService.' is not yet supported for the type of request '.$function);
148 }
149
150 $configurationsJson = getDolGlobalString('AI_CONFIGURATIONS_PROMPT');
151 $configurations = json_decode($configurationsJson, true);
152
153 $prePrompt = '';
154 $postPrompt = '';
155
156 if (isset($configurations[$function])) {
157 if (isset($configurations[$function]['prePrompt'])) {
158 $prePrompt = $configurations[$function]['prePrompt'];
159 }
160
161 if (isset($configurations[$function]['postPrompt'])) {
162 $postPrompt = $configurations[$function]['postPrompt'];
163 }
164 }
165
166 // Get the default value of prePrompt if not defined
167 if (empty($prePrompt) && $function == 'textgenerationemail') {
168 $prePrompt = self::AI_DEFAULT_PROMPT_FOR_EMAIL;
169 }
170 if (empty($prePrompt) && $function == 'textgenerationwebpage') {
171 $prePrompt = self::AI_DEFAULT_PROMPT_FOR_WEBPAGE;
172 }
173 if (empty($prePrompt) && $function == 'textgenerationextrafield') {
174 $prePrompt = self::AI_DEFAULT_PROMPT_FOR_EXTRAFIELD_FILLER;
175 }
176 if (empty($prePrompt) && $function == 'texttranslation') {
177 $prePrompt = self::AI_DEFAULT_PROMPT_FOR_TEXT_TRANSLATION;
178 }
179 if (empty($prePrompt) && $function == 'textsummarize') {
180 $prePrompt = self::AI_DEFAULT_PROMPT_FOR_TEXT_SUMMARIZE;
181 }
182 if (empty($prePrompt) && $function == 'textrephraser') {
183 $prePrompt = self::AI_DEFAULT_PROMPT_FOR_TEXT_REPHRASER;
184 }
185
186 $fullInstructions = $instructions.($postPrompt ? (preg_match('/[\.\!\?]$/', $instructions) ? '' : '.').' '.$postPrompt : '');
187
188 // Set payload string
189 /*{
190 "messages": [
191 {
192 "content": "You are a helpful assistant.",
193 "role": "system"
194 },
195 {
196 "content": "Hello!",
197 "role": "user"
198 }
199 ],
200 "model": "tinyllama-1.1b",
201 "stream": true,
202 "max_tokens": 2048,
203 "stop": [
204 "hello"
205 ],
206 "frequency_penalty": 0,
207 "presence_penalty": 0,
208 "temperature": 0.7,
209 "top_p": 0.95
210 }*/
211
212 $arrayforpayload = array(
213 'messages' => array(array('role' => 'user', 'content' => $fullInstructions)),
214 'model' => $model,
215 );
216
217 // Add a system message
218 $addDateTimeContext = false;
219 if ($addDateTimeContext) { // @phpstan-ignore-line
220 $prePrompt = ($prePrompt ? $prePrompt.(preg_match('/[\.\!\?]$/', $prePrompt) ? '' : '.').' ' : '').'Today we are '.dol_print_date(dol_now(), 'dayhourtext');
221 }
222 if ($prePrompt) {
223 $arrayforpayload['messages'][] = array('role' => 'system', 'content' => $prePrompt);
224 }
225
226 /*
227 $arrayforpayload['temperature'] = 0.7;
228 $arrayforpayload['max_tokens'] = -1;
229 $arrayforpayload['stream'] = false;
230 */
231
232 $payload = json_encode($arrayforpayload);
233
234 $headers = array(
235 'Authorization: Bearer ' . $this->apiKey,
236 'Content-Type: application/json'
237 );
238
239 if (getDolGlobalString("AI_DEBUG")) {
240 if (@is_writable($dolibarr_main_data_root)) { // Avoid fatal error on fopen with open_basedir
241 $outputfile = $dolibarr_main_data_root."/dolibarr_ai.log";
242 $fp = fopen($outputfile, "w"); // overwrite
243
244 if ($fp) {
245 fwrite($fp, "Call endpoint ".$this->apiEndpoint." with POST and the following HTTP headers and Payload:\n");
246 fwrite($fp, var_export($headers, true)."\n");
247 fwrite($fp, var_export($payload, true)."\n");
248
249 fclose($fp);
250 dolChmod($outputfile);
251 }
252 }
253 }
254
255 // By default, we accept only external endpoints ($dolibarr_ai_allow_local_endpoints is not set).
256 // To allow local endpoints, we must set $dolibarr_ai_allow_local_endpoints to 1 or 2 in conf.php.
257 global $dolibarr_ai_allow_local_endpoints;
258 $localurl = $dolibarr_ai_allow_local_endpoints ?? 0;
259
260 $response = getURLContent($this->apiEndpoint, 'POST', $payload, 1, $headers, array('http', 'https'), $localurl);
261
262 if (empty($response['http_code'])) {
263 throw new Exception('API request failed. No http received');
264 }
265 if (!empty($response['http_code']) && $response['http_code'] != 200) {
266 if (in_array($response['http_code'], array(400, 401, 403, 429)) && !empty($response['content'])) {
267 $tmp = json_decode($response['content'], true);
268 if (!empty($tmp['message'])) {
269 return array(
270 'error' => true,
271 'message' => $tmp['message'],
272 'code' => (empty($response['http_code']) ? 0 : $response['http_code']),
273 'curl_error_no' => (empty($response['curl_error_no']) ? 0 : $response['curl_error_no']),
274 'format' => $format,
275 'service' => $this->apiService,
276 'function' => $function
277 );
278 }
279 }
280 throw new Exception('API request on AI endpoint '.$this->apiEndpoint.' failed with status code '.$response['http_code']);
281 }
282
283 if (getDolGlobalString("AI_DEBUG")) {
284 if (@is_writable($dolibarr_main_data_root)) { // Avoid fatal error on fopen with open_basedir
285 $outputfile = $dolibarr_main_data_root."/dolibarr_ai.log";
286 $fp = fopen($outputfile, "a");
287
288 if ($fp) {
289 fwrite($fp, var_export((empty($response['content']) ? 'No content result' : $response['content']), true)."\n");
290
291 fclose($fp);
292 dolChmod($outputfile);
293 }
294 }
295 }
296
297
298 // Decode JSON response
299 $decodedResponse = json_decode($response['content'], true);
300
301 // Extraction content
302 if (!empty($decodedResponse['error'])) {
303 if (is_scalar($decodedResponse['error'])) {
304 $generatedContent = $decodedResponse['error'];
305 } else {
306 $generatedContent = var_export($decodedResponse['error'], true);
307 }
308 } else {
309 $generatedContent = $decodedResponse['choices'][0]['message']['content'];
310 }
311 dol_syslog("ai->generatedContent returned: ".dol_trunc($generatedContent, 50));
312
313 // If content is not HTML, we convert it into HTML
314 if ($format == 'html') {
315 if (!dol_textishtml($generatedContent)) {
316 dol_syslog("Result was detected as not HTML so we convert it into HTML.");
317 $generatedContent = dol_nl2br($generatedContent);
318 } else {
319 dol_syslog("Result was detected as already HTML. Do nothing.");
320 }
321
322 // TODO If content is for website module, we must
323 // - clan html header, keep body only and remove ``` ticks added by AI
324 // - add tags <section contenEditable="true"> </section>
325 }
326
327 return $generatedContent;
328 } catch (Exception $e) {
329 $errormessage = $e->getMessage();
330 $errormessagelog = $e->getMessage();
331 if (!empty($response['content'])) {
332 $decodedResponse = json_decode($response['content'], true);
333 $errormessagelog .= ' - '.$response['content'];
334
335 if (!empty($decodedResponse['error']['message'])) {
336 // With OpenAI, error is into an object error into the content
337 $errormessage .= ' - '.$decodedResponse['error']['message'];
338 } else {
339 $errormessage .= ' - '.$response['content'];
340 }
341 }
342
343 if (getDolGlobalString("AI_DEBUG")) {
344 if (@is_writable($dolibarr_main_data_root)) { // Avoid fatal error on fopen with open_basedir
345 $outputfile = $dolibarr_main_data_root."/dolibarr_ai.log";
346 $fp = fopen($outputfile, "a");
347
348 if ($fp) {
349 fwrite($fp, "Error: ".$errormessagelog."\n");
350
351 fclose($fp);
352 dolChmod($outputfile);
353 }
354 }
355 }
356
357 return array(
358 'error' => true,
359 'message' => $errormessage,
360 'code' => (empty($response['http_code']) ? 0 : $response['http_code']),
361 'curl_error_no' => (empty($response['curl_error_no']) ? 0 : $response['curl_error_no']),
362 'format' => $format,
363 'service' => $this->apiService,
364 'function' => $function
365 );
366 }
367 }
368}
getListOfAIServices()
Get list of available ai services.
Definition ai.lib.php:64
Class for AI.
Definition ai.class.php:36
generateContent($instructions, $model='auto', $function='textgeneration', $format='')
Generate response of instructions.
Definition ai.class.php:88
__construct($db)
Constructor.
Definition ai.class.php:70
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())
Function to get a content from an URL (use proxy if proxy defined).