From 6978fbb5b87e4eb26ac9844219ddbadc4f5fa433 Mon Sep 17 00:00:00 2001 From: cyber-mawonaj Date: Sat, 1 Aug 2026 10:40:18 -0400 Subject: [PATCH] =?UTF-8?q?feat(app)=20:=20phase=202=20=E2=80=94=20endpoin?= =?UTF-8?q?t=20/api/alerts,=20inbox,=20flows=20Node-RED?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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) --- README.md | 5 +- app/src/hooks.server.ts | 4 +- app/src/lib/i18n/fr.ts | 26 ++ app/src/lib/schemas.ts | 34 +++ app/src/lib/server/alertes.test.ts | 26 ++ app/src/lib/server/alertes.ts | 66 +++++ app/src/routes/+layout.svelte | 3 +- app/src/routes/+page.svelte | 11 +- app/src/routes/alertes/+page.server.ts | 21 ++ app/src/routes/alertes/+page.svelte | 240 ++++++++++++++++++ app/src/routes/api/alerts/+server.ts | 39 +++ app/src/routes/registre/[id]/+page.server.ts | 22 +- app/src/routes/registre/[id]/+page.svelte | 2 +- .../routes/registre/nouveau/+page.server.ts | 25 +- app/src/routes/registre/nouveau/+page.svelte | 6 +- nodered-flows/README.md | 38 +++ nodered-flows/classifier.json | 143 +++++++++++ nodered-flows/license-watch.json | 69 +++++ nodered-flows/notify.json | 69 +++++ nodered-flows/rss-ingest.json | 126 +++++++++ 20 files changed, 955 insertions(+), 20 deletions(-) create mode 100644 app/src/lib/server/alertes.test.ts create mode 100644 app/src/lib/server/alertes.ts create mode 100644 app/src/routes/alertes/+page.server.ts create mode 100644 app/src/routes/alertes/+page.svelte create mode 100644 app/src/routes/api/alerts/+server.ts create mode 100644 nodered-flows/README.md create mode 100644 nodered-flows/classifier.json create mode 100644 nodered-flows/license-watch.json create mode 100644 nodered-flows/notify.json create mode 100644 nodered-flows/rss-ingest.json diff --git a/README.md b/README.md index 677871c..2d5eb14 100644 --- a/README.md +++ b/README.md @@ -86,6 +86,9 @@ Optionnel. Si le paquet `ollama_ynh` est indisponible : Ollama sur la machine lo - CRUD registre (`/registre`) et profils (`/profils`) : form actions, validation Zod, commit git à chaque écriture, champs tri-état (null = « à vérifier ») - Moteur de recommandation : page `/recommander` + API `POST /api/recommander` (JSON `{profil_id, filtres: {modalite?, vram_max?}}`, SSO requis) — filtrage dur puis scoring pondéré, justification et warnings par modèle - Exporteur markdown (`/exports`) : templates éditables, génération des guides depuis le registre, commit git par document -- [ ] **Phase 2** — inbox alertes + flows Node-RED +- [x] **Phase 2** — inbox alertes + flows Node-RED + - `POST /api/alerts` : token Bearer (`ALERTS_TOKEN`, comparaison en temps constant), schéma d'alerte du PRD §4.4 validé par Zod, 503 si non configuré + - Inbox `/alertes` : filtres par profil impacté et statut, actions « Marquer traité » et « Créer/MAJ fiche » (pré-remplissage du formulaire registre depuis l'alerte, sans inventer de champ) + - `nodered-flows/` : 4 flows importables (rss-ingest, license-watch, classifier avec le prompt figé du PRD §6, notify) — nœuds core uniquement, configuration par variables d'env - [ ] **Phase 3** — templates ComfyUI + smoke tests - [ ] **Phase 4** — durcissement (i18n gcf/en, niveau 8 package_check) diff --git a/app/src/hooks.server.ts b/app/src/hooks.server.ts index 4f47b7a..537382a 100644 --- a/app/src/hooks.server.ts +++ b/app/src/hooks.server.ts @@ -9,6 +9,7 @@ import { estRoutePublique, resoudreUtilisateur } from '$lib/server/sso'; * Authentification SSO : l'utilisateur vient du header injecté par le reverse proxy * YunoHost (SSOwat). Sans utilisateur, seule la page d'accueil publique est accessible ; * toute autre route (pages et endpoints API) renvoie 401. + * Exception : POST /api/alerts, protégé par token Bearer au lieu du SSO (webhook Node-RED). */ const authHandle: Handle = async ({ event, resolve }) => { event.locals.user = resoudreUtilisateur(event.request.headers, { @@ -17,7 +18,8 @@ const authHandle: Handle = async ({ event, resolve }) => { dev }); - if (!event.locals.user && !estRoutePublique(event.url.pathname, base)) { + const estWebhookAlertes = event.url.pathname === `${base}/api/alerts`; + if (!event.locals.user && !estRoutePublique(event.url.pathname, base) && !estWebhookAlertes) { error(401, 'Authentification requise'); } diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index dcfaebe..ca5d284 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -190,6 +190,32 @@ export const fr = { genere_le: 'Généré le', tokens_aide: 'Tokens disponibles : {{date}}, {{tableau_registre}}, {{tableau_classements}}' }, + alertes: { + titre: 'Inbox alertes', + intro: 'Événements classifiés reçus depuis Node-RED (POST /api/alerts).', + filtre_profil: 'Profil impacté', + filtre_statut: 'Statut', + tous: 'tous', + toutes: 'toutes', + a_traiter: 'à traiter', + traite: 'traitées', + marquer_traite: 'Marquer traité', + creer_fiche: 'Créer la fiche', + maj_fiche: 'Mettre à jour la fiche', + aucune: 'Aucune alerte pour ces filtres.', + recue_le: 'Reçue le', + urgence: { + haute: 'urgence haute', + moyenne: 'urgence moyenne', + faible: 'urgence faible' + }, + types: { + nouvelle_sortie: 'nouvelle sortie', + changement_licence: 'changement de licence', + changement_classement: 'changement de classement', + comfyui_support: 'support ComfyUI' + } + }, pied: { ligne: 'veille-ia — application AGPL-3.0, auto-hébergée.', registre_vide: 'Registre et profils initialisés depuis le PRD au premier démarrage.' diff --git a/app/src/lib/schemas.ts b/app/src/lib/schemas.ts index 0143e6c..e9a434a 100644 --- a/app/src/lib/schemas.ts +++ b/app/src/lib/schemas.ts @@ -87,3 +87,37 @@ export const profilSchema = z.object({ contraintes_specifiques: z.string().nullable().default(null) }); export type Profil = z.infer; + +// ── Alertes (PRD §4.4) ── + +export const typeAlerteSchema = z.enum([ + 'nouvelle_sortie', + 'changement_licence', + 'changement_classement', + 'comfyui_support' +]); +export type TypeAlerte = z.infer; + +export const urgenceSchema = z.enum(['haute', 'moyenne', 'faible']); +export type Urgence = z.infer; + +/** Charge utile reçue sur POST /api/alerts (émise par le classifieur Node-RED, PRD §6). */ +export const alerteEntranteSchema = z.object({ + type: typeAlerteSchema, + modele_id: slugSchema.nullable().default(null), + titre: z.string().min(1), + resume: z.string().min(1), + impact_profils: z.array(z.string()).default([]), + urgence: urgenceSchema.default('moyenne'), + sources: z.array(z.string().url()).min(1, 'au moins une source est obligatoire'), + date: z.string().min(1) +}); +export type AlerteEntrante = z.infer; + +/** Alerte persistée : charge utile + métadonnées ajoutées par le serveur. */ +export const alerteSchema = alerteEntranteSchema.extend({ + id: z.string().min(1), + recue_le: z.string().min(1), + statut: z.enum(['a_traiter', 'traite']).default('a_traiter') +}); +export type Alerte = z.infer; diff --git a/app/src/lib/server/alertes.test.ts b/app/src/lib/server/alertes.test.ts new file mode 100644 index 0000000..01b1696 --- /dev/null +++ b/app/src/lib/server/alertes.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { tokenValide } from './alertes'; + +describe('tokenValide', () => { + it('accepte le token exact', () => { + expect(tokenValide('secret-123', 'secret-123')).toBe(true); + }); + + it('rejette un token différent', () => { + expect(tokenValide('secret-124', 'secret-123')).toBe(false); + }); + + it('rejette un token de longueur différente', () => { + expect(tokenValide('secret-12', 'secret-123')).toBe(false); + }); + + it('rejette null des deux côtés', () => { + expect(tokenValide(null, 'secret-123')).toBe(false); + expect(tokenValide('secret-123', null)).toBe(false); + expect(tokenValide(null, null)).toBe(false); + }); + + it('rejette une chaîne vide', () => { + expect(tokenValide('', 'secret-123')).toBe(false); + }); +}); diff --git a/app/src/lib/server/alertes.ts b/app/src/lib/server/alertes.ts new file mode 100644 index 0000000..5b85345 --- /dev/null +++ b/app/src/lib/server/alertes.ts @@ -0,0 +1,66 @@ +import { timingSafeEqual } from 'node:crypto'; +import { z } from 'zod'; +import { alerteSchema, type Alerte, type AlerteEntrante } from '../schemas'; +import { committer } from './git'; +import { initialiserDonnees } from './stockage'; +import { existsSync } from 'node:fs'; +import { readFile, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { load as loadYaml, dump as dumpYaml } from 'js-yaml'; +import { config } from './config'; + +/** + * Vérification du token Bearer de POST /api/alerts — fonction pure, testable. + * Comparaison en temps constant (jamais de === sur des secrets). + */ +export function tokenValide(tokenRecu: string | null, tokenAttendu: string | null): boolean { + if (!tokenRecu || !tokenAttendu) return false; + const a = Buffer.from(tokenRecu); + const b = Buffer.from(tokenAttendu); + if (a.length !== b.length) return false; + return timingSafeEqual(a, b); +} + +const FICHIER_ALERTES = 'alertes.yaml'; +const alertesFichierSchema = z.array(alerteSchema); + +function cheminAlertes(): string { + return join(config.dataDir, FICHIER_ALERTES); +} + +export async function lireAlertes(): Promise { + await initialiserDonnees(); + if (!existsSync(cheminAlertes())) return []; + const brut = await readFile(cheminAlertes(), 'utf8'); + const charge = loadYaml(brut); + if (charge === null || charge === undefined) return []; + return alertesFichierSchema.parse(charge); +} + +async function ecrireAlertes(alertes: Alerte[], messageCommit: string): Promise { + const valides = alertesFichierSchema.parse(alertes); + await writeFile(cheminAlertes(), dumpYaml(valides, { lineWidth: 100, noRefs: true }), 'utf8'); + await committer([FICHIER_ALERTES], messageCommit); +} + +/** Ajoute une alerte en tête d'inbox (plus récente d'abord). */ +export async function ajouterAlerte(alerte: AlerteEntrante): Promise { + const alertes = await lireAlertes(); + const complete: Alerte = alerteSchema.parse({ + ...alerte, + id: crypto.randomUUID(), + recue_le: new Date().toISOString(), + statut: 'a_traiter' + }); + await ecrireAlertes([complete, ...alertes], `feat : alerte reçue « ${complete.titre} »`); + return complete; +} + +export async function marquerAlerteTraitee(id: string): Promise { + const alertes = await lireAlertes(); + const index = alertes.findIndex((a) => a.id === id); + if (index === -1) return false; + alertes[index] = { ...alertes[index], statut: 'traite' }; + await ecrireAlertes(alertes, `feat : alerte « ${alertes[index].titre} » marquée traitée`); + return true; +} diff --git a/app/src/routes/+layout.svelte b/app/src/routes/+layout.svelte index addf5fa..20f2dcb 100644 --- a/app/src/routes/+layout.svelte +++ b/app/src/routes/+layout.svelte @@ -9,14 +9,13 @@ let { data, children }: LayoutProps = $props(); // Navigation privée : visible uniquement avec un utilisateur SSO. - // L'entrée "bientot" correspond à la phase 2 du PRD. const entrees = [ { href: '/', libelle: fr.nav.accueil, icone: 'accueil', bientot: false }, { href: '/registre', libelle: fr.nav.registre, icone: 'registre', bientot: false }, { href: '/profils', libelle: fr.nav.profils, icone: 'profils', bientot: false }, { href: '/recommander', libelle: fr.nav.recommander, icone: 'cible', bientot: false }, { href: '/exports', libelle: fr.nav.exports, icone: 'registre', bientot: false }, - { href: '/alertes', libelle: fr.nav.alertes, icone: 'alerte', bientot: true } + { href: '/alertes', libelle: fr.nav.alertes, icone: 'alerte', bientot: false } ] as const; diff --git a/app/src/routes/+page.svelte b/app/src/routes/+page.svelte index 15db3ce..e47e75a 100644 --- a/app/src/routes/+page.svelte +++ b/app/src/routes/+page.svelte @@ -29,7 +29,7 @@ titre: fr.accueil.carte_alertes_titre, texte: fr.accueil.carte_alertes_texte, icone: 'alerte', - href: null + href: '/alertes' } ] as const; @@ -48,16 +48,9 @@

