diff --git a/sql/MSOP-3-radar-analysis.sql b/sql/MSOP-3-radar-analysis.sql index 6724df5c..76c72ef7 100644 --- a/sql/MSOP-3-radar-analysis.sql +++ b/sql/MSOP-3-radar-analysis.sql @@ -1,10 +1,14 @@ -- MSOP-3 — colonnes analyse IA dossards (dev) -- Exécuter sur la BD CRM de DEV après IDEE-4-radar-proto.sql +-- Si « Duplicate column » sur analysis : OK déjà fait — lancer seulement le ADD gmail_message_id. 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 ''; + +-- Id message Gmail pour relire le corps lors de l’analyse IA (hors Sync). +ALTER TABLE radar_threads + ADD COLUMN gmail_message_id VARCHAR(64) NOT NULL DEFAULT '' AFTER gmail_thread_id; diff --git a/sql/MSOP-3-radar-gmail-message-id.sql b/sql/MSOP-3-radar-gmail-message-id.sql new file mode 100644 index 00000000..ce053471 --- /dev/null +++ b/sql/MSOP-3-radar-gmail-message-id.sql @@ -0,0 +1,5 @@ +-- MSOP-3 suite — uniquement si analysis existe déjà (erreur 1060 avant). +-- À lancer une fois sur DEV. + +ALTER TABLE radar_threads + ADD COLUMN gmail_message_id VARCHAR(64) NOT NULL DEFAULT '' AFTER gmail_thread_id; diff --git a/v4_ci4/app/Config/Radar.php b/v4_ci4/app/Config/Radar.php index cc7820ce..114874f3 100644 --- a/v4_ci4/app/Config/Radar.php +++ b/v4_ci4/app/Config/Radar.php @@ -33,6 +33,6 @@ class Radar extends BaseConfig public string $gmailScope = 'https://www.googleapis.com/auth/gmail.readonly'; - /** Max analyses IA dossards par run de sync (garde-fou coût). */ - public int $maxAiAnalysesPerSync = 15; + /** Max analyses IA dossards par clic « Analyser » (garde-fou temps HTTP). */ + public int $maxAiAnalysesPerSync = 3; } diff --git a/v4_ci4/app/Config/Routes.php b/v4_ci4/app/Config/Routes.php index a80a47ab..74fb0b17 100644 --- a/v4_ci4/app/Config/Routes.php +++ b/v4_ci4/app/Config/Routes.php @@ -10,7 +10,8 @@ $routes->get('/', 'Home::index'); // IDEE-5 — proto Radar (écran CRM v4) $routes->get('radar', 'Radar::index'); $routes->get('radar/feed', 'Radar::feed'); -$routes->get('radar/sync', 'Radar::sync'); // MSOP-4 — déclenche sync allowlist (auth requise) +$routes->get('radar/sync', 'Radar::sync'); // MSOP-4 — Gmail allowlist (rapide) +$routes->get('radar/analyze', 'Radar::analyze'); // MSOP-3 — IA dossards (séparé) $routes->get('inventaire/redirect/(:segment)', 'Inventaire::redirect/$1'); $routes->get('inventaire/test/(:segment)', 'Inventaire::test/$1'); diff --git a/v4_ci4/app/Controllers/Radar.php b/v4_ci4/app/Controllers/Radar.php index d28bbd83..4af10c34 100644 --- a/v4_ci4/app/Controllers/Radar.php +++ b/v4_ci4/app/Controllers/Radar.php @@ -8,7 +8,7 @@ use Config\Radar as RadarConfig; /** * IDEE-5 / MSOP-2 — proto écran Radar (CI4 / Raven). - * MSOP-4 — sync Gmail allowlist. + * MSOP-4 — sync Gmail. MSOP-3 — analyse IA (route séparée). */ class Radar extends BaseController { @@ -18,18 +18,24 @@ class Radar extends BaseController $ready = $model->tablesExist(); $cfg = config(RadarConfig::class); - // Flash session souvent perdu sous /v4/ — fallback query string. $syncMsg = session()->getFlashdata('radar_sync_msg'); if ($syncMsg === null || $syncMsg === '') { $syncMsg = $this->request->getGet('sync_msg'); } + $pendingAi = 0; + if ($ready && $model->hasAnalysisColumns()) { + $pendingAi = $model->countThreadsNeedingDossardAnalysis(); + } + return view('radar/index', [ 'ready' => $ready, 'exceptions' => $ready ? $model->listExceptions() : [], 'threads' => $ready ? $model->listThreads() : [], 'feedUrl' => site_url('v4/radar/feed'), 'syncUrl' => site_url('v4/radar/sync'), + 'analyzeUrl' => site_url('v4/radar/analyze'), + 'pendingAi' => $pendingAi, 'allowlist' => $cfg->mailboxAllowlist, 'syncMsg' => is_string($syncMsg) ? $syncMsg : null, ]); @@ -53,7 +59,7 @@ class Radar extends BaseController } /** - * Sync Gmail → radar_* (mailbox allowlist uniquement). + * Sync Gmail → radar_* (rapide, sans LLM). */ public function sync() { @@ -80,28 +86,47 @@ class Radar extends BaseController $msg .= ' | ' . ($row['mailbox'] ?? '?') . ': ' . ($row['error'] ?? 'fail'); } } - if (! empty($result['ai'])) { - $ai = $result['ai']; - if (! empty($ai['enabled'])) { - $msg .= sprintf( - ' | IA dossards: %d analysé(s)', - (int) ($ai['ran'] ?? 0) - ); - if (! empty($ai['model'])) { - $msg .= ' [' . $ai['model'] . ']'; - } - if (! empty($ai['errors'])) { - $msg .= ', ' . (int) $ai['errors'] . ' erreur(s)'; - } - if (! empty($ai['note']) && (int) ($ai['errors'] ?? 0) > 0) { - $msg .= ' (' . \mb_substr((string) $ai['note'], 0, 120) . ')'; - } - } else { - $msg .= ' | IA: ' . ($ai['note'] ?: 'off'); + $msg .= ' — pour l’IA : bouton Analyser dossards'; + + return $this->redirectWithMsg($msg); + } + + /** + * MSOP-3 — analyse IA des fils dossards en attente (peut prendre 1–2 min). + */ + public function analyze() + { + $result = (new RadarSyncService())->analyzePending(); + + if ($this->request->isAJAX() || $this->request->getGet('json') !== null) { + return $this->response->setJSON($result); + } + + if (! empty($result['error'])) { + $msg = 'Analyse IA en erreur — ' . $result['error']; + } else { + $msg = sprintf( + 'Analyse IA : %d fait(s), %d encore en attente [%s]', + (int) ($result['ran'] ?? 0), + (int) ($result['pending'] ?? 0), + (string) ($result['model'] ?? '') + ); + if (! empty($result['errors'])) { + $msg .= ', ' . (int) $result['errors'] . ' erreur(s)'; + } + if (! empty($result['note']) && (int) ($result['errors'] ?? 0) > 0) { + $msg .= ' (' . \mb_substr((string) $result['note'], 0, 120) . ')'; + } + if ((int) ($result['pending'] ?? 0) > 0) { + $msg .= ' — recliquer Analyser pour continuer'; } } - // Message aussi en query : visible même si la flash session CI4 ne tient pas. + return $this->redirectWithMsg($msg); + } + + private function redirectWithMsg(string $msg) + { $q = rawurlencode(mb_substr($msg, 0, 800)); return redirect()->to(site_url('v4/radar') . '?sync_msg=' . $q)->with('radar_sync_msg', $msg); diff --git a/v4_ci4/app/Libraries/Radar/RadarSyncService.php b/v4_ci4/app/Libraries/Radar/RadarSyncService.php index 3e206f9c..373afa73 100644 --- a/v4_ci4/app/Libraries/Radar/RadarSyncService.php +++ b/v4_ci4/app/Libraries/Radar/RadarSyncService.php @@ -8,7 +8,7 @@ use Config\Radar as RadarConfig; /** * MSOP-4 — sync Gmail allowlist → radar_threads. - * MSOP-3 — analyse IA dossards via couche Ai plateforme. + * MSOP-3 — analyse IA dossards via couche Ai (appel séparé, pas dans le Sync HTTP). */ class RadarSyncService { @@ -21,7 +21,9 @@ class RadarSyncService } /** - * @return array{ok:bool, mailboxes:list>, error?:string, ai?:array} + * Lecture Gmail uniquement (rapide). Pas d’appel LLM ici. + * + * @return array{ok:bool, mailboxes:list>, error?:string} */ public function sync(): array { @@ -43,27 +45,7 @@ class RadarSyncService return ['ok' => false, 'mailboxes' => [], 'error' => $e->getMessage()]; } - $analyzer = null; - $aiStats = ['enabled' => false, 'ran' => 0, 'skipped' => 0, 'errors' => 0, 'note' => '', 'model' => '']; - try { - if (! $this->model->hasAnalysisColumns()) { - $aiStats['note'] = 'Colonnes analysis absentes — exécuter sql/MSOP-3-radar-analysis.sql'; - } else { - $ai = AiFactory::client(); - if ($ai->isConfigured()) { - $analyzer = new RadarAnalyzeService($ai); - $aiStats['enabled'] = true; - $aiStats['model'] = $ai->model(); - } else { - $aiStats['note'] = 'IA non configurée (clé absente)'; - } - } - } catch (\Throwable $e) { - $aiStats['note'] = $e->getMessage(); - } - - $results = []; - $aiBudget = $this->config->maxAiAnalysesPerSync; + $results = []; foreach ($this->config->mailboxAllowlist as $mailbox) { $mailbox = \strtolower(\trim($mailbox)); @@ -80,7 +62,6 @@ class RadarSyncService $updated = 0; foreach ($threads as $t) { $skills = $this->guessSkills($t['subject'] . ' ' . $t['snippet']); - $status = 'a_classer'; $n = $this->model->upsertThread([ 'gmail_thread_id' => $t['gmail_thread_id'], 'mailbox' => $mailbox, @@ -89,52 +70,22 @@ class RadarSyncService 'from_name' => \mb_substr($t['from_name'], 0, 190), 'gmail_url' => \mb_substr($t['gmail_url'], 0, 500), 'summary' => \mb_substr($t['snippet'], 0, 1000), - 'status' => $status, + 'status' => 'a_classer', 'confidence' => $skills === [] ? 20 : 45, 'is_example' => 0, 'received_at' => $t['received_at'], 'proposed_client_no' => '', + 'gmail_message_id' => $t['gmail_message_id'] ?? '', ], $skills); + // Stocker l’id message pour l’analyse IA ultérieure + if (! empty($t['gmail_message_id'])) { + $this->model->saveGmailMessageId($n['id'], (string) $t['gmail_message_id']); + } 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'] - ); - $aiResult = $analyzer->analyzeDossards( - $t['subject'], - $t['from_email'], - $body !== '' ? $body : $t['snippet'], - $priors - ); - $this->model->saveAnalysis( - $n['id'], - $aiResult['analysis'], - $aiResult['delta'], - $aiResult['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, @@ -163,7 +114,125 @@ class RadarSyncService return [ 'ok' => $anyOk || $results === [], 'mailboxes' => $results, - 'ai' => $aiStats, + ]; + } + + /** + * MSOP-3 — analyse les fils dossards sans analysis (batch limité). + * + * @return array{ok:bool, ran:int, pending:int, errors:int, model:string, note:string, error?:string} + */ + public function analyzePending(): array + { + if (! $this->model->tablesExist()) { + return [ + 'ok' => false, + 'ran' => 0, + 'pending' => 0, + 'errors' => 0, + 'model' => '', + 'note' => '', + 'error' => 'Tables radar_* absentes', + ]; + } + + if (! $this->model->hasAnalysisColumns()) { + return [ + 'ok' => false, + 'ran' => 0, + 'pending' => 0, + 'errors' => 0, + 'model' => '', + 'note' => '', + 'error' => 'Colonnes analysis absentes — sql/MSOP-3-radar-analysis.sql', + ]; + } + + try { + $ai = AiFactory::client(); + } catch (\Throwable $e) { + return [ + 'ok' => false, + 'ran' => 0, + 'pending' => 0, + 'errors' => 0, + 'model' => '', + 'note' => '', + 'error' => $e->getMessage(), + ]; + } + + if (! $ai->isConfigured()) { + return [ + 'ok' => false, + 'ran' => 0, + 'pending' => 0, + 'errors' => 0, + 'model' => '', + 'note' => '', + 'error' => 'IA non configurée (clé absente)', + ]; + } + + @\set_time_limit(300); + + $jsonPath = \realpath(ROOTPATH . $this->config->serviceAccountJson) + ?: (ROOTPATH . $this->config->serviceAccountJson); + $tokenClient = new GoogleWorkspaceToken($jsonPath); + $reader = new GmailReader($tokenClient, $this->config->gmailScope); + $analyzer = new RadarAnalyzeService($ai); + + $budget = $this->config->maxAiAnalysesPerSync; + $pending = $this->model->listThreadsNeedingDossardAnalysis($budget + 20); + $ran = 0; + $errors = 0; + $note = ''; + + foreach ($pending as $row) { + if ($ran + $errors >= $budget) { + break; + } + $msgId = (string) ($row['gmail_message_id'] ?? ''); + if ($msgId === '') { + // Pas d’id message stocké : on ne peut pas relire le corps ; skip propre. + $errors++; + $note = 'gmail_message_id manquant — re-Sync Gmail puis Analyser'; + continue; + } + try { + $body = $reader->getPlainBody((string) $row['mailbox'], $msgId); + $priors = $this->model->listPriorDossardThreads( + (string) $row['from_email'], + (string) $row['gmail_thread_id'] + ); + $aiResult = $analyzer->analyzeDossards( + (string) $row['subject'], + (string) $row['from_email'], + $body !== '' ? $body : (string) $row['summary'], + $priors + ); + $this->model->saveAnalysis( + (int) $row['id'], + $aiResult['analysis'], + $aiResult['delta'], + $aiResult['confidence'] + ); + $ran++; + } catch (\Throwable $e) { + $errors++; + $note = $e->getMessage(); + } + } + + $still = $this->model->countThreadsNeedingDossardAnalysis(); + + return [ + 'ok' => $errors === 0 || $ran > 0, + 'ran' => $ran, + 'pending' => $still, + 'errors' => $errors, + 'model' => $ai->model(), + 'note' => $note, ]; } diff --git a/v4_ci4/app/Models/RadarModel.php b/v4_ci4/app/Models/RadarModel.php index 77c24c2f..c22ef0e5 100644 --- a/v4_ci4/app/Models/RadarModel.php +++ b/v4_ci4/app/Models/RadarModel.php @@ -33,6 +33,67 @@ class RadarModel return $cached; } + public function hasGmailMessageIdColumn(): bool + { + static $cached = null; + if ($cached !== null) { + return $cached; + } + $row = $this->db()->query("SHOW COLUMNS FROM radar_threads LIKE 'gmail_message_id'")->getRowArray(); + $cached = ! empty($row); + + return $cached; + } + + public function saveGmailMessageId(int $threadId, string $messageId): void + { + if (! $this->hasGmailMessageIdColumn() || $messageId === '') { + return; + } + $this->db()->table('radar_threads')->where('id', $threadId)->update([ + 'gmail_message_id' => \mb_substr($messageId, 0, 64), + ]); + } + + /** + * Fils dossards sans analyse IA, du plus récent au plus ancien. + * + * @return list> + */ + public function listThreadsNeedingDossardAnalysis(int $limit = 20): array + { + if (! $this->hasAnalysisColumns()) { + return []; + } + + $msgCol = $this->hasGmailMessageIdColumn() ? ', t.gmail_message_id' : ', \'\' AS gmail_message_id'; + $sql = 'SELECT t.id, t.gmail_thread_id, t.mailbox, t.subject, t.from_email, t.summary' + . $msgCol + . ' FROM radar_threads t + INNER JOIN radar_thread_skills s ON s.thread_id = t.id AND s.skill = ? + WHERE t.is_example = 0 + AND (t.analysis IS NULL OR t.analysis = \'\') + ORDER BY t.received_at DESC, t.id DESC + LIMIT ' . (int) $limit; + + return $this->db()->query($sql, ['dossards'])->getResultArray(); + } + + public function countThreadsNeedingDossardAnalysis(): int + { + if (! $this->hasAnalysisColumns()) { + return 0; + } + + $sql = 'SELECT COUNT(*) AS c FROM radar_threads t + INNER JOIN radar_thread_skills s ON s.thread_id = t.id AND s.skill = ? + WHERE t.is_example = 0 + AND (t.analysis IS NULL OR t.analysis = \'\')'; + $row = $this->db()->query($sql, ['dossards'])->getRowArray(); + + return (int) ($row['c'] ?? 0); + } + public function listExceptions(): array { return $this->db()->table('radar_exceptions') diff --git a/v4_ci4/app/Views/radar/index.php b/v4_ci4/app/Views/radar/index.php index 93985f88..59830594 100644 --- a/v4_ci4/app/Views/radar/index.php +++ b/v4_ci4/app/Views/radar/index.php @@ -11,6 +11,8 @@ $exceptions = $exceptions ?? []; $threads = $threads ?? []; $feedUrl = $feedUrl ?? site_url('v4/radar/feed'); $syncUrl = $syncUrl ?? site_url('v4/radar/sync'); +$analyzeUrl = $analyzeUrl ?? site_url('v4/radar/analyze'); +$pendingAi = (int) ($pendingAi ?? 0); $allowlist = $allowlist ?? []; $syncMsg = $syncMsg ?? session()->getFlashdata('radar_sync_msg'); @@ -61,9 +63,15 @@ $statusClass = static function (string $status): string {
- - Sync Gmail (v0) - +

