Refactor Radar functionality: replace Gemini client with AiClient for dossards analysis, update sync service to utilize AI platform, and enhance comments for clarity. Remove deprecated GeminiClient class and adjust related methods for improved data handling.
This commit is contained in:
13
.cursor/rules/projet/10-ai-plateforme.mdc
Normal file
13
.cursor/rules/projet/10-ai-plateforme.mdc
Normal file
@ -0,0 +1,13 @@
|
||||
---
|
||||
description: Couche IA plateforme CRM (20 ans)
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# IA plateforme — CRM MS1
|
||||
|
||||
- Code : `v4_ci4/app/Libraries/Ai/` + `Config/Ai.php`.
|
||||
- Les features (Radar, plus tard Projets/Clients/…) passent par **`AiFactory::client()` / `AiClient`**. Jamais d’appel vendor dans un module métier.
|
||||
- Provider primary : **Gemini** (adaptateur). Autres providers = nouveaux adaptateurs, même contrat.
|
||||
- Modèle par défaut : alias stable `gemini-flash-latest` (éviter les IDs versionnés qui meurent).
|
||||
- Clé : `application/credentials/gemini_api_key.txt` (même flux Git que le SA pour l’instant ; viser projet GCP MS1 facturé, pas free perso long terme).
|
||||
- Agent : pas de patch model-string au feeling ; pas de commit/push sans demande.
|
||||
@ -10,7 +10,7 @@ alwaysApply: true
|
||||
- UI : CI4 / Raven `/v4/radar` — pas SmartAdmin.
|
||||
- Cible long terme : tout `@ms1timing.com` moins `radar_exceptions`.
|
||||
- **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`.
|
||||
- **MSOP-3** : analyse dossards + delta via **`AiClient`** (couche `Libraries/Ai`), pas un client Gemini collé au Radar. 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.
|
||||
|
||||
37
v4_ci4/app/Config/Ai.php
Normal file
37
v4_ci4/app/Config/Ai.php
Normal file
@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace Config;
|
||||
|
||||
use CodeIgniter\Config\BaseConfig;
|
||||
|
||||
/**
|
||||
* Couche IA plateforme CRM (20 ans) — pas spécifique Radar.
|
||||
* Les features (Radar, plus tard Projets/Clients/…) consomment AiClient, jamais un vendor.
|
||||
*/
|
||||
class Ai extends BaseConfig
|
||||
{
|
||||
/**
|
||||
* Provider actif : gemini | (openai|anthropic plus tard).
|
||||
*/
|
||||
public string $provider = 'gemini';
|
||||
|
||||
/**
|
||||
* Modèle. Préférer un alias stable (*-latest) pour limiter le churn d’IDs Google.
|
||||
* Validé via models.list sur la clé du projet (août 2026) : gemini-flash-latest existe.
|
||||
*/
|
||||
public string $model = 'gemini-flash-latest';
|
||||
|
||||
/**
|
||||
* Clé API Gemini — même dossier que le SA Calendar.
|
||||
* Relatif à ROOTPATH (v4_ci4/).
|
||||
*/
|
||||
public string $geminiApiKeyFile = '../application/credentials/gemini_api_key.txt';
|
||||
|
||||
public string $geminiBaseUrl = 'https://generativelanguage.googleapis.com/v1beta';
|
||||
|
||||
public int $timeoutSeconds = 90;
|
||||
|
||||
public int $maxOutputTokens = 2048;
|
||||
|
||||
public float $temperature = 0.2;
|
||||
}
|
||||
@ -5,7 +5,8 @@ namespace Config;
|
||||
use CodeIgniter\Config\BaseConfig;
|
||||
|
||||
/**
|
||||
* MSOP-4 — Radar Gmail v0 (allowlist une mailbox).
|
||||
* MSOP-4 / MSOP-3 — Radar (Gmail + consommation Ai plateforme).
|
||||
* Clé / modèle LLM → Config\Ai (pas ici).
|
||||
*/
|
||||
class Radar extends BaseConfig
|
||||
{
|
||||
@ -32,16 +33,6 @@ class Radar extends BaseConfig
|
||||
|
||||
public string $gmailScope = 'https://www.googleapis.com/auth/gmail.readonly';
|
||||
|
||||
/**
|
||||
* MSOP-3 — clé API Gemini (1 ligne).
|
||||
* Même modèle que service_account.json : versionné + déployé via Git.
|
||||
* Relatif à ROOTPATH (v4_ci4/) → application/credentials/gemini_api_key.txt
|
||||
*/
|
||||
public string $geminiApiKeyFile = '../application/credentials/gemini_api_key.txt';
|
||||
|
||||
/** Modèle Gemini (2.0-flash retiré juin 2026 → 2.5-flash). */
|
||||
public string $geminiModel = 'gemini-2.5-flash';
|
||||
|
||||
/** Max analyses IA par run de sync (garde-fou coût). */
|
||||
/** Max analyses IA dossards par run de sync (garde-fou coût). */
|
||||
public int $maxAiAnalysesPerSync = 15;
|
||||
}
|
||||
|
||||
@ -87,6 +87,9 @@ class Radar extends BaseController
|
||||
' | 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)';
|
||||
}
|
||||
|
||||
29
v4_ci4/app/Libraries/Ai/AiClient.php
Normal file
29
v4_ci4/app/Libraries/Ai/AiClient.php
Normal file
@ -0,0 +1,29 @@
|
||||
<?php
|
||||
|
||||
namespace App\Libraries\Ai;
|
||||
|
||||
/**
|
||||
* Contrat stable CRM — les modules métier ne voient que ça.
|
||||
*/
|
||||
interface AiClient
|
||||
{
|
||||
public function provider(): string;
|
||||
|
||||
public function model(): string;
|
||||
|
||||
public function isConfigured(): bool;
|
||||
|
||||
/**
|
||||
* Complétion texte simple (prompt utilisateur).
|
||||
*
|
||||
* @param array{system?:string, temperature?:float, maxOutputTokens?:int} $options
|
||||
*/
|
||||
public function complete(string $prompt, array $options = []): AiResponse;
|
||||
|
||||
/**
|
||||
* Modèles supportant generateContent (ops / diagnostic).
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
public function listModels(): array;
|
||||
}
|
||||
10
v4_ci4/app/Libraries/Ai/AiException.php
Normal file
10
v4_ci4/app/Libraries/Ai/AiException.php
Normal file
@ -0,0 +1,10 @@
|
||||
<?php
|
||||
|
||||
namespace App\Libraries\Ai;
|
||||
|
||||
/**
|
||||
* Erreur IA plateforme (fournisseur / config / réseau).
|
||||
*/
|
||||
class AiException extends \RuntimeException
|
||||
{
|
||||
}
|
||||
24
v4_ci4/app/Libraries/Ai/AiFactory.php
Normal file
24
v4_ci4/app/Libraries/Ai/AiFactory.php
Normal file
@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace App\Libraries\Ai;
|
||||
|
||||
use App\Libraries\Ai\Providers\GeminiProvider;
|
||||
use Config\Ai as AiConfig;
|
||||
|
||||
/**
|
||||
* Point d’entrée CRM : un seul endroit pour obtenir le client IA actif.
|
||||
*/
|
||||
final class AiFactory
|
||||
{
|
||||
public static function client(?AiConfig $config = null): AiClient
|
||||
{
|
||||
$config ??= config(AiConfig::class);
|
||||
|
||||
return match ($config->provider) {
|
||||
'gemini' => new GeminiProvider($config),
|
||||
default => throw new AiException(
|
||||
'Provider IA inconnu: ' . $config->provider . ' (attendus: gemini)'
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
92
v4_ci4/app/Libraries/Ai/AiHttp.php
Normal file
92
v4_ci4/app/Libraries/Ai/AiHttp.php
Normal file
@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
namespace App\Libraries\Ai;
|
||||
|
||||
/**
|
||||
* HTTP minimal pour les providers IA (curl, fallback stream).
|
||||
* Indépendant du service account Gmail.
|
||||
*/
|
||||
final class AiHttp
|
||||
{
|
||||
/**
|
||||
* @param list<string> $headers
|
||||
* @return array{0:int,1:string}
|
||||
*/
|
||||
public function request(string $method, string $url, array $headers = [], ?string $body = null, int $timeout = 90): array
|
||||
{
|
||||
if (\function_exists('curl_init')) {
|
||||
return $this->viaCurl($method, $url, $headers, $body, $timeout);
|
||||
}
|
||||
|
||||
return $this->viaStream($method, $url, $headers, $body, $timeout);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $headers
|
||||
* @return array{0:int,1:string}
|
||||
*/
|
||||
private function viaCurl(string $method, string $url, array $headers, ?string $body, int $timeout): array
|
||||
{
|
||||
$ch = \curl_init($url);
|
||||
$opts = [
|
||||
\CURLOPT_RETURNTRANSFER => true,
|
||||
\CURLOPT_CUSTOMREQUEST => \strtoupper($method),
|
||||
\CURLOPT_TIMEOUT => $timeout,
|
||||
\CURLOPT_HTTPHEADER => $headers,
|
||||
];
|
||||
if ($body !== null) {
|
||||
$opts[\CURLOPT_POSTFIELDS] = $body;
|
||||
}
|
||||
\curl_setopt_array($ch, $opts);
|
||||
$res = \curl_exec($ch);
|
||||
$code = (int) \curl_getinfo($ch, \CURLINFO_HTTP_CODE);
|
||||
if ($res === false) {
|
||||
$err = \curl_error($ch);
|
||||
\curl_close($ch);
|
||||
throw new AiException('AI HTTP cURL: ' . $err);
|
||||
}
|
||||
\curl_close($ch);
|
||||
|
||||
return [$code, $res];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $headers
|
||||
* @return array{0:int,1:string}
|
||||
*/
|
||||
private function viaStream(string $method, string $url, array $headers, ?string $body, int $timeout): array
|
||||
{
|
||||
$opts = [
|
||||
'http' => [
|
||||
'method' => \strtoupper($method),
|
||||
'header' => \implode("\r\n", $headers),
|
||||
'timeout' => $timeout,
|
||||
'ignore_errors' => true,
|
||||
],
|
||||
'ssl' => [
|
||||
'verify_peer' => true,
|
||||
'verify_peer_name' => true,
|
||||
],
|
||||
];
|
||||
if ($body !== null) {
|
||||
$opts['http']['content'] = $body;
|
||||
}
|
||||
|
||||
$ctx = \stream_context_create($opts);
|
||||
$res = @\file_get_contents($url, false, $ctx);
|
||||
$code = 0;
|
||||
if (isset($http_response_header) && \is_array($http_response_header)) {
|
||||
foreach ($http_response_header as $line) {
|
||||
if (\preg_match('#^HTTP/\S+\s+(\d+)#', $line, $m)) {
|
||||
$code = (int) $m[1];
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if ($res === false) {
|
||||
throw new AiException('AI HTTP stream error for ' . $url);
|
||||
}
|
||||
|
||||
return [$code, $res];
|
||||
}
|
||||
}
|
||||
17
v4_ci4/app/Libraries/Ai/AiResponse.php
Normal file
17
v4_ci4/app/Libraries/Ai/AiResponse.php
Normal file
@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
namespace App\Libraries\Ai;
|
||||
|
||||
/**
|
||||
* Réponse normalisée — indépendante du fournisseur.
|
||||
*/
|
||||
final class AiResponse
|
||||
{
|
||||
public function __construct(
|
||||
public readonly string $text,
|
||||
public readonly string $provider,
|
||||
public readonly string $model,
|
||||
public readonly ?string $raw = null,
|
||||
) {
|
||||
}
|
||||
}
|
||||
161
v4_ci4/app/Libraries/Ai/Providers/GeminiProvider.php
Normal file
161
v4_ci4/app/Libraries/Ai/Providers/GeminiProvider.php
Normal file
@ -0,0 +1,161 @@
|
||||
<?php
|
||||
|
||||
namespace App\Libraries\Ai\Providers;
|
||||
|
||||
use App\Libraries\Ai\AiClient;
|
||||
use App\Libraries\Ai\AiException;
|
||||
use App\Libraries\Ai\AiHttp;
|
||||
use App\Libraries\Ai\AiResponse;
|
||||
use Config\Ai as AiConfig;
|
||||
|
||||
/**
|
||||
* Adaptateur Gemini (Generative Language API).
|
||||
* Seul endroit du CRM qui parle l’API Google Generative.
|
||||
*/
|
||||
final class GeminiProvider implements AiClient
|
||||
{
|
||||
private AiHttp $http;
|
||||
|
||||
public function __construct(
|
||||
private AiConfig $config,
|
||||
?AiHttp $http = null,
|
||||
) {
|
||||
$this->http = $http ?? new AiHttp();
|
||||
}
|
||||
|
||||
public function provider(): string
|
||||
{
|
||||
return 'gemini';
|
||||
}
|
||||
|
||||
public function model(): string
|
||||
{
|
||||
return $this->config->model;
|
||||
}
|
||||
|
||||
public function isConfigured(): bool
|
||||
{
|
||||
return $this->apiKey() !== '';
|
||||
}
|
||||
|
||||
public function complete(string $prompt, array $options = []): AiResponse
|
||||
{
|
||||
$key = $this->apiKey();
|
||||
if ($key === '') {
|
||||
throw new AiException('Clé Gemini absente — application/credentials/gemini_api_key.txt');
|
||||
}
|
||||
|
||||
$model = $options['model'] ?? $this->config->model;
|
||||
$url = \rtrim($this->config->geminiBaseUrl, '/')
|
||||
. '/models/' . \rawurlencode($model)
|
||||
. ':generateContent?key=' . \rawurlencode($key);
|
||||
|
||||
$contents = [];
|
||||
if (! empty($options['system'])) {
|
||||
// systemInstruction (API v1beta)
|
||||
$payload = [
|
||||
'systemInstruction' => [
|
||||
'parts' => [['text' => (string) $options['system']]],
|
||||
],
|
||||
'contents' => [
|
||||
['role' => 'user', 'parts' => [['text' => $prompt]]],
|
||||
],
|
||||
];
|
||||
} else {
|
||||
$payload = [
|
||||
'contents' => [
|
||||
['role' => 'user', 'parts' => [['text' => $prompt]]],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
$payload['generationConfig'] = [
|
||||
'temperature' => $options['temperature'] ?? $this->config->temperature,
|
||||
'maxOutputTokens' => $options['maxOutputTokens'] ?? $this->config->maxOutputTokens,
|
||||
];
|
||||
|
||||
$json = \json_encode($payload, \JSON_UNESCAPED_UNICODE);
|
||||
[$code, $body] = $this->http->request(
|
||||
'POST',
|
||||
$url,
|
||||
['Content-Type: application/json'],
|
||||
$json ?: '{}',
|
||||
$this->config->timeoutSeconds
|
||||
);
|
||||
|
||||
if ($code !== 200) {
|
||||
throw new AiException($this->shortError($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 AiException('Gemini: réponse vide (model=' . $model . ')');
|
||||
}
|
||||
|
||||
return new AiResponse($text, $this->provider(), $model, $body);
|
||||
}
|
||||
|
||||
public function listModels(): array
|
||||
{
|
||||
$key = $this->apiKey();
|
||||
if ($key === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$url = \rtrim($this->config->geminiBaseUrl, '/')
|
||||
. '/models?key=' . \rawurlencode($key) . '&pageSize=100';
|
||||
|
||||
[$code, $body] = $this->http->request('GET', $url, [], null, 60);
|
||||
if ($code !== 200) {
|
||||
throw new AiException($this->shortError($code, $body));
|
||||
}
|
||||
|
||||
$data = \json_decode($body, true) ?: [];
|
||||
$out = [];
|
||||
foreach ($data['models'] ?? [] as $m) {
|
||||
$methods = $m['supportedGenerationMethods'] ?? [];
|
||||
if (! \in_array('generateContent', $methods, true)) {
|
||||
continue;
|
||||
}
|
||||
$name = (string) ($m['name'] ?? '');
|
||||
if (\str_starts_with($name, 'models/')) {
|
||||
$name = \substr($name, 7);
|
||||
}
|
||||
if ($name !== '') {
|
||||
$out[] = $name;
|
||||
}
|
||||
}
|
||||
\sort($out);
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
private function apiKey(): string
|
||||
{
|
||||
$path = \realpath(ROOTPATH . $this->config->geminiApiKeyFile)
|
||||
?: (ROOTPATH . $this->config->geminiApiKeyFile);
|
||||
if (! \is_file($path)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return \trim((string) \file_get_contents($path));
|
||||
}
|
||||
|
||||
private function shortError(int $code, string $body): string
|
||||
{
|
||||
$msg = $body;
|
||||
$j = \json_decode($body, true);
|
||||
if (\is_array($j) && ! empty($j['error']['message'])) {
|
||||
$msg = (string) $j['error']['message'];
|
||||
}
|
||||
|
||||
return 'Gemini HTTP ' . $code . ': ' . \mb_substr($msg, 0, 240);
|
||||
}
|
||||
}
|
||||
@ -1,87 +0,0 @@
|
||||
<?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];
|
||||
}
|
||||
}
|
||||
@ -2,19 +2,24 @@
|
||||
|
||||
namespace App\Libraries\Radar;
|
||||
|
||||
use App\Libraries\Ai\AiClient;
|
||||
use App\Libraries\Ai\AiException;
|
||||
use App\Libraries\Ai\AiFactory;
|
||||
|
||||
/**
|
||||
* 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).
|
||||
* MSOP-3 — analyse métier dossards + delta.
|
||||
* Utilise la couche IA plateforme (AiClient), pas un vendor direct.
|
||||
*/
|
||||
class RadarAnalyzeService
|
||||
{
|
||||
public function __construct(private GeminiClient $gemini)
|
||||
public function __construct(private ?AiClient $ai = null)
|
||||
{
|
||||
$this->ai ??= AiFactory::client();
|
||||
}
|
||||
|
||||
public function isEnabled(): bool
|
||||
{
|
||||
return $this->gemini->isConfigured();
|
||||
return $this->ai->isConfigured();
|
||||
}
|
||||
|
||||
/**
|
||||
@ -36,12 +41,14 @@ class RadarAnalyzeService
|
||||
}
|
||||
|
||||
$isFirst = $priors === [];
|
||||
$prompt = <<<PROMPT
|
||||
$system = <<<'SYS'
|
||||
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}
|
||||
SYS;
|
||||
|
||||
$prompt = <<<PROMPT
|
||||
- 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).
|
||||
@ -58,16 +65,20 @@ Mail courant (corps) :
|
||||
{$body}
|
||||
PROMPT;
|
||||
|
||||
$out = $this->gemini->generate($prompt);
|
||||
$text = $out['text'];
|
||||
// Extraire JSON si le modèle ajoute du bruit
|
||||
try {
|
||||
$out = $this->ai->complete($prompt, ['system' => $system]);
|
||||
} catch (AiException $e) {
|
||||
throw new \RuntimeException($e->getMessage(), (int) $e->getCode(), $e);
|
||||
}
|
||||
|
||||
$text = $out->text;
|
||||
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),
|
||||
'analysis' => \mb_substr($out->text, 0, 2000),
|
||||
'delta' => $isFirst ? '' : 'Delta non structuré — voir analyse.',
|
||||
'confidence' => 40,
|
||||
];
|
||||
@ -82,7 +93,7 @@ PROMPT;
|
||||
}
|
||||
|
||||
return [
|
||||
'analysis' => \mb_substr($analysis !== '' ? $analysis : $out['text'], 0, 4000),
|
||||
'analysis' => \mb_substr($analysis !== '' ? $analysis : $out->text, 0, 4000),
|
||||
'delta' => \mb_substr($delta, 0, 2000),
|
||||
'confidence' => $conf,
|
||||
];
|
||||
|
||||
@ -2,12 +2,13 @@
|
||||
|
||||
namespace App\Libraries\Radar;
|
||||
|
||||
use App\Libraries\Ai\AiFactory;
|
||||
use App\Models\RadarModel;
|
||||
use Config\Radar as RadarConfig;
|
||||
|
||||
/**
|
||||
* MSOP-4 — sync Gmail allowlist → radar_threads.
|
||||
* MSOP-3 — analyse IA dossards (+ delta) si Gemini configuré.
|
||||
* MSOP-3 — analyse IA dossards via couche Ai plateforme.
|
||||
*/
|
||||
class RadarSyncService
|
||||
{
|
||||
@ -43,21 +44,22 @@ class RadarSyncService
|
||||
}
|
||||
|
||||
$analyzer = null;
|
||||
$aiStats = ['enabled' => false, 'ran' => 0, 'skipped' => 0, 'errors' => 0, 'note' => ''];
|
||||
$aiStats = ['enabled' => false, 'ran' => 0, 'skipped' => 0, 'errors' => 0, 'note' => '', 'model' => ''];
|
||||
try {
|
||||
$gemini = new GeminiClient($this->config, $tokenClient);
|
||||
if ($gemini->isConfigured()) {
|
||||
$analyzer = new RadarAnalyzeService($gemini);
|
||||
$ai = AiFactory::client();
|
||||
if ($ai->isConfigured()) {
|
||||
$analyzer = new RadarAnalyzeService($ai);
|
||||
$aiStats['enabled'] = true;
|
||||
$aiStats['model'] = $ai->model();
|
||||
} else {
|
||||
$aiStats['note'] = 'Gemini non configuré (fichier clé absent)';
|
||||
$aiStats['note'] = 'IA non configurée (clé absente)';
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$aiStats['note'] = $e->getMessage();
|
||||
}
|
||||
|
||||
$results = [];
|
||||
$aiBudget = $this->config->maxAiAnalysesPerSync;
|
||||
$results = [];
|
||||
$aiBudget = $this->config->maxAiAnalysesPerSync;
|
||||
|
||||
foreach ($this->config->mailboxAllowlist as $mailbox) {
|
||||
$mailbox = \strtolower(\trim($mailbox));
|
||||
@ -108,7 +110,7 @@ class RadarSyncService
|
||||
$t['from_email'],
|
||||
$t['gmail_thread_id']
|
||||
);
|
||||
$ai = $analyzer->analyzeDossards(
|
||||
$aiResult = $analyzer->analyzeDossards(
|
||||
$t['subject'],
|
||||
$t['from_email'],
|
||||
$body !== '' ? $body : $t['snippet'],
|
||||
@ -116,9 +118,9 @@ class RadarSyncService
|
||||
);
|
||||
$this->model->saveAnalysis(
|
||||
$n['id'],
|
||||
$ai['analysis'],
|
||||
$ai['delta'],
|
||||
$ai['confidence']
|
||||
$aiResult['analysis'],
|
||||
$aiResult['delta'],
|
||||
$aiResult['confidence']
|
||||
);
|
||||
$aiStats['ran']++;
|
||||
$aiBudget--;
|
||||
@ -162,8 +164,6 @@ class RadarSyncService
|
||||
}
|
||||
|
||||
/**
|
||||
* Heuristique skills (avant IA).
|
||||
*
|
||||
* @return list<string>
|
||||
*/
|
||||
private function guessSkills(string $text): array
|
||||
|
||||
@ -87,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>.
|
||||
MSOP-3 : analyse IA dossards (+ delta) si clé Gemini présente.
|
||||
Analyse dossards via couche IA plateforme (Config\Ai).
|
||||
</p>
|
||||
<a href="<?= esc($syncUrl) ?>" class="btn bg-primary text-white shrink-0">
|
||||
Sync Gmail (v0)
|
||||
|
||||
Reference in New Issue
Block a user