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
+4 -1
View File
@@ -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)
+3 -1
View File
@@ -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');
}
+26
View File
@@ -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.'
+34
View File
@@ -87,3 +87,37 @@ export const profilSchema = z.object({
contraintes_specifiques: z.string().nullable().default(null)
});
export type Profil = z.infer<typeof profilSchema>;
// ── Alertes (PRD §4.4) ──
export const typeAlerteSchema = z.enum([
'nouvelle_sortie',
'changement_licence',
'changement_classement',
'comfyui_support'
]);
export type TypeAlerte = z.infer<typeof typeAlerteSchema>;
export const urgenceSchema = z.enum(['haute', 'moyenne', 'faible']);
export type Urgence = z.infer<typeof urgenceSchema>;
/** 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<typeof alerteEntranteSchema>;
/** 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<typeof alerteSchema>;
+26
View File
@@ -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);
});
});
+66
View File
@@ -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<Alerte[]> {
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<void> {
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<Alerte> {
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<boolean> {
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;
}
+1 -2
View File
@@ -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;
</script>
+1 -8
View File
@@ -29,7 +29,7 @@
titre: fr.accueil.carte_alertes_titre,
texte: fr.accueil.carte_alertes_texte,
icone: 'alerte',
href: null
href: '/alertes'
}
] as const;
</script>
@@ -48,16 +48,9 @@
<article class="carte">
<h2>
<Icon nom={carte.icone} taille={22} />
{#if carte.href}
<a class="carte-lien" href={resolve(carte.href)}>{carte.titre}</a>
{:else}
{carte.titre}
{/if}
</h2>
<p>{carte.texte}</p>
{#if !carte.href}
<span class="tag">{fr.accueil.phase_a_venir}</span>
{/if}
</article>
{/each}
</div>
+21
View File
@@ -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 };
}
};
+240
View File
@@ -0,0 +1,240 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
import { fr } from '$lib/i18n/fr';
import Icon from '$lib/components/Icon.svelte';
import type { ActionData, PageData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
let filtreProfil = $state('');
let filtreStatut = $state<'a_traiter' | 'traite' | ''>('a_traiter');
const alertesFiltrees = $derived(
data.alertes.filter(
(a) =>
(filtreStatut === '' || a.statut === filtreStatut) &&
(filtreProfil === '' || a.impact_profils.includes(filtreProfil))
)
);
function nomProfil(id: string): string {
return data.profils.find((p) => p.id === id)?.nom ?? id;
}
function dateCourte(iso: string): string {
return iso.slice(0, 10);
}
</script>
<svelte:head>
<title>{fr.alertes.titre}{fr.app.nom}</title>
</svelte:head>
<h1>{fr.alertes.titre}</h1>
<p class="intro">{fr.alertes.intro}</p>
{#if form && 'erreur' in form}
<p class="erreur" role="alert">{form.erreur}</p>
{/if}
<div class="filtres" role="search">
<label class="champ">
<span>{fr.alertes.filtre_statut}</span>
<select bind:value={filtreStatut}>
<option value="a_traiter">{fr.alertes.a_traiter}</option>
<option value="traite">{fr.alertes.traite}</option>
<option value="">{fr.alertes.toutes}</option>
</select>
</label>
<label class="champ">
<span>{fr.alertes.filtre_profil}</span>
<select bind:value={filtreProfil}>
<option value="">{fr.alertes.tous}</option>
{#each data.profils as profil (profil.id)}
<option value={profil.id}>{profil.nom}</option>
{/each}
</select>
</label>
</div>
{#if alertesFiltrees.length === 0}
<p>{fr.alertes.aucune}</p>
{:else}
<div class="liste">
{#each alertesFiltrees as alerte (alerte.id)}
<article class="carte alerte" class:traitee={alerte.statut === 'traite'}>
<header>
<span class="tag" class:tag-signal={alerte.urgence === 'haute'}>
{fr.alertes.types[alerte.type]} · {fr.alertes.urgence[alerte.urgence]}
</span>
<time datetime={alerte.recue_le}>
{fr.alertes.recue_le}
{dateCourte(alerte.recue_le)}
</time>
</header>
<h2>{alerte.titre}</h2>
<p class="resume">{alerte.resume}</p>
{#if alerte.impact_profils.length > 0}
<p class="impacts">
{#each alerte.impact_profils as profilId (profilId)}
<span class="tag">{nomProfil(profilId)}</span>
{/each}
</p>
{/if}
<p class="sources">
{#each alerte.sources as source (source)}
<a href={source} rel="external noopener noreferrer" target="_blank">{source}</a>
{/each}
</p>
<div class="actions">
{#if alerte.modele_id}
<a
class="bouton"
href={resolve(`/registre/${alerte.modele_id}?depuis_alerte=${alerte.id}`)}
>
<Icon nom="registre" taille={16} />
{fr.alertes.maj_fiche}
</a>
{:else}
<a class="bouton" href={resolve(`/registre/nouveau?depuis_alerte=${alerte.id}`)}>
<Icon nom="registre" taille={16} />
{fr.alertes.creer_fiche}
</a>
{/if}
{#if alerte.statut === 'a_traiter'}
<form method="post" action="?/traiter" use:enhance>
<input type="hidden" name="id" value={alerte.id} />
<button class="bouton bouton-secondaire" type="submit">
<Icon nom="verifie" taille={16} />
{fr.alertes.marquer_traite}
</button>
</form>
{/if}
</div>
</article>
{/each}
</div>
{/if}
<style>
h1 {
margin-bottom: var(--space-1);
}
.intro {
color: var(--muted);
margin-bottom: var(--space-3);
}
.filtres {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
margin-bottom: var(--space-3);
}
.champ {
display: flex;
flex-direction: column;
gap: 0.3em;
}
.champ > span {
color: var(--muted);
font-size: 0.9rem;
}
select {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
color: var(--fg);
padding: 0.5em 0.75em;
min-height: 44px;
}
.liste {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.alerte {
content-visibility: auto;
contain-intrinsic-size: 180px;
}
.alerte.traitee {
opacity: 0.55;
}
.alerte header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
margin-bottom: var(--space-1);
}
.alerte time {
color: var(--muted);
font-size: 0.85rem;
}
.alerte h2 {
font-size: 1.05rem;
margin-bottom: var(--space-1);
}
.resume {
color: var(--muted);
margin-bottom: var(--space-2);
}
.impacts {
display: flex;
flex-wrap: wrap;
gap: 0.5em;
margin-bottom: var(--space-2);
}
.sources {
display: flex;
flex-direction: column;
font-size: 0.85rem;
margin-bottom: var(--space-2);
word-break: break-all;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.actions .bouton {
display: inline-flex;
align-items: center;
gap: 0.4em;
}
.bouton-secondaire {
color: var(--succes);
border-color: var(--succes);
}
.bouton-secondaire:hover {
color: var(--succes);
border-color: var(--succes);
opacity: 0.8;
}
.erreur {
color: var(--signal);
font-weight: 600;
}
</style>
+39
View File
@@ -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 });
};
+20 -2
View File
@@ -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<string, unknown> | 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 = {
+1 -1
View File
@@ -17,5 +17,5 @@
edition
actionSupprimer="?/supprimer"
erreurs={form?.erreurs ?? []}
brut={(form?.brut as Record<string, unknown> | undefined) ?? null}
brut={(form?.brut as Record<string, unknown> | undefined) ?? data.brut}
/>
@@ -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 }) => {
+3 -3
View File
@@ -1,9 +1,9 @@
<script lang="ts">
import { fr } from '$lib/i18n/fr';
import ModeleForm from '$lib/components/ModeleForm.svelte';
import type { ActionData } from './$types';
import type { ActionData, PageData } from './$types';
let { form }: { form: ActionData } = $props();
let { data, form }: { data: PageData; form: ActionData } = $props();
</script>
<svelte:head>
@@ -14,5 +14,5 @@
<ModeleForm
erreurs={form?.erreurs ?? []}
brut={(form?.brut as Record<string, unknown> | undefined) ?? null}
brut={(form?.brut as Record<string, unknown> | undefined) ?? data.brut}
/>
+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": []
}
]