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.
This commit is contained in:
@ -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.
|
||||
|
||||
7
.gitignore
vendored
7
.gitignore
vendored
@ -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
|
||||
Thumbs.db
|
||||
|
||||
7
application/credentials/gemini_api_key.txt.example
Normal file
7
application/credentials/gemini_api_key.txt.example
Normal file
@ -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
|
||||
10
sql/MSOP-3-radar-analysis.sql
Normal file
10
sql/MSOP-3-radar-analysis.sql
Normal file
@ -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 '';
|
||||
@ -1,6 +0,0 @@
|
||||
<?php
|
||||
/**
|
||||
* TEMP diagnostic — remove after checking curl on ea-php82.
|
||||
* URL: /v4/phpinfo.php
|
||||
*/
|
||||
phpinfo();
|
||||
@ -31,4 +31,17 @@ class Radar extends BaseConfig
|
||||
public string $serviceAccountJson = '../application/credentials/service_account.json';
|
||||
|
||||
public string $gmailScope = 'https://www.googleapis.com/auth/gmail.readonly';
|
||||
|
||||
/**
|
||||
* MSOP-3 — clé API Gemini (1 ligne). Relatif à ROOTPATH (v4_ci4/).
|
||||
* Fichier typique : ../application/credentials/gemini_api_key.txt (1Password).
|
||||
* Vide / absent = sync Gmail OK, analyse IA ignorée.
|
||||
*/
|
||||
public string $geminiApiKeyFile = '../application/credentials/gemini_api_key.txt';
|
||||
|
||||
/** Modèle Gemini (flash = coût/latence bas pour v0). */
|
||||
public string $geminiModel = 'gemini-2.0-flash';
|
||||
|
||||
/** Max analyses IA par run de sync (garde-fou coût). */
|
||||
public int $maxAiAnalysesPerSync = 15;
|
||||
}
|
||||
|
||||
@ -80,6 +80,23 @@ 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['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');
|
||||
}
|
||||
}
|
||||
|
||||
// Message aussi en query : visible même si la flash session CI4 ne tient pas.
|
||||
$q = rawurlencode(mb_substr($msg, 0, 800));
|
||||
|
||||
87
v4_ci4/app/Libraries/Radar/GeminiClient.php
Normal file
87
v4_ci4/app/Libraries/Radar/GeminiClient.php
Normal file
@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
namespace App\Libraries\Radar;
|
||||
|
||||
use Config\Radar as RadarConfig;
|
||||
|
||||
/**
|
||||
* MSOP-3 — client Gemini (Generative Language API).
|
||||
*/
|
||||
class GeminiClient
|
||||
{
|
||||
public function __construct(
|
||||
private RadarConfig $config,
|
||||
private GoogleWorkspaceToken $http,
|
||||
) {
|
||||
}
|
||||
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return $this->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];
|
||||
}
|
||||
}
|
||||
@ -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<string, mixed> $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}
|
||||
*/
|
||||
|
||||
95
v4_ci4/app/Libraries/Radar/RadarAnalyzeService.php
Normal file
95
v4_ci4/app/Libraries/Radar/RadarAnalyzeService.php
Normal file
@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace App\Libraries\Radar;
|
||||
|
||||
/**
|
||||
* MSOP-3 — analyse métier dossards + delta vs mails précédents du même expéditeur.
|
||||
* L’IA signale ; elle n’approuve jamais (pas de Go / tampon).
|
||||
*/
|
||||
class RadarAnalyzeService
|
||||
{
|
||||
public function __construct(private GeminiClient $gemini)
|
||||
{
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->gemini->isConfigured();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<array{subject:string,summary:string,analysis:?string,received_at:?string}> $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 = <<<PROMPT
|
||||
Tu es l'assistant Radar du CRM MS1 Timing (chronométrage / dossards).
|
||||
Tu SIGNALES seulement. Tu n'APPROUVES JAMAIS une impression ni un Go.
|
||||
Réponds UNIQUEMENT en JSON valide UTF-8, sans markdown autour, schéma :
|
||||
{"analysis":"string","delta":"string","confidence":0}
|
||||
|
||||
- analysis : analyse métier dossards courte (FR), points utiles : logos, séquences, imprimeur, délais, fichiers, questions ouvertes, signaux faibles vs forts. Pas de copier-coller du mail. Max ~1200 caractères.
|
||||
- delta : si 1er mail → chaîne vide "". Sinon : ce qui change / stable / nouveau / contradictoire vs l'historique. Max ~800 caractères.
|
||||
- confidence : entier 0-100 (fiabilité de ton analyse, pas un feu vert).
|
||||
|
||||
Contexte :
|
||||
Expéditeur : {$fromEmail}
|
||||
Sujet : {$subject}
|
||||
Premier mail dossards pour cet expéditeur : {$this->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';
|
||||
}
|
||||
}
|
||||
@ -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<array<string,mixed>>, error?:string}
|
||||
* @return array{ok:bool, mailboxes:list<array<string,mixed>>, error?:string, ai?:array<string,mixed>}
|
||||
*/
|
||||
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<string>
|
||||
*/
|
||||
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';
|
||||
|
||||
@ -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<array<string, mixed>>
|
||||
*/
|
||||
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<string, mixed> $data
|
||||
* @param list<string> $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']);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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 {
|
||||
<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 IA (MSOP-3) pas encore.
|
||||
MSOP-3 : analyse IA dossards (+ delta) si clé Gemini présente.
|
||||
</p>
|
||||
<a href="<?= esc($syncUrl) ?>" class="btn bg-primary text-white shrink-0">
|
||||
Sync Gmail (v0)
|
||||
@ -121,10 +130,10 @@ $statusClass = static function (string $status): string {
|
||||
<article class="rounded-lg border border-zinc-100 p-3 dark:border-zinc-800">
|
||||
<div class="flex flex-wrap items-start justify-between gap-2">
|
||||
<div>
|
||||
<p class="font-medium text-zinc-900 dark:text-white"><?= esc($t['subject'] ?: '(sans objet)') ?></p>
|
||||
<p class="font-medium text-zinc-900 dark:text-white"><?= esc($plain($t['subject'] ?: '(sans objet)')) ?></p>
|
||||
<p class="text-xs text-zinc-500">
|
||||
<?= esc($t['from_name'] !== '' ? $t['from_name'] . ' · ' : '') ?><?= esc($t['from_email']) ?>
|
||||
· <?= esc($t['mailbox']) ?>
|
||||
<?= esc($t['from_name'] !== '' ? $plain($t['from_name']) . ' · ' : '') ?><?= esc($plain($t['from_email'])) ?>
|
||||
· <?= esc($plain($t['mailbox'])) ?>
|
||||
</p>
|
||||
</div>
|
||||
<span class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium <?= $statusClass($t['status']) ?>">
|
||||
@ -142,8 +151,19 @@ $statusClass = static function (string $status): string {
|
||||
<span class="rounded-md border border-dashed border-zinc-300 px-2 py-0.5 text-xs text-zinc-500">exemple proto</span>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php if ($t['summary'] !== ''): ?>
|
||||
<p class="mt-2 text-sm text-zinc-600 dark:text-zinc-400"><?= esc($t['summary']) ?></p>
|
||||
<?php if (!empty($t['analysis'])): ?>
|
||||
<div class="mt-2 rounded-lg bg-zinc-50 px-3 py-2 text-sm text-zinc-800 dark:bg-zinc-800/60 dark:text-zinc-200">
|
||||
<p class="mb-1 text-xs font-semibold uppercase tracking-wide text-zinc-500">Analyse IA</p>
|
||||
<p class="m-0 whitespace-pre-wrap"><?= esc($plain($t['analysis'])) ?></p>
|
||||
</div>
|
||||
<?php if (!empty($t['analysis_delta'])): ?>
|
||||
<div class="mt-2 rounded-lg border border-zinc-200 bg-amber-500/10 px-3 py-2 text-sm text-zinc-800 dark:border-zinc-700 dark:text-zinc-200">
|
||||
<p class="mb-1 text-xs font-semibold uppercase tracking-wide text-zinc-500">Delta vs mails dossards précédents</p>
|
||||
<p class="m-0 whitespace-pre-wrap"><?= esc($plain($t['analysis_delta'])) ?></p>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
<?php elseif ($t['summary'] !== ''): ?>
|
||||
<p class="mt-2 text-sm text-zinc-600 dark:text-zinc-400"><?= esc($plain($t['summary'])) ?></p>
|
||||
<?php endif; ?>
|
||||
<div class="mt-2 flex flex-wrap gap-3 text-xs">
|
||||
<?php if (!empty($t['projet_url'])): ?>
|
||||
@ -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 += '<a class="text-zinc-500 hover:underline" href="' + esc(t.gmail_url) + '" target="_blank" rel="noopener">Gmail</a>';
|
||||
}
|
||||
let body = '';
|
||||
if (t.analysis) {
|
||||
body += '<div class="mt-2 rounded-lg bg-zinc-50 px-3 py-2 text-sm text-zinc-800 dark:bg-zinc-800/60 dark:text-zinc-200">'
|
||||
+ '<p class="mb-1 text-xs font-semibold uppercase tracking-wide text-zinc-500">Analyse IA</p>'
|
||||
+ '<p class="m-0 whitespace-pre-wrap">' + esc(plain(t.analysis)) + '</p></div>';
|
||||
if (t.analysis_delta) {
|
||||
body += '<div class="mt-2 rounded-lg border border-zinc-200 bg-amber-500/10 px-3 py-2 text-sm text-zinc-800 dark:border-zinc-700 dark:text-zinc-200">'
|
||||
+ '<p class="mb-1 text-xs font-semibold uppercase tracking-wide text-zinc-500">Delta vs mails dossards précédents</p>'
|
||||
+ '<p class="m-0 whitespace-pre-wrap">' + esc(plain(t.analysis_delta)) + '</p></div>';
|
||||
}
|
||||
} else if (t.summary) {
|
||||
body += '<p class="mt-2 text-sm text-zinc-600 dark:text-zinc-400">' + esc(plain(t.summary)) + '</p>';
|
||||
}
|
||||
return '<article class="rounded-lg border border-zinc-100 p-3 dark:border-zinc-800">'
|
||||
+ '<div class="flex flex-wrap items-start justify-between gap-2"><div>'
|
||||
+ '<p class="font-medium text-zinc-900 dark:text-white">' + esc(t.subject || '(sans objet)') + '</p>'
|
||||
+ '<p class="text-xs text-zinc-500">' + esc((t.from_name ? t.from_name + ' · ' : '') + t.from_email) + ' · ' + esc(t.mailbox) + '</p>'
|
||||
+ '<p class="font-medium text-zinc-900 dark:text-white">' + esc(plain(t.subject) || '(sans objet)') + '</p>'
|
||||
+ '<p class="text-xs text-zinc-500">' + esc((t.from_name ? plain(t.from_name) + ' · ' : '') + plain(t.from_email)) + ' · ' + esc(plain(t.mailbox)) + '</p>'
|
||||
+ '</div><span class="inline-flex rounded-full px-2 py-0.5 text-xs font-medium ' + (statusClass[st] || statusClass.a_classer) + '">'
|
||||
+ esc(statusLabel[st] || st) + '</span></div>'
|
||||
+ '<div class="mt-2 flex flex-wrap gap-1">' + skills + num + ex + '</div>'
|
||||
+ (t.summary ? '<p class="mt-2 text-sm text-zinc-600 dark:text-zinc-400">' + esc(t.summary) + '</p>' : '')
|
||||
+ body
|
||||
+ '<div class="mt-2 flex flex-wrap gap-3 text-xs">' + links + '</div></article>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user