- {#if carte.href} - {carte.titre} - {:else} - {carte.titre} - {/if} + {carte.titre}

{carte.texte}

- {#if !carte.href} - {fr.accueil.phase_a_venir} - {/if}
{/each} diff --git a/app/src/routes/alertes/+page.server.ts b/app/src/routes/alertes/+page.server.ts new file mode 100644 index 0000000..2fd7de6 --- /dev/null +++ b/app/src/routes/alertes/+page.server.ts @@ -0,0 +1,21 @@ +import { fail } from '@sveltejs/kit'; +import { lireAlertes, marquerAlerteTraitee } from '$lib/server/alertes'; +import { lireProfils } from '$lib/server/stockage'; +import { chaineOuNull } from '$lib/server/formulaires'; +import type { Actions, PageServerLoad } from './$types'; + +export const load: PageServerLoad = async () => { + const [alertes, profils] = await Promise.all([lireAlertes(), lireProfils()]); + return { alertes, profils }; +}; + +export const actions: Actions = { + traiter: async ({ request }) => { + const data = await request.formData(); + const id = chaineOuNull(data.get('id')); + if (!id || !(await marquerAlerteTraitee(id))) { + return fail(404, { erreur: 'Alerte introuvable.' }); + } + return { traitee: id }; + } +}; diff --git a/app/src/routes/alertes/+page.svelte b/app/src/routes/alertes/+page.svelte new file mode 100644 index 0000000..3a649bf --- /dev/null +++ b/app/src/routes/alertes/+page.svelte @@ -0,0 +1,240 @@ + + + + {fr.alertes.titre} — {fr.app.nom} + + +

{fr.alertes.titre}

+

{fr.alertes.intro}

+ +{#if form && 'erreur' in form} + +{/if} + + + +{#if alertesFiltrees.length === 0} +

{fr.alertes.aucune}

+{:else} +
+ {#each alertesFiltrees as alerte (alerte.id)} +
+
+ + {fr.alertes.types[alerte.type]} · {fr.alertes.urgence[alerte.urgence]} + + +
+

{alerte.titre}

+

{alerte.resume}

+ + {#if alerte.impact_profils.length > 0} +

+ {#each alerte.impact_profils as profilId (profilId)} + {nomProfil(profilId)} + {/each} +

+ {/if} + +

+ {#each alerte.sources as source (source)} + {source} + {/each} +

+ +
+ {#if alerte.modele_id} + + + {fr.alertes.maj_fiche} + + {:else} + + + {fr.alertes.creer_fiche} + + {/if} + {#if alerte.statut === 'a_traiter'} +
+ + +
+ {/if} +
+
+ {/each} +
+{/if} + + diff --git a/app/src/routes/api/alerts/+server.ts b/app/src/routes/api/alerts/+server.ts new file mode 100644 index 0000000..036ed63 --- /dev/null +++ b/app/src/routes/api/alerts/+server.ts @@ -0,0 +1,39 @@ +import { error, json } from '@sveltejs/kit'; +import { alerteEntranteSchema } from '$lib/schemas'; +import { ajouterAlerte, tokenValide } from '$lib/server/alertes'; +import { config } from '$lib/server/config'; +import type { RequestHandler } from './$types'; + +/** + * POST /api/alerts (PRD §4.4) — reçoit les événements classifiés depuis Node-RED. + * Protégé par token Bearer (ALERTS_TOKEN, généré à l'install YunoHost) — pas par le SSO + * (cf. hooks.server : cette route est exemptée du 401 SSO, le token fait foi). + */ +export const POST: RequestHandler = async ({ request }) => { + if (!config.alertsToken) { + error(503, 'ALERTS_TOKEN non configuré sur ce serveur'); + } + const autorisation = request.headers.get('authorization'); + const token = autorisation?.startsWith('Bearer ') ? autorisation.slice(7).trim() : null; + if (!tokenValide(token, config.alertsToken)) { + error(401, 'Token invalide'); + } + + let corpsBrut: unknown; + try { + corpsBrut = await request.json(); + } catch { + error(400, 'Corps JSON invalide'); + } + + const corps = alerteEntranteSchema.safeParse(corpsBrut); + if (!corps.success) { + error( + 400, + `Alerte invalide : ${corps.error.issues.map((i) => `${i.path.join('.')} ${i.message}`).join(', ')}` + ); + } + + const alerte = await ajouterAlerte(corps.data); + return json({ id: alerte.id }, { status: 201 }); +}; diff --git a/app/src/routes/registre/[id]/+page.server.ts b/app/src/routes/registre/[id]/+page.server.ts index 17c7bb2..1052bef 100644 --- a/app/src/routes/registre/[id]/+page.server.ts +++ b/app/src/routes/registre/[id]/+page.server.ts @@ -1,14 +1,32 @@ import { error, fail, redirect } from '@sveltejs/kit'; import { modeleSchema } from '$lib/schemas'; import { erreursZod, modeleDepuisFormulaire } from '$lib/server/formulaires'; +import { lireAlertes } from '$lib/server/alertes'; import { ecrireRegistre, lireRegistre } from '$lib/server/stockage'; import type { Actions, PageServerLoad } from './$types'; -export const load: PageServerLoad = async ({ params }) => { +export const load: PageServerLoad = async ({ params, url }) => { const registre = await lireRegistre(); const modele = registre.find((m) => m.id === params.id); if (!modele) error(404, 'Modèle introuvable'); - return { modele }; + + // Pré-remplissage depuis une alerte (action « Mettre à jour la fiche » de l'inbox) : + // fusionne les sources, préfixe les notes du résumé — sans toucher aux champs vérifiés. + const alerteId = url.searchParams.get('depuis_alerte'); + let brut: Record | null = null; + if (alerteId) { + const alertes = await lireAlertes(); + const alerte = alertes.find((a) => a.id === alerteId); + if (alerte) { + brut = { + ...modele, + sources: [...new Set([...modele.sources, ...alerte.sources])], + notes: `${alerte.resume}\n\n${modele.notes ?? ''}`.trim() + }; + } + } + + return { modele, brut }; }; export const actions: Actions = { diff --git a/app/src/routes/registre/[id]/+page.svelte b/app/src/routes/registre/[id]/+page.svelte index 812f000..c374ad6 100644 --- a/app/src/routes/registre/[id]/+page.svelte +++ b/app/src/routes/registre/[id]/+page.svelte @@ -17,5 +17,5 @@ edition actionSupprimer="?/supprimer" erreurs={form?.erreurs ?? []} - brut={(form?.brut as Record | undefined) ?? null} + brut={(form?.brut as Record | undefined) ?? data.brut} /> diff --git a/app/src/routes/registre/nouveau/+page.server.ts b/app/src/routes/registre/nouveau/+page.server.ts index 45226be..4a93f7e 100644 --- a/app/src/routes/registre/nouveau/+page.server.ts +++ b/app/src/routes/registre/nouveau/+page.server.ts @@ -1,8 +1,31 @@ import { fail, redirect } from '@sveltejs/kit'; import { modeleSchema } from '$lib/schemas'; import { erreursZod, modeleDepuisFormulaire } from '$lib/server/formulaires'; +import { lireAlertes } from '$lib/server/alertes'; import { ecrireRegistre, lireRegistre } from '$lib/server/stockage'; -import type { Actions } from './$types'; +import type { Actions, PageServerLoad } from './$types'; + +/** + * Pré-remplissage depuis une alerte (action « Créer la fiche » de l'inbox) : + * reprend les sources et le résumé — sans inventer aucun champ licence/capacité. + */ +export const load: PageServerLoad = async ({ url }) => { + const alerteId = url.searchParams.get('depuis_alerte'); + if (!alerteId) return { brut: null }; + + const alertes = await lireAlertes(); + const alerte = alertes.find((a) => a.id === alerteId); + if (!alerte) return { brut: null }; + + return { + brut: { + id: alerte.modele_id ?? '', + nom: alerte.modele_id ? '' : alerte.titre, + sources: alerte.sources, + notes: `${alerte.resume}\n\n(alerte du ${alerte.date}, type : ${alerte.type})` + } + }; +}; export const actions: Actions = { default: async ({ request }) => { diff --git a/app/src/routes/registre/nouveau/+page.svelte b/app/src/routes/registre/nouveau/+page.svelte index c0f6217..902a51d 100644 --- a/app/src/routes/registre/nouveau/+page.svelte +++ b/app/src/routes/registre/nouveau/+page.svelte @@ -1,9 +1,9 @@ @@ -14,5 +14,5 @@ | undefined) ?? null} + brut={(form?.brut as Record | undefined) ?? data.brut} /> diff --git a/nodered-flows/README.md b/nodered-flows/README.md new file mode 100644 index 0000000..1d59364 --- /dev/null +++ b/nodered-flows/README.md @@ -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). diff --git a/nodered-flows/classifier.json b/nodered-flows/classifier.json new file mode 100644 index 0000000..c3f86d1 --- /dev/null +++ b/nodered-flows/classifier.json @@ -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": [] + } +] diff --git a/nodered-flows/license-watch.json b/nodered-flows/license-watch.json new file mode 100644 index 0000000..3cb08be --- /dev/null +++ b/nodered-flows/license-watch.json @@ -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": [] + } +] diff --git a/nodered-flows/notify.json b/nodered-flows/notify.json new file mode 100644 index 0000000..5aef558 --- /dev/null +++ b/nodered-flows/notify.json @@ -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": [] + } +] diff --git a/nodered-flows/rss-ingest.json b/nodered-flows/rss-ingest.json new file mode 100644 index 0000000..90076d7 --- /dev/null +++ b/nodered-flows/rss-ingest.json @@ -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": [] + } +]