Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 70288da4a2 | |||
| 991b220662 | |||
| 68f725c798 | |||
| efdd90da45 | |||
| 38448d828b | |||
| e7d525804b | |||
| dfe85296a8 | |||
| b6e8961bf8 | |||
| 1011f47aff | |||
| 2cfb157e82 | |||
| 40272afde1 | |||
| 84e50b42e0 | |||
| 1b5284a8b7 | |||
| df50d8535c | |||
| b9320aa717 | |||
| 728445e8ba | |||
| b6aa436588 | |||
| 55e6edcde4 | |||
| d2488df4d9 | |||
| 7f9749ed29 | |||
| 67eeb0cdd5 | |||
| a60c22f718 | |||
| 84e9143f59 | |||
| 0b517c4647 | |||
| 44ac1770db | |||
| 30998edad5 | |||
| d41bdad079 | |||
| 9dd279794c | |||
| 6b1aa0764e | |||
| f6a2444d2c | |||
| 8d3da5da60 | |||
| 83e9df906a | |||
| eb4d615118 |
21
.cursor/rules/entreprise/00-checklist-nouveau-projet.mdc
Normal file
21
.cursor/rules/entreprise/00-checklist-nouveau-projet.mdc
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
description: Checklist agent — monter un nouveau projet sur le modèle Progiweb
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Checklist nouveau projet (agent)
|
||||||
|
|
||||||
|
Quand l'utilisateur demande de reproduire le modèle Reader / Inscription / CRM :
|
||||||
|
|
||||||
|
1. Créer la structure `.cursor/rules/{entreprise,projet,ops}/` (copier `entreprise/` depuis un projet déjà aligné).
|
||||||
|
2. Remplir `ops/20-parametres.mdc` (URLs Gitea, Bitbucket, chemins serveur, webhook, SSH).
|
||||||
|
3. Remplir `ops/20-git-deploy-playbook.mdc` (layout git root : `$HOME` vs `public_html`).
|
||||||
|
4. Remplir `projet/` (stack, conventions, préfixe JIRA).
|
||||||
|
5. Windows : remote Gitea, branche `dev`, `.gitignore` secrets / runtime.
|
||||||
|
6. Versionner `deploy_dev.php` au bon endroit (docroot) **avant** d'activer le webhook.
|
||||||
|
7. Serveur : `~/deploy/deploy_dev.sh` (chemins absolus), git init/fetch sur le bon root, tester `curl` webhook.
|
||||||
|
8. Gitea : webhook push → URL `deploy_dev.php` ; miroir Bitbucket avec sync **à chaque soumission** + intervalle filet (ex. `1h`).
|
||||||
|
9. Test bout en bout : push `dev` → commit visible sur Gitea, serveur, Bitbucket.
|
||||||
|
10. Secrets uniquement dans 1Password (référencer le titre de l'item dans `ops/20-parametres.mdc`).
|
||||||
|
|
||||||
|
Ne pas inventer Bitbucket SSH sur le serveur : le serveur tire depuis Gitea local (`127.0.0.1:3000`) en général.
|
||||||
46
.cursor/rules/entreprise/00-git-modele.mdc
Normal file
46
.cursor/rules/entreprise/00-git-modele.mdc
Normal file
@ -0,0 +1,46 @@
|
|||||||
|
---
|
||||||
|
description: Progiweb — modèle Git / Gitea / Bitbucket / serveur (entreprise)
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Modèle Git entreprise (Progiweb)
|
||||||
|
|
||||||
|
Référentiel agent. Détails par projet → `ops/20-parametres.mdc` et `ops/20-git-deploy-playbook.mdc`.
|
||||||
|
|
||||||
|
## Rôles
|
||||||
|
|
||||||
|
| Système | Rôle |
|
||||||
|
|---|---|
|
||||||
|
| **Gitea** (`git.progiweb.ca`) | Source de vérité — push depuis Windows |
|
||||||
|
| **Bitbucket** | Miroir / backup opérationnel hors serveurs |
|
||||||
|
| **Serveur (cPanel)** | Déploiement uniquement — **jamais** de commits sur le serveur |
|
||||||
|
|
||||||
|
## Branches
|
||||||
|
|
||||||
|
- Travail quotidien : **`dev`**
|
||||||
|
- Prod : **`master`** (quand le projet l'utilise ; ne pas inventer le flux prod)
|
||||||
|
- Ne pas pousser `master` sans demande explicite
|
||||||
|
|
||||||
|
## Flux nominal
|
||||||
|
|
||||||
|
```
|
||||||
|
Windows --push--> Gitea(dev) --webhook--> serveur (deploy_dev)
|
||||||
|
|
|
||||||
|
+--miroir push--> Bitbucket(dev)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Agent : jamais de `git push`.** L'utilisateur synchronise (voir `00-jamais-push.mdc`).
|
||||||
|
|
||||||
|
|
||||||
|
## Déploiement (règles absolues)
|
||||||
|
|
||||||
|
- Script shell hors webroot : typiquement `~/deploy/deploy_dev.sh`
|
||||||
|
- Déclencheur versionné dans le **docroot** : `deploy_dev.php` (sinon `git clean -fd` le détruit)
|
||||||
|
- Le script fait : `git fetch` → `git reset --hard FETCH_HEAD` → `git clean` **ciblé** (jamais un clean aveugle sur tout le home cPanel)
|
||||||
|
- Via PHP/Apache, **`HOME` est souvent absent** → chemins **absolus** dans le `.sh`
|
||||||
|
- Auth Gitea Windows : token (éviter OAuth HTTP « Bad Request »)
|
||||||
|
- Auth miroir Bitbucket : user `x-token-auth` + Repository Access Token (1Password) — pas le mot de passe web
|
||||||
|
|
||||||
|
## Nouveau projet
|
||||||
|
|
||||||
|
Suivre `entreprise/00-checklist-nouveau-projet.mdc` et recopier `entreprise/` + remplir `ops/` + `projet/`.
|
||||||
34
.cursor/rules/entreprise/00-jamais-push.mdc
Normal file
34
.cursor/rules/entreprise/00-jamais-push.mdc
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
---
|
||||||
|
description: INTERDIT — l'agent ne commit / push / deploy jamais tout seul
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Jamais de commit / push / deploy — l'utilisateur pilote Git
|
||||||
|
|
||||||
|
Règle absolue. Pas d'exception « pour aider », « c’est urgent », ou « pour que ça marche sur le serveur ».
|
||||||
|
|
||||||
|
## Interdit (sauf demande explicite de l'utilisateur dans le message)
|
||||||
|
|
||||||
|
- `git commit` (toutes formes)
|
||||||
|
- `git push` (tous remotes)
|
||||||
|
- Déclencher `deploy_dev.php`, webhook, ou `deploy_dev.sh`
|
||||||
|
- « Sync Changes » / publier à la place de l'utilisateur
|
||||||
|
|
||||||
|
## Comportement par défaut
|
||||||
|
|
||||||
|
1. Modifier les fichiers **en local** si la tâche le demande.
|
||||||
|
2. Dire clairement **quels fichiers** ont changé et **quoi** faire (commit / sync).
|
||||||
|
3. **S'arrêter** — l'utilisateur commit et synchronise lui-même.
|
||||||
|
|
||||||
|
## Autorisé seulement si l'utilisateur le dit clairement
|
||||||
|
|
||||||
|
Exemples qui autorisent : « commit », « fais le commit », « commit ça », « pousse », « sync », « déploie ».
|
||||||
|
|
||||||
|
- S'il demande **seulement** commit → commit, **pas** de push.
|
||||||
|
- S'il demande push / sync / deploy → seulement ce qu'il a demandé (et rappeler le modèle utilisateur-sync si la règle push s'applique encore).
|
||||||
|
|
||||||
|
Ambigu (« mets ça en ligne », « arrange Git ») → **demander** avant d'exécuter.
|
||||||
|
|
||||||
|
## Pourquoi
|
||||||
|
|
||||||
|
L'utilisateur doit **voir** chaque commit. Un agent qui commit/push en silence = perte de contrôle et risque de mélange DEV/prod.
|
||||||
35
.cursor/rules/entreprise/00-workflow.mdc
Normal file
35
.cursor/rules/entreprise/00-workflow.mdc
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
---
|
||||||
|
description: Progiweb — workflow agent (entreprise). Toujours s'y référer.
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Workflow entreprise (Progiweb)
|
||||||
|
|
||||||
|
Référentiel agent — pas une doc humaine. S'appliquer à tous les projets Progiweb montés sur ce modèle.
|
||||||
|
|
||||||
|
## Git
|
||||||
|
|
||||||
|
**Jamais de commit, push ou deploy** sauf demande explicite. Voir `entreprise/00-jamais-push.mdc`.
|
||||||
|
L'utilisateur voit les diffs, commit, synchronise.
|
||||||
|
|
||||||
|
## Avant de modifier du code
|
||||||
|
|
||||||
|
- Demander le **numéro JIRA** si l'utilisateur ne l'a pas fourni (préfixe selon le projet, voir `projet/`).
|
||||||
|
- Ne pas bloquer une simple question ou une lecture seule.
|
||||||
|
|
||||||
|
## Portée
|
||||||
|
|
||||||
|
- **Changement minimal** : uniquement ce qui est demandé.
|
||||||
|
- Pas de refactor large, renommage massif ou nettoyage opportuniste sans accord explicite.
|
||||||
|
- Cohérence locale (fichier / module) plutôt que modernisation globale.
|
||||||
|
|
||||||
|
## Incohérences voisines
|
||||||
|
|
||||||
|
1. Signaler brièvement.
|
||||||
|
2. Proposer une correction ciblée.
|
||||||
|
3. Attendre confirmation avant de sortir du périmètre.
|
||||||
|
|
||||||
|
## Fin de tâche
|
||||||
|
|
||||||
|
- Résumer : quoi, où, déploiement / SQL / étapes manuelles restantes.
|
||||||
|
- Ne pas créer de README ou docs hors demande explicite (les règles agent vivent dans `.cursor/rules/`).
|
||||||
30
.cursor/rules/ops/20-git-deploy-playbook.mdc
Normal file
30
.cursor/rules/ops/20-git-deploy-playbook.mdc
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
---
|
||||||
|
description: CRM MS1 — playbook Git/deploy + état connu (ops)
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Playbook Git / deploy — CRM MS1
|
||||||
|
|
||||||
|
## Layout (≠ Reader)
|
||||||
|
|
||||||
|
- Dépôt Git serveur = **`~/public_html`** (comme un site entier dans le docroot).
|
||||||
|
- `deploy_dev.php` doit être **versionné** à la racine du dépôt — sinon chaque `git clean -fd` l'efface.
|
||||||
|
|
||||||
|
## Philosophie
|
||||||
|
|
||||||
|
- Serveur = déploiement only (pas de `user.name` / commits serveur).
|
||||||
|
- Windows → Gitea → webhook → `deploy_dev.php` → `~/deploy/deploy_dev.sh`.
|
||||||
|
- **L'agent ne pousse jamais** et ne déclenche jamais `deploy_dev.php`. L'utilisateur synchronise.
|
||||||
|
|
||||||
|
## Subtilités / état à surveiller
|
||||||
|
|
||||||
|
- `git clean -fd` est agressif : tout fichier non versionné dans `public_html` disparaît.
|
||||||
|
- Si le push Windows échoue : auth Gitea (token), pas OAuth HTTP cassé.
|
||||||
|
- Miroir Bitbucket : sync à chaque soumission + intervalle filet `1h` (aligner sur Reader).
|
||||||
|
- Tunnel SQL / Cursor : SSH **7822**.
|
||||||
|
|
||||||
|
## Checklist locale agent
|
||||||
|
|
||||||
|
1. `deploy_dev.php` présent et tracké avant d'activer le webhook.
|
||||||
|
2. Tester `curl` / hit sur `deploy_dev.php` après correction des chemins absolus dans le `.sh`.
|
||||||
|
3. Vérifier Gitea + serveur sur le même SHA `dev`.
|
||||||
43
.cursor/rules/ops/20-parametres.mdc
Normal file
43
.cursor/rules/ops/20-parametres.mdc
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
---
|
||||||
|
description: CRM MS1 — paramètres figés (ops)
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Paramètres — CRM MS1
|
||||||
|
|
||||||
|
Secrets → 1Password uniquement.
|
||||||
|
|
||||||
|
## Git / remotes
|
||||||
|
|
||||||
|
| Clé | Valeur |
|
||||||
|
|---|---|
|
||||||
|
| Gitea (Windows) | `http://git.progiweb.ca/stephan/crm-ms1.git` |
|
||||||
|
| Gitea (serveur, typique) | `http://127.0.0.1:3000/stephan/crm-ms1.git` |
|
||||||
|
| Branche travail | `dev` |
|
||||||
|
| Autre branche remote vue | `main` (historique) — travail sur **`dev`** |
|
||||||
|
| Bitbucket | Ancien ; source de vérité = **Gitea** |
|
||||||
|
|
||||||
|
## Webhook / deploy (dev)
|
||||||
|
|
||||||
|
| Clé | Valeur |
|
||||||
|
|---|---|
|
||||||
|
| URL webhook prévue | `https://ms1crmdev.progiweb.net/deploy_dev.php` |
|
||||||
|
| Script | `~/deploy/deploy_dev.sh` |
|
||||||
|
| Git root serveur | **`~/public_html`** (tout le CRM = docroot) |
|
||||||
|
| Comportement script | fetch → reset --hard → `git clean -fd` |
|
||||||
|
|
||||||
|
## SSH / SQLTools
|
||||||
|
|
||||||
|
| Clé | Valeur |
|
||||||
|
|---|---|
|
||||||
|
| Port SSH | **7822** (pas 3306 pour le tunnel) |
|
||||||
|
| Host SSH | `ms1crmdev.progiweb.net` |
|
||||||
|
| User SSH / home | `devcrmms1` / `/home/devcrmms1` |
|
||||||
|
| cPanel | `https://serveur-prod.progiweb.net:2083` |
|
||||||
|
| Exemple SQLTools | `.vscode/settings.json.example` → copier en `settings.json` (gitignored) |
|
||||||
|
| BD CRM dev | `devcrmms1_dev` |
|
||||||
|
| BD Inscription (2e groupe CI) | `devinscription_prod` (même host MySQL si grants OK) |
|
||||||
|
|
||||||
|
## 1Password / auth
|
||||||
|
|
||||||
|
- Même modèle Reader : token Gitea pour push Windows ; miroir Bitbucket si activé = `x-token-auth` + access token.
|
||||||
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
.cursor/rules/projet/10-documentation.mdc
Normal file
10
.cursor/rules/projet/10-documentation.mdc
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
---
|
||||||
|
description: CRM MS1 — documentation agent
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Documentation agent — CRM MS1
|
||||||
|
|
||||||
|
- Documenter la logique métier non évidente.
|
||||||
|
- Référencer le ticket JIRA dans le code / SQL quand fourni.
|
||||||
|
- Fin de tâche : quoi / où / déploiement.
|
||||||
11
.cursor/rules/projet/10-php-conventions.mdc
Normal file
11
.cursor/rules/projet/10-php-conventions.mdc
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
description: Conventions PHP CRM MS1
|
||||||
|
globs: **/*.php,application/**/*.php,v4/**/*.php,v4_ci4/**/*.php
|
||||||
|
alwaysApply: false
|
||||||
|
---
|
||||||
|
|
||||||
|
# Conventions PHP — CRM MS1
|
||||||
|
|
||||||
|
- Suivre le fichier ouvert (style local).
|
||||||
|
- Changement minimal ; pas de modernisation globale sans accord.
|
||||||
|
- Pas de secrets dans le code ; pas de `var_dump` laissé en prod.
|
||||||
16
.cursor/rules/projet/10-radar-mail.mdc
Normal file
16
.cursor/rules/projet/10-radar-mail.mdc
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
description: Radar mail — proto CRM v4 (IDEE-4 / MSOP)
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Radar mail (CRM)
|
||||||
|
|
||||||
|
Épique **MSOP-1**. Idée : **IDEE-4**. Proto écran : **MSOP-2**. Analyse IA : **MSOP-3**. Moteur Gmail : **MSOP-4**.
|
||||||
|
|
||||||
|
- 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** : 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.
|
||||||
11
.cursor/rules/projet/10-stack.mdc
Normal file
11
.cursor/rules/projet/10-stack.mdc
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
---
|
||||||
|
description: CRM MS1 — stack et préfixe JIRA (projet)
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Projet CRM MS1 — stack
|
||||||
|
|
||||||
|
- Stack : PHP applicatif CRM (`application/`, `v4/`, `v4_ci4/`, etc.).
|
||||||
|
- Préfixe JIRA développement : **`MSOP`** (espace MS1 Ops). Idées = `IDEE` (pas de code). Inscription = `MSIN`.
|
||||||
|
- Workspace local typique : `C:\dev_v4\crm-ms1`.
|
||||||
|
- Ne pas committer `credentials.json` ni secrets.
|
||||||
4
.gitignore
vendored
4
.gitignore
vendored
@ -1,8 +1,12 @@
|
|||||||
|
# IDE — ne jamais committer les connexions SQL (copier settings.json.example)
|
||||||
|
.vscode/settings.json
|
||||||
|
|
||||||
# Runtime / data
|
# Runtime / data
|
||||||
data/
|
data/
|
||||||
logs/
|
logs/
|
||||||
error_log
|
error_log
|
||||||
|
|
||||||
|
|
||||||
# CI4 writable runtime
|
# CI4 writable runtime
|
||||||
*/writable/logs/
|
*/writable/logs/
|
||||||
*/writable/cache/
|
*/writable/cache/
|
||||||
|
|||||||
47
.vscode/settings.json.example
vendored
Normal file
47
.vscode/settings.json.example
vendored
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
// Copier vers settings.json (gitignored) et renseigner vos identifiants locaux.
|
||||||
|
"sqltools.connections": [
|
||||||
|
{
|
||||||
|
"mysqlOptions": {
|
||||||
|
"authProtocol": "default",
|
||||||
|
"enableSsl": "Disabled"
|
||||||
|
},
|
||||||
|
"ssh": "Enabled",
|
||||||
|
"previewLimit": 50,
|
||||||
|
"server": "localhost",
|
||||||
|
"port": 3306,
|
||||||
|
"sshOptions": {
|
||||||
|
"port": 7822,
|
||||||
|
"host": "ms1crmdev.progiweb.net",
|
||||||
|
"username": "YOUR_SSH_USER",
|
||||||
|
"password": "YOUR_SSH_PASSWORD"
|
||||||
|
},
|
||||||
|
"driver": "MariaDB",
|
||||||
|
"name": "CRM MS1 — dev",
|
||||||
|
"username": "YOUR_DB_USER",
|
||||||
|
"password": "YOUR_DB_PASSWORD",
|
||||||
|
"database": "devcrmms1_dev"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mysqlOptions": {
|
||||||
|
"authProtocol": "default",
|
||||||
|
"enableSsl": "Disabled"
|
||||||
|
},
|
||||||
|
"ssh": "Enabled",
|
||||||
|
"previewLimit": 50,
|
||||||
|
"server": "localhost",
|
||||||
|
"port": 3306,
|
||||||
|
"sshOptions": {
|
||||||
|
"port": 7822,
|
||||||
|
"host": "ms1crmdev.progiweb.net",
|
||||||
|
"username": "YOUR_SSH_USER",
|
||||||
|
"password": "YOUR_SSH_PASSWORD"
|
||||||
|
},
|
||||||
|
"driver": "MariaDB",
|
||||||
|
"name": "MS1 Inscription (via CRM host)",
|
||||||
|
"username": "YOUR_DB_USER",
|
||||||
|
"password": "YOUR_DB_PASSWORD",
|
||||||
|
"database": "devinscription_prod"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
0
CRM MS1 — dev.session.sql
Normal file
0
CRM MS1 — dev.session.sql
Normal file
0
MS1 Inscription (via CRM host).session.sql
Normal file
0
MS1 Inscription (via CRM host).session.sql
Normal file
@ -23,7 +23,7 @@ defined('BASEPATH') OR exit('No direct script access allowed');
|
|||||||
| a PHP script and you can easily do that on your own.
|
| a PHP script and you can easily do that on your own.
|
||||||
|
|
|
|
||||||
*/
|
*/
|
||||||
$config['base_url'] = 'https://ms1crm.interne.ca/';
|
$config['base_url'] = 'https://ms1crmdev.progiweb.net/';
|
||||||
$config['titre']='Ms1-CRM-dev';
|
$config['titre']='Ms1-CRM-dev';
|
||||||
$config['emailreservation']='mayrand.p@ms1timing.com';
|
$config['emailreservation']='mayrand.p@ms1timing.com';
|
||||||
/*
|
/*
|
||||||
|
|||||||
118
application/config/database.php
Normal file
118
application/config/database.php
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
<?php
|
||||||
|
defined('BASEPATH') OR exit('No direct script access allowed');
|
||||||
|
|
||||||
|
/*
|
||||||
|
| -------------------------------------------------------------------
|
||||||
|
| DATABASE CONNECTIVITY SETTINGS
|
||||||
|
| -------------------------------------------------------------------
|
||||||
|
| This file will contain the settings needed to access your database.
|
||||||
|
|
|
||||||
|
| For complete instructions please consult the 'Database Connection'
|
||||||
|
| page of the User Guide.
|
||||||
|
|
|
||||||
|
| -------------------------------------------------------------------
|
||||||
|
| EXPLANATION OF VARIABLES
|
||||||
|
| -------------------------------------------------------------------
|
||||||
|
|
|
||||||
|
| ['dsn'] The full DSN string describe a connection to the database.
|
||||||
|
| ['hostname'] The hostname of your database server.
|
||||||
|
| ['username'] The username used to connect to the database
|
||||||
|
| ['password'] The password used to connect to the database
|
||||||
|
| ['database'] The name of the database you want to connect to
|
||||||
|
| ['dbdriver'] The database driver. e.g.: mysqli.
|
||||||
|
| Currently supported:
|
||||||
|
| cubrid, ibase, mssql, mysql, mysqli, oci8,
|
||||||
|
| odbc, pdo, postgre, sqlite, sqlite3, sqlsrv
|
||||||
|
| ['dbprefix'] You can add an optional prefix, which will be added
|
||||||
|
| to the table name when using the Query Builder class
|
||||||
|
| ['pconnect'] TRUE/FALSE - Whether to use a persistent connection
|
||||||
|
| ['db_debug'] TRUE/FALSE - Whether database errors should be displayed.
|
||||||
|
| ['cache_on'] TRUE/FALSE - Enables/disables query caching
|
||||||
|
| ['cachedir'] The path to the folder where cache files should be stored
|
||||||
|
| ['char_set'] The character set used in communicating with the database
|
||||||
|
| ['dbcollat'] The character collation used in communicating with the database
|
||||||
|
| NOTE: For MySQL and MySQLi databases, this setting is only used
|
||||||
|
| as a backup if your server is running PHP < 5.2.3 or MySQL < 5.0.7
|
||||||
|
| (and in table creation queries made with DB Forge).
|
||||||
|
| There is an incompatibility in PHP with mysql_real_escape_string() which
|
||||||
|
| can make your site vulnerable to SQL injection if you are using a
|
||||||
|
| multi-byte character set and are running versions lower than these.
|
||||||
|
| Sites using Latin-1 or UTF-8 database character set and collation are unaffected.
|
||||||
|
| ['swap_pre'] A default table prefix that should be swapped with the dbprefix
|
||||||
|
| ['encrypt'] Whether or not to use an encrypted connection.
|
||||||
|
|
|
||||||
|
| 'mysql' (deprecated), 'sqlsrv' and 'pdo/sqlsrv' drivers accept TRUE/FALSE
|
||||||
|
| 'mysqli' and 'pdo/mysql' drivers accept an array with the following options:
|
||||||
|
|
|
||||||
|
| 'ssl_key' - Path to the private key file
|
||||||
|
| 'ssl_cert' - Path to the public key certificate file
|
||||||
|
| 'ssl_ca' - Path to the certificate authority file
|
||||||
|
| 'ssl_capath' - Path to a directory containing trusted CA certificates in PEM format
|
||||||
|
| 'ssl_cipher' - List of *allowed* ciphers to be used for the encryption, separated by colons (':')
|
||||||
|
| 'ssl_verify' - TRUE/FALSE; Whether verify the server certificate or not
|
||||||
|
|
|
||||||
|
| ['compress'] Whether or not to use client compression (MySQL only)
|
||||||
|
| ['stricton'] TRUE/FALSE - forces 'Strict Mode' connections
|
||||||
|
| - good for ensuring strict SQL while developing
|
||||||
|
| ['ssl_options'] Used to set various SSL options that can be used when making SSL connections.
|
||||||
|
| ['failover'] array - A array with 0 or more data for connections if the main should fail.
|
||||||
|
| ['save_queries'] TRUE/FALSE - Whether to "save" all executed queries.
|
||||||
|
| NOTE: Disabling this will also effectively disable both
|
||||||
|
| $this->db->last_query() and profiling of DB queries.
|
||||||
|
| When you run a query, with this setting set to TRUE (default),
|
||||||
|
| CodeIgniter will store the SQL statement for debugging purposes.
|
||||||
|
| However, this may cause high memory usage, especially if you run
|
||||||
|
| a lot of SQL queries ... disable this to avoid that problem.
|
||||||
|
|
|
||||||
|
| The $active_group variable lets you choose which connection group to
|
||||||
|
| make active. By default there is only one group (the 'default' group).
|
||||||
|
|
|
||||||
|
| The $query_builder variables lets you determine whether or not to load
|
||||||
|
| the query builder class.
|
||||||
|
*/
|
||||||
|
$active_group = 'default';
|
||||||
|
$query_builder = TRUE;
|
||||||
|
|
||||||
|
$db['default'] = array(
|
||||||
|
'dsn' => '',
|
||||||
|
'hostname' => 'localhost',
|
||||||
|
'username' => 'devcrmms1_bd',
|
||||||
|
'password' => '.vB==c$s_Yk7',
|
||||||
|
'database' => 'devcrmms1_dev',
|
||||||
|
'dbdriver' => 'mysqli',
|
||||||
|
'dbprefix' => '',
|
||||||
|
'pconnect' => FALSE,
|
||||||
|
'db_debug' => (ENVIRONMENT !== 'dev'),
|
||||||
|
'cache_on' => FALSE,
|
||||||
|
'cachedir' => '',
|
||||||
|
'char_set' => 'utf8',
|
||||||
|
'dbcollat' => 'utf8_general_ci',
|
||||||
|
'swap_pre' => '',
|
||||||
|
'encrypt' => FALSE,
|
||||||
|
'compress' => FALSE,
|
||||||
|
'stricton' => FALSE,
|
||||||
|
'failover' => array(),
|
||||||
|
'save_queries' => TRUE
|
||||||
|
);
|
||||||
|
|
||||||
|
$db['ms1inscription'] = array(
|
||||||
|
'dsn' => '',
|
||||||
|
'hostname' => 'localhost',
|
||||||
|
'username' => 'devinscription_bd',
|
||||||
|
'password' => 'x$@_v0-l_I((',
|
||||||
|
'database' => 'devinscription_prod',
|
||||||
|
'dbdriver' => 'mysqli',
|
||||||
|
'dbprefix' => '',
|
||||||
|
'pconnect' => FALSE,
|
||||||
|
'db_debug' => (ENVIRONMENT !== 'dev'),
|
||||||
|
'cache_on' => FALSE,
|
||||||
|
'cachedir' => '',
|
||||||
|
'char_set' => 'utf8',
|
||||||
|
'dbcollat' => 'utf8_general_ci',
|
||||||
|
'swap_pre' => '',
|
||||||
|
'encrypt' => FALSE,
|
||||||
|
'compress' => FALSE,
|
||||||
|
'stricton' => FALSE,
|
||||||
|
'failover' => array(),
|
||||||
|
'save_queries' => TRUE
|
||||||
|
);
|
||||||
@ -90,10 +90,19 @@ class Login extends CI_Controller {
|
|||||||
|
|
||||||
if ($page) {
|
if ($page) {
|
||||||
setcookie('derniere_page','',time()-3600,'/');
|
setcookie('derniere_page','',time()-3600,'/');
|
||||||
header("Location: ".$page);
|
// Path only — rester sur le host où on s’est logué (pas prod via URL absolue).
|
||||||
|
if (preg_match('#^https?://#i', $page)) {
|
||||||
|
$parts = parse_url($page);
|
||||||
|
$page = ($parts['path'] ?? '/')
|
||||||
|
. (isset($parts['query']) ? '?'.$parts['query'] : '');
|
||||||
|
}
|
||||||
|
if ($page === '' || $page[0] !== '/') {
|
||||||
|
$page = '/';
|
||||||
|
}
|
||||||
|
header('Location: '.$page);
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
redirect('/');
|
redirect('/');
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
1
application/credentials/gemini_api_key.txt
Normal file
1
application/credentials/gemini_api_key.txt
Normal file
@ -0,0 +1 @@
|
|||||||
|
AQ.Ab8RN6IZXKEJWxsI5iu-aFti8BlaIczY1T5yVFSK19L4S3bjRQ
|
||||||
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
|
||||||
2
deploy_dev.php
Normal file
2
deploy_dev.php
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
<?php
|
||||||
|
echo shell_exec('/home/devcrmms1/deploy/deploy_dev.sh 2>&1');
|
||||||
BIN
img/logo.png
BIN
img/logo.png
Binary file not shown.
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 7.6 KiB |
BIN
img/logoexp.png
Normal file
BIN
img/logoexp.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.8 KiB |
BIN
img/logoms1.png
BIN
img/logoms1.png
Binary file not shown.
|
Before Width: | Height: | Size: 7.6 KiB |
2
sl.php
2
sl.php
@ -1,4 +1,6 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
|
|
||||||
|
|
||||||
<html lang="fr">
|
<html lang="fr">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
|
|||||||
129
sql/IDEE-4-radar-proto.sql
Normal file
129
sql/IDEE-4-radar-proto.sql
Normal file
@ -0,0 +1,129 @@
|
|||||||
|
-- IDEE-4 / IDEE-5 — Radar mail proto (CRM)
|
||||||
|
-- Tables pour l’écran /v4/radar. Moteur Gmail Push + Gemini : story suivante.
|
||||||
|
-- Exécuter sur la BD CRM de DEV (devcrmms1_dev), pas en prod.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS radar_exceptions (
|
||||||
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
kind ENUM('mailbox', 'sender') NOT NULL DEFAULT 'mailbox',
|
||||||
|
value VARCHAR(190) NOT NULL,
|
||||||
|
note VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
active TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uq_radar_exc (kind, value)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS radar_threads (
|
||||||
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
gmail_thread_id VARCHAR(64) NOT NULL,
|
||||||
|
mailbox VARCHAR(190) NOT NULL DEFAULT '',
|
||||||
|
subject VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
from_email VARCHAR(190) NOT NULL DEFAULT '',
|
||||||
|
from_name VARCHAR(190) NOT NULL DEFAULT '',
|
||||||
|
gmail_url VARCHAR(500) NOT NULL DEFAULT '',
|
||||||
|
client_id INT UNSIGNED NULL DEFAULT NULL,
|
||||||
|
projet_id INT UNSIGNED NULL DEFAULT NULL,
|
||||||
|
proposed_client_no VARCHAR(32) NOT NULL DEFAULT '',
|
||||||
|
status VARCHAR(32) NOT NULL DEFAULT 'a_classer',
|
||||||
|
summary VARCHAR(500) NOT NULL DEFAULT '',
|
||||||
|
confidence TINYINT UNSIGNED NOT NULL DEFAULT 0,
|
||||||
|
is_example TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
received_at DATETIME NULL DEFAULT NULL,
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uq_radar_thread (gmail_thread_id),
|
||||||
|
KEY idx_radar_status (status),
|
||||||
|
KEY idx_radar_received (received_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS radar_thread_skills (
|
||||||
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
thread_id INT UNSIGNED NOT NULL,
|
||||||
|
skill VARCHAR(32) NOT NULL,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uq_radar_skill (thread_id, skill),
|
||||||
|
KEY idx_radar_skill (skill)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS radar_rules (
|
||||||
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
match_type ENUM('email', 'domain') NOT NULL DEFAULT 'email',
|
||||||
|
match_value VARCHAR(190) NOT NULL,
|
||||||
|
client_id INT UNSIGNED NULL DEFAULT NULL,
|
||||||
|
projet_id INT UNSIGNED NULL DEFAULT NULL,
|
||||||
|
skill VARCHAR(32) NOT NULL DEFAULT '',
|
||||||
|
source VARCHAR(32) NOT NULL DEFAULT 'human',
|
||||||
|
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uq_radar_rule (match_type, match_value, skill)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
INSERT INTO radar_exceptions (kind, value, note, active)
|
||||||
|
SELECT 'mailbox', 'confirmation@ms1timing.com', 'Mails transactionnels inscription — ne pas ouvrir', 1
|
||||||
|
FROM DUAL
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM radar_exceptions
|
||||||
|
WHERE kind = 'mailbox' AND value = 'confirmation@ms1timing.com'
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO radar_threads (
|
||||||
|
gmail_thread_id, mailbox, subject, from_email, from_name, gmail_url,
|
||||||
|
proposed_client_no, status, summary, confidence, is_example, received_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'proto-ex-1',
|
||||||
|
'desjardins.lp@ms1timing.com',
|
||||||
|
'Logos dossards + fichiers départ site',
|
||||||
|
'marie@course-exemple.ca',
|
||||||
|
'Marie Exemple',
|
||||||
|
'',
|
||||||
|
'1842',
|
||||||
|
'a_classer',
|
||||||
|
'Deux sujets dans le même fil : logos pour l’imprimeur, et fichiers à déposer pour le site d’inscription.',
|
||||||
|
72,
|
||||||
|
1,
|
||||||
|
NOW()
|
||||||
|
FROM DUAL
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM radar_threads WHERE gmail_thread_id = 'proto-ex-1');
|
||||||
|
|
||||||
|
INSERT INTO radar_thread_skills (thread_id, skill)
|
||||||
|
SELECT t.id, 'dossards' FROM radar_threads t
|
||||||
|
WHERE t.gmail_thread_id = 'proto-ex-1'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM radar_thread_skills s WHERE s.thread_id = t.id AND s.skill = 'dossards'
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO radar_thread_skills (thread_id, skill)
|
||||||
|
SELECT t.id, 'site' FROM radar_threads t
|
||||||
|
WHERE t.gmail_thread_id = 'proto-ex-1'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM radar_thread_skills s WHERE s.thread_id = t.id AND s.skill = 'site'
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO radar_threads (
|
||||||
|
gmail_thread_id, mailbox, subject, from_email, from_name, gmail_url,
|
||||||
|
proposed_client_no, status, summary, confidence, is_example, received_at
|
||||||
|
)
|
||||||
|
SELECT
|
||||||
|
'proto-ex-2',
|
||||||
|
'info@ms1timing.com',
|
||||||
|
'Question impression dossards',
|
||||||
|
'nouveau@inconnu.example',
|
||||||
|
'Contact inconnu',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'a_mettre_dans_crm',
|
||||||
|
'Adresse absente du CRM. Créer le contact / projet, puis relancer — le fil doit se recoller tout seul.',
|
||||||
|
40,
|
||||||
|
1,
|
||||||
|
DATE_SUB(NOW(), INTERVAL 25 MINUTE)
|
||||||
|
FROM DUAL
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM radar_threads WHERE gmail_thread_id = 'proto-ex-2');
|
||||||
|
|
||||||
|
INSERT INTO radar_thread_skills (thread_id, skill)
|
||||||
|
SELECT t.id, 'dossards' FROM radar_threads t
|
||||||
|
WHERE t.gmail_thread_id = 'proto-ex-2'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM radar_thread_skills s WHERE s.thread_id = t.id AND s.skill = 'dossards'
|
||||||
|
);
|
||||||
14
sql/MSOP-3-radar-analysis.sql
Normal file
14
sql/MSOP-3-radar-analysis.sql
Normal file
@ -0,0 +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;
|
||||||
|
|
||||||
|
ALTER TABLE radar_threads
|
||||||
|
MODIFY summary VARCHAR(1000) NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
-- Id message Gmail pour relire le corps lors de l’analyse IA (hors Sync).
|
||||||
|
ALTER TABLE radar_threads
|
||||||
|
ADD COLUMN gmail_message_id VARCHAR(64) NOT NULL DEFAULT '' AFTER gmail_thread_id;
|
||||||
5
sql/MSOP-3-radar-gmail-message-id.sql
Normal file
5
sql/MSOP-3-radar-gmail-message-id.sql
Normal 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;
|
||||||
7
sql/MSOP-4-gmail-v0-notes.sql
Normal file
7
sql/MSOP-4-gmail-v0-notes.sql
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
-- MSOP-4 — note (pas obligatoire)
|
||||||
|
-- Allowlist v0 = Config\Radar::$mailboxAllowlist (leith.s@ms1timing.com).
|
||||||
|
-- Pas de nouvelle table : lecture Gmail → radar_threads existantes.
|
||||||
|
-- Prérequis Google Admin (Domain-Wide Delegation) pour le SA
|
||||||
|
-- crm-calendar-service@….iam.gserviceaccount.com :
|
||||||
|
-- ajouter le scope https://www.googleapis.com/auth/gmail.readonly
|
||||||
|
-- (Calendar seul ne suffit pas).
|
||||||
39
v4_ci4/app/Commands/RadarAiPing.php
Normal file
39
v4_ci4/app/Commands/RadarAiPing.php
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Commands;
|
||||||
|
|
||||||
|
use App\Libraries\Ai\AiFactory;
|
||||||
|
use CodeIgniter\CLI\BaseCommand;
|
||||||
|
use CodeIgniter\CLI\CLI;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test connectivité Gemini depuis le serveur (pas depuis Windows).
|
||||||
|
* Usage : php spark radar:ai-ping
|
||||||
|
*/
|
||||||
|
class RadarAiPing extends BaseCommand
|
||||||
|
{
|
||||||
|
protected $group = 'Radar';
|
||||||
|
protected $name = 'radar:ai-ping';
|
||||||
|
protected $description = 'Ping API Gemini (complete court) pour valider le réseau serveur.';
|
||||||
|
|
||||||
|
public function run(array $params)
|
||||||
|
{
|
||||||
|
CLI::write('Test AiFactory → Gemini…', 'yellow');
|
||||||
|
try {
|
||||||
|
$ai = AiFactory::client();
|
||||||
|
if (! $ai->isConfigured()) {
|
||||||
|
CLI::error('Clé absente (gemini_api_key.txt)');
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
CLI::write('Provider=' . $ai->provider() . ' model=' . $ai->model(), 'white');
|
||||||
|
$t0 = \microtime(true);
|
||||||
|
$res = $ai->complete('Réponds uniquement: PONG');
|
||||||
|
$ms = (int) \round((\microtime(true) - $t0) * 1000);
|
||||||
|
CLI::write('OK en ' . $ms . ' ms — ' . \mb_substr($res->text, 0, 80), 'green');
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
CLI::error($e->getMessage());
|
||||||
|
CLI::write('Si timeout 0 bytes : firewall/IPv6 sortant vers generativelanguage.googleapis.com', 'yellow');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
47
v4_ci4/app/Commands/RadarSync.php
Normal file
47
v4_ci4/app/Commands/RadarSync.php
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Commands;
|
||||||
|
|
||||||
|
use App\Libraries\Radar\RadarSyncService;
|
||||||
|
use CodeIgniter\CLI\BaseCommand;
|
||||||
|
use CodeIgniter\CLI\CLI;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MSOP-4 — php spark radar:sync
|
||||||
|
*/
|
||||||
|
class RadarSync extends BaseCommand
|
||||||
|
{
|
||||||
|
protected $group = 'Radar';
|
||||||
|
protected $name = 'radar:sync';
|
||||||
|
protected $description = 'Lit les mailboxes allowlist Gmail et remplit radar_threads (v0).';
|
||||||
|
|
||||||
|
public function run(array $params)
|
||||||
|
{
|
||||||
|
CLI::write('Radar sync MSOP-4…', 'yellow');
|
||||||
|
$result = (new RadarSyncService())->sync();
|
||||||
|
|
||||||
|
if (! empty($result['error']) && empty($result['mailboxes'])) {
|
||||||
|
CLI::error($result['error']);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($result['mailboxes'] as $row) {
|
||||||
|
if (! empty($row['ok'])) {
|
||||||
|
CLI::write(sprintf(
|
||||||
|
'OK %s — fetched=%d insert=%d update=%d',
|
||||||
|
$row['mailbox'],
|
||||||
|
$row['fetched'] ?? 0,
|
||||||
|
$row['inserted'] ?? 0,
|
||||||
|
$row['updated'] ?? 0
|
||||||
|
), 'green');
|
||||||
|
} else {
|
||||||
|
CLI::error(($row['mailbox'] ?? '?') . ' — ' . ($row['error'] ?? 'erreur'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (! $result['ok']) {
|
||||||
|
CLI::write('Astuce DWD : ajouter le scope gmail.readonly pour le SA dans Google Admin.', 'yellow');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
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 = 25;
|
||||||
|
|
||||||
|
public int $maxOutputTokens = 2048;
|
||||||
|
|
||||||
|
public float $temperature = 0.2;
|
||||||
|
}
|
||||||
@ -17,7 +17,7 @@ class App extends BaseConfig
|
|||||||
* E.g., http://example.com/
|
* E.g., http://example.com/
|
||||||
* public string $baseURL = 'https://ms1crmdev.progiweb.net/';
|
* public string $baseURL = 'https://ms1crmdev.progiweb.net/';
|
||||||
*/
|
*/
|
||||||
public string $baseURL = 'https://ms1crm.interne.ca/';
|
public string $baseURL = 'https://ms1crmdev.progiweb.net/';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Allowed Hostnames in the Site URL other than the hostname in the baseURL.
|
* Allowed Hostnames in the Site URL other than the hostname in the baseURL.
|
||||||
|
|||||||
@ -8,7 +8,8 @@ class Database extends Config
|
|||||||
{
|
{
|
||||||
public string $filesPath = APPPATH . 'Database' . DIRECTORY_SEPARATOR;
|
public string $filesPath = APPPATH . 'Database' . DIRECTORY_SEPARATOR;
|
||||||
|
|
||||||
public string $defaultGroup = 'default';
|
// Sur ms1crmdev : utiliser defaultdev (sinon CI4 lit ms1crm_prod → Radar « tables absentes »).
|
||||||
|
public string $defaultGroup = 'defaultdev';
|
||||||
|
|
||||||
public array $defaultdev = [
|
public array $defaultdev = [
|
||||||
'DSN' => '',
|
'DSN' => '',
|
||||||
|
|||||||
@ -72,7 +72,8 @@ class Filters extends BaseFilters
|
|||||||
* }
|
* }
|
||||||
*/
|
*/
|
||||||
public array $globals = [
|
public array $globals = [
|
||||||
'before' => ['auth'
|
'before' => [
|
||||||
|
'auth',
|
||||||
// 'honeypot',
|
// 'honeypot',
|
||||||
// 'csrf',
|
// 'csrf',
|
||||||
// 'invalidchars',
|
// 'invalidchars',
|
||||||
|
|||||||
@ -34,12 +34,12 @@ class Logger extends BaseConfig
|
|||||||
*
|
*
|
||||||
* array(1, 2, 3, 8) = Emergency, Alert, Critical, and Debug messages
|
* array(1, 2, 3, 8) = Emergency, Alert, Critical, and Debug messages
|
||||||
*
|
*
|
||||||
* For a live site you'll usually enable Critical or higher (3) to be logged otherwise
|
* CRM sur hébergement partagé : JAMAIS threshold 9 (debug).
|
||||||
* your log files will fill up very fast.
|
* development + Radar/feed en boucle = logs qui peuvent saturer le quota.
|
||||||
*
|
*
|
||||||
* @var int|list<int>
|
* @var int|list<int>
|
||||||
*/
|
*/
|
||||||
public $threshold = (ENVIRONMENT === 'production') ? 4 : 9;
|
public $threshold = 4;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* --------------------------------------------------------------------------
|
* --------------------------------------------------------------------------
|
||||||
@ -88,11 +88,8 @@ class Logger extends BaseConfig
|
|||||||
'critical',
|
'critical',
|
||||||
'alert',
|
'alert',
|
||||||
'emergency',
|
'emergency',
|
||||||
'debug',
|
|
||||||
'error',
|
'error',
|
||||||
'info',
|
// pas debug/info/notice — trop verbeux sur cPanel
|
||||||
'notice',
|
|
||||||
'warning',
|
|
||||||
],
|
],
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|||||||
38
v4_ci4/app/Config/Radar.php
Normal file
38
v4_ci4/app/Config/Radar.php
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Config;
|
||||||
|
|
||||||
|
use CodeIgniter\Config\BaseConfig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MSOP-4 / MSOP-3 — Radar (Gmail + consommation Ai plateforme).
|
||||||
|
* Clé / modèle LLM → Config\Ai (pas ici).
|
||||||
|
*/
|
||||||
|
class Radar extends BaseConfig
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Seules ces boîtes sont lues en v0. Domaine entier = story suivante.
|
||||||
|
*
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
public array $mailboxAllowlist = [
|
||||||
|
'leith.s@ms1timing.com',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Fenêtre de lecture initiale (jours). */
|
||||||
|
public int $lookbackDays = 14;
|
||||||
|
|
||||||
|
/** Max messages par boîte et run (garde-fou — Sync doit rester rapide). */
|
||||||
|
public int $maxMessagesPerMailbox = 15;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clé SA (même fichier que Calendar CRM).
|
||||||
|
* Relatif à ROOTPATH (dossier v4_ci4/).
|
||||||
|
*/
|
||||||
|
public string $serviceAccountJson = '../application/credentials/service_account.json';
|
||||||
|
|
||||||
|
public string $gmailScope = 'https://www.googleapis.com/auth/gmail.readonly';
|
||||||
|
|
||||||
|
/** Max analyses IA dossards par clic « Analyser » (1 = ~30–60 s, page ne meurt pas). */
|
||||||
|
public int $maxAiAnalysesPerSync = 1;
|
||||||
|
}
|
||||||
@ -7,6 +7,12 @@ use CodeIgniter\Router\RouteCollection;
|
|||||||
*/
|
*/
|
||||||
$routes->get('/', 'Home::index');
|
$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 — 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/redirect/(:segment)', 'Inventaire::redirect/$1');
|
||||||
$routes->get('inventaire/test/(:segment)', 'Inventaire::test/$1');
|
$routes->get('inventaire/test/(:segment)', 'Inventaire::test/$1');
|
||||||
|
|
||||||
|
|||||||
134
v4_ci4/app/Controllers/Radar.php
Normal file
134
v4_ci4/app/Controllers/Radar.php
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controllers;
|
||||||
|
|
||||||
|
use App\Libraries\Radar\RadarSyncService;
|
||||||
|
use App\Models\RadarModel;
|
||||||
|
use Config\Radar as RadarConfig;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IDEE-5 / MSOP-2 — proto écran Radar (CI4 / Raven).
|
||||||
|
* MSOP-4 — sync Gmail. MSOP-3 — analyse IA (route séparée).
|
||||||
|
*/
|
||||||
|
class Radar extends BaseController
|
||||||
|
{
|
||||||
|
public function index()
|
||||||
|
{
|
||||||
|
$model = new RadarModel();
|
||||||
|
$ready = $model->tablesExist();
|
||||||
|
$cfg = config(RadarConfig::class);
|
||||||
|
|
||||||
|
$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,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function feed()
|
||||||
|
{
|
||||||
|
$model = new RadarModel();
|
||||||
|
if (! $model->tablesExist()) {
|
||||||
|
return $this->response->setJSON([
|
||||||
|
'ok' => false,
|
||||||
|
'threads' => [],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->response->setJSON([
|
||||||
|
'ok' => true,
|
||||||
|
'at' => date('c'),
|
||||||
|
'threads' => $model->listThreads(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sync Gmail → radar_* (rapide, sans LLM).
|
||||||
|
*/
|
||||||
|
public function sync()
|
||||||
|
{
|
||||||
|
$result = (new RadarSyncService())->sync();
|
||||||
|
|
||||||
|
if ($this->request->isAJAX() || $this->request->getGet('json') !== null) {
|
||||||
|
return $this->response->setJSON($result);
|
||||||
|
}
|
||||||
|
|
||||||
|
$msg = $result['ok'] ? 'Sync OK' : 'Sync en erreur';
|
||||||
|
if (! empty($result['error'])) {
|
||||||
|
$msg .= ' — ' . $result['error'];
|
||||||
|
}
|
||||||
|
foreach ($result['mailboxes'] as $row) {
|
||||||
|
if (! empty($row['ok'])) {
|
||||||
|
$msg .= sprintf(
|
||||||
|
' | %s: +%d / ~%d (lus %d)',
|
||||||
|
$row['mailbox'],
|
||||||
|
$row['inserted'] ?? 0,
|
||||||
|
$row['updated'] ?? 0,
|
||||||
|
$row['fetched'] ?? 0
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$msg .= ' | ' . ($row['mailbox'] ?? '?') . ': ' . ($row['error'] ?? 'fail');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$msg .= ' — pour l’IA : bouton Analyser dossards';
|
||||||
|
|
||||||
|
return $this->redirectWithMsg($msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MSOP-3 — analyse IA des fils dossards en attente (peut prendre 1–2 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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -13,19 +13,11 @@ class Auth implements FilterInterface
|
|||||||
{
|
{
|
||||||
$token = $_COOKIE['auth_token'] ?? null;
|
$token = $_COOKIE['auth_token'] ?? null;
|
||||||
|
|
||||||
// Aucun token → logout CI4 et redirection
|
if (! $token) {
|
||||||
if (!$token) {
|
|
||||||
|
|
||||||
session()->destroy();
|
session()->destroy();
|
||||||
|
setcookie('derniere_page', $_SERVER['REQUEST_URI'] ?? '/', time() + 3600, '/');
|
||||||
|
|
||||||
setcookie(
|
return $this->deny($request);
|
||||||
'derniere_page',
|
|
||||||
$_SERVER['REQUEST_URI'],
|
|
||||||
time() + 3600,
|
|
||||||
'/'
|
|
||||||
);
|
|
||||||
|
|
||||||
return redirect()->to('/login');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$db = Database::connect();
|
$db = Database::connect();
|
||||||
@ -36,46 +28,61 @@ class Auth implements FilterInterface
|
|||||||
->get()
|
->get()
|
||||||
->getRow();
|
->getRow();
|
||||||
|
|
||||||
// Token invalide → logout CI4
|
if (! $row) {
|
||||||
if (!$row) {
|
|
||||||
|
|
||||||
session()->destroy();
|
session()->destroy();
|
||||||
|
setcookie('derniere_page', $_SERVER['REQUEST_URI'] ?? '/', time() + 3600, '/');
|
||||||
|
|
||||||
setcookie(
|
return $this->deny($request);
|
||||||
'derniere_page',
|
|
||||||
$_SERVER['REQUEST_URI'],
|
|
||||||
time() + 3600,
|
|
||||||
'/'
|
|
||||||
);
|
|
||||||
|
|
||||||
return redirect()->to('/login');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Charger utilisateur
|
|
||||||
$user = $db->table('usa_usagers')
|
$user = $db->table('usa_usagers')
|
||||||
->where('usa_id', $row->user_id)
|
->where('usa_id', $row->user_id)
|
||||||
->get()
|
->get()
|
||||||
->getRow();
|
->getRow();
|
||||||
|
|
||||||
if (!$user) {
|
if (! $user) {
|
||||||
|
|
||||||
session()->destroy();
|
session()->destroy();
|
||||||
|
|
||||||
return redirect()->to('/login');
|
return $this->deny($request);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Si session CI4 inexistante → la créer
|
if (! session()->get('usa_id')) {
|
||||||
if (!session()->get('usa_id')) {
|
|
||||||
|
|
||||||
session()->set([
|
session()->set([
|
||||||
'usa_id' => $user->usa_id,
|
'usa_id' => $user->usa_id,
|
||||||
'usa_nom' => $user->usa_nom
|
'usa_nom' => $user->usa_nom,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
|
public function after(RequestInterface $request, ResponseInterface $response, $arguments = null)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch/JSON → 401 JSON (évite Unexpected token '<' sur du HTML login).
|
||||||
|
* Navigation normale → redirect login.
|
||||||
|
*/
|
||||||
|
private function deny(RequestInterface $request)
|
||||||
|
{
|
||||||
|
$wantsJson = $request->isAJAX()
|
||||||
|
|| $request->getGet('json') !== null
|
||||||
|
|| str_contains((string) $request->getHeaderLine('Accept'), 'application/json');
|
||||||
|
|
||||||
|
if ($wantsJson) {
|
||||||
|
return service('response')
|
||||||
|
->setStatusCode(401)
|
||||||
|
->setJSON(['ok' => false, 'error' => 'non authentifié']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return redirect()->to($this->loginUrl());
|
||||||
|
}
|
||||||
|
|
||||||
|
private function loginUrl(): string
|
||||||
|
{
|
||||||
|
$https = (! empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
|
||||||
|
|| (($_SERVER['HTTP_X_FORWARDED_PROTO'] ?? '') === 'https');
|
||||||
|
$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
||||||
|
|
||||||
|
return ($https ? 'https' : 'http') . '://' . $host . '/login';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
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)'
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
98
v4_ci4/app/Libraries/Ai/AiHttp.php
Normal file
98
v4_ci4/app/Libraries/Ai/AiHttp.php
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
<?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_CONNECTTIMEOUT => 10,
|
||||||
|
\CURLOPT_TIMEOUT => $timeout,
|
||||||
|
\CURLOPT_HTTPHEADER => $headers,
|
||||||
|
// cPanel : IPv6 cassé → hang 90s / 0 bytes. Forcer IPv4.
|
||||||
|
\CURLOPT_IPRESOLVE => \CURL_IPRESOLVE_V4,
|
||||||
|
];
|
||||||
|
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(
|
||||||
|
'Serveur → Gemini timeout/réseau (' . $err . '). '
|
||||||
|
. 'Souvent IPv6 ou firewall sortant vers generativelanguage.googleapis.com.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
\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);
|
||||||
|
}
|
||||||
|
}
|
||||||
299
v4_ci4/app/Libraries/Radar/GmailReader.php
Normal file
299
v4_ci4/app/Libraries/Radar/GmailReader.php
Normal file
@ -0,0 +1,299 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Libraries\Radar;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lecture Gmail (readonly) pour une mailbox impersonnée.
|
||||||
|
*/
|
||||||
|
class GmailReader
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private GoogleWorkspaceToken $tokenClient,
|
||||||
|
private string $scopes,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array{
|
||||||
|
* gmail_thread_id:string,
|
||||||
|
* mailbox:string,
|
||||||
|
* subject:string,
|
||||||
|
* from_email:string,
|
||||||
|
* from_name:string,
|
||||||
|
* snippet:string,
|
||||||
|
* gmail_url:string,
|
||||||
|
* received_at:?string
|
||||||
|
* }>
|
||||||
|
*/
|
||||||
|
public function listRecentThreads(string $mailbox, int $lookbackDays, int $maxMessages): array
|
||||||
|
{
|
||||||
|
$access = $this->tokenClient->getAccessToken($mailbox, $this->scopes);
|
||||||
|
$q = 'newer_than:' . max(1, $lookbackDays) . 'd';
|
||||||
|
$url = 'https://gmail.googleapis.com/gmail/v1/users/me/messages?'
|
||||||
|
. \http_build_query([
|
||||||
|
'q' => $q,
|
||||||
|
'maxResults' => $maxMessages,
|
||||||
|
]);
|
||||||
|
|
||||||
|
[$code, $body] = $this->tokenClient->http(
|
||||||
|
'GET',
|
||||||
|
$url,
|
||||||
|
['Authorization: Bearer ' . $access]
|
||||||
|
);
|
||||||
|
if ($code !== 200) {
|
||||||
|
throw new \RuntimeException("Gmail messages.list ({$code}): {$body}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = \json_decode($body, true) ?: [];
|
||||||
|
$ids = [];
|
||||||
|
foreach ($data['messages'] ?? [] as $m) {
|
||||||
|
if (! empty($m['id'])) {
|
||||||
|
$ids[] = (string) $m['id'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($ids === []) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$details = $this->getMessagesMetaBatch($access, $ids);
|
||||||
|
|
||||||
|
$byThread = [];
|
||||||
|
foreach ($details as $detail) {
|
||||||
|
$tid = $detail['gmail_thread_id'];
|
||||||
|
if (! isset($byThread[$tid]) || ($detail['internal_ts'] ?? 0) > ($byThread[$tid]['internal_ts'] ?? 0)) {
|
||||||
|
$detail['mailbox'] = $mailbox;
|
||||||
|
$byThread[$tid] = $detail;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$out = array_values($byThread);
|
||||||
|
usort($out, static fn ($a, $b) => ($b['internal_ts'] ?? 0) <=> ($a['internal_ts'] ?? 0));
|
||||||
|
|
||||||
|
foreach ($out as &$row) {
|
||||||
|
unset($row['internal_ts']);
|
||||||
|
}
|
||||||
|
unset($row);
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batch Gmail (1 requête HTTP) au lieu de N gets séquentiels qui bloquent la page.
|
||||||
|
*
|
||||||
|
* @param list<string> $messageIds
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function getMessagesMetaBatch(string $access, array $messageIds): array
|
||||||
|
{
|
||||||
|
$boundary = 'batch_radar_' . \bin2hex(\random_bytes(6));
|
||||||
|
$parts = '';
|
||||||
|
foreach ($messageIds as $i => $messageId) {
|
||||||
|
$path = '/gmail/v1/users/me/messages/' . \rawurlencode($messageId)
|
||||||
|
. '?format=metadata&metadataHeaders=From&metadataHeaders=Subject&metadataHeaders=Date';
|
||||||
|
$parts .= '--' . $boundary . "\r\n"
|
||||||
|
. "Content-Type: application/http\r\n"
|
||||||
|
. 'Content-ID: <item' . $i . ">\r\n\r\n"
|
||||||
|
. 'GET ' . $path . " HTTP/1.1\r\n\r\n";
|
||||||
|
}
|
||||||
|
$parts .= '--' . $boundary . "--\r\n";
|
||||||
|
|
||||||
|
[$code, $body] = $this->tokenClient->http(
|
||||||
|
'POST',
|
||||||
|
'https://gmail.googleapis.com/batch/gmail/v1',
|
||||||
|
[
|
||||||
|
'Authorization: Bearer ' . $access,
|
||||||
|
'Content-Type: multipart/mixed; boundary=' . $boundary,
|
||||||
|
],
|
||||||
|
$parts
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($code !== 200) {
|
||||||
|
// Fallback séquentiel limité (ne pas bloquer 40×60s)
|
||||||
|
$out = [];
|
||||||
|
foreach (\array_slice($messageIds, 0, 8) as $messageId) {
|
||||||
|
$detail = $this->getMessageMeta($access, $messageId);
|
||||||
|
if ($detail !== null) {
|
||||||
|
$out[] = $detail;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->parseBatchMessageMetas($body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function parseBatchMessageMetas(string $batchBody): array
|
||||||
|
{
|
||||||
|
$out = [];
|
||||||
|
// Chaque sous-réponse JSON message Gmail
|
||||||
|
if (\preg_match_all('/\{[\s\S]*?"id"\s*:\s*"[^"]+"[\s\S]*?\}/m', $batchBody, $matches)) {
|
||||||
|
foreach ($matches[0] as $json) {
|
||||||
|
$msg = \json_decode($json, true);
|
||||||
|
if (! \is_array($msg) || empty($msg['id'])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$parsed = $this->messageArrayToMeta($msg, (string) $msg['id']);
|
||||||
|
if ($parsed !== null) {
|
||||||
|
$out[] = $parsed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return ?array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function getMessageMeta(string $access, string $messageId): ?array
|
||||||
|
{
|
||||||
|
$url = 'https://gmail.googleapis.com/gmail/v1/users/me/messages/' . \rawurlencode($messageId)
|
||||||
|
. '?format=metadata&metadataHeaders=From&metadataHeaders=Subject&metadataHeaders=Date';
|
||||||
|
|
||||||
|
[$code, $body] = $this->tokenClient->http(
|
||||||
|
'GET',
|
||||||
|
$url,
|
||||||
|
['Authorization: Bearer ' . $access]
|
||||||
|
);
|
||||||
|
if ($code !== 200) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$msg = \json_decode($body, true);
|
||||||
|
if (! \is_array($msg)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->messageArrayToMeta($msg, $messageId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed> $msg
|
||||||
|
* @return ?array<string, mixed>
|
||||||
|
*/
|
||||||
|
private function messageArrayToMeta(array $msg, string $messageId): ?array
|
||||||
|
{
|
||||||
|
$headers = [];
|
||||||
|
foreach ($msg['payload']['headers'] ?? [] as $h) {
|
||||||
|
$headers[\strtolower($h['name'] ?? '')] = $h['value'] ?? '';
|
||||||
|
}
|
||||||
|
|
||||||
|
$from = $this->parseFrom($headers['from'] ?? '');
|
||||||
|
$ts = isset($msg['internalDate']) ? (int) \floor(((int) $msg['internalDate']) / 1000) : 0;
|
||||||
|
|
||||||
|
return [
|
||||||
|
'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}
|
||||||
|
*/
|
||||||
|
private function parseFrom(string $from): array
|
||||||
|
{
|
||||||
|
if (\preg_match('/^(.*)<([^>]+)>$/', \trim($from), $m)) {
|
||||||
|
return [
|
||||||
|
'name' => \trim($m[1], " \t\"'"),
|
||||||
|
'email' => \strtolower(\trim($m[2])),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$email = \strtolower(\trim($from));
|
||||||
|
|
||||||
|
return ['email' => $email, 'name' => ''];
|
||||||
|
}
|
||||||
|
}
|
||||||
174
v4_ci4/app/Libraries/Radar/GoogleWorkspaceToken.php
Normal file
174
v4_ci4/app/Libraries/Radar/GoogleWorkspaceToken.php
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Libraries\Radar;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JWT + access token Google Workspace (Domain-Wide Delegation).
|
||||||
|
* Même pattern que application/libraries/GCalService.php (MSOP-4).
|
||||||
|
*/
|
||||||
|
class GoogleWorkspaceToken
|
||||||
|
{
|
||||||
|
private string $jsonPath;
|
||||||
|
private string $tokenUri = 'https://oauth2.googleapis.com/token';
|
||||||
|
|
||||||
|
public function __construct(string $jsonPath)
|
||||||
|
{
|
||||||
|
$this->jsonPath = $jsonPath;
|
||||||
|
if (! \is_file($this->jsonPath)) {
|
||||||
|
throw new \RuntimeException('Service account JSON introuvable: ' . $this->jsonPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getAccessToken(string $impersonateEmail, string $scopes): string
|
||||||
|
{
|
||||||
|
$creds = \json_decode((string) \file_get_contents($this->jsonPath), true);
|
||||||
|
if (! \is_array($creds) || empty($creds['client_email']) || empty($creds['private_key'])) {
|
||||||
|
throw new \RuntimeException('service_account.json invalide');
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = \time();
|
||||||
|
$header = ['alg' => 'RS256', 'typ' => 'JWT'];
|
||||||
|
$claim = [
|
||||||
|
'iss' => $creds['client_email'],
|
||||||
|
'scope' => $scopes,
|
||||||
|
'aud' => $this->tokenUri,
|
||||||
|
'exp' => $now + 3600,
|
||||||
|
'iat' => $now,
|
||||||
|
'sub' => $impersonateEmail,
|
||||||
|
];
|
||||||
|
|
||||||
|
$unsigned = $this->b64url(\json_encode($header)) . '.' . $this->b64url(\json_encode($claim));
|
||||||
|
$signature = '';
|
||||||
|
if (! \openssl_sign($unsigned, $signature, $creds['private_key'], \OPENSSL_ALGO_SHA256)) {
|
||||||
|
throw new \RuntimeException('openssl_sign failed');
|
||||||
|
}
|
||||||
|
$jwt = $unsigned . '.' . $this->b64url($signature);
|
||||||
|
|
||||||
|
[$code, $body] = $this->http(
|
||||||
|
'POST',
|
||||||
|
$this->tokenUri,
|
||||||
|
['Content-Type: application/x-www-form-urlencoded'],
|
||||||
|
\http_build_query([
|
||||||
|
'grant_type' => 'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||||
|
'assertion' => $jwt,
|
||||||
|
])
|
||||||
|
);
|
||||||
|
|
||||||
|
if ($code !== 200) {
|
||||||
|
throw new \RuntimeException("Token Google error ({$code}): {$body}");
|
||||||
|
}
|
||||||
|
|
||||||
|
$tok = \json_decode($body, true);
|
||||||
|
if (empty($tok['access_token'])) {
|
||||||
|
throw new \RuntimeException('Pas d’access_token dans la réponse Google');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $tok['access_token'];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* HTTP sans dépendre de l’ext curl (souvent absente en cPanel).
|
||||||
|
*
|
||||||
|
* @param list<string> $headers
|
||||||
|
* @return array{0:int,1:string}
|
||||||
|
*/
|
||||||
|
public function http(string $method, string $url, array $headers = [], ?string $body = null): array
|
||||||
|
{
|
||||||
|
if (\function_exists('curl_init')) {
|
||||||
|
return $this->httpCurl($method, $url, $headers, $body);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->httpStream($method, $url, $headers, $body);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $headers
|
||||||
|
* @return array{0:int,1:string}
|
||||||
|
*/
|
||||||
|
private function httpCurl(string $method, string $url, array $headers, ?string $body): array
|
||||||
|
{
|
||||||
|
$ch = \curl_init($url);
|
||||||
|
$opts = [
|
||||||
|
\CURLOPT_RETURNTRANSFER => true,
|
||||||
|
\CURLOPT_CUSTOMREQUEST => $method,
|
||||||
|
\CURLOPT_CONNECTTIMEOUT => 8,
|
||||||
|
\CURLOPT_TIMEOUT => 25,
|
||||||
|
\CURLOPT_HTTPHEADER => $headers,
|
||||||
|
// cPanel : IPv6 cassé → hang / 0 bytes. Forcer IPv4 (comme AiHttp).
|
||||||
|
\CURLOPT_IPRESOLVE => \CURL_IPRESOLVE_V4,
|
||||||
|
];
|
||||||
|
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 \RuntimeException('cURL error: ' . $err);
|
||||||
|
}
|
||||||
|
\curl_close($ch);
|
||||||
|
|
||||||
|
return [$code, $res];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<string> $headers
|
||||||
|
* @return array{0:int,1:string}
|
||||||
|
*/
|
||||||
|
private function httpStream(string $method, string $url, array $headers, ?string $body): array
|
||||||
|
{
|
||||||
|
$headerLines = $headers;
|
||||||
|
if ($body !== null && $body !== '') {
|
||||||
|
$hasContentType = false;
|
||||||
|
foreach ($headerLines as $h) {
|
||||||
|
if (\stripos($h, 'Content-Type:') === 0) {
|
||||||
|
$hasContentType = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (! $hasContentType) {
|
||||||
|
$headerLines[] = 'Content-Type: application/x-www-form-urlencoded';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$opts = [
|
||||||
|
'http' => [
|
||||||
|
'method' => \strtoupper($method),
|
||||||
|
'header' => \implode("\r\n", $headerLines),
|
||||||
|
'timeout' => 60,
|
||||||
|
'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 \RuntimeException('HTTP stream error for ' . $url);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [$code, $res];
|
||||||
|
}
|
||||||
|
|
||||||
|
private function b64url(string $data): string
|
||||||
|
{
|
||||||
|
return \rtrim(\strtr(\base64_encode($data), '+/', '-_'), '=');
|
||||||
|
}
|
||||||
|
}
|
||||||
106
v4_ci4/app/Libraries/Radar/RadarAnalyzeService.php
Normal file
106
v4_ci4/app/Libraries/Radar/RadarAnalyzeService.php
Normal file
@ -0,0 +1,106 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
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.
|
||||||
|
* Utilise la couche IA plateforme (AiClient), pas un vendor direct.
|
||||||
|
*/
|
||||||
|
class RadarAnalyzeService
|
||||||
|
{
|
||||||
|
public function __construct(private ?AiClient $ai = null)
|
||||||
|
{
|
||||||
|
$this->ai ??= AiFactory::client();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function isEnabled(): bool
|
||||||
|
{
|
||||||
|
return $this->ai->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 === [];
|
||||||
|
$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).
|
||||||
|
|
||||||
|
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;
|
||||||
|
|
||||||
|
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),
|
||||||
|
'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';
|
||||||
|
}
|
||||||
|
}
|
||||||
257
v4_ci4/app/Libraries/Radar/RadarSyncService.php
Normal file
257
v4_ci4/app/Libraries/Radar/RadarSyncService.php
Normal file
@ -0,0 +1,257 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
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 via couche Ai (appel séparé, pas dans le Sync HTTP).
|
||||||
|
*/
|
||||||
|
class RadarSyncService
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private ?RadarConfig $config = null,
|
||||||
|
private ?RadarModel $model = null,
|
||||||
|
) {
|
||||||
|
$this->config ??= config(RadarConfig::class);
|
||||||
|
$this->model ??= new RadarModel();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lecture Gmail uniquement (rapide). Pas d’appel LLM ici.
|
||||||
|
*
|
||||||
|
* @return array{ok:bool, mailboxes:list<array<string,mixed>>, error?:string}
|
||||||
|
*/
|
||||||
|
public function sync(): array
|
||||||
|
{
|
||||||
|
@\set_time_limit(90);
|
||||||
|
|
||||||
|
if (! $this->model->tablesExist()) {
|
||||||
|
return [
|
||||||
|
'ok' => false,
|
||||||
|
'mailboxes' => [],
|
||||||
|
'error' => 'Tables radar_* absentes — exécuter sql/IDEE-4-radar-proto.sql',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$jsonPath = \realpath(ROOTPATH . $this->config->serviceAccountJson)
|
||||||
|
?: (ROOTPATH . $this->config->serviceAccountJson);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$tokenClient = new GoogleWorkspaceToken($jsonPath);
|
||||||
|
$reader = new GmailReader($tokenClient, $this->config->gmailScope);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
return ['ok' => false, 'mailboxes' => [], 'error' => $e->getMessage()];
|
||||||
|
}
|
||||||
|
|
||||||
|
$results = [];
|
||||||
|
|
||||||
|
foreach ($this->config->mailboxAllowlist as $mailbox) {
|
||||||
|
$mailbox = \strtolower(\trim($mailbox));
|
||||||
|
if ($mailbox === '') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
$threads = $reader->listRecentThreads(
|
||||||
|
$mailbox,
|
||||||
|
$this->config->lookbackDays,
|
||||||
|
$this->config->maxMessagesPerMailbox
|
||||||
|
);
|
||||||
|
$inserted = 0;
|
||||||
|
$updated = 0;
|
||||||
|
foreach ($threads as $t) {
|
||||||
|
$skills = $this->guessSkills($t['subject'] . ' ' . $t['snippet']);
|
||||||
|
$n = $this->model->upsertThread([
|
||||||
|
'gmail_thread_id' => $t['gmail_thread_id'],
|
||||||
|
'mailbox' => $mailbox,
|
||||||
|
'subject' => \mb_substr($t['subject'], 0, 255),
|
||||||
|
'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, 1000),
|
||||||
|
'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 l’id message pour l’analyse 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++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$results[] = [
|
||||||
|
'mailbox' => $mailbox,
|
||||||
|
'ok' => true,
|
||||||
|
'fetched' => count($threads),
|
||||||
|
'inserted' => $inserted,
|
||||||
|
'updated' => $updated,
|
||||||
|
];
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$results[] = [
|
||||||
|
'mailbox' => $mailbox,
|
||||||
|
'ok' => false,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$anyOk = false;
|
||||||
|
foreach ($results as $r) {
|
||||||
|
if (! empty($r['ok'])) {
|
||||||
|
$anyOk = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
'ok' => $anyOk || $results === [],
|
||||||
|
'mailboxes' => $results,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 d’id 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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
private function guessSkills(string $text): array
|
||||||
|
{
|
||||||
|
$t = \mb_strtolower($text);
|
||||||
|
$skills = [];
|
||||||
|
if (\preg_match('/dossard|bib\b|imprimeur|s[eé]quence|logo\s*doss/', $t)) {
|
||||||
|
$skills[] = 'dossards';
|
||||||
|
}
|
||||||
|
if (\preg_match('/inscription|site\s*web|formulaire|chronotrack|ouverture\s+site/', $t)) {
|
||||||
|
$skills[] = 'site';
|
||||||
|
}
|
||||||
|
|
||||||
|
return $skills;
|
||||||
|
}
|
||||||
|
}
|
||||||
272
v4_ci4/app/Models/RadarModel.php
Normal file
272
v4_ci4/app/Models/RadarModel.php
Normal file
@ -0,0 +1,272 @@
|
|||||||
|
<?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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -168,6 +168,11 @@ class DynamicForms extends Controller
|
|||||||
}
|
}
|
||||||
// =================================================
|
// =================================================
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// =================================================
|
// =================================================
|
||||||
// LISTES ENFANTS (quand on est sur le parent)
|
// LISTES ENFANTS (quand on est sur le parent)
|
||||||
// =================================================
|
// =================================================
|
||||||
|
|||||||
@ -64,7 +64,7 @@
|
|||||||
<script src="<?= base_url('v4/assets/raven/vendor/js/quill.js') ?>"></script>
|
<script src="<?= base_url('v4/assets/raven/vendor/js/quill.js') ?>"></script>
|
||||||
<script src="<?= base_url('v4/assets/raven/js/theme.js') ?>"></script>
|
<script src="<?= base_url('v4/assets/raven/js/theme.js') ?>"></script>
|
||||||
<script src="https://unpkg.com/lucide@latest"></script>
|
<script src="https://unpkg.com/lucide@latest"></script>
|
||||||
<script>lucide.createIcons();</script>
|
<script>if (window.lucide && typeof lucide.createIcons === 'function') { lucide.createIcons(); }</script>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -20,6 +20,12 @@
|
|||||||
<div class="flex flex-col h-full overflow-y-auto" data-simplebar>
|
<div class="flex flex-col h-full overflow-y-auto" data-simplebar>
|
||||||
<div class="flex flex-col py-3 px-3">
|
<div class="flex flex-col py-3 px-3">
|
||||||
<nav class="flex flex-col space-y-0.5" id="SidebarCollapseGroup">
|
<nav class="flex flex-col space-y-0.5" id="SidebarCollapseGroup">
|
||||||
|
<a href="/v4/radar" class="sidebar-link">
|
||||||
|
<span class="sidebar-icon">
|
||||||
|
<span class="icon-[lucide--mail]"></span>
|
||||||
|
</span>
|
||||||
|
<span class="sidebar-text">Radar mail</span>
|
||||||
|
</a>
|
||||||
<a href="/v4/dynamic/list/inventaire" class="sidebar-link ">
|
<a href="/v4/dynamic/list/inventaire" class="sidebar-link ">
|
||||||
<span class="sidebar-icon">
|
<span class="sidebar-icon">
|
||||||
<span class="icon-[lucide--file-chart-column]"></span>
|
<span class="icon-[lucide--file-chart-column]"></span>
|
||||||
|
|||||||
362
v4_ci4/app/Views/radar/index.php
Normal file
362
v4_ci4/app/Views/radar/index.php
Normal file
@ -0,0 +1,362 @@
|
|||||||
|
<?php
|
||||||
|
$title = 'Radar mail';
|
||||||
|
$menu = 'menus/men_list';
|
||||||
|
$header = 'headers/hea_default';
|
||||||
|
$this->setVar('menu', $menu);
|
||||||
|
$this->setVar('header', $header);
|
||||||
|
$this->setVar('title', $title);
|
||||||
|
|
||||||
|
$ready = $ready ?? false;
|
||||||
|
$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');
|
||||||
|
|
||||||
|
/** 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é',
|
||||||
|
'a_classer' => 'À classer',
|
||||||
|
'a_mettre_dans_crm' => 'À mettre dans le CRM',
|
||||||
|
'ignore' => 'Ignoré',
|
||||||
|
default => $status,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
$statusClass = static function (string $status): string {
|
||||||
|
// Classes présentes dans assets/raven/css/style.css (pas indigo-* purgé).
|
||||||
|
return match ($status) {
|
||||||
|
'classe' => 'bg-primary/10 text-primary',
|
||||||
|
'a_mettre_dans_crm' => 'bg-amber-500/20 text-zinc-900 dark:text-zinc-100',
|
||||||
|
'ignore' => 'bg-zinc-200 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300',
|
||||||
|
default => 'bg-zinc-100 text-zinc-800 dark:bg-zinc-800 dark:text-zinc-200',
|
||||||
|
};
|
||||||
|
};
|
||||||
|
?>
|
||||||
|
|
||||||
|
<?= $this->extend('layouts/default') ?>
|
||||||
|
<?= $this->section('content') ?>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex flex-wrap items-end justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<p class="text-xs font-medium uppercase tracking-wide text-zinc-500">CRM v4 · IDEE-5</p>
|
||||||
|
<h1 class="text-2xl font-semibold text-zinc-900 dark:text-white">Radar mail</h1>
|
||||||
|
<p class="mt-1 text-sm text-zinc-600 dark:text-zinc-400">
|
||||||
|
MSOP-4 v0 : lecture allowlist
|
||||||
|
<?php if ($allowlist !== []): ?>
|
||||||
|
(<span class="font-mono text-xs"><?= esc(implode(', ', $allowlist)) ?></span>)
|
||||||
|
<?php endif; ?>
|
||||||
|
— domaine entier plus tard. Le CRM est la vérité.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-col items-end gap-2">
|
||||||
|
<?php if ($ready): ?>
|
||||||
|
<div class="flex flex-wrap justify-end gap-2">
|
||||||
|
<a href="<?= esc($syncUrl) ?>" class="btn bg-primary text-white" id="radar-sync-btn">
|
||||||
|
Sync Gmail
|
||||||
|
</a>
|
||||||
|
<button type="button" class="btn btn-default" id="radar-analyze-btn"
|
||||||
|
data-url="<?= esc($analyzeUrl) ?>?json=1"
|
||||||
|
<?= $pendingAi < 1 ? 'disabled' : '' ?>>
|
||||||
|
Analyser dossards<?= $pendingAi > 0 ? ' (' . $pendingAi . ')' : '' ?>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<div class="text-right">
|
||||||
|
<p class="text-xs text-zinc-500">Fil mis à jour</p>
|
||||||
|
<p id="radar-clock" class="text-sm font-medium text-zinc-800 dark:text-zinc-200">—</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?php if ($syncMsg): ?>
|
||||||
|
<div id="radar-status" 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">
|
||||||
|
<?= esc($syncMsg) ?>
|
||||||
|
</div>
|
||||||
|
<?php else: ?>
|
||||||
|
<div id="radar-status" class="hidden 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"></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
|
||||||
|
<?php if (!$ready): ?>
|
||||||
|
<div class="rounded-xl border border-zinc-200 bg-zinc-100 px-4 py-3 text-sm text-zinc-900 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-100">
|
||||||
|
Tables absentes. Exécuter <code class="font-mono">sql/IDEE-4-radar-proto.sql</code> sur la BD CRM de <strong>dev</strong>, puis recharger.
|
||||||
|
</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">
|
||||||
|
<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; ?>
|
||||||
|
|
||||||
|
<div class="grid gap-4 lg:grid-cols-3">
|
||||||
|
<div class="rounded-xl border border-zinc-200 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-900 lg:col-span-1">
|
||||||
|
<h2 class="text-sm font-semibold text-zinc-900 dark:text-white">Exceptions</h2>
|
||||||
|
<p class="mt-1 text-xs text-zinc-500">On exclut, on ne tient pas de liste blanche.</p>
|
||||||
|
<ul class="mt-3 space-y-2 text-sm" id="radar-exceptions">
|
||||||
|
<?php if ($exceptions === []): ?>
|
||||||
|
<li class="text-zinc-400">Aucune</li>
|
||||||
|
<?php else: ?>
|
||||||
|
<?php foreach ($exceptions as $ex): ?>
|
||||||
|
<li class="flex flex-col rounded-lg bg-zinc-50 px-3 py-2 dark:bg-zinc-800/60">
|
||||||
|
<span class="font-mono text-xs text-zinc-500"><?= esc($ex['kind']) ?></span>
|
||||||
|
<span class="font-medium"><?= esc($ex['value']) ?></span>
|
||||||
|
<?php if ($ex['note'] !== ''): ?>
|
||||||
|
<span class="text-xs text-zinc-500"><?= esc($ex['note']) ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
</li>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-xl border border-zinc-200 bg-white p-4 dark:border-zinc-800 dark:bg-zinc-900 lg:col-span-2">
|
||||||
|
<div class="mb-3 flex items-center justify-between">
|
||||||
|
<h2 class="text-sm font-semibold text-zinc-900 dark:text-white">Signaux</h2>
|
||||||
|
<span class="text-xs text-zinc-400" id="radar-count"><?= count($threads) ?> fil(s)</span>
|
||||||
|
</div>
|
||||||
|
<div id="radar-feed" class="space-y-3">
|
||||||
|
<?php if ($threads === []): ?>
|
||||||
|
<p class="text-sm text-zinc-400">Aucun signal pour l’instant.</p>
|
||||||
|
<?php else: ?>
|
||||||
|
<?php foreach ($threads as $t): ?>
|
||||||
|
<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($plain($t['subject'] ?: '(sans objet)')) ?></p>
|
||||||
|
<p class="text-xs text-zinc-500">
|
||||||
|
<?= 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']) ?>">
|
||||||
|
<?= esc($statusLabel($t['status'])) ?>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="mt-2 flex flex-wrap gap-1">
|
||||||
|
<?php foreach ($t['skills'] as $sk): ?>
|
||||||
|
<span class="rounded-md bg-zinc-100 px-2 py-0.5 text-xs font-medium text-zinc-700 dark:bg-zinc-800 dark:text-zinc-200"><?= esc($sk) ?></span>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php if (!empty($t['proposed_client_no'])): ?>
|
||||||
|
<span class="rounded-md bg-zinc-100 px-2 py-0.5 text-xs font-mono text-zinc-700 dark:bg-zinc-800 dark:text-zinc-200">#<?= esc($t['proposed_client_no']) ?></span>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (!empty($t['is_example'])): ?>
|
||||||
|
<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 (!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'])): ?>
|
||||||
|
<a class="font-medium text-primary hover:underline" href="<?= esc($t['projet_url']) ?>">Ouvrir le projet CRM</a>
|
||||||
|
<?php elseif ($t['status'] === 'a_mettre_dans_crm'): ?>
|
||||||
|
<a class="font-medium text-amber-700 hover:underline dark:text-amber-300" href="/index.php/clients">Créer dans le CRM, puis relancer</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (!empty($t['client_url'])): ?>
|
||||||
|
<a class="text-zinc-500 hover:underline" href="<?= esc($t['client_url']) ?>">Fiche client</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
<?php if (!empty($t['gmail_url'])): ?>
|
||||||
|
<a class="text-zinc-500 hover:underline" href="<?= esc($t['gmail_url']) ?>" target="_blank" rel="noopener">Gmail</a>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<?php endforeach; ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<?= $this->endSection() ?>
|
||||||
|
|
||||||
|
<?= $this->section('scripts') ?>
|
||||||
|
<script>
|
||||||
|
(function () {
|
||||||
|
const feedUrl = <?= json_encode($feedUrl) ?>;
|
||||||
|
const root = document.getElementById('radar-feed');
|
||||||
|
const clock = document.getElementById('radar-clock');
|
||||||
|
const countEl = document.getElementById('radar-count');
|
||||||
|
if (!root || !feedUrl) return;
|
||||||
|
|
||||||
|
const statusLabel = {
|
||||||
|
classe: 'Classé',
|
||||||
|
a_classer: 'À classer',
|
||||||
|
a_mettre_dans_crm: 'À mettre dans le CRM',
|
||||||
|
ignore: 'Ignoré'
|
||||||
|
};
|
||||||
|
const statusClass = {
|
||||||
|
classe: 'bg-primary/10 text-primary',
|
||||||
|
a_mettre_dans_crm: 'bg-amber-500/20 text-zinc-900 dark:text-zinc-100',
|
||||||
|
ignore: 'bg-zinc-200 text-zinc-700 dark:bg-zinc-800 dark:text-zinc-300',
|
||||||
|
a_classer: 'bg-zinc-100 text-zinc-800 dark:bg-zinc-800 dark:text-zinc-200'
|
||||||
|
};
|
||||||
|
|
||||||
|
function esc(s) {
|
||||||
|
const d = document.createElement('div');
|
||||||
|
d.textContent = s == null ? '' : String(s);
|
||||||
|
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) {
|
||||||
|
if (countEl) countEl.textContent = (threads.length || 0) + ' fil(s)';
|
||||||
|
if (!threads.length) {
|
||||||
|
root.innerHTML = '<p class="text-sm text-zinc-400">Aucun signal pour l’instant.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
root.innerHTML = threads.map(function (t) {
|
||||||
|
const st = t.status || 'a_classer';
|
||||||
|
const skills = (t.skills || []).map(function (sk) {
|
||||||
|
return '<span class="rounded-md bg-zinc-100 px-2 py-0.5 text-xs font-medium text-zinc-700 dark:bg-zinc-800 dark:text-zinc-200">' + esc(sk) + '</span>';
|
||||||
|
}).join('');
|
||||||
|
const num = t.proposed_client_no
|
||||||
|
? '<span class="rounded-md bg-zinc-100 px-2 py-0.5 text-xs font-mono text-zinc-700 dark:bg-zinc-800 dark:text-zinc-200">#' + esc(t.proposed_client_no) + '</span>'
|
||||||
|
: '';
|
||||||
|
const ex = t.is_example && t.is_example !== '0'
|
||||||
|
? '<span class="rounded-md border border-dashed border-zinc-300 px-2 py-0.5 text-xs text-zinc-500">exemple proto</span>'
|
||||||
|
: '';
|
||||||
|
let links = '';
|
||||||
|
if (t.projet_url) {
|
||||||
|
links += '<a class="font-medium text-primary hover:underline" href="' + esc(t.projet_url) + '">Ouvrir le projet CRM</a>';
|
||||||
|
} else if (st === 'a_mettre_dans_crm') {
|
||||||
|
links += '<a class="font-medium text-amber-700 hover:underline dark:text-amber-300" href="/index.php/clients">Créer dans le CRM, puis relancer</a>';
|
||||||
|
}
|
||||||
|
if (t.client_url) {
|
||||||
|
links += '<a class="text-zinc-500 hover:underline" href="' + esc(t.client_url) + '">Fiche client</a>';
|
||||||
|
}
|
||||||
|
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(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>'
|
||||||
|
+ body
|
||||||
|
+ '<div class="mt-2 flex flex-wrap gap-3 text-xs">' + links + '</div></article>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
let busy = false;
|
||||||
|
async function tick() {
|
||||||
|
if (busy) return;
|
||||||
|
busy = true;
|
||||||
|
try {
|
||||||
|
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 ct = (res.headers.get('content-type') || '');
|
||||||
|
if (!res.ok || ct.indexOf('application/json') === -1) {
|
||||||
|
if (clock) clock.textContent = 'hors ligne';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(text, show) {
|
||||||
|
const el = document.getElementById('radar-status');
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = text;
|
||||||
|
if (show) el.classList.remove('hidden');
|
||||||
|
else el.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
const analyzeBtn = document.getElementById('radar-analyze-btn');
|
||||||
|
if (analyzeBtn) {
|
||||||
|
analyzeBtn.addEventListener('click', async function () {
|
||||||
|
const url = analyzeBtn.getAttribute('data-url');
|
||||||
|
if (!url || analyzeBtn.disabled) return;
|
||||||
|
analyzeBtn.disabled = true;
|
||||||
|
setStatus('Analyse IA en cours (1 mail, ~30–60 s) — ne ferme pas la page…', true);
|
||||||
|
try {
|
||||||
|
const ctrl = new AbortController();
|
||||||
|
const timer = setTimeout(function () { ctrl.abort(); }, 120000);
|
||||||
|
const res = await fetch(url, { credentials: 'same-origin', signal: ctrl.signal });
|
||||||
|
clearTimeout(timer);
|
||||||
|
const ct = (res.headers.get('content-type') || '');
|
||||||
|
if (ct.indexOf('application/json') === -1) {
|
||||||
|
setStatus('Analyse interrompue (session / login). Recharge et reconnecte-toi.', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.error) {
|
||||||
|
setStatus('Analyse IA en erreur — ' + data.error, true);
|
||||||
|
} else {
|
||||||
|
let msg = 'Analyse IA : ' + (data.ran || 0) + ' fait(s), '
|
||||||
|
+ (data.pending || 0) + ' encore en attente';
|
||||||
|
if (data.model) msg += ' [' + data.model + ']';
|
||||||
|
if (data.errors) msg += ', ' + data.errors + ' erreur(s)';
|
||||||
|
if (data.note && data.errors) msg += ' (' + String(data.note).slice(0, 120) + ')';
|
||||||
|
if ((data.pending || 0) > 0) msg += ' — recliquer Analyser pour le suivant';
|
||||||
|
setStatus(msg, true);
|
||||||
|
if (data.pending > 0) analyzeBtn.disabled = false;
|
||||||
|
analyzeBtn.textContent = 'Analyser dossards' + (data.pending > 0 ? ' (' + data.pending + ')' : '');
|
||||||
|
}
|
||||||
|
await tick();
|
||||||
|
} catch (e) {
|
||||||
|
setStatus('Analyse IA : délai dépassé ou réseau. Reessaie une fois.', true);
|
||||||
|
analyzeBtn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
tick();
|
||||||
|
setInterval(tick, 15000);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
<?= $this->endSection() ?>
|
||||||
30
v4_ci4/spark
Normal file
30
v4_ci4/spark
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
#!/usr/bin/env php
|
||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CodeIgniter CLI — CRM MS1 (layout: public = ../v4/, app = ./app/)
|
||||||
|
*
|
||||||
|
* Usage (sur le serveur) :
|
||||||
|
* cd ~/public_html/v4_ci4 && php spark radar:ai-ping
|
||||||
|
*/
|
||||||
|
|
||||||
|
use CodeIgniter\Boot;
|
||||||
|
use Config\Paths;
|
||||||
|
|
||||||
|
$minPhpVersion = '8.2';
|
||||||
|
if (version_compare(PHP_VERSION, $minPhpVersion, '<')) {
|
||||||
|
fwrite(STDERR, "PHP {$minPhpVersion}+ requis. Actuel: " . PHP_VERSION . PHP_EOL);
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Front controller web = dossier v4/
|
||||||
|
define('FCPATH', realpath(__DIR__ . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR . 'v4') . DIRECTORY_SEPARATOR);
|
||||||
|
chdir(FCPATH);
|
||||||
|
|
||||||
|
require __DIR__ . '/app/Config/Paths.php';
|
||||||
|
|
||||||
|
$paths = new Paths();
|
||||||
|
|
||||||
|
require $paths->systemDirectory . '/Boot.php';
|
||||||
|
|
||||||
|
exit(Boot::bootSpark($paths));
|
||||||
Reference in New Issue
Block a user