From 84e50b42e0e6ea95a4b65ed519faccac910473cf Mon Sep 17 00:00:00 2001 From: stephan Date: Fri, 21 Aug 2026 11:59:05 -0400 Subject: [PATCH] Enhance Radar functionality: integrate Gemini API for AI analysis on dossards, update sync service to handle AI analysis results, and improve data handling in the Radar model. Add new methods for analysis and ensure UI reflects AI analysis status. Update .gitignore to include sensitive credential files. --- .cursor/rules/projet/10-radar-mail.mdc | 6 +- .gitignore | 7 +- .../credentials/gemini_api_key.txt.example | 7 ++ sql/MSOP-3-radar-analysis.sql | 10 ++ v4/phpinfo.php | 6 - v4_ci4/app/Config/Radar.php | 13 ++ v4_ci4/app/Controllers/Radar.php | 17 +++ v4_ci4/app/Libraries/Radar/GeminiClient.php | 87 +++++++++++++ v4_ci4/app/Libraries/Radar/GmailReader.php | 99 +++++++++++++-- .../Libraries/Radar/RadarAnalyzeService.php | 95 ++++++++++++++ .../app/Libraries/Radar/RadarSyncService.php | 78 ++++++++++-- v4_ci4/app/Models/RadarModel.php | 118 ++++++++++++++---- v4_ci4/app/Views/radar/index.php | 58 +++++++-- 13 files changed, 538 insertions(+), 63 deletions(-) create mode 100644 application/credentials/gemini_api_key.txt.example create mode 100644 sql/MSOP-3-radar-analysis.sql delete mode 100644 v4/phpinfo.php create mode 100644 v4_ci4/app/Libraries/Radar/GeminiClient.php create mode 100644 v4_ci4/app/Libraries/Radar/RadarAnalyzeService.php diff --git a/.cursor/rules/projet/10-radar-mail.mdc b/.cursor/rules/projet/10-radar-mail.mdc index 04dc1406..04f0c5a5 100644 --- a/.cursor/rules/projet/10-radar-mail.mdc +++ b/.cursor/rules/projet/10-radar-mail.mdc @@ -9,8 +9,8 @@ alwaysApply: true - UI : CI4 / Raven `/v4/radar` — pas SmartAdmin. - Cible long terme : tout `@ms1timing.com` moins `radar_exceptions`. -- **MSOP-4 v0 (en cours)** : allowlist **une** mailbox — `leith.s@ms1timing.com` — avant domaine entier. -- Moteur : Gmail (réutiliser SA + DWD comme Calendar) + plus tard Gemini. L’écran lit `radar_threads`. -- Un fil = N skills. Vérité = CRM (on n’invente pas un client). +- **MSOP-4 v0** : allowlist **une** mailbox — `leith.s@ms1timing.com`. +- **MSOP-3** : Gemini sur skill `dossards` → `analysis` + `analysis_delta` ; clé dans `application/credentials/gemini_api_key.txt` (1Password). SQL : `sql/MSOP-3-radar-analysis.sql`. +- Un fil = N skills. Vérité = CRM (on n’invente pas un client). L’IA signale, n’approuve pas. - Pas dans MS1 Inscription. - Agent : **pas de commit/push** sauf demande explicite. diff --git a/.gitignore b/.gitignore index cee60b89..105e3348 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,11 @@ # IDE — ne jamais committer les connexions SQL (copier settings.json.example) .vscode/settings.json +# Secrets Google / Gemini (1Password) +application/credentials/gemini_api_key.txt +application/credentials/service_account.json +application/credentials/credentials.json + # Runtime / data data/ logs/ @@ -15,4 +20,4 @@ error_log # OS .DS_Store -Thumbs.db \ No newline at end of file +Thumbs.db diff --git a/application/credentials/gemini_api_key.txt.example b/application/credentials/gemini_api_key.txt.example new file mode 100644 index 00000000..961f7611 --- /dev/null +++ b/application/credentials/gemini_api_key.txt.example @@ -0,0 +1,7 @@ +# Colle ici la clé API Gemini (une seule ligne). +# Google AI Studio → Create API key (projet fast-academy-472418-r4 si possible). +# Activer aussi « Generative Language API » sur le projet GCP. +# +# Chemin attendu par Radar : +# application/credentials/gemini_api_key.txt +AQ.Ab8RN6IZXKEJWxsI5iu-aFti8BlaIczY1T5yVFSK19L4S3bjRQ \ No newline at end of file diff --git a/sql/MSOP-3-radar-analysis.sql b/sql/MSOP-3-radar-analysis.sql new file mode 100644 index 00000000..6724df5c --- /dev/null +++ b/sql/MSOP-3-radar-analysis.sql @@ -0,0 +1,10 @@ +-- MSOP-3 — colonnes analyse IA dossards (dev) +-- Exécuter sur la BD CRM de DEV après IDEE-4-radar-proto.sql + +ALTER TABLE radar_threads + ADD COLUMN analysis TEXT NULL AFTER summary, + ADD COLUMN analysis_delta TEXT NULL AFTER analysis; + +-- Agrandir le résumé court (snippet) si besoin +ALTER TABLE radar_threads + MODIFY summary VARCHAR(1000) NOT NULL DEFAULT ''; diff --git a/v4/phpinfo.php b/v4/phpinfo.php deleted file mode 100644 index cb81ba09..00000000 --- a/v4/phpinfo.php +++ /dev/null @@ -1,6 +0,0 @@ - 0) { + $msg .= ' (' . \mb_substr((string) $ai['note'], 0, 120) . ')'; + } + } else { + $msg .= ' | IA: ' . ($ai['note'] ?: 'off'); + } + } // Message aussi en query : visible même si la flash session CI4 ne tient pas. $q = rawurlencode(mb_substr($msg, 0, 800)); diff --git a/v4_ci4/app/Libraries/Radar/GeminiClient.php b/v4_ci4/app/Libraries/Radar/GeminiClient.php new file mode 100644 index 00000000..8635ff84 --- /dev/null +++ b/v4_ci4/app/Libraries/Radar/GeminiClient.php @@ -0,0 +1,87 @@ +apiKey() !== ''; + } + + public function apiKey(): string + { + $path = \realpath(ROOTPATH . $this->config->geminiApiKeyFile) + ?: (ROOTPATH . $this->config->geminiApiKeyFile); + if (! \is_file($path)) { + return ''; + } + $key = \trim((string) \file_get_contents($path)); + + return $key; + } + + /** + * @return array{text:string, raw:string} + */ + public function generate(string $prompt): array + { + $key = $this->apiKey(); + if ($key === '') { + throw new \RuntimeException('Clé Gemini absente (gemini_api_key.txt)'); + } + + $model = \rawurlencode($this->config->geminiModel); + $url = 'https://generativelanguage.googleapis.com/v1beta/models/' + . $model . ':generateContent?key=' . \rawurlencode($key); + + $payload = \json_encode([ + 'contents' => [ + [ + 'role' => 'user', + 'parts' => [['text' => $prompt]], + ], + ], + 'generationConfig' => [ + 'temperature' => 0.2, + 'maxOutputTokens' => 2048, + ], + ], \JSON_UNESCAPED_UNICODE); + + [$code, $body] = $this->http->http( + 'POST', + $url, + ['Content-Type: application/json'], + $payload ?: '{}' + ); + + if ($code !== 200) { + throw new \RuntimeException("Gemini error ({$code}): {$body}"); + } + + $data = \json_decode($body, true); + $text = ''; + foreach ($data['candidates'][0]['content']['parts'] ?? [] as $part) { + if (! empty($part['text'])) { + $text .= $part['text']; + } + } + $text = \trim($text); + if ($text === '') { + throw new \RuntimeException('Gemini: réponse vide'); + } + + return ['text' => $text, 'raw' => $body]; + } +} diff --git a/v4_ci4/app/Libraries/Radar/GmailReader.php b/v4_ci4/app/Libraries/Radar/GmailReader.php index b40d7a95..0b8969d0 100644 --- a/v4_ci4/app/Libraries/Radar/GmailReader.php +++ b/v4_ci4/app/Libraries/Radar/GmailReader.php @@ -110,17 +110,100 @@ class GmailReader $ts = isset($msg['internalDate']) ? (int) \floor(((int) $msg['internalDate']) / 1000) : 0; return [ - 'gmail_thread_id' => (string) ($msg['threadId'] ?? $messageId), - 'subject' => (string) ($headers['subject'] ?? '(sans sujet)'), - 'from_email' => $from['email'], - 'from_name' => $from['name'], - 'snippet' => (string) ($msg['snippet'] ?? ''), - 'gmail_url' => 'https://mail.google.com/mail/u/0/#all/' . \rawurlencode((string) ($msg['threadId'] ?? $messageId)), - 'received_at' => $ts > 0 ? \date('Y-m-d H:i:s', $ts) : null, - 'internal_ts' => $ts, + 'gmail_message_id' => $messageId, + 'gmail_thread_id' => (string) ($msg['threadId'] ?? $messageId), + 'subject' => self::decodeText((string) ($headers['subject'] ?? '(sans sujet)')), + 'from_email' => $from['email'], + 'from_name' => self::decodeText($from['name']), + 'snippet' => self::decodeText((string) ($msg['snippet'] ?? '')), + 'gmail_url' => 'https://mail.google.com/mail/u/0/#all/' . \rawurlencode((string) ($msg['threadId'] ?? $messageId)), + 'received_at' => $ts > 0 ? \date('Y-m-d H:i:s', $ts) : null, + 'internal_ts' => $ts, ]; } + /** + * Corps texte du message (pour analyse IA). format=full. + */ + public function getPlainBody(string $mailbox, string $messageId): string + { + $access = $this->tokenClient->getAccessToken($mailbox, $this->scopes); + $url = 'https://gmail.googleapis.com/gmail/v1/users/me/messages/' . \rawurlencode($messageId) + . '?format=full'; + + [$code, $body] = $this->tokenClient->http( + 'GET', + $url, + ['Authorization: Bearer ' . $access] + ); + if ($code !== 200) { + throw new \RuntimeException("Gmail messages.get ({$code}): {$body}"); + } + + $msg = \json_decode($body, true); + if (! \is_array($msg)) { + return ''; + } + + $text = $this->extractPlainFromPayload($msg['payload'] ?? []); + if ($text === '' && ! empty($msg['snippet'])) { + $text = (string) $msg['snippet']; + } + + return self::decodeText(\mb_substr($text, 0, 12000)); + } + + /** + * @param array $payload + */ + private function extractPlainFromPayload(array $payload): string + { + $mime = \strtolower((string) ($payload['mimeType'] ?? '')); + if ($mime === 'text/plain' && ! empty($payload['body']['data'])) { + return $this->b64urlDecode((string) $payload['body']['data']); + } + + foreach ($payload['parts'] ?? [] as $part) { + if (! \is_array($part)) { + continue; + } + $found = $this->extractPlainFromPayload($part); + if ($found !== '') { + return $found; + } + } + + if ($mime === 'text/html' && ! empty($payload['body']['data'])) { + $html = $this->b64urlDecode((string) $payload['body']['data']); + + return \trim(\html_entity_decode(\strip_tags($html), \ENT_QUOTES | \ENT_HTML5, 'UTF-8')); + } + + return ''; + } + + private function b64urlDecode(string $data): string + { + $raw = \strtr($data, '-_', '+/'); + $pad = \strlen($raw) % 4; + if ($pad > 0) { + $raw .= \str_repeat('=', 4 - $pad); + } + $out = \base64_decode($raw, true); + + return $out === false ? '' : $out; + } + + /** Gmail renvoie souvent des entités HTML dans snippet / headers. */ + public static function decodeText(string $text): string + { + if ($text === '') { + return ''; + } + + return \html_entity_decode($text, \ENT_QUOTES | \ENT_HTML5, 'UTF-8'); + } + /** * @return array{email:string,name:string} */ diff --git a/v4_ci4/app/Libraries/Radar/RadarAnalyzeService.php b/v4_ci4/app/Libraries/Radar/RadarAnalyzeService.php new file mode 100644 index 00000000..c7ddfbb5 --- /dev/null +++ b/v4_ci4/app/Libraries/Radar/RadarAnalyzeService.php @@ -0,0 +1,95 @@ +gemini->isConfigured(); + } + + /** + * @param list $priors + * @return array{analysis:string, delta:string, confidence:int} + */ + public function analyzeDossards( + string $subject, + string $fromEmail, + string $body, + array $priors, + ): array { + $hist = ''; + foreach ($priors as $i => $p) { + $n = $i + 1; + $hist .= "--- Mail dossards #{$n} ({$p['received_at']}) ---\n" + . 'Sujet: ' . ($p['subject'] ?? '') . "\n" + . 'Analyse/snippet: ' . (($p['analysis'] ?: $p['summary']) ?? '') . "\n\n"; + } + + $isFirst = $priors === []; + $prompt = <<boolFr($isFirst)} + +Historique dossards déjà en Radar (du plus ancien au plus récent) : +{$hist} + +Mail courant (corps) : +{$body} +PROMPT; + + $out = $this->gemini->generate($prompt); + $text = $out['text']; + // Extraire JSON si le modèle ajoute du bruit + if (\preg_match('/\{.*\}/s', $text, $m)) { + $text = $m[0]; + } + $json = \json_decode($text, true); + if (! \is_array($json)) { + return [ + 'analysis' => \mb_substr($out['text'], 0, 2000), + 'delta' => $isFirst ? '' : 'Delta non structuré — voir analyse.', + 'confidence' => 40, + ]; + } + + $analysis = \trim((string) ($json['analysis'] ?? '')); + $delta = \trim((string) ($json['delta'] ?? '')); + $conf = (int) ($json['confidence'] ?? 50); + $conf = \max(0, \min(100, $conf)); + if ($isFirst) { + $delta = ''; + } + + return [ + 'analysis' => \mb_substr($analysis !== '' ? $analysis : $out['text'], 0, 4000), + 'delta' => \mb_substr($delta, 0, 2000), + 'confidence' => $conf, + ]; + } + + private function boolFr(bool $v): string + { + return $v ? 'oui' : 'non'; + } +} diff --git a/v4_ci4/app/Libraries/Radar/RadarSyncService.php b/v4_ci4/app/Libraries/Radar/RadarSyncService.php index a95542c4..5f61558f 100644 --- a/v4_ci4/app/Libraries/Radar/RadarSyncService.php +++ b/v4_ci4/app/Libraries/Radar/RadarSyncService.php @@ -6,7 +6,8 @@ use App\Models\RadarModel; use Config\Radar as RadarConfig; /** - * MSOP-4 — sync Gmail allowlist → radar_threads / radar_thread_skills. + * MSOP-4 — sync Gmail allowlist → radar_threads. + * MSOP-3 — analyse IA dossards (+ delta) si Gemini configuré. */ class RadarSyncService { @@ -19,7 +20,7 @@ class RadarSyncService } /** - * @return array{ok:bool, mailboxes:list>, error?:string} + * @return array{ok:bool, mailboxes:list>, error?:string, ai?:array} */ public function sync(): array { @@ -41,14 +42,30 @@ class RadarSyncService return ['ok' => false, 'mailboxes' => [], 'error' => $e->getMessage()]; } - $results = []; + $analyzer = null; + $aiStats = ['enabled' => false, 'ran' => 0, 'skipped' => 0, 'errors' => 0, 'note' => '']; + try { + $gemini = new GeminiClient($this->config, $tokenClient); + if ($gemini->isConfigured()) { + $analyzer = new RadarAnalyzeService($gemini); + $aiStats['enabled'] = true; + } else { + $aiStats['note'] = 'Gemini non configuré (fichier clé absent)'; + } + } catch (\Throwable $e) { + $aiStats['note'] = $e->getMessage(); + } + + $results = []; + $aiBudget = $this->config->maxAiAnalysesPerSync; + foreach ($this->config->mailboxAllowlist as $mailbox) { - $mailbox = strtolower(trim($mailbox)); + $mailbox = \strtolower(\trim($mailbox)); if ($mailbox === '') { continue; } try { - $threads = $reader->listRecentThreads( + $threads = $reader->listRecentThreads( $mailbox, $this->config->lookbackDays, $this->config->maxMessagesPerMailbox @@ -57,7 +74,7 @@ class RadarSyncService $updated = 0; foreach ($threads as $t) { $skills = $this->guessSkills($t['subject'] . ' ' . $t['snippet']); - $status = $skills === [] ? 'a_classer' : 'a_classer'; + $status = 'a_classer'; $n = $this->model->upsertThread([ 'gmail_thread_id' => $t['gmail_thread_id'], 'mailbox' => $mailbox, @@ -65,18 +82,53 @@ class RadarSyncService 'from_email' => \mb_substr($t['from_email'], 0, 190), 'from_name' => \mb_substr($t['from_name'], 0, 190), 'gmail_url' => \mb_substr($t['gmail_url'], 0, 500), - 'summary' => \mb_substr($t['snippet'], 0, 500), + 'summary' => \mb_substr($t['snippet'], 0, 1000), 'status' => $status, 'confidence' => $skills === [] ? 20 : 45, 'is_example' => 0, 'received_at' => $t['received_at'], 'proposed_client_no' => '', ], $skills); - if ($n === 'insert') { + if ($n['op'] === 'insert') { $inserted++; } else { $updated++; } + + if ( + $analyzer + && \in_array('dossards', $skills, true) + && $aiBudget > 0 + && ! empty($t['gmail_message_id']) + && $this->model->needsAnalysis($n['id']) + ) { + try { + $body = $reader->getPlainBody($mailbox, $t['gmail_message_id']); + $priors = $this->model->listPriorDossardThreads( + $t['from_email'], + $t['gmail_thread_id'] + ); + $ai = $analyzer->analyzeDossards( + $t['subject'], + $t['from_email'], + $body !== '' ? $body : $t['snippet'], + $priors + ); + $this->model->saveAnalysis( + $n['id'], + $ai['analysis'], + $ai['delta'], + $ai['confidence'] + ); + $aiStats['ran']++; + $aiBudget--; + } catch (\Throwable $e) { + $aiStats['errors']++; + $aiStats['note'] = $e->getMessage(); + } + } elseif (\in_array('dossards', $skills, true) && $analyzer && $aiBudget <= 0) { + $aiStats['skipped']++; + } } $results[] = [ 'mailbox' => $mailbox, @@ -102,17 +154,21 @@ class RadarSyncService } } - return ['ok' => $anyOk || $results === [], 'mailboxes' => $results]; + return [ + 'ok' => $anyOk || $results === [], + 'mailboxes' => $results, + 'ai' => $aiStats, + ]; } /** - * Heuristique v0 — pas encore Gemini (MSOP-3). + * Heuristique skills (avant IA). * * @return list */ private function guessSkills(string $text): array { - $t = \mb_strtolower($text); + $t = \mb_strtolower($text); $skills = []; if (\preg_match('/dossard|bib\b|imprimeur|s[eé]quence|logo\s*doss/', $t)) { $skills[] = 'dossards'; diff --git a/v4_ci4/app/Models/RadarModel.php b/v4_ci4/app/Models/RadarModel.php index 80ed92a0..5dc56bb8 100644 --- a/v4_ci4/app/Models/RadarModel.php +++ b/v4_ci4/app/Models/RadarModel.php @@ -5,8 +5,7 @@ namespace App\Models; use Config\Database; /** - * IDEE-5 — lecture proto Radar (exceptions + fils + skills). - * Le moteur Gmail/Gemini n’écrit pas encore ici. + * IDEE-5 / MSOP-4 / MSOP-3 — radar_threads + skills + analyse IA. */ class RadarModel { @@ -19,7 +18,19 @@ class RadarModel { $row = $this->db()->query("SHOW TABLES LIKE 'radar_threads'")->getRowArray(); - return !empty($row); + return ! empty($row); + } + + public function hasAnalysisColumns(): bool + { + static $cached = null; + if ($cached !== null) { + return $cached; + } + $row = $this->db()->query("SHOW COLUMNS FROM radar_threads LIKE 'analysis'")->getRowArray(); + $cached = ! empty($row); + + return $cached; } public function listExceptions(): array @@ -58,10 +69,10 @@ class RadarModel foreach ($rows as &$row) { $id = (int) $row['id']; $row['skills'] = $byThread[$id] ?? []; - $row['client_url'] = !empty($row['client_id']) + $row['client_url'] = ! empty($row['client_id']) ? '/index.php/clients/form/' . (int) $row['client_id'] : ''; - $row['projet_url'] = !empty($row['projet_id']) + $row['projet_url'] = ! empty($row['projet_id']) ? '/index.php/projets/form/' . (int) $row['projet_id'] : ''; } @@ -71,12 +82,36 @@ class RadarModel } /** - * Insert ou met à jour un fil (dédup gmail_thread_id). Retourne insert|update. + * Historique dossards pour le même expéditeur (hors fil courant), du plus ancien au plus récent. * + * @return list> + */ + public function listPriorDossardThreads(string $fromEmail, string $excludeGmailThreadId, int $limit = 5): array + { + $fromEmail = \strtolower(\trim($fromEmail)); + if ($fromEmail === '') { + return []; + } + + $sql = 'SELECT t.subject, t.summary, t.received_at' + . ($this->hasAnalysisColumns() ? ', t.analysis' : ', NULL AS analysis') + . ' FROM radar_threads t + INNER JOIN radar_thread_skills s ON s.thread_id = t.id AND s.skill = ? + WHERE t.from_email = ? + AND t.gmail_thread_id <> ? + AND t.is_example = 0 + ORDER BY t.received_at ASC, t.id ASC + LIMIT ' . (int) $limit; + + return $this->db()->query($sql, ['dossards', $fromEmail, $excludeGmailThreadId])->getResultArray(); + } + + /** * @param array $data * @param list $skills + * @return array{op:string,id:int} */ - public function upsertThread(array $data, array $skills = []): string + public function upsertThread(array $data, array $skills = []): array { $db = $this->db(); $existing = $db->table('radar_threads') @@ -85,24 +120,27 @@ class RadarModel ->getRowArray(); if ($existing) { - $db->table('radar_threads') - ->where('id', $existing['id']) - ->update([ - 'mailbox' => $data['mailbox'], - 'subject' => $data['subject'], - 'from_email' => $data['from_email'], - 'from_name' => $data['from_name'], - 'gmail_url' => $data['gmail_url'], - 'summary' => $data['summary'], - 'status' => $data['status'], - 'confidence' => $data['confidence'], - 'received_at' => $data['received_at'], - 'is_example' => 0, - ]); + $update = [ + 'mailbox' => $data['mailbox'], + 'subject' => $data['subject'], + 'from_email' => $data['from_email'], + 'from_name' => $data['from_name'], + 'gmail_url' => $data['gmail_url'], + 'summary' => $data['summary'], + 'status' => $data['status'], + 'confidence' => $data['confidence'], + 'received_at' => $data['received_at'], + 'is_example' => 0, + ]; + // Ne pas écraser une analyse IA déjà présente avec le seul snippet. + if ($this->hasAnalysisColumns() && ! empty($existing['analysis'])) { + unset($update['confidence']); + } + $db->table('radar_threads')->where('id', $existing['id'])->update($update); $threadId = (int) $existing['id']; $op = 'update'; } else { - $db->table('radar_threads')->insert([ + $insert = [ 'gmail_thread_id' => $data['gmail_thread_id'], 'mailbox' => $data['mailbox'], 'subject' => $data['subject'], @@ -115,13 +153,14 @@ class RadarModel 'is_example' => (int) ($data['is_example'] ?? 0), 'received_at' => $data['received_at'], 'proposed_client_no' => $data['proposed_client_no'] ?? '', - ]); + ]; + $db->table('radar_threads')->insert($insert); $threadId = (int) $db->insertID(); $op = 'insert'; } foreach ($skills as $skill) { - $skill = strtolower(trim($skill)); + $skill = \strtolower(\trim($skill)); if ($skill === '') { continue; } @@ -137,6 +176,35 @@ class RadarModel } } - return $op; + return ['op' => $op, 'id' => $threadId]; + } + + public function saveAnalysis(int $threadId, string $analysis, string $delta, int $confidence): void + { + if (! $this->hasAnalysisColumns()) { + // Fallback : ranger l’analyse dans summary si colonnes absentes. + $this->db()->table('radar_threads')->where('id', $threadId)->update([ + 'summary' => \mb_substr($analysis, 0, 1000), + 'confidence' => $confidence, + ]); + + return; + } + + $this->db()->table('radar_threads')->where('id', $threadId)->update([ + 'analysis' => $analysis, + 'analysis_delta' => $delta, + 'confidence' => $confidence, + ]); + } + + public function needsAnalysis(int $threadId): bool + { + if (! $this->hasAnalysisColumns()) { + return true; + } + $row = $this->db()->table('radar_threads')->select('analysis')->where('id', $threadId)->get()->getRowArray(); + + return empty($row['analysis']); } } diff --git a/v4_ci4/app/Views/radar/index.php b/v4_ci4/app/Views/radar/index.php index f3d98494..904a4086 100644 --- a/v4_ci4/app/Views/radar/index.php +++ b/v4_ci4/app/Views/radar/index.php @@ -14,6 +14,15 @@ $syncUrl = $syncUrl ?? site_url('v4/radar/sync'); $allowlist = $allowlist ?? []; $syncMsg = $syncMsg ?? session()->getFlashdata('radar_sync_msg'); +/** Texte Gmail / BD : décoder entités puis échapper pour HTML. */ +$plain = static function (?string $s): string { + if ($s === null || $s === '') { + return ''; + } + + return \html_entity_decode($s, \ENT_QUOTES | \ENT_HTML5, 'UTF-8'); +}; + $statusLabel = static function (string $status): string { return match ($status) { 'classe' => 'Classé', @@ -78,7 +87,7 @@ $statusClass = static function (string $status): string {