Fil mis à jour

@@ -74,7 +82,7 @@ $statusClass = static function (string $status): string {
- Résultat sync : +
@@ -84,15 +92,11 @@ $statusClass = static function (string $status): string {
-
-

- Sync = Gmail readonly sur . - Analyse dossards via couche IA plateforme (Config\Ai). -

- - Sync Gmail (v0) - -
+

+ Sync Gmail = lecture rapide. + Analyser dossards = IA (séparé, ne bloque pas le Sync). + Mailbox : . +

@@ -277,19 +281,27 @@ $statusClass = static function (string $status): string { }).join(''); } + let busy = false; async function tick() { + if (busy) return; + busy = true; try { - const res = await fetch(feedUrl, { credentials: 'same-origin' }); + const ctrl = new AbortController(); + const timer = setTimeout(function () { ctrl.abort(); }, 8000); + const res = await fetch(feedUrl, { credentials: 'same-origin', signal: ctrl.signal }); + clearTimeout(timer); const data = await res.json(); if (clock) clock.textContent = new Date().toLocaleTimeString('fr-CA'); if (data && data.ok) render(data.threads || []); } catch (e) { if (clock) clock.textContent = 'hors ligne'; + } finally { + busy = false; } } tick(); - setInterval(tick, 4000); + setInterval(tick, 15000); })(); endSection() ?>