feat(app) : phase 2 — endpoint /api/alerts, inbox, flows Node-RED

- POST /api/alerts : token Bearer (comparaison temps constant, 503 si non
  configuré), schéma d'alerte PRD §4.4 validé Zod, exempté du SSO dans les hooks
- Persistance alertes.yaml + commits git (réception, « traité »)
- Inbox /alertes : filtres par profil impacté et statut, actions « Marquer
  traité » et « Créer/MAJ fiche » avec pré-remplissage du formulaire registre
  depuis l'alerte (aucun champ licence/capacité inventé)
- nodered-flows/ : 4 flows importables (rss-ingest, license-watch, classifier
  avec le prompt figé du PRD §6 embarqué tel quel, notify) — nœuds core
  uniquement, configuration par variables d'env, README de câblage
- Vérifié : check/lint/test (27) /build verts, autofixer propre, JSON des flows
  et fonctions embarquées validés, test fumée HTTP (201/400/401 sur l'endpoint,
  inbox, traité + commit, pré-remplissage MAJ fiche)
This commit is contained in:
cyber-mawonaj
2026-08-01 10:40:18 -04:00
parent 0c9f83d3f5
commit 6978fbb5b8
20 changed files with 955 additions and 20 deletions
+38
View File
@@ -0,0 +1,38 @@
# Flows Node-RED — veille-ia
Flows importables individuellement (Node-RED → menu → Import), conformes au PRD §5.
N'utilisent que des nœuds **core** (aucune dépendance externe à installer).
| Fichier | Rôle | Entrée | Sortie |
|---|---|---|---|
| `rss-ingest.json` | Poll flux RSS (HF blog, releases GitHub ComfyUI), normalisation + dédup | inject horaire | POST `/veille-ia/classifier` |
| `license-watch.json` | Webhooks changedetection (pages ToS/licences) | POST `/veille-ia/license-watch` | POST `/veille-ia/classifier` (type `changement_licence`, urgence haute) |
| `classifier.json` | Classifieur LLM, prompt système figé du PRD §6 embarqué tel quel | POST `/veille-ia/classifier` | Ollama `/api/chat` → POST `/api/alerts` → notify si urgence haute |
| `notify.json` | Notification des urgences hautes via ntfy | POST `/veille-ia/notify` | POST `$NTFY_URL` |
## Variables d'environnement Node-RED
| Variable | Défaut | Rôle |
|---|---|---|
| `OLLAMA_URL` | `http://127.0.0.1:11434` | Endpoint Ollama |
| `CLASSIFIER_MODEL` | `qwen3:32b` | Modèle classifieur |
| `VEILLE_IA_URL` | `http://127.0.0.1:3000` | URL de l'app (avec sous-chemin si applicable, ex. `https://domaine.tld/veille`) |
| `ALERTS_TOKEN` | *(aucun — obligatoire)* | Token Bearer de `POST /api/alerts` (visible dans `/var/www/veille-ia/app/.env`) |
| `CLASSIFIER_URL` | `http://127.0.0.1:1880/veille-ia/classifier` | Chaînage interne des flows |
| `NTFY_URL` | *(aucun)* | Topic ntfy (ex. `https://ntfy.sh/mon-topic`) ; si absent, notification ignorée |
## Câblage changedetection_ynh
Dans changedetection, pour chaque URL de ToS/licence surveillée (BFL, MiniMax Community License,
cards HF, Midjourney, Runway, Suno, politiques Steam/itch.io) :
```
Notification URL : post://127.0.0.1:1880/veille-ia/license-watch
```
## Notes
- L'email SMTP YunoHost n'est pas inclus (le nœud e-mail est une dépendance externe) :
ajouter `node-red-node-email` avec `smtp localhost:25` si souhaité.
- La chaîne RSS → classifier → alerts est idempotente côté registre : les alertes
arrivent en inbox, l'action « Créer/MAJ fiche » reste humaine (anti-hallucination).
+143
View File
@@ -0,0 +1,143 @@
[
{
"id": "tab_classifier",
"type": "tab",
"label": "veille-ia : classifier",
"disabled": false,
"info": "PRD §5.3 — Classifieur LLM : prompt système figé (PRD §6, embarqué tel quel) → Ollama /api/chat (format json) → POST /api/alerts de veille-ia → si urgence haute, notification.\n\nVariables d'environnement :\n- OLLAMA_URL (défaut http://127.0.0.1:11434)\n- CLASSIFIER_MODEL (défaut qwen3:32b)\n- VEILLE_IA_URL (défaut http://127.0.0.1:3000 — avec sous-chemin si applicable, ex. https://domaine.tld/veille)\n- ALERTS_TOKEN (obligatoire — visible dans /var/www/veille-ia/app/.env)\n- NOTIFY_URL (défaut http://127.0.0.1:1880/veille-ia/notify)"
},
{
"id": "cl_http_in",
"type": "http in",
"z": "tab_classifier",
"name": "POST classifier",
"url": "/veille-ia/classifier",
"method": "post",
"upload": false,
"swaggerDoc": "",
"x": 140,
"y": 120,
"wires": [["cl_requete_ollama"]]
},
{
"id": "cl_requete_ollama",
"type": "function",
"z": "tab_classifier",
"name": "Requête Ollama (prompt PRD §6)",
"func": "// Prompt système FIGÉ du PRD §6 — ne pas modifier sans versionner le PRD.\nconst SYSTEM_PROMPT = `Tu es un classifieur d'événements IA pour un créateur indépendant.\nRéponds UNIQUEMENT en JSON valide respectant ce schéma :\n{type, modele, editeur, modalites[], licence: {nom, commercial_ok(nullable),\nseuil(nullable), attribution_requise(nullable), poids_ouverts(nullable)},\nnsfw_ok(nullable), vram_gb(nullable), comfyui_natif(nullable), statut,\nimpact_profils[], resume(2 phrases max), sources[], confiance: haute|moyenne|faible}\nRÈGLES STRICTES : si une information n'est pas explicitement présente dans le\ntexte source, mets null. N'invente jamais une licence, un prix ou un benchmark.\nSignale les benchmarks auto-rapportés par l'éditeur (confiance: moyenne max).`;\n\nconst ollama = (env.get('OLLAMA_URL') || 'http://127.0.0.1:11434').replace(/\\/$/, '');\nconst modele = env.get('CLASSIFIER_MODEL') || 'qwen3:32b';\n\nmsg.method = 'POST';\nmsg.url = `${ollama}/api/chat`;\nmsg.headers = { 'content-type': 'application/json' };\nmsg.payload = {\n model: modele,\n stream: false,\n format: 'json',\n messages: [\n { role: 'system', content: SYSTEM_PROMPT },\n {\n role: 'user',\n content: [\n `Titre : ${msg.titreOriginal || '(sans titre)'}`,\n `Type suggéré : ${msg.typeSuggere || 'nouvelle_sortie'}`,\n '',\n 'Texte source :',\n msg.texte || msg.payload || ''\n ].join('\\n')\n }\n ]\n};\nreturn msg;",
"outputs": 1,
"timeout": "",
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 410,
"y": 120,
"wires": [["cl_ollama"]]
},
{
"id": "cl_ollama",
"type": "http request",
"z": "tab_classifier",
"name": "POST Ollama /api/chat",
"method": "use",
"ret": "obj",
"paytoqs": "ignore",
"url": "",
"tls": "",
"persist": false,
"proxy": "",
"insecureHTTPParser": false,
"authType": "",
"senderr": true,
"headers": [],
"x": 660,
"y": 120,
"wires": [["cl_parser"]]
},
{
"id": "cl_parser",
"type": "function",
"z": "tab_classifier",
"name": "Parser + mapper alerte",
"func": "// Parse la réponse du classifieur et la mappe sur le schéma d'alerte du PRD §4.4.\n// Règle anti-hallucination : rien n'est inventé — les champs absents restent null/absents,\n// et une alerte sans aucune source est rejetée (l'app les refuserait de toute façon).\nconst TYPES = ['nouvelle_sortie', 'changement_licence', 'changement_classement', 'comfyui_support'];\nconst brut = msg.payload?.message?.content;\nlet ev;\ntry {\n ev = JSON.parse(brut);\n} catch (e) {\n node.error(`Réponse Ollama non JSON : ${String(brut).slice(0, 200)}`, msg);\n return null;\n}\n\nfunction slug(texte) {\n return typeof texte === 'string' && texte\n ? texte.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') || null\n : null;\n}\n\nconst sources = Array.isArray(ev.sources) ? ev.sources.filter((s) => typeof s === 'string' && s.startsWith('http')) : [];\nif (sources.length === 0 && msg.lien) sources.push(msg.lien);\nif (sources.length === 0) {\n node.error('Alerte rejetée : aucune source (règle anti-hallucination)', msg);\n return null;\n}\n\nconst confiance = ['haute', 'moyenne', 'faible'].includes(ev.confiance) ? ev.confiance : 'faible';\n\nmsg.alerte = {\n type: TYPES.includes(ev.type) ? ev.type : (msg.typeSuggere || 'nouvelle_sortie'),\n modele_id: slug(ev.modele),\n titre: msg.titreOriginal || (ev.modele ? `Événement : ${ev.modele}` : 'Événement IA'),\n resume: typeof ev.resume === 'string' && ev.resume\n ? `[confiance ${confiance}] ${ev.resume}`\n : `[confiance ${confiance}] (pas de résumé)`,\n impact_profils: Array.isArray(ev.impact_profils)\n ? ev.impact_profils.filter((p) => typeof p === 'string')\n : [],\n urgence: msg.urgenceSuggeree || 'moyenne',\n sources,\n date: new Date().toISOString().slice(0, 10)\n};\n\nconst veille = (env.get('VEILLE_IA_URL') || 'http://127.0.0.1:3000').replace(/\\/$/, '');\nconst token = env.get('ALERTS_TOKEN');\nif (!token) {\n node.error('ALERTS_TOKEN non défini dans l\\'environnement Node-RED', msg);\n return null;\n}\nmsg.method = 'POST';\nmsg.url = `${veille}/api/alerts`;\nmsg.headers = {\n 'content-type': 'application/json',\n 'authorization': `Bearer ${token}`\n};\nmsg.payload = msg.alerte;\nreturn msg;",
"outputs": 1,
"timeout": "",
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 890,
"y": 120,
"wires": [["cl_vers_app"]]
},
{
"id": "cl_vers_app",
"type": "http request",
"z": "tab_classifier",
"name": "POST veille-ia /api/alerts",
"method": "use",
"ret": "obj",
"paytoqs": "ignore",
"url": "",
"tls": "",
"persist": false,
"proxy": "",
"insecureHTTPParser": false,
"authType": "",
"senderr": true,
"headers": [],
"x": 1140,
"y": 120,
"wires": [["cl_urgence"]]
},
{
"id": "cl_urgence",
"type": "switch",
"z": "tab_classifier",
"name": "urgence haute ?",
"property": "alerte.urgence",
"propertyType": "msg",
"rules": [
{ "t": "eq", "v": "haute", "vt": "str" },
{ "t": "else" }
],
"checkall": "true",
"repair": false,
"outputs": 2,
"x": 1130,
"y": 200,
"wires": [["cl_vers_notify"], ["cl_response"]]
},
{
"id": "cl_vers_notify",
"type": "http request",
"z": "tab_classifier",
"name": "POST notify",
"method": "POST",
"ret": "txt",
"paytoqs": "ignore",
"url": "http://127.0.0.1:1880/veille-ia/notify",
"tls": "",
"persist": false,
"proxy": "",
"insecureHTTPParser": false,
"authType": "",
"senderr": false,
"headers": [],
"x": 1330,
"y": 180,
"wires": [["cl_response"]]
},
{
"id": "cl_response",
"type": "http response",
"z": "tab_classifier",
"name": "200",
"statusCode": "200",
"headers": {},
"x": 1350,
"y": 240,
"wires": []
}
]
+69
View File
@@ -0,0 +1,69 @@
[
{
"id": "tab_license_watch",
"type": "tab",
"label": "veille-ia : license-watch",
"disabled": false,
"info": "PRD §5.2 — Réception des webhooks changedetection_ynh (URLs ToS/licences : BFL, MiniMax Community License, cards HF, Midjourney, Runway, Suno, Steam/itch.io policies) → POST classifier avec type changement_licence et urgence haute.\n\nDans changedetection : Notification URL = post://127.0.0.1:1880/veille-ia/license-watch (adapter si Node-RED sur un autre hôte).\n\nVariables d'environnement :\n- CLASSIFIER_URL (défaut http://127.0.0.1:1880/veille-ia/classifier)"
},
{
"id": "lw_http_in",
"type": "http in",
"z": "tab_license_watch",
"name": "webhook changedetection",
"url": "/veille-ia/license-watch",
"method": "post",
"upload": false,
"swaggerDoc": "",
"x": 170,
"y": 100,
"wires": [["lw_preparer"]]
},
{
"id": "lw_preparer",
"type": "function",
"z": "tab_license_watch",
"name": "Préparer (type licence, urgence haute)",
"func": "// changedetection.io envoie selon sa configuration :\n// title, watch_url, diff_url, body/diff…\nconst p = msg.payload || {};\nmsg.titreOriginal = `Changement détecté : ${p.title || p.watch_url || 'page surveillée'}`;\nmsg.texte = [\n `Page surveillée : ${p.watch_url || '(inconnue)'}`,\n p.diff_url ? `Diff : ${p.diff_url}` : '',\n '',\n p.body || p.diff || JSON.stringify(p)\n].join('\\n');\nmsg.lien = p.watch_url || null;\nmsg.typeSuggere = 'changement_licence';\nmsg.urgenceSuggeree = 'haute';\nmsg.method = 'POST';\nmsg.url = env.get('CLASSIFIER_URL') || 'http://127.0.0.1:1880/veille-ia/classifier';\nmsg.headers = { 'content-type': 'application/json' };\nmsg.payload = msg.texte;\nreturn msg;",
"outputs": 1,
"timeout": "",
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 460,
"y": 100,
"wires": [["lw_vers_classifier"]]
},
{
"id": "lw_vers_classifier",
"type": "http request",
"z": "tab_license_watch",
"name": "POST classifier",
"method": "use",
"ret": "txt",
"paytoqs": "ignore",
"url": "",
"tls": "",
"persist": false,
"proxy": "",
"insecureHTTPParser": false,
"authType": "",
"senderr": false,
"headers": [],
"x": 730,
"y": 100,
"wires": [["lw_response"]]
},
{
"id": "lw_response",
"type": "http response",
"z": "tab_license_watch",
"name": "202",
"statusCode": "202",
"headers": {},
"x": 890,
"y": 100,
"wires": []
}
]
+69
View File
@@ -0,0 +1,69 @@
[
{
"id": "tab_notify",
"type": "tab",
"label": "veille-ia : notify",
"disabled": false,
"info": "PRD §5.4 — Routage des urgences hautes : notification ntfy (instance locale ou ntfy.sh). Le reste reste en inbox.\n\nVariables d'environnement :\n- NTFY_URL (ex. https://ntfy.sh/mon-topic-secret ou https://ntfy.mon-domaine.tld/veille) — si absent, la notification est ignorée avec un avertissement.\n\nPour l'email SMTP YunoHost : ajouter un noeud e-mail (node-red-node-email, non inclus ici pour rester sans dépendance externe) avec smtp localhost:25."
},
{
"id": "nt_http_in",
"type": "http in",
"z": "tab_notify",
"name": "POST notify",
"url": "/veille-ia/notify",
"method": "post",
"upload": false,
"swaggerDoc": "",
"x": 130,
"y": 100,
"wires": [["nt_formater"]]
},
{
"id": "nt_formater",
"type": "function",
"z": "tab_notify",
"name": "Formater notification",
"func": "const a = msg.alerte || msg.payload || {};\nconst topic = env.get('NTFY_URL');\nif (!topic) {\n node.warn('NTFY_URL non défini : notification ignorée');\n msg.payload = { envoye: false, raison: 'NTFY_URL absent' };\n return [null, msg];\n}\nmsg.method = 'POST';\nmsg.url = topic;\nmsg.headers = {\n 'Title': `veille-ia : ${a.type || 'alerte'}`,\n 'Priority': a.urgence === 'haute' ? '5' : '3',\n 'Tags': 'warning',\n 'content-type': 'text/plain; charset=utf-8'\n};\nmsg.payload = [\n a.titre || '(sans titre)',\n '',\n a.resume || '',\n '',\n ...(a.sources || [])\n].join('\\n');\nreturn [msg, null];",
"outputs": 2,
"timeout": "",
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 350,
"y": 100,
"wires": [["nt_ntfy"], ["nt_response"]]
},
{
"id": "nt_ntfy",
"type": "http request",
"z": "tab_notify",
"name": "POST ntfy",
"method": "use",
"ret": "txt",
"paytoqs": "ignore",
"url": "",
"tls": "",
"persist": false,
"proxy": "",
"insecureHTTPParser": false,
"authType": "",
"senderr": false,
"headers": [],
"x": 570,
"y": 80,
"wires": [["nt_response"]]
},
{
"id": "nt_response",
"type": "http response",
"z": "tab_notify",
"name": "200",
"statusCode": "200",
"headers": {},
"x": 730,
"y": 100,
"wires": []
}
]
+126
View File
@@ -0,0 +1,126 @@
[
{
"id": "tab_rss_ingest",
"type": "tab",
"label": "veille-ia : rss-ingest",
"disabled": false,
"info": "PRD §5.1 — Poll flux RSS (HF blog, releases GitHub ComfyUI) → dédup → POST classifier.\n\nVariables d'environnement :\n- CLASSIFIER_URL (défaut http://127.0.0.1:1880/veille-ia/classifier)\n\nPour FreshRSS (API Google Reader) : remplacer le noeud « Sources RSS » par un http request authentifié vers l'instance FreshRSS_ynh.\nSources complémentaires à surveiller via changedetection + license-watch : BFL, MiniMax, Krea, Artificial Analysis, Midjourney, Runway, Suno."
},
{
"id": "rss_inject",
"type": "inject",
"z": "tab_rss_ingest",
"name": "toutes les heures",
"props": [{ "p": "payload" }],
"repeat": "3600",
"crontab": "",
"once": true,
"onceDelay": 30,
"topic": "",
"payload": "",
"payloadType": "date",
"x": 140,
"y": 100,
"wires": [["rss_sources"]]
},
{
"id": "rss_sources",
"type": "function",
"z": "tab_rss_ingest",
"name": "Sources RSS",
"func": "// Un message par flux. URLs vérifiées au 2026-08 :\n// - blog Hugging Face (feed.xml)\n// - releases GitHub (.atom, fonctionne pour tout dépôt)\nconst FEEDS = [\n 'https://huggingface.co/blog/feed.xml',\n 'https://github.com/comfyanonymous/ComfyUI/releases.atom'\n];\nreturn [FEEDS.map((url) => ({ url, method: 'GET' }))];",
"outputs": 1,
"timeout": "",
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 330,
"y": 100,
"wires": [["rss_requete"]]
},
{
"id": "rss_requete",
"type": "http request",
"z": "tab_rss_ingest",
"name": "GET flux",
"method": "use",
"ret": "txt",
"paytoqs": "ignore",
"url": "",
"tls": "",
"persist": false,
"proxy": "",
"insecureHTTPParser": false,
"authType": "",
"senderr": false,
"headers": [],
"x": 510,
"y": 100,
"wires": [["rss_xml"]]
},
{
"id": "rss_xml",
"type": "xml",
"z": "tab_rss_ingest",
"name": "XML → objet",
"property": "payload",
"attr": "$",
"chr": "",
"x": 670,
"y": 100,
"wires": [["rss_normaliser"]]
},
{
"id": "rss_normaliser",
"type": "function",
"z": "tab_rss_ingest",
"name": "Normaliser + dédup",
"func": "// Normalise RSS 2.0 et Atom, déduplique par guid (contexte flow).\nfunction texteDe(v) {\n if (v == null) return '';\n if (typeof v === 'string') return v;\n if (Array.isArray(v)) return texteDe(v[0]);\n if (typeof v === 'object') return v._ || v.$t || '';\n return String(v);\n}\nconst canal = msg.payload?.rss?.channel?.[0];\nconst items = canal?.item || msg.payload?.feed?.entry || [];\nconst vus = flow.get('vus') || {};\nconst nouveaux = [];\nfor (const it of items) {\n const titre = texteDe(it.title);\n const lien = texteDe(it.link) || it.link?.[0]?.$?.href || '';\n const guid = texteDe(it.guid) || texteDe(it.id) || lien;\n const texte = texteDe(it.description) || texteDe(it.summary) || texteDe(it.content);\n if (!guid || vus[guid]) continue;\n vus[guid] = Date.now();\n nouveaux.push({\n titreOriginal: titre || '(sans titre)',\n texte: `${titre}\\n\\n${texte}`,\n lien,\n typeSuggere: 'nouvelle_sortie'\n });\n}\nflow.set('vus', vus);\nif (nouveaux.length === 0) return null;\nconst classifier = env.get('CLASSIFIER_URL') || 'http://127.0.0.1:1880/veille-ia/classifier';\nreturn [nouveaux.map((n) => ({\n ...n,\n method: 'POST',\n url: classifier,\n headers: { 'content-type': 'application/json' },\n payload: n.texte\n}))];",
"outputs": 1,
"timeout": "",
"noerr": 0,
"initialize": "",
"finalize": "",
"libs": [],
"x": 860,
"y": 100,
"wires": [["rss_vers_classifier"]]
},
{
"id": "rss_vers_classifier",
"type": "http request",
"z": "tab_rss_ingest",
"name": "POST classifier",
"method": "use",
"ret": "txt",
"paytoqs": "ignore",
"url": "",
"tls": "",
"persist": false,
"proxy": "",
"insecureHTTPParser": false,
"authType": "",
"senderr": false,
"headers": [],
"x": 1070,
"y": 100,
"wires": [["rss_debug"]]
},
{
"id": "rss_debug",
"type": "debug",
"z": "tab_rss_ingest",
"name": "items envoyés",
"active": false,
"tosidebar": true,
"console": false,
"tostatus": true,
"complete": "titreOriginal",
"statusVal": "titreOriginal",
"statusType": "msg",
"x": 1060,
"y": 160,
"wires": []
}
]