Sync = Gmail readonly sur . - Analyse IA (MSOP-3) pas encore. + MSOP-3 : analyse IA dossards (+ delta) si clé Gemini présente.

Sync Gmail (v0) @@ -121,10 +130,10 @@ $statusClass = static function (string $status): string {
-

+

- - · + + ·

@@ -142,8 +151,19 @@ $statusClass = static function (string $status): string { exemple proto
- -

+ +
+

Analyse IA

+

+
+ +
+

Delta vs mails dossards précédents

+

+
+ + +

@@ -196,6 +216,13 @@ $statusClass = static function (string $status): string { return d.innerHTML; } + function plain(s) { + if (s == null || s === '') return ''; + const t = document.createElement('textarea'); + t.innerHTML = String(s); + return t.value; + } + function render(threads) { countEl.textContent = (threads.length || 0) + ' fil(s)'; if (!threads.length) { @@ -225,14 +252,27 @@ $statusClass = static function (string $status): string { if (t.gmail_url) { links += 'Gmail'; } + let body = ''; + if (t.analysis) { + body += '
' + + '

Analyse IA

' + + '

' + esc(plain(t.analysis)) + '

'; + if (t.analysis_delta) { + body += '
' + + '

Delta vs mails dossards précédents

' + + '

' + esc(plain(t.analysis_delta)) + '

'; + } + } else if (t.summary) { + body += '

' + esc(plain(t.summary)) + '

'; + } return '
' + '
' - + '

' + esc(t.subject || '(sans objet)') + '

' - + '

' + esc((t.from_name ? t.from_name + ' · ' : '') + t.from_email) + ' · ' + esc(t.mailbox) + '

' + + '

' + esc(plain(t.subject) || '(sans objet)') + '

' + + '

' + esc((t.from_name ? plain(t.from_name) + ' · ' : '') + plain(t.from_email)) + ' · ' + esc(plain(t.mailbox)) + '

' + '
' + esc(statusLabel[st] || st) + '
' + '
' + skills + num + ex + '
' - + (t.summary ? '

' + esc(t.summary) + '

' : '') + + body + '
' + links + '
'; }).join(''); }