db()->query("SHOW TABLES LIKE 'radar_threads'")->getRowArray(); 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 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') ->where('active', 1) ->orderBy('kind', 'ASC') ->orderBy('value', 'ASC') ->get() ->getResultArray(); } public function listThreads(): array { $rows = $this->db()->table('radar_threads') ->orderBy('received_at', 'DESC') ->orderBy('id', 'DESC') ->get() ->getResultArray(); if ($rows === []) { return []; } $ids = array_column($rows, 'id'); $skillRows = $this->db()->table('radar_thread_skills') ->whereIn('thread_id', $ids) ->get() ->getResultArray(); $byThread = []; foreach ($skillRows as $s) { $byThread[(int) $s['thread_id']][] = $s['skill']; } foreach ($rows as &$row) { $id = (int) $row['id']; $row['skills'] = $byThread[$id] ?? []; $row['client_url'] = ! empty($row['client_id']) ? '/index.php/clients/form/' . (int) $row['client_id'] : ''; $row['projet_url'] = ! empty($row['projet_id']) ? '/index.php/projets/form/' . (int) $row['projet_id'] : ''; } unset($row); return $rows; } /** * 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 = []): array { $db = $this->db(); $existing = $db->table('radar_threads') ->where('gmail_thread_id', $data['gmail_thread_id']) ->get() ->getRowArray(); if ($existing) { $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 { $insert = [ 'gmail_thread_id' => $data['gmail_thread_id'], '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'], '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)); if ($skill === '') { continue; } $exists = $db->table('radar_thread_skills') ->where('thread_id', $threadId) ->where('skill', $skill) ->countAllResults(); if ($exists === 0) { $db->table('radar_thread_skills')->insert([ 'thread_id' => $threadId, 'skill' => $skill, ]); } } 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()) { // Sans sql/MSOP-3-radar-analysis.sql on ne lance pas l’IA en boucle. return false; } $row = $this->db()->table('radar_threads')->select('analysis')->where('id', $threadId)->get()->getRowArray(); return empty($row['analysis']); } }