273 lines
9.0 KiB
PHP
273 lines
9.0 KiB
PHP
<?php
|
||
|
||
namespace App\Models;
|
||
|
||
use Config\Database;
|
||
|
||
/**
|
||
* IDEE-5 / MSOP-4 / MSOP-3 — radar_threads + skills + analyse IA.
|
||
*/
|
||
class RadarModel
|
||
{
|
||
private function db()
|
||
{
|
||
return Database::connect();
|
||
}
|
||
|
||
public function tablesExist(): bool
|
||
{
|
||
$row = $this->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<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')
|
||
->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<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 = []): 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']);
|
||
}
|
||
}
|