Enhance Radar functionality: add new columns for AI analysis and Gmail message ID in the radar_threads table, update Radar controller to handle separate analysis requests, and improve UI to reflect pending AI analyses. Adjust sync service to store Gmail message IDs for future analysis and refine comments for clarity.

This commit is contained in:
2026-08-21 14:41:20 -04:00
parent dfe85296a8
commit e7d525804b
8 changed files with 279 additions and 102 deletions

View File

@ -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 lanalyse IA (hors Sync).
ALTER TABLE radar_threads
ADD COLUMN gmail_message_id VARCHAR(64) NOT NULL DEFAULT '' AFTER gmail_thread_id;

View File

@ -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;

View File

@ -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;
}

View File

@ -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');

View File

@ -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 lIA : bouton Analyser dossards';
return $this->redirectWithMsg($msg);
}
/**
* MSOP-3 — analyse IA des fils dossards en attente (peut prendre 12 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);

View File

@ -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<array<string,mixed>>, error?:string, ai?:array<string,mixed>}
* Lecture Gmail uniquement (rapide). Pas dappel LLM ici.
*
* @return array{ok:bool, mailboxes:list<array<string,mixed>>, 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 lid message pour lanalyse 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 did 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,
];
}

View File

@ -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<array<string, mixed>>
*/
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')

View File

@ -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 {
</div>
<div class="flex flex-col items-end gap-2">
<?php if ($ready): ?>
<a href="<?= esc($syncUrl) ?>" class="btn bg-primary text-white">
Sync Gmail (v0)
</a>
<div class="flex flex-wrap justify-end gap-2">
<a href="<?= esc($syncUrl) ?>" class="btn bg-primary text-white">
Sync Gmail
</a>
<a href="<?= esc($analyzeUrl) ?>" class="btn btn-default"
title="Peut prendre 12 minutes (batch limité)">
Analyser dossards<?= $pendingAi > 0 ? ' (' . $pendingAi . ')' : '' ?>
</a>
</div>
<?php endif; ?>
<div class="text-right">
<p class="text-xs text-zinc-500">Fil mis à jour</p>
@ -74,7 +82,7 @@ $statusClass = static function (string $status): string {
<?php if ($syncMsg): ?>
<div class="rounded-xl border border-zinc-300 bg-amber-500/20 px-4 py-3 text-sm font-medium text-zinc-900 dark:border-zinc-600 dark:text-zinc-100">
Résultat sync : <?= esc($syncMsg) ?>
<?= esc($syncMsg) ?>
</div>
<?php endif; ?>
@ -84,15 +92,11 @@ $statusClass = static function (string $status): string {
</div>
<?php else: ?>
<div class="rounded-xl border border-zinc-200 bg-white px-4 py-4 text-sm text-zinc-600 dark:border-zinc-800 dark:bg-zinc-900 dark:text-zinc-300">
<div class="flex flex-wrap items-center justify-between gap-3">
<p class="m-0 max-w-2xl">
Sync = Gmail readonly sur <span class="font-mono text-xs"><?= esc(implode(', ', $allowlist)) ?></span>.
Analyse dossards via couche IA plateforme (Config\Ai).
</p>
<a href="<?= esc($syncUrl) ?>" class="btn bg-primary text-white shrink-0">
Sync Gmail (v0)
</a>
</div>
<p class="m-0">
<strong>Sync Gmail</strong> = lecture rapide.
<strong>Analyser dossards</strong> = IA (séparé, ne bloque pas le Sync).
Mailbox : <span class="font-mono text-xs"><?= esc(implode(', ', $allowlist)) ?></span>.
</p>
</div>
<?php endif; ?>
@ -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);
})();
</script>
<?= $this->endSection() ?>