16require_once DOL_DOCUMENT_ROOT.
'/core/lib/files.lib.php';
23 private const TABLE_AI_REQUEST_LOG =
'ai_request_log';
61 if (strpos($actionCode,
'hook') !== 0)
return false;
62 return in_array($actionCode, array(
'hook_ai_emailcleaner',
'hookaiemailcleaner'),
true);
74 global
$conf, $dolibarr_main_data_root;
78 $subject = (string) ($parameters[
'subject'] ??
'');
79 $header = (string) ($parameters[
'header'] ??
'');
82 $from = (string) ($parameters[
'from'] ??
'');
91 $entity = (!empty(
$conf->entity) ? (int)
$conf->entity : 1);
92 $isolatedMode = (int)
getDolGlobalInt(
'AI_EMAILCLEANER_ISOLATED_MODE', 1);
93 $thirdpartyId = ($isolatedMode ? 0 : (!empty($parameters[
'thirdpartyid']) ? (int) $parameters[
'thirdpartyid'] : 0));
94 $objectId = ($isolatedMode ? 0 : (!empty($parameters[
'objectid']) ? (int) $parameters[
'objectid'] : 0));
95 $hasAttachments = (!empty($parameters[
'attachments']) && is_array($parameters[
'attachments']));
96 $actionParamRaw = (string) ($parameters[
'actionparam'] ??
'');
98 $contextProfileCode = trim((
string) (!empty($actionParamCfg[
'context_profile_code']) ? $actionParamCfg[
'context_profile_code'] :
''));
99 $contextProfileVersion = trim((
string) (!empty($actionParamCfg[
'context_profile_version']) ? $actionParamCfg[
'context_profile_version'] :
''));
101 if (trim($rawBody) ===
'')
return array();
105 $msgid = sha1($subject.
"\n".$from.
"\n".
dol_substr($rawBody, 0, 4000,
'8bit'));
108 $replyOnly = (string) ($messageScopes[
'reply_only'] ??
'');
109 $quotedContext = (string) ($messageScopes[
'quoted_context'] ??
'');
113 $ancestorMessageIds =
self::normalizeMessageIdList(array_merge($normalizedReferences, ($normalizedInReplyTo !==
'' ? array($normalizedInReplyTo) : array())));
114 $emailContext = array(
115 'threading' => array(
116 'message_id' => ($normalizedMessageId !==
'' ? $normalizedMessageId : $msgid),
117 'in_reply_to' => ($normalizedInReplyTo !==
'' ? $normalizedInReplyTo :
null),
118 'references' => $normalizedReferences,
119 'ancestor_message_ids' => $ancestorMessageIds,
120 'thread_depth' => count($ancestorMessageIds),
121 'thread_key' =>
self::buildThreadKey($normalizedMessageId, $normalizedInReplyTo, $normalizedReferences),
122 'link_type' => ($normalizedInReplyTo !==
'' ?
'direct_reply' : (!empty($normalizedReferences) ?
'referenced_thread' :
'standalone')),
124 'addresses' => array(
126 'to' => ($to !==
'' ? $to :
null),
127 'cc' => ($cc !==
'' ? $cc :
null),
128 'reply_to' => ($replyTo !==
'' ? $replyTo :
null),
131 'date_header' => ($headerDate !==
'' ? $headerDate :
null),
132 'subject' => $subject,
133 'subject_normalized' => self::normalizeText($subject),
134 'collector_id' => $collectorId,
135 'thirdparty_id' => ($thirdpartyId > 0 ? $thirdpartyId :
null),
136 'object_id' => ($objectId > 0 ? $objectId :
null),
137 'has_attachments' => ($hasAttachments ? 1 : 0),
138 'isolated_mode' => ($isolatedMode ? 1 : 0),
140 'body_context' => array(
141 'reply_only' => ($replyOnly !==
'' ? $replyOnly :
null),
142 'quoted_context' => ($quotedContext !==
'' ? $quotedContext :
null),
143 'has_quoted_context' => ($quotedContext !==
'' ? 1 : 0),
144 'quoted_block_count' => count((array) ($messageScopes[
'quoted_blocks'] ?? array())),
145 'quoted_blocks' => (array) ($messageScopes[
'quoted_blocks'] ?? array()),
146 'parsing_warnings' => (array) ($messageScopes[
'parsing_warnings'] ?? array()),
149 'order' =>
'current_then_quoted_newest_to_oldest',
150 'entries' => (array) ($messageScopes[
'timeline'] ?? array()),
154 (isset($parameters[
'attachments']) && is_array($parameters[
'attachments']) ? $parameters[
'attachments'] : array()),
155 (isset($parameters[
'savedattachments']) && is_array($parameters[
'savedattachments']) ? $parameters[
'savedattachments'] : array()),
156 (
string) ($parameters[
'savedattachmentsdir'] ??
'')
158 $hasPdfAttachments =
false;
159 foreach ($attachmentRows as $attRow) {
160 if (!empty($attRow[
'is_pdf'])) {
161 $hasPdfAttachments =
true;
167 $cleanedText = (string) ($cleanResult[
'clean_body'] ??
'');
168 $confidence = (float) ($cleanResult[
'confidence'] ?? 0.0);
169 $segments = (!empty($cleanResult[
'segments']) && is_array($cleanResult[
'segments'])) ? $cleanResult[
'segments'] : array();
170 $emailUnderstanding = (!empty($cleanResult[
'email_understanding']) && is_array($cleanResult[
'email_understanding'])) ? $cleanResult[
'email_understanding'] :
self::buildFallbackEmailUnderstanding($cleanedText);
171 $engine = (string) ($cleanResult[
'engine'] ??
'fallback');
172 $fallbackUsed = !empty($cleanResult[
'fallback_used']);
174 $emailContext[
'body_context'][
'excluded_context_summary'] = $hCleanup;
176 $pdfAttachmentItems = array();
177 if ($hasPdfAttachments) {
178 foreach ($attachmentRows as $attRow) {
179 if (empty($attRow[
'is_pdf']))
continue;
180 $pdfAttachmentItems[] = array(
181 'name' => (
string) ($attRow[
'name'] ??
''),
182 'relative_path' => (
string) ($attRow[
'relative_path'] ??
''),
183 'sha256' => (
string) ($attRow[
'sha256'] ??
''),
184 'detected_doc_type' => self::detectPdfDocTypeFromAttachmentName((
string) ($attRow[
'name'] ??
'')),
185 'linking_target' =>
'actioncomm',
186 'final_object_attachment_pending' => 1,
190 $handoffPayload = array(
191 'handoff_version' =>
'emailcleaner_v2',
193 'subject' => $subject,
195 'date' => ($headerDate !==
'' ? $headerDate :
null),
196 'raw_hash' => hash(
'sha256', $rawBody),
197 'message_id' => $msgid,
199 'relevant_text' => array(
201 'type' =>
'main_content',
202 'text' => $cleanedText,
203 'confidence' => $confidence,
204 'source' =>
'clean_body',
207 'email_understanding' => $emailUnderstanding,
208 'conversation_context' => $emailContext,
209 'supporting_evidence' => array(
210 'segment_count' => count($segments),
211 'fallback_used' => ($fallbackUsed ? 1 : 0),
212 'understanding_source' => ($engine ===
'ai' ?
'ai_structured_preprocess' :
'fallback_clean_text'),
214 'excluded_noise_summary' => $hCleanup,
215 'compliance' => $complianceMeta,
216 'needs_reprocessing' => ($fallbackUsed || $confidence < (float)
getDolGlobalString(
'AI_EMAILCLEANER_MIN_CONFIDENCE',
'0.60') ? 1 : 0),
217 'needs_pdf_linking' => ($hasPdfAttachments ? 1 : 0),
218 'pdf_attachments' => $pdfAttachmentItems,
221 $baseDir = (!empty(
$conf->ai->dir_output) ?
$conf->ai->dir_output : $dolibarr_main_data_root.
'/ai');
222 $storeDir = rtrim($baseDir,
'/').
'/emailcleaner/entity_'.$entity;
225 $safeKey = preg_replace(
'/[^a-zA-Z0-9_\-\.]/',
'_', $msgid);
226 if ($safeKey ===
'' || $safeKey ===
null) $safeKey = sha1($msgid);
231 'collector_id' => $collectorId,
232 'isolated_mode' => ($isolatedMode ? 1 : 0),
233 'message_id' => $msgid,
234 'email_from' => $from,
235 'email_subject' => $subject,
236 'raw_hash' => hash(
'sha256', $rawBody),
237 'clean_hash' => hash(
'sha256', $cleanedText),
238 'raw_body' => $rawBody,
239 'clean_body' => $cleanedText,
240 'segments' => $segments,
241 'cleaning_confidence' => $confidence,
242 'cleaning_model' => ($engine ===
'ai' ?
'auto' :
null),
243 'prompt_code' =>
'email_cleaner_v2',
244 'prompt_version' =>
'2',
245 'context_profile_code' => ($contextProfileCode !==
'' ? $contextProfileCode :
null),
246 'context_profile_version' => ($contextProfileVersion !==
'' ? $contextProfileVersion :
null),
248 'fallback_used' => ($fallbackUsed ? 1 : 0),
249 'email_context' => $emailContext,
250 'email_understanding' => $emailUnderstanding,
251 'compliance' => $complianceMeta,
252 'handoff_payload_json' => $handoffPayload,
257 $payload[
'fk_actioncomm'] = ($actioncommId > 0 ? $actioncommId :
null);
269 ($engine ===
'ai' ?
'auto' :
null),
273 $contextProfileVersion,
279 if ($aiRequestLogId > 0) {
280 $payload[
'ai_request_log_id'] = $aiRequestLogId;
281 $handoffPayload[
'ai_request_log_id'] = $aiRequestLogId;
282 $payload[
'handoff_payload_json'] = $handoffPayload;
283 } elseif ($actioncommId <= 0) {
284 $payload[
'ai_request_log_status'] =
'not_persisted_no_event';
286 $payload[
'ai_request_log_status'] =
'not_persisted_logging_disabled';
288 $payload[
'ai_request_log_status'] =
'not_persisted_storage_unavailable';
291 $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
292 if ($json !==
false) {
294 dol_syslog(__METHOD__.
": save email cleaner output into ".$file, LOG_DEBUG);
295 @file_put_contents($file, $json);
296 $payload[
'file'] = $file;
311 $msgid = trim((
string) $msgid);
312 if ($msgid ===
'')
return 0;
315 $sql .=
" FROM ".MAIN_DB_PREFIX.
"actioncomm";
316 $sql .=
" WHERE entity = ".((int) $entity);
317 $sql .=
" AND email_msgid = '".$this->db->escape($msgid).
"'";
318 $sql .=
" ORDER BY id DESC";
319 $sql .= $this->db->plimit(1);
321 $resql = $this->db->query($sql);
322 if (!$resql)
return 0;
323 $obj = $this->db->fetch_object($resql);
324 $this->db->free($resql);
326 return ($obj && !empty($obj->id) ? (
int) $obj->id : 0);
352 private function insertAiRequestLogRow($entity, $actioncommId, $collectorId, $msgid, $rawBody, $cleanedText, $segments, $confidence, $engine, $model, $promptCode, $promptVersion, $contextProfileCode, $contextProfileVersion, $emailContext, $emailUnderstanding, $handoffPayload, $payload)
356 if ($actioncommId <= 0)
return 0;
360 require_once DOL_DOCUMENT_ROOT.
'/ai/lib/ai.lib.php';
362 $inputMetadata = array(
363 'source' =>
'emailcollector',
364 'collector_id' => (
int) $collectorId,
365 'message_id' => (
string) $msgid,
366 'raw_hash' => hash(
'sha256', (
string) $rawBody),
367 'clean_hash' => hash(
'sha256', (
string) $cleanedText),
368 'prompt_code' => (
string) $promptCode,
369 'prompt_version' => (
string) $promptVersion,
370 'context_profile_code' => ($contextProfileCode !==
'' ? (
string) $contextProfileCode :
null),
371 'context_profile_version' => ($contextProfileVersion !==
'' ? (
string) $contextProfileVersion :
null),
374 'clean_body' => $cleanedText,
375 'segments' => (is_array($segments) ? $segments : array()),
376 'cleaning_confidence' => (
float) $confidence,
377 'cleaning_model' => ($model !==
null && $model !==
'' ? (string) $model :
null),
378 'engine' => (
string) $engine,
379 'collector_id' => (int) $collectorId,
380 'message_id' => (
string) $msgid,
381 'context_profile_code' => ($contextProfileCode !==
'' ? (string) $contextProfileCode :
null),
382 'context_profile_version' => ($contextProfileVersion !==
'' ? (
string) $contextProfileVersion :
null),
383 'email_context' => (is_array($emailContext) ? $emailContext : array()),
384 'email_understanding' => (is_array($emailUnderstanding) ? $emailUnderstanding : array()),
385 'handoff_payload_json' => (is_array($handoffPayload) ? $handoffPayload : array()),
386 'cleaner_payload' => (is_array($payload) ? $payload : array()),
388 $inputMetadataRaw = json_encode($inputMetadata, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
389 $outputRaw = json_encode($outputJson, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
390 if ($inputMetadataRaw ===
false || $outputRaw ===
false)
return 0;
392 $inputHash = hash(
'sha256', (
string) $msgid);
393 $outputHash = hash(
'sha256', $outputRaw);
394 $securityHash = hash(
'sha256', ((
int) $entity).
'|'.((
int) $actioncommId).
'|email_cleaner|'.$inputHash.
'|'.$outputHash);
395 $minConfidence = (float)
getDolGlobalString(
'AI_EMAILCLEANER_MIN_CONFIDENCE',
'0.60');
396 if ($minConfidence <= 0 || $minConfidence > 1) $minConfidence = 0.60;
397 $status = ((float) $confidence >= $minConfidence ?
'Success' :
'LowConfidence');
398 if ((
string) $engine ===
'fallback') $status =
'Fallback';
399 $provider = ((string) $engine ===
'ai' ?
'dolibarr_ai' :
'fallback');
402 $queryText =
'EmailCleaner message '.$msgid;
406 'fk_actioncomm' => (
int) $actioncommId,
407 'input_hash' => $inputHash,
408 'output_hash' => $outputHash,
409 'security_hash' => $securityHash,
410 'preserve_payloads' =>
true,
417 array(
'tool' =>
'email_cleaner'),
439 static $available =
null;
440 if ($available !==
null)
return $available;
441 $available = (bool) count($this->db->DDLInfoTable(MAIN_DB_PREFIX.self::TABLE_AI_REQUEST_LOG));
453 $raw = trim((
string) $raw);
454 if ($raw ===
'')
return array();
455 if ($raw[0] !==
'{')
return array(
'context_profile_code' => $raw);
456 $dec = json_decode($raw,
true);
457 if (!is_array($dec))
return array();
469 $out = array(
'counts' => array(),
'samples' => array());
470 if (empty($segments) || !is_array($segments))
return $out;
471 foreach ($segments as $seg) {
472 if (!is_array($seg))
continue;
473 $type =
dol_strtolower(trim((
string) ($seg[
'type'] ??
'unknown')));
474 $text = trim((
string) ($seg[
'text'] ??
''));
475 if ($type ===
'') $type =
'unknown';
476 if (empty($out[
'counts'][$type])) $out[
'counts'][$type] = 0;
477 $out[
'counts'][$type]++;
478 if ($text !==
'' && (empty($out[
'samples'][$type]) || !is_array($out[
'samples'][$type]))) $out[
'samples'][$type] = array();
479 if ($text !==
'' && count($out[
'samples'][$type]) < 2) {
496 $minConfidence = (float)
getDolGlobalString(
'AI_EMAILCLEANER_MIN_CONFIDENCE',
'0.60');
497 if ($minConfidence <= 0 || $minConfidence > 1) $minConfidence = 0.60;
500 'ai_transparency_label' =>
'AI-generated technical preprocessing output',
501 'human_review_required' => 1,
502 'autonomous_business_action_allowed' => 0,
503 'pii_redaction_enabled' => (
getDolGlobalInt(
'AI_PRIVACY_REDACTION', 0) ? 1 : 0),
504 'ai_system_used' => ((
string) $engine ===
'ai' ? 1 : 0),
505 'fallback_used' => ($fallbackUsed ? 1 : 0),
506 'cleaning_confidence' => (
float) $confidence,
507 'confidence_threshold' => $minConfidence,
508 'policy_scope' =>
'diagnostic_only_no_business_decision',
523 $savedByName = array();
524 if (is_array($savedAttachments)) {
525 foreach ($savedAttachments as $sa) {
526 if (!is_array($sa))
continue;
527 $n = trim((
string) ($sa[
'name'] ??
''));
528 if ($n ===
'')
continue;
533 if (is_array($attachments)) {
534 foreach ($attachments as $att) {
538 if (is_object($att)) {
539 if (is_callable(array($att,
'getName'))) $name = (string) call_user_func(array($att,
'getName'));
540 if (is_callable(array($att,
'getPath'))) $relative = (
string) call_user_func(array($att,
'getPath'));
541 if (is_callable(array($att,
'getContent'))) {
542 $content = (string) call_user_func(array($att,
'getContent'));
543 if ($content !==
'') $sha = hash(
'sha256', $content);
545 } elseif (is_array($att)) {
546 $name = (string) (!empty($att[
'name']) ? $att[
'name'] :
'');
547 $relative = (string) (!empty($att[
'path']) ? $att[
'path'] :
'');
549 if ($name ===
'')
continue;
551 if (isset($savedByName[$lookup]) && is_array($savedByName[$lookup])) {
552 $sa = $savedByName[$lookup];
553 if (!empty($sa[
'relative_path'])) $relative = (string) $sa[
'relative_path'];
554 if (!empty($sa[
'sha256'])) $sha = (
string) $sa[
'sha256'];
556 if ($sha ===
'' && $relative !==
'') {
557 $candidate = $relative;
558 if ($candidate[0] !==
'/' && $savedDir !==
'') $candidate = rtrim($savedDir,
'/').
'/'.$candidate;
559 if (@is_readable($candidate)) {
560 $h = @hash_file(
'sha256', $candidate);
561 if (is_string($h)) $sha = $h;
567 'relative_path' => $relative,
569 'is_pdf' => ($ext ===
'pdf'),
574 if (empty($out) && is_array($savedAttachments)) {
575 foreach ($savedAttachments as $sa) {
576 if (!is_array($sa))
continue;
577 $name = trim((
string) ($sa[
'name'] ??
''));
578 if ($name ===
'')
continue;
582 'relative_path' => (
string) ($sa[
'relative_path'] ??
''),
583 'sha256' => (
string) ($sa[
'sha256'] ??
''),
584 'is_pdf' => ($ext ===
'pdf'),
600 $ext =
dol_strtolower(pathinfo((
string) $filename, PATHINFO_EXTENSION));
601 return ($ext ===
'pdf' ?
'pdf_document' :
'unknown');
616 'clean_body' => $fallbackCleanBody,
617 'confidence' => 0.40,
618 'segments' => array(),
619 'email_understanding' => self::buildFallbackEmailUnderstanding($fallbackCleanBody),
620 'engine' =>
'fallback',
621 'fallback_used' =>
true,
624 require_once DOL_DOCUMENT_ROOT.
'/ai/class/ai.class.php';
625 $ai =
new Ai($this->db);
626 if (empty($ai->getApiService()))
return $out;
629 if ($maxInput <= 1000) $maxInput = 16000;
632 $fromForPrompt = $from;
633 $subjectForPrompt = $subject;
634 $bodyForPrompt = $rawBody;
636 $bodyForPrompt =
dol_substr($bodyForPrompt, 0, $maxInput);
638 if ($redactionEnabled) {
639 require_once DOL_DOCUMENT_ROOT.
'/ai/class/privacy_guard.class.php';
640 if (class_exists(
'PrivacyGuard')) {
642 $fromForPrompt = $guard->mask((
string) $fromForPrompt);
643 $subjectForPrompt = $guard->mask((
string) $subjectForPrompt);
644 $bodyForPrompt = $guard->mask((
string) $bodyForPrompt);
648 $prompt =
"You are an EmailCleaner and structured email preprocessor for ERP ingestion.\n";
649 $prompt .=
"Task: separate current message content from quoted thread/noise and extract neutral understanding of the email.\n";
650 $prompt .=
"Important constraints:\n";
651 $prompt .=
"- Do NOT classify business intent and do NOT decide any action.\n";
652 $prompt .=
"- Do NOT create, update, approve, reject, match, or route business objects.\n";
653 $prompt .=
"- Extract only explicit information present in the email. Do not guess missing dates, amounts, references, contacts, or meaning.\n";
654 $prompt .=
"- Be conservative: when unsure, keep text in clean_body.\n";
655 $prompt .=
"- Put quoted/forwarded old thread content in segments, but keep only current-message facts in email_understanding.\n";
656 $prompt .=
"- Return valid JSON only.\n";
657 $prompt .=
"JSON schema:\n";
659 $prompt .=
" \"clean_body\": string,\n";
660 $prompt .=
" \"segments\": [\n";
661 $prompt .=
" {\"type\":\"main_content|quoted_thread|signature|legal_disclaimer|environmental_notice|security_notice|social_footer|system_noise|unknown\",\"text\":string}\n";
663 $prompt .=
" \"email_understanding\": {\n";
664 $prompt .=
" \"key_points\": string[],\n";
665 $prompt .=
" \"standardized\": {\n";
666 $prompt .=
" \"dates\": string[],\n";
667 $prompt .=
" \"amounts\": string[],\n";
668 $prompt .=
" \"references\": string[],\n";
669 $prompt .=
" \"contacts\": string[],\n";
670 $prompt .=
" \"languages\": string[]\n";
672 $prompt .=
" \"categories\": {\n";
673 $prompt .=
" \"topics\": string[],\n";
674 $prompt .=
" \"referenced_items\": string[],\n";
675 $prompt .=
" \"requested_operations\": string[],\n";
676 $prompt .=
" \"risks_or_warnings\": string[],\n";
677 $prompt .=
" \"noise_types\": string[]\n";
679 $prompt .=
" \"structured\": {\n";
680 $prompt .=
" \"primary_elements\": string[],\n";
681 $prompt .=
" \"secondary_elements\": string[],\n";
682 $prompt .=
" \"other_elements\": string[]\n";
685 $prompt .=
" \"confidence\": number\n";
687 $prompt .=
"Constraints:\n";
688 $prompt .=
"- clean_body max 2000 chars.\n";
689 $prompt .=
"- key_points max 8 items, each max 160 chars.\n";
690 $prompt .=
"- every standardized/categories/structured list max 10 items, each max 180 chars.\n";
691 $prompt .=
"Email metadata:\n";
692 $prompt .=
"FROM: ".$fromForPrompt.
"\n";
693 $prompt .=
"SUBJECT: ".$subjectForPrompt.
"\n";
694 $prompt .=
"BODY:\n".$bodyForPrompt;
696 $generated = $ai->generateContent($prompt,
'auto',
'textgeneration',
'');
697 if (is_array($generated) && !empty($generated[
'error']))
return $out;
698 if (!is_string($generated) || trim($generated) ===
'')
return $out;
700 $generated = $guard->unmaskAiResponse($generated);
704 if (empty($decoded) || !is_array($decoded))
return $out;
707 if ($cleanBody ===
'')
return $out;
709 $confidence = (float) ($decoded[
'confidence'] ?? 0.0);
710 if ($confidence < 0) $confidence = 0;
711 if ($confidence > 1) $confidence = 1;
713 $minConfidence = (float)
getDolGlobalString(
'AI_EMAILCLEANER_MIN_CONFIDENCE',
'0.60');
714 if ($minConfidence <= 0 || $minConfidence > 1) $minConfidence = 0.60;
717 if (!empty($decoded[
'segments']) && is_array($decoded[
'segments'])) {
718 foreach ($decoded[
'segments'] as $seg) {
719 if (!is_array($seg))
continue;
720 $type = trim((
string) ($seg[
'type'] ??
'unknown'));
722 if ($text ===
'')
continue;
723 if ($type ===
'') $type =
'unknown';
724 $segments[] = array(
'type' => $type,
'text' => $text);
725 if (count($segments) >= 80)
break;
729 if ($confidence < $minConfidence) {
732 $out[
'confidence'] = $confidence;
733 $out[
'segments'] = $segments;
735 $out[
'engine'] =
'ai_low_confidence_fallback';
736 $out[
'fallback_used'] =
true;
740 $out[
'clean_body'] = $cleanBody;
741 $out[
'confidence'] = $confidence;
742 $out[
'segments'] = $segments;
744 $out[
'engine'] =
'ai';
745 $out[
'fallback_used'] =
false;
759 $keyPoints = array();
760 if ($cleanBody !==
'') {
761 $lines = preg_split(
"/\n/", $cleanBody);
762 if (is_array($lines)) {
763 foreach ($lines as $line) {
764 $line = trim((
string) $line);
765 if ($line ===
'')
continue;
766 $line = preg_replace(
'/\s+/',
' ', $line);
768 $keyPoints[] = $line;
769 if (count($keyPoints) >= 5)
break;
775 'key_points' => $keyPoints,
776 'standardized' => array(
778 'amounts' => array(),
779 'references' => array(),
780 'contacts' => array(),
781 'languages' => array(),
783 'categories' => array(
785 'referenced_items' => array(),
786 'requested_operations' => array(),
787 'risks_or_warnings' => array(),
788 'noise_types' => array(),
790 'structured' => array(
791 'primary_elements' => $keyPoints,
792 'secondary_elements' => array(),
793 'other_elements' => array(),
807 if (!is_array($raw)) {
814 $standardized = (isset($raw[
'standardized']) && is_array($raw[
'standardized'])) ? $raw[
'standardized'] : array();
815 $out[
'standardized'] = array(
816 'dates' => self::sanitizeStringList($standardized[
'dates'] ?? array(), 10, 80),
817 'amounts' => self::sanitizeStringList($standardized[
'amounts'] ?? array(), 10, 80),
818 'references' => self::sanitizeStringList($standardized[
'references'] ?? array(), 10, 120),
819 'contacts' => self::sanitizeStringList($standardized[
'contacts'] ?? array(), 10, 160),
820 'languages' => self::sanitizeStringList($standardized[
'languages'] ?? array(), 5, 40),
823 $categories = (isset($raw[
'categories']) && is_array($raw[
'categories'])) ? $raw[
'categories'] : array();
824 $out[
'categories'] = array(
825 'topics' => self::sanitizeStringList($categories[
'topics'] ?? array(), 10, 160),
826 'referenced_items' => self::sanitizeStringList($categories[
'referenced_items'] ?? array(), 10, 160),
827 'requested_operations' => self::sanitizeStringList($categories[
'requested_operations'] ?? array(), 10, 160),
828 'risks_or_warnings' => self::sanitizeStringList($categories[
'risks_or_warnings'] ?? array(), 10, 180),
829 'noise_types' => self::sanitizeStringList($categories[
'noise_types'] ?? array(), 10, 80),
832 $structured = (isset($raw[
'structured']) && is_array($raw[
'structured'])) ? $raw[
'structured'] : array();
833 $out[
'structured'] = array(
834 'primary_elements' => self::sanitizeStringList($structured[
'primary_elements'] ?? array(), 12, 180),
835 'secondary_elements' => self::sanitizeStringList($structured[
'secondary_elements'] ?? array(), 12, 180),
836 'other_elements' => self::sanitizeStringList($structured[
'other_elements'] ?? array(), 12, 180),
839 if (empty($out[
'key_points']) && empty($out[
'structured'][
'primary_elements'])) {
856 $items = is_array($items) ? $items : array();
857 $maxItems = max(0, (
int) $maxItems);
858 $maxLen = max(1, (
int) $maxLen);
862 foreach ($items as $item) {
863 $item = trim((
string) $item);
865 $item = preg_replace(
'/\s+/',
' ', $item);
866 $item = trim((
string) $item);
867 if ($item ===
'')
continue;
870 $item = rtrim($item,
" \t\r\n,;:");
873 if (!empty($seen[$key]))
continue;
876 if (count($out) >= $maxItems)
break;
890 $text = (string) $text;
891 $text = str_replace(
"\r\n",
"\n", $text);
892 $text = str_replace(
"\r",
"\n", $text);
904 $header = (string) $header;
905 if ($header ===
'')
return '';
906 if (preg_match(
'/^Message-ID:\s*<([^>]+)>/mi', $header, $m)) {
907 return trim((
string) $m[1]);
909 if (preg_match(
'/^Message-ID:\s*([^\s]+)/mi', $header, $m)) {
910 return trim(str_replace(array(
'<',
'>'),
'', (
string) $m[1]));
924 $header = str_replace(
"\r\n",
"\n", (
string) $header);
925 $header = str_replace(
"\r",
"\n", $header);
926 $fieldName = trim((
string) $fieldName);
927 if ($header ===
'' || $fieldName ===
'')
return '';
929 $re =
'/^'.preg_quote($fieldName,
'/').
'\s*:\s*(.+)$/mi';
930 if (!preg_match($re, $header, $m, PREG_OFFSET_CAPTURE))
return '';
932 $val = (string) ($m[1][0] ??
'');
933 $offset = (int) ($m[1][1] ?? 0);
936 $lines = preg_split(
"/\n/", $after);
937 if (is_array($lines)) {
938 foreach ($lines as $ln) {
939 if ($ln ===
'')
break;
940 if (preg_match(
'/^[ \t]+/', $ln)) {
941 $val .=
' '.trim($ln);
949 return trim(str_replace(array(
'<',
'>'),
'', $val));
960 $raw = trim((
string) $raw);
961 if ($raw ===
'')
return array();
964 if (preg_match_all(
'/<([^>]+)>/', $raw, $m)) {
965 foreach ((array) $m[1] as $v) {
966 $v = trim((
string) $v);
967 if ($v ===
'')
continue;
972 $parts = preg_split(
'/\s+/', str_replace(array(
',',
';'),
' ', $raw));
973 if (is_array($parts)) {
974 foreach ($parts as $p) {
975 $p = trim(str_replace(array(
'<',
'>'),
'', (
string) $p));
976 if ($p ===
'' || strpos($p,
'@') ===
false)
continue;
982 $out = array_values(array_unique($out));
983 if (count($out) > 20) $out = array_slice($out, -20);
995 $messageId = trim((
string) $messageId);
996 $messageId = trim($messageId,
"<> \t\n\r\0\x0B");
1010 foreach ((array) $messageIds as $messageId) {
1012 if ($messageId ===
'' || isset($seen[$messageId]))
continue;
1013 $seen[$messageId] =
true;
1014 $out[] = $messageId;
1016 if (count($out) > 20) $out = array_slice($out, -20);
1037 return ($root !==
'' ?
'rfc822:'.hash(
'sha256', $root) :
'');
1052 public static function buildMessageScopesBasic($body, $subject =
'', $from =
'', $date =
'', $messageId =
'', $inReplyTo =
'', $references = array())
1057 $quotedParts = array();
1058 $timeline = array(array(
1059 'kind' =>
'current_message',
1061 'from' => (trim((
string) $from) !==
'' ? trim((
string) $from) :
null),
1062 'date_raw' => (trim((
string) $date) !==
'' ? trim((
string) $date) :
null),
1063 'subject' => (trim((
string) $subject) !==
'' ? trim((
string) $subject) :
null),
1066 'references' => self::normalizeMessageIdList($references),
1069 foreach ($quotedBlocks as $index => $block) {
1070 if (!is_array($block))
continue;
1071 $text = trim((
string) ($block[
'text'] ??
''));
1072 if ($text !==
'') $quotedParts[] = $text;
1073 $timeline[] = array(
1074 'kind' =>
'quoted_message',
1075 'index' => (
int) $index,
1076 'from' => ($block[
'from'] ??
null),
1077 'date_raw' => ($block[
'date_raw'] ??
null),
1078 'subject' => ($block[
'subject'] ??
null),
1079 'message_id' => ($block[
'message_id'] ??
null),
1080 'in_reply_to' => ($block[
'in_reply_to'] ??
null),
1081 'references' => (array) ($block[
'references'] ?? array()),
1085 $quotedContext = trim(implode(
"\n\n", $quotedParts));
1089 $warnings = array();
1090 if ($quotedContext !==
'' && empty($quotedBlocks)) $warnings[] =
'quoted_context_without_structured_blocks';
1093 'schema_version' => 1,
1094 'parser' =>
'emailcleaner_message_scopes_v1',
1095 'reply_only' => $replyOnly,
1096 'quoted_context' => $quotedContext,
1097 'quoted_blocks' => $quotedBlocks,
1098 'timeline' => $timeline,
1099 'parsing_warnings' => $warnings,
1113 $maxBlocks = max(1, min(32, (
int) $maxBlocks));
1114 if ($body ===
'')
return array();
1116 $lines = preg_split(
'/\n/', $body);
1117 if (!is_array($lines))
return array();
1120 $collecting =
false;
1127 $flush =
function (array &$blocks, array &$current,
bool $collecting) use ($maxBlocks) {
1128 if (!$collecting || empty($current) || count($blocks) >= $maxBlocks) {
1132 $raw = trim(implode(
"\n", $current));
1134 if ($raw ===
'')
return;
1136 $text = trim((
string) ($header[
'body'] ?? $raw));
1137 if ($text ===
'')
return;
1139 'from' => ($header[
'from'] ??
null),
1140 'date_raw' => ($header[
'date_raw'] ??
null),
1141 'subject' => ($header[
'subject'] ??
null),
1142 'message_id' => ($header[
'message_id'] ??
null),
1143 'in_reply_to' => ($header[
'in_reply_to'] ??
null),
1144 'references' => (array) ($header[
'references'] ?? array()),
1149 foreach ($lines as $line) {
1150 $trim = trim((
string) $line);
1151 $isSeparator = (bool) preg_match(
'/^(?:-{2,}\s*)?(?:original message|mensaje original|mensagem original|forwarded message|message transfere|weitergeleitete nachricht)(?:\s*-{2,})?$/iu', trim($trim,
'- '));
1152 $isWroteLine = (bool) preg_match(
'/^(?:on|el|em|le|am|op)\s+.{1,300}\s+(?:wrote|escribio|escreveu|a ecrit|schrieb|schreef)\s*:?$/iu', $trim);
1153 $isHeaderStart = (bool) preg_match(
'/^(?:from|de|da|von|van|sent|enviado|enviada|date|fecha|data)\s*:/iu', $trim);
1154 if ($isSeparator || $isWroteLine || ($isHeaderStart && !$collecting)) {
1155 $flush($blocks, $current, $collecting);
1157 if (!$isSeparator) $current[] = preg_replace(
'/^>+\s*/',
'', (
string) $line);
1161 if ($isHeaderStart && !empty($current) && preg_match(
'/(?:^|\n)\s*$/', implode(
"\n", array_slice($current, -2)))) {
1162 $flush($blocks, $current, $collecting);
1165 $current[] = preg_replace(
'/^>+\s*/',
'', (
string) $line);
1168 $flush($blocks, $current, $collecting);
1181 $lines = preg_split(
'/\n/', self::normalizeText((
string) $raw));
1182 if (!is_array($lines)) $lines = array((
string) $raw);
1183 $headerLines = array();
1184 $bodyLines = array();
1186 foreach ($lines as $line) {
1187 $trim = trim((
string) $line);
1188 if ($inHeader && ($trim ===
'' || preg_match(
'/^(?:from|de|da|von|van|sent|enviado|enviada|date|fecha|data|to|para|subject|asunto|assunto|objet|betreff|onderwerp|message-id|in-reply-to|references)\s*:/iu', $trim))) {
1189 $headerLines[] = $line;
1190 if ($trim ===
'' && count($headerLines) > 1) $inHeader =
false;
1194 $bodyLines[] = $line;
1196 $header = implode(
"\n", $headerLines);
1201 $get =
function (array $names) use ($header) {
1202 foreach ((array) $names as $name) {
1203 if (preg_match(
'/^'.preg_quote((
string) $name,
'/').
'\s*:\s*(.+)$/miu', $header, $m))
return trim((
string) $m[1]);
1210 'from' => ($get(array(
'From',
'De',
'Da',
'Von',
'Van')) ?:
null),
1211 'date_raw' => ($get(array(
'Sent',
'Enviado',
'Enviada',
'Date',
'Fecha',
'Data')) ?:
null),
1212 'subject' => ($get(array(
'Subject',
'Asunto',
'Assunto',
'Objet',
'Betreff',
'Onderwerp')) ?:
null),
1213 'message_id' => ($messageId !==
'' ? $messageId :
null),
1214 'in_reply_to' => ($inReplyTo !==
'' ? $inReplyTo :
null),
1215 'references' => self::normalizeMessageIdList(self::extractHeaderMessageIds($get(array(
'References')))),
1216 'body' => trim(implode(
"\n", $bodyLines)),
1229 if ($body ===
'')
return '';
1231 $separators = array(
1232 '-----Original Message-----',
1233 '----- Original Message -----',
1234 '-----Mensaje original-----',
1235 '----- Mensaje original -----',
1236 '-----Mensagem original-----',
1237 '----- Mensagem original -----',
1238 '----- Forwarded message -----',
1240 foreach ($separators as $sep) {
1241 $pos = stripos($body, $sep);
1242 if ($pos !==
false && $pos > 0) {
1243 return trim(
dol_substr($body, 0, $pos,
'8bit'));
1247 $lines = preg_split(
"/\n/", $body);
1248 if (!is_array($lines))
return $body;
1250 foreach ($lines as $line) {
1251 $trim = trim((
string) $line);
1253 if (!empty($out)) $out[] =
'';
1256 if (preg_match(
'/^(from|de|sent|enviado|to|para|subject|asunto)\b[^:]{0,12}:/i', $trim))
break;
1257 if (preg_match(
'/^>+/', $trim))
break;
1259 if (
dol_strlen(implode(
"\n", $out)) > 4000)
break;
1262 return trim(implode(
"\n", $out));
1275 $maxLen = (int) $maxLen;
1276 if ($body ===
'')
return '';
1277 if ($maxLen <= 0) $maxLen = 1200;
1280 foreach (array(
'original message',
'mensaje original',
'mensagem original',
'forwarded message') as $needle) {
1281 $p = stripos($body, $needle);
1282 if ($p !==
false && ($startPos ===
false || $p < $startPos)) $startPos = $p;
1284 if ($startPos ===
false) {
1285 $lines = preg_split(
"/\n/", $body);
1286 if (!is_array($lines))
return '';
1287 $quoteLines = array();
1288 foreach ($lines as $ln) {
1289 $t = trim((
string) $ln);
1290 if ($t ===
'')
continue;
1291 if (preg_match(
'/^>+/', $t)) {
1292 $quoteLines[] = preg_replace(
'/^>+\s*/',
'', $t);
1295 $txt = trim(implode(
"\n", $quoteLines));
1299 $txt = trim(
dol_substr($body, (
int) $startPos,
null,
'8bit'));
1313 if ($text ===
'')
return '';
1315 $lines = preg_split(
'/\n+/', $text);
1316 if (!is_array($lines))
return trim($text);
1319 foreach ($lines as $line) {
1320 $line = trim((
string) $line);
1321 if ($line ===
'')
continue;
1325 if (preg_match(
'/^(from|de|sent|enviado|to|para|subject|asunto)\b[^:]{0,12}:/i', $line))
break;
1326 if (strpos($low,
'original message') !==
false)
break;
1327 if (strpos($low,
'mensaje original') !==
false)
break;
1328 if (strpos($low,
'forwarded message') !==
false)
break;
1329 if (preg_match(
'/^>+/', $line))
continue;
1330 if (preg_match(
'/\b(confidential|privacidad|confidencialidad)\b/i', $line))
continue;
1333 if (
dol_strlen(implode(
"\n", $out)) > 6000)
break;
1336 $clean = trim(implode(
"\n", $out));
1337 if ($clean ===
'') {
1352 $raw = trim((
string) $raw);
1353 if ($raw ===
'')
return null;
1355 $decoded = json_decode($raw,
true);
1356 if (is_array($decoded))
return $decoded;
1358 $first = strpos($raw,
'{');
1359 $last = strrpos($raw,
'}');
1360 if ($first ===
false || $last ===
false || $last <= $first)
return null;
1362 $payload =
dol_substr($raw, $first, $last - $first + 1,
'8bit');
1363 $decoded = json_decode($payload,
true);
1364 if (!is_array($decoded))
return null;
if(! $sortfield) if(! $sortorder) $object
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.
processEmailCollectorMessage($parameters, &$object)
Process one EmailCollector message through the isolated EmailCleaner workflow.
static buildThreadKey($messageId, $inReplyTo, $references)
Build a stable technical thread key from RFC lineage.
insertAiRequestLogRow($entity, $actioncommId, $collectorId, $msgid, $rawBody, $cleanedText, $segments, $confidence, $engine, $model, $promptCode, $promptVersion, $contextProfileCode, $contextProfileVersion, $emailContext, $emailUnderstanding, $handoffPayload, $payload)
Insert cleaner output in the shared AI request log.
static buildNoiseSummaryFromSegments($segments)
Build minimal excluded-noise summary from segments.
static extractHeaderFieldValue($header, $fieldName)
Extract unfolded RFC822 header value.
runEmailCleaner($subject, $from, $rawBody)
Run AI cleaner with conservative fallback.
static fallbackCleanBody($rawBody)
Conservative fallback cleaner.
findActionCommIdByMessageId($entity, $msgid)
Find the agenda event created for the collected email.
static extractLeadingQuotedHeaderBasic($raw)
Parse neutral metadata from the leading header of a quoted block.
static collectAttachmentRows($attachments, $savedAttachments, $savedDir='')
Extract attachment rows with stable metadata.
static parseActionParamConfig($raw)
Parse action parameter configuration (JSON).
static detectPdfDocTypeFromAttachmentName($filename)
Detect a generic document-like type from attachment metadata.
static isRuntimeAvailable()
Check if the runtime dependencies for EmailCleaner are available.
static extractQuotedMessageBlocksBasic($body, $maxBlocks=16)
Extract quoted message blocks with neutral RFC metadata.
static decodeJsonFromAiString($raw)
Decode JSON from AI output that may contain wrappers.
static isEmailCollectorHookAction($action)
Check if the EmailCollector hook action targets EmailCleaner.
static normalizeText($text)
Normalize text to UTF-8 LF-only.
static buildMessageScopesBasic($body, $subject='', $from='', $date='', $messageId='', $inReplyTo='', $references=array())
Build deterministic current/quoted scopes and a technical timeline.
static normalizeMessageId($messageId)
Normalize one RFC message identifier for comparisons.
static extractReplyOnlyTextBasic($body)
Extract reply-only part with a conservative separator strategy.
static buildFallbackEmailUnderstanding($cleanBody)
Build a minimal understanding payload when AI structured preprocessing is unavailable.
isAiRequestLogTableAvailable()
Check if the shared AI request log table is available.
static extractMessageIdFromHeader($header)
Extract Message-ID from raw RFC822 header.
static normalizeMessageIdList($messageIds)
Normalize and de-duplicate RFC message identifiers without changing order.
__construct($db)
Constructor.
static extractQuotedThreadSnippetBasic($body, $maxLen=1200)
Build compact quoted context snippet for auditing/context transfer.
static sanitizeEmailUnderstanding($raw, $fallbackCleanBody='')
Sanitize structured understanding returned by AI.
static sanitizeStringList($items, $maxItems, $maxLen)
Sanitize a list of short strings.
static extractHeaderMessageIds($raw)
Parse message-id list from References-like header.
static buildComplianceMetadata($engine, $fallbackUsed, $confidence)
Build compliance metadata for transparency and supervision.
Class to manage privacy data masking and unmasking.
dol_now($mode='gmt')
Return date for now.
dol_strtolower($string, $encoding="UTF-8")
Convert a string to lower.
dol_string_nospecial($str, $newstr='_', $badcharstoreplace='', $badcharstoremove='', $keepspaces=0)
Clean a string from all punctuation characters to use it as a ref or login.
dol_sanitizeFileName($str, $newstr='_', $unaccent=1, $includequotes=0, $allowdash=0)
Clean a string to use it as a file name.
dol_strlen($string, $stringencoding='UTF-8')
Make a strlen call.
getDolGlobalInt($key, $default=0)
Return a Dolibarr global constant int value.
dol_substr($string, $start, $length=null, $stringencoding='', $trunconbytes=0)
Make a substring.
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).
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.
dol_mkdir($dir, $dataroot='', $newmask='')
Creation of a directory (this can create recursive subdir)
$context
@method int call_trigger(string $triggerName, ?User $user)