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
+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": []
}
]