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:
@@ -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 »)
|
- 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
|
- 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
|
- 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 3** — templates ComfyUI + smoke tests
|
||||||
- [ ] **Phase 4** — durcissement (i18n gcf/en, niveau 8 package_check)
|
- [ ] **Phase 4** — durcissement (i18n gcf/en, niveau 8 package_check)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { estRoutePublique, resoudreUtilisateur } from '$lib/server/sso';
|
|||||||
* Authentification SSO : l'utilisateur vient du header injecté par le reverse proxy
|
* Authentification SSO : l'utilisateur vient du header injecté par le reverse proxy
|
||||||
* YunoHost (SSOwat). Sans utilisateur, seule la page d'accueil publique est accessible ;
|
* YunoHost (SSOwat). Sans utilisateur, seule la page d'accueil publique est accessible ;
|
||||||
* toute autre route (pages et endpoints API) renvoie 401.
|
* 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 }) => {
|
const authHandle: Handle = async ({ event, resolve }) => {
|
||||||
event.locals.user = resoudreUtilisateur(event.request.headers, {
|
event.locals.user = resoudreUtilisateur(event.request.headers, {
|
||||||
@@ -17,7 +18,8 @@ const authHandle: Handle = async ({ event, resolve }) => {
|
|||||||
dev
|
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');
|
error(401, 'Authentification requise');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -190,6 +190,32 @@ export const fr = {
|
|||||||
genere_le: 'Généré le',
|
genere_le: 'Généré le',
|
||||||
tokens_aide: 'Tokens disponibles : {{date}}, {{tableau_registre}}, {{tableau_classements}}'
|
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: {
|
pied: {
|
||||||
ligne: 'veille-ia — application AGPL-3.0, auto-hébergée.',
|
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.'
|
registre_vide: 'Registre et profils initialisés depuis le PRD au premier démarrage.'
|
||||||
|
|||||||
@@ -87,3 +87,37 @@ export const profilSchema = z.object({
|
|||||||
contraintes_specifiques: z.string().nullable().default(null)
|
contraintes_specifiques: z.string().nullable().default(null)
|
||||||
});
|
});
|
||||||
export type Profil = z.infer<typeof profilSchema>;
|
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>;
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -9,14 +9,13 @@
|
|||||||
let { data, children }: LayoutProps = $props();
|
let { data, children }: LayoutProps = $props();
|
||||||
|
|
||||||
// Navigation privée : visible uniquement avec un utilisateur SSO.
|
// Navigation privée : visible uniquement avec un utilisateur SSO.
|
||||||
// L'entrée "bientot" correspond à la phase 2 du PRD.
|
|
||||||
const entrees = [
|
const entrees = [
|
||||||
{ href: '/', libelle: fr.nav.accueil, icone: 'accueil', bientot: false },
|
{ href: '/', libelle: fr.nav.accueil, icone: 'accueil', bientot: false },
|
||||||
{ href: '/registre', libelle: fr.nav.registre, icone: 'registre', bientot: false },
|
{ href: '/registre', libelle: fr.nav.registre, icone: 'registre', bientot: false },
|
||||||
{ href: '/profils', libelle: fr.nav.profils, icone: 'profils', bientot: false },
|
{ href: '/profils', libelle: fr.nav.profils, icone: 'profils', bientot: false },
|
||||||
{ href: '/recommander', libelle: fr.nav.recommander, icone: 'cible', bientot: false },
|
{ href: '/recommander', libelle: fr.nav.recommander, icone: 'cible', bientot: false },
|
||||||
{ href: '/exports', libelle: fr.nav.exports, icone: 'registre', 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;
|
] as const;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
titre: fr.accueil.carte_alertes_titre,
|
titre: fr.accueil.carte_alertes_titre,
|
||||||
texte: fr.accueil.carte_alertes_texte,
|
texte: fr.accueil.carte_alertes_texte,
|
||||||
icone: 'alerte',
|
icone: 'alerte',
|
||||||
href: null
|
href: '/alertes'
|
||||||
}
|
}
|
||||||
] as const;
|
] as const;
|
||||||
</script>
|
</script>
|
||||||
@@ -48,16 +48,9 @@
|
|||||||
<article class="carte">
|
<article class="carte">
|
||||||
<h2>
|
<h2>
|
||||||
<Icon nom={carte.icone} taille={22} />
|
<Icon nom={carte.icone} taille={22} />
|
||||||
{#if carte.href}
|
<a class="carte-lien" href={resolve(carte.href)}>{carte.titre}</a>
|
||||||
<a class="carte-lien" href={resolve(carte.href)}>{carte.titre}</a>
|
|
||||||
{:else}
|
|
||||||
{carte.titre}
|
|
||||||
{/if}
|
|
||||||
</h2>
|
</h2>
|
||||||
<p>{carte.texte}</p>
|
<p>{carte.texte}</p>
|
||||||
{#if !carte.href}
|
|
||||||
<span class="tag">{fr.accueil.phase_a_venir}</span>
|
|
||||||
{/if}
|
|
||||||
</article>
|
</article>
|
||||||
{/each}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
@@ -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 });
|
||||||
|
};
|
||||||
@@ -1,14 +1,32 @@
|
|||||||
import { error, fail, redirect } from '@sveltejs/kit';
|
import { error, fail, redirect } from '@sveltejs/kit';
|
||||||
import { modeleSchema } from '$lib/schemas';
|
import { modeleSchema } from '$lib/schemas';
|
||||||
import { erreursZod, modeleDepuisFormulaire } from '$lib/server/formulaires';
|
import { erreursZod, modeleDepuisFormulaire } from '$lib/server/formulaires';
|
||||||
|
import { lireAlertes } from '$lib/server/alertes';
|
||||||
import { ecrireRegistre, lireRegistre } from '$lib/server/stockage';
|
import { ecrireRegistre, lireRegistre } from '$lib/server/stockage';
|
||||||
import type { Actions, PageServerLoad } from './$types';
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
|
||||||
export const load: PageServerLoad = async ({ params }) => {
|
export const load: PageServerLoad = async ({ params, url }) => {
|
||||||
const registre = await lireRegistre();
|
const registre = await lireRegistre();
|
||||||
const modele = registre.find((m) => m.id === params.id);
|
const modele = registre.find((m) => m.id === params.id);
|
||||||
if (!modele) error(404, 'Modèle introuvable');
|
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 = {
|
export const actions: Actions = {
|
||||||
|
|||||||
@@ -17,5 +17,5 @@
|
|||||||
edition
|
edition
|
||||||
actionSupprimer="?/supprimer"
|
actionSupprimer="?/supprimer"
|
||||||
erreurs={form?.erreurs ?? []}
|
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 { fail, redirect } from '@sveltejs/kit';
|
||||||
import { modeleSchema } from '$lib/schemas';
|
import { modeleSchema } from '$lib/schemas';
|
||||||
import { erreursZod, modeleDepuisFormulaire } from '$lib/server/formulaires';
|
import { erreursZod, modeleDepuisFormulaire } from '$lib/server/formulaires';
|
||||||
|
import { lireAlertes } from '$lib/server/alertes';
|
||||||
import { ecrireRegistre, lireRegistre } from '$lib/server/stockage';
|
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 = {
|
export const actions: Actions = {
|
||||||
default: async ({ request }) => {
|
default: async ({ request }) => {
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { fr } from '$lib/i18n/fr';
|
import { fr } from '$lib/i18n/fr';
|
||||||
import ModeleForm from '$lib/components/ModeleForm.svelte';
|
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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
@@ -14,5 +14,5 @@
|
|||||||
|
|
||||||
<ModeleForm
|
<ModeleForm
|
||||||
erreurs={form?.erreurs ?? []}
|
erreurs={form?.erreurs ?? []}
|
||||||
brut={(form?.brut as Record<string, unknown> | undefined) ?? null}
|
brut={(form?.brut as Record<string, unknown> | undefined) ?? data.brut}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -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": []
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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": []
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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": []
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -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": []
|
||||||
|
}
|
||||||
|
]
|
||||||
Reference in New Issue
Block a user