Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e0220b1c9 | ||
|
|
7a90ddd641 | ||
|
|
6978fbb5b8 | ||
|
|
0c9f83d3f5 |
@@ -20,12 +20,13 @@ Prérequis : Node 24 LTS, npm.
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd app
|
cd app
|
||||||
cp .env.example .env # DEV_USER=dev simule un utilisateur SSO connecté
|
|
||||||
npm ci
|
npm ci
|
||||||
npm run dev
|
npm run dev:local # zéro config : utilisateur SSO simulé (dev), token d'alertes factice, données dans ./data
|
||||||
```
|
```
|
||||||
|
|
||||||
Sans `DEV_USER`, l'accueil public minimal est affiché et toute autre route renvoie 401 — comme en production sans header SSO.
|
`npm run dev:local` injecte `DEV_USER=dev` (utilisateur SSO simulé — dev uniquement), `ALERTS_TOKEN=dev-token-local` (pour tester `POST /api/alerts`) et `DATA_DIR=./data`. L'app est sur http://localhost:5173, connectée en tant que `dev`.
|
||||||
|
|
||||||
|
Pour tester finement une variable précise, copie `.env.example` en `.env` et utilise `npm run dev`. Sans `DEV_USER`, l'accueil public minimal est affiché et toute autre route renvoie 401 — comme en production sans header SSO.
|
||||||
|
|
||||||
### Variables d'environnement
|
### Variables d'environnement
|
||||||
|
|
||||||
@@ -34,6 +35,7 @@ Sans `DEV_USER`, l'accueil public minimal est affiché et toute autre route renv
|
|||||||
| `DATA_DIR` | `./data` (relatif à `app/`) | Dossier persistant : YAML + git interne. En prod : `/home/yunohost.app/veille-ia` |
|
| `DATA_DIR` | `./data` (relatif à `app/`) | Dossier persistant : YAML + git interne. En prod : `/home/yunohost.app/veille-ia` |
|
||||||
| `OLLAMA_URL` | `http://127.0.0.1:11434` | Endpoint Ollama. Optionnel : dégradation gracieuse si injoignable |
|
| `OLLAMA_URL` | `http://127.0.0.1:11434` | Endpoint Ollama. Optionnel : dégradation gracieuse si injoignable |
|
||||||
| `ALERTS_TOKEN` | *(aucun)* | Token Bearer de `POST /api/alerts`. Généré à l'install YNH, jamais commité |
|
| `ALERTS_TOKEN` | *(aucun)* | Token Bearer de `POST /api/alerts`. Généré à l'install YNH, jamais commité |
|
||||||
|
| `COMFYUI_URL` | `http://127.0.0.1:8188` | API ComfyUI pour les smoke tests. Dégradation gracieuse si injoignable |
|
||||||
| `SSO_HEADER` | `ynh-user` | Header HTTP injecté par SSOwat portant l'utilisateur authentifié |
|
| `SSO_HEADER` | `ynh-user` | Header HTTP injecté par SSOwat portant l'utilisateur authentifié |
|
||||||
| `DEV_USER` | *(aucun)* | Dev uniquement : simule un utilisateur SSO. Ignoré en production |
|
| `DEV_USER` | *(aucun)* | Dev uniquement : simule un utilisateur SSO. Ignoré en production |
|
||||||
| `BASE_PATH` | *(aucun)* | Build uniquement : sous-chemin de montage (ex. `/veille`) pour `paths.base` |
|
| `BASE_PATH` | *(aucun)* | Build uniquement : sous-chemin de montage (ex. `/veille`) pour `paths.base` |
|
||||||
@@ -81,7 +83,18 @@ Optionnel. Si le paquet `ollama_ynh` est indisponible : Ollama sur la machine lo
|
|||||||
## État d'avancement
|
## État d'avancement
|
||||||
|
|
||||||
- [x] **Phase 0** — socle SvelteKit (SSO, thème OKI, CSP) + packaging YNH + dev local
|
- [x] **Phase 0** — socle SvelteKit (SSO, thème OKI, CSP) + packaging YNH + dev local
|
||||||
- [ ] **Phase 1** — registre + profils + moteur de recommandation + export markdown
|
- [x] **Phase 1** — registre + profils + moteur de recommandation + export markdown
|
||||||
- [ ] **Phase 2** — inbox alertes + flows Node-RED
|
- Seeds au premier démarrage : `registre_modeles.yaml` (PRD §4.1), `profils_projets.yaml` (5 profils), templates markdown — commit git initial automatique dans le `DATA_DIR`
|
||||||
- [ ] **Phase 3** — templates ComfyUI + smoke tests
|
- 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
|
||||||
|
- [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
|
||||||
|
- [x] **Phase 3** — templates ComfyUI + smoke tests
|
||||||
|
- Templates paramétrés (`data/workflows/templates/`) : substitution de variables (`{{modele_checkpoint}}`, `{{resolution}}`, `{{vram_profile}}`…), **jamais de réécriture de topologie**
|
||||||
|
- Génération de JSON concrets (`/workflows`) + **régénération automatique à chaque modification du registre**, commit git ; suppression si le modèle disparaît
|
||||||
|
- Champ `comfyui_checkpoint` dans le registre ; génération refusée tant qu'il est null (anti-hallucination)
|
||||||
|
- Smoke test : prompt canari sans modèle (EmptyImage→PreviewImage) posté sur `COMFYUI_URL/prompt`, statut remonté dans l'UI, dégradation gracieuse si injoignable
|
||||||
- [ ] **Phase 4** — durcissement (i18n gcf/en, niveau 8 package_check)
|
- [ ] **Phase 4** — durcissement (i18n gcf/en, niveau 8 package_check)
|
||||||
|
|||||||
@@ -7,6 +7,9 @@
|
|||||||
# Endpoint Ollama (optionnel : l'app fonctionne sans, en mode dégradé)
|
# Endpoint Ollama (optionnel : l'app fonctionne sans, en mode dégradé)
|
||||||
# OLLAMA_URL=http://127.0.0.1:11434
|
# OLLAMA_URL=http://127.0.0.1:11434
|
||||||
|
|
||||||
|
# Endpoint ComfyUI pour les smoke tests (optionnel : dégradation gracieuse)
|
||||||
|
# COMFYUI_URL=http://127.0.0.1:8188
|
||||||
|
|
||||||
# Token Bearer protégeant POST /api/alerts (généré à l'install YunoHost — jamais commité)
|
# Token Bearer protégeant POST /api/alerts (généré à l'install YunoHost — jamais commité)
|
||||||
# ALERTS_TOKEN=
|
# ALERTS_TOKEN=
|
||||||
|
|
||||||
|
|||||||
+11
-1
@@ -4,10 +4,20 @@ Voir le [README racine](../README.md) pour la vue d'ensemble, le dev local et le
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm ci
|
npm ci
|
||||||
npm run dev # dev (DEV_USER=dev dans .env simule le SSO)
|
npm run dev:local # dev zéro config : DEV_USER=dev + ALERTS_TOKEN=dev-token-local + DATA_DIR=./data
|
||||||
|
npm run dev # dev nu (lit .env — voir .env.example)
|
||||||
npm run check # svelte-check — 0 erreur / 0 warning exigé
|
npm run check # svelte-check — 0 erreur / 0 warning exigé
|
||||||
npm run lint # prettier + eslint
|
npm run lint # prettier + eslint
|
||||||
npm run test # vitest
|
npm run test # vitest
|
||||||
npm run build # build adapter-node (BASE_PATH=/veille pour un sous-chemin)
|
npm run build # build adapter-node (BASE_PATH=/veille pour un sous-chemin)
|
||||||
node build/index.js
|
node build/index.js
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Test de `POST /api/alerts` en local (avec `dev:local`) :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
curl -X POST http://localhost:5173/api/alerts \
|
||||||
|
-H "authorization: Bearer dev-token-local" \
|
||||||
|
-H "content-type: application/json" \
|
||||||
|
-d '{"type":"nouvelle_sortie","titre":"Test","resume":"Résumé test.","sources":["https://example.org"],"date":"2026-08-01"}'
|
||||||
|
```
|
||||||
|
|||||||
Generated
+99
-22
@@ -1,17 +1,23 @@
|
|||||||
{
|
{
|
||||||
"name": "app",
|
"name": "veille-ia",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "app",
|
"name": "veille-ia",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
|
"dependencies": {
|
||||||
|
"js-yaml": "^5.2.3",
|
||||||
|
"simple-git": "^3.36.0",
|
||||||
|
"zod": "^4.4.3"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"@sveltejs/adapter-node": "^5.5.4",
|
"@sveltejs/adapter-node": "^5.5.4",
|
||||||
"@sveltejs/kit": "^2.63.0",
|
"@sveltejs/kit": "^2.63.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||||
|
"@types/js-yaml": "^4.0.9",
|
||||||
"@types/node": "^22",
|
"@types/node": "^22",
|
||||||
"eslint": "^10.4.1",
|
"eslint": "^10.4.1",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
@@ -305,6 +311,21 @@
|
|||||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@kwsites/file-exists": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"debug": "^4.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@kwsites/promise-deferred": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
|
"node_modules/@napi-rs/lzma-linux-x64-gnu": {
|
||||||
"version": "1.5.1",
|
"version": "1.5.1",
|
||||||
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
|
"resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz",
|
||||||
@@ -1090,6 +1111,21 @@
|
|||||||
"win32"
|
"win32"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"node_modules/@simple-git/args-pathspec": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@simple-git/args-pathspec/-/args-pathspec-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-ngJMaHlsWDTfjyq9F3VIQ8b7NXbBLq5j9i5bJ6XLYtD6qlDXT7fdKY2KscWWUF8t18xx052Y/PUO1K1TRc9yKA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@simple-git/argv-parser": {
|
||||||
|
"version": "1.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@simple-git/argv-parser/-/argv-parser-1.1.1.tgz",
|
||||||
|
"integrity": "sha512-Q9lBcfQ+VQCpQqGJFHe5yooOS5hGdLFFbJ5R+R5aDsnkPCahtn1hSkMcORX65J2Z5lxSkD0lQorMsncuBQxYUw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@simple-git/args-pathspec": "^1.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@standard-schema/spec": {
|
"node_modules/@standard-schema/spec": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
|
||||||
@@ -1246,6 +1282,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/js-yaml": {
|
||||||
|
"version": "4.0.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
|
||||||
|
"integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/@types/json-schema": {
|
"node_modules/@types/json-schema": {
|
||||||
"version": "7.0.15",
|
"version": "7.0.15",
|
||||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||||
@@ -1670,6 +1713,12 @@
|
|||||||
"url": "https://github.com/sponsors/epoberezkin"
|
"url": "https://github.com/sponsors/epoberezkin"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/argparse": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
|
||||||
|
"license": "Python-2.0"
|
||||||
|
},
|
||||||
"node_modules/aria-query": {
|
"node_modules/aria-query": {
|
||||||
"version": "5.3.1",
|
"version": "5.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz",
|
||||||
@@ -1815,7 +1864,6 @@
|
|||||||
"version": "4.4.3",
|
"version": "4.4.3",
|
||||||
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
|
||||||
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"ms": "^2.1.3"
|
"ms": "^2.1.3"
|
||||||
@@ -2390,6 +2438,28 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/js-yaml": {
|
||||||
|
"version": "5.2.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-5.2.3.tgz",
|
||||||
|
"integrity": "sha512-n+mUVyUX5bVv7G/G2zyIHOhdxfuU1dY2NOFzTQUWiMUbFss8b57NFlgCCaggU78wSw5KVS9cllzeLyzyR+n5nw==",
|
||||||
|
"funding": [
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/puzrin"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/nodeca"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"argparse": "^2.0.1"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"js-yaml": "bin/js-yaml.mjs"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/json-buffer": {
|
"node_modules/json-buffer": {
|
||||||
"version": "3.0.1",
|
"version": "3.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz",
|
||||||
@@ -2796,7 +2866,6 @@
|
|||||||
"version": "2.1.3",
|
"version": "2.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||||
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
@@ -3309,6 +3378,23 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/simple-git": {
|
||||||
|
"version": "3.36.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.36.0.tgz",
|
||||||
|
"integrity": "sha512-cGQjLjK8bxJw4QuYT7gxHw3/IouVESbhahSsHrX97MzCL1gu2u7oy38W6L2ZIGECEfIBG4BabsWDPjBxJENv9Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@kwsites/file-exists": "^1.1.1",
|
||||||
|
"@kwsites/promise-deferred": "^1.1.1",
|
||||||
|
"@simple-git/args-pathspec": "^1.0.3",
|
||||||
|
"@simple-git/argv-parser": "^1.1.0",
|
||||||
|
"debug": "^4.4.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/steveukx/git-js?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/sirv": {
|
"node_modules/sirv": {
|
||||||
"version": "3.0.2",
|
"version": "3.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
|
||||||
@@ -3884,24 +3970,6 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/yaml": {
|
|
||||||
"version": "2.9.0",
|
|
||||||
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
|
|
||||||
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
|
|
||||||
"dev": true,
|
|
||||||
"license": "ISC",
|
|
||||||
"optional": true,
|
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
|
||||||
"yaml": "bin.mjs"
|
|
||||||
},
|
|
||||||
"engines": {
|
|
||||||
"node": ">= 14.6"
|
|
||||||
},
|
|
||||||
"funding": {
|
|
||||||
"url": "https://github.com/sponsors/eemeli"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/yocto-queue": {
|
"node_modules/yocto-queue": {
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz",
|
||||||
@@ -3921,6 +3989,15 @@
|
|||||||
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
|
"integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/zod": {
|
||||||
|
"version": "4.4.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||||
|
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/colinhacks"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
|
"dev:local": "DEV_USER=dev ALERTS_TOKEN=dev-token-local DATA_DIR=./data vite dev",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"prepare": "svelte-kit sync || echo ''",
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
@@ -20,6 +21,7 @@
|
|||||||
"@sveltejs/adapter-node": "^5.5.4",
|
"@sveltejs/adapter-node": "^5.5.4",
|
||||||
"@sveltejs/kit": "^2.63.0",
|
"@sveltejs/kit": "^2.63.0",
|
||||||
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||||
|
"@types/js-yaml": "^4.0.9",
|
||||||
"@types/node": "^22",
|
"@types/node": "^22",
|
||||||
"eslint": "^10.4.1",
|
"eslint": "^10.4.1",
|
||||||
"eslint-config-prettier": "^10.1.8",
|
"eslint-config-prettier": "^10.1.8",
|
||||||
@@ -33,5 +35,10 @@
|
|||||||
"typescript-eslint": "^8.60.1",
|
"typescript-eslint": "^8.60.1",
|
||||||
"vite": "^8.0.16",
|
"vite": "^8.0.16",
|
||||||
"vitest": "^4.1.8"
|
"vitest": "^4.1.8"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"js-yaml": "^5.2.3",
|
||||||
|
"simple-git": "^3.36.0",
|
||||||
|
"zod": "^4.4.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Badge « à vérifier » — règle anti-hallucination du PRD : tout champ null
|
||||||
|
* est signalé, jamais masqué. Ne repose pas sur la couleur seule (picto + texte).
|
||||||
|
*/
|
||||||
|
import { fr } from '$lib/i18n/fr';
|
||||||
|
import Icon from './Icon.svelte';
|
||||||
|
|
||||||
|
let { texte = null }: { texte?: string | null } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span class="tag">
|
||||||
|
<Icon nom="avertissement" taille={14} />
|
||||||
|
{#if texte}{texte}{:else}{fr.badge.a_verifier}{/if}
|
||||||
|
</span>
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Formulaire modèle (création et édition).
|
||||||
|
* Champs tri-état : '' = null = « non vérifié » (règle anti-hallucination).
|
||||||
|
* `brut` reçoit les valeurs soumises en cas d'erreur de validation (ré-affichage).
|
||||||
|
*/
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import { fr } from '$lib/i18n/fr';
|
||||||
|
import { modaliteSchema, statutModeleSchema, type Modele } from '$lib/schemas';
|
||||||
|
|
||||||
|
type ModelePartiel = {
|
||||||
|
[K in keyof Modele]?: K extends 'licence'
|
||||||
|
? Partial<Modele['licence']>
|
||||||
|
: K extends 'capacites'
|
||||||
|
? Partial<Modele['capacites']>
|
||||||
|
: Modele[K];
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
modele = null,
|
||||||
|
brut = null,
|
||||||
|
erreurs = [],
|
||||||
|
edition = false,
|
||||||
|
actionSupprimer = null
|
||||||
|
}: {
|
||||||
|
modele?: Modele | null;
|
||||||
|
brut?: Record<string, unknown> | null;
|
||||||
|
erreurs?: string[];
|
||||||
|
edition?: boolean;
|
||||||
|
actionSupprimer?: string | null;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const m = $derived((brut ?? modele ?? {}) as ModelePartiel);
|
||||||
|
const licence = $derived(m.licence ?? {});
|
||||||
|
const capacites = $derived(m.capacites ?? {});
|
||||||
|
|
||||||
|
function s(v: unknown): string {
|
||||||
|
return typeof v === 'string' ? v : v == null ? '' : String(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
function tri(v: unknown): string {
|
||||||
|
return v === true ? 'true' : v === false ? 'false' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoresTexte(v: unknown): string {
|
||||||
|
if (!v || typeof v !== 'object') return '';
|
||||||
|
return Object.entries(v as Record<string, number | null>)
|
||||||
|
.map(([cle, val]) => `${cle} = ${val ?? ''}`)
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
const libellesModalites = fr.formulaires.modalites;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if erreurs.length > 0}
|
||||||
|
<div class="bloc-erreurs" role="alert">
|
||||||
|
<p>{fr.formulaires.erreurs_titre}</p>
|
||||||
|
<ul>
|
||||||
|
{#each erreurs as erreur (erreur)}
|
||||||
|
<li>{erreur}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#snippet triEtat(nom: string, libelle: string, valeur: unknown)}
|
||||||
|
<label class="champ">
|
||||||
|
<span>{libelle}</span>
|
||||||
|
<select name={nom}>
|
||||||
|
<option value="" selected={tri(valeur) === ''}>{fr.formulaires.non_verifie}</option>
|
||||||
|
<option value="true" selected={tri(valeur) === 'true'}>{fr.formulaires.ouvert}</option>
|
||||||
|
<option value="false" selected={tri(valeur) === 'false'}>{fr.formulaires.ferme}</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{/snippet}
|
||||||
|
|
||||||
|
<form method="post" use:enhance>
|
||||||
|
<fieldset>
|
||||||
|
<label class="champ">
|
||||||
|
<span>{fr.registre.champ_id}</span>
|
||||||
|
<input name="id" value={s(m.id)} required pattern="[a-z0-9][a-z0-9_\-]*" readonly={edition} />
|
||||||
|
</label>
|
||||||
|
<label class="champ">
|
||||||
|
<span>{fr.registre.champ_nom}</span>
|
||||||
|
<input name="nom" value={s(m.nom)} required />
|
||||||
|
</label>
|
||||||
|
<label class="champ">
|
||||||
|
<span>{fr.registre.champ_editeur}</span>
|
||||||
|
<input name="editeur" value={s(m.editeur)} required />
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>{fr.registre.champ_modalites}</legend>
|
||||||
|
<div class="choix">
|
||||||
|
{#each modaliteSchema.options as modalite (modalite)}
|
||||||
|
<label class="case">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="modalites"
|
||||||
|
value={modalite}
|
||||||
|
checked={m.modalites?.includes(modalite)}
|
||||||
|
/>
|
||||||
|
{libellesModalites[modalite]}
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>{fr.registre.section_licence}</legend>
|
||||||
|
<p class="aide">{fr.registre.aide_tri_etat}</p>
|
||||||
|
<label class="champ">
|
||||||
|
<span>{fr.registre.champ_licence_nom}</span>
|
||||||
|
<input name="licence_nom" value={s(licence.nom)} />
|
||||||
|
</label>
|
||||||
|
<label class="champ">
|
||||||
|
<span>{fr.registre.champ_licence_url}</span>
|
||||||
|
<input name="licence_url" type="url" value={s(licence.url)} />
|
||||||
|
</label>
|
||||||
|
<div class="rangee">
|
||||||
|
{@render triEtat('commercial_ok', fr.registre.champ_commercial_ok, licence.commercial_ok)}
|
||||||
|
{@render triEtat(
|
||||||
|
'attribution_requise',
|
||||||
|
fr.registre.champ_attribution,
|
||||||
|
licence.attribution_requise
|
||||||
|
)}
|
||||||
|
{@render triEtat('poids_ouverts', fr.registre.champ_poids_ouverts, licence.poids_ouverts)}
|
||||||
|
{@render triEtat(
|
||||||
|
'garde_fous_obligatoires',
|
||||||
|
fr.registre.champ_garde_fous,
|
||||||
|
licence.garde_fous_obligatoires
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<label class="champ">
|
||||||
|
<span>{fr.registre.champ_seuil}</span>
|
||||||
|
<input name="seuil" value={s(licence.seuil)} placeholder="ex. équipes <50 sièges" />
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>{fr.registre.section_capacites}</legend>
|
||||||
|
<div class="rangee">
|
||||||
|
{@render triEtat('nsfw_ok', fr.registre.champ_nsfw, capacites.nsfw_ok)}
|
||||||
|
{@render triEtat('comfyui_natif', fr.registre.champ_comfyui, capacites.comfyui_natif)}
|
||||||
|
<label class="champ">
|
||||||
|
<span>{fr.registre.champ_vram}</span>
|
||||||
|
<input name="vram_gb" type="number" min="1" step="1" value={s(capacites.vram_gb)} />
|
||||||
|
</label>
|
||||||
|
<label class="champ">
|
||||||
|
<span>{fr.registre.champ_resolution}</span>
|
||||||
|
<input name="resolution_max" value={s(capacites.resolution_max)} placeholder="ex. 2K" />
|
||||||
|
</label>
|
||||||
|
<label class="champ">
|
||||||
|
<span>{fr.registre.champ_outputs}</span>
|
||||||
|
<select name="licence_outputs">
|
||||||
|
<option value="" selected={s(capacites.licence_outputs) === ''}>
|
||||||
|
{fr.formulaires.non_verifie}
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
value="commercialisables"
|
||||||
|
selected={s(capacites.licence_outputs) === 'commercialisables'}
|
||||||
|
>
|
||||||
|
{fr.registre.outputs_commercialisables}
|
||||||
|
</option>
|
||||||
|
<option
|
||||||
|
value="non_commercial"
|
||||||
|
selected={s(capacites.licence_outputs) === 'non_commercial'}
|
||||||
|
>
|
||||||
|
{fr.registre.outputs_non_commercial}
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="champ">
|
||||||
|
<span>{fr.registre.champ_checkpoint}</span>
|
||||||
|
<input
|
||||||
|
name="comfyui_checkpoint"
|
||||||
|
value={s(capacites.comfyui_checkpoint)}
|
||||||
|
placeholder="ex. modele_fp8.safetensors"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<label class="champ">
|
||||||
|
<span>{fr.registre.champ_scores}</span>
|
||||||
|
<textarea name="scores" rows="3">{scoresTexte(m.scores)}</textarea>
|
||||||
|
</label>
|
||||||
|
<div class="rangee">
|
||||||
|
<label class="champ">
|
||||||
|
<span>{fr.registre.champ_statut}</span>
|
||||||
|
<select name="statut">
|
||||||
|
{#each statutModeleSchema.options as statut (statut)}
|
||||||
|
<option value={statut} selected={s(m.statut) === statut}>
|
||||||
|
{fr.formulaires.statuts[statut]}
|
||||||
|
</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="champ">
|
||||||
|
<span>{fr.registre.champ_verif}</span>
|
||||||
|
<input
|
||||||
|
name="derniere_verif"
|
||||||
|
value={s(m.derniere_verif)}
|
||||||
|
placeholder="AAAA-MM-JJ"
|
||||||
|
pattern="\d{4}-\d{2}-\d{2}"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<label class="champ">
|
||||||
|
<span>{fr.registre.champ_sources}</span>
|
||||||
|
<textarea name="sources" rows="3" required>{(m.sources ?? []).join('\n')}</textarea>
|
||||||
|
</label>
|
||||||
|
<label class="champ">
|
||||||
|
<span>{fr.registre.champ_notes}</span>
|
||||||
|
<textarea name="notes" rows="3">{s(m.notes)}</textarea>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button class="bouton" type="submit">{fr.formulaires.enregistrer}</button>
|
||||||
|
{#if edition && actionSupprimer}
|
||||||
|
<button
|
||||||
|
class="bouton bouton-danger"
|
||||||
|
type="submit"
|
||||||
|
formaction={actionSupprimer}
|
||||||
|
formnovalidate
|
||||||
|
>
|
||||||
|
{fr.formulaires.supprimer}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
form,
|
||||||
|
fieldset {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldset {
|
||||||
|
border: var(--border-card);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-3);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
legend {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
padding-inline: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.champ {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.champ > span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--fg);
|
||||||
|
padding: 0.5em 0.75em;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[readonly] {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rangee {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rangee .champ {
|
||||||
|
flex: 1 1 180px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choix {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.case {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4em;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aide {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bloc-erreurs {
|
||||||
|
border: 1px solid var(--signal);
|
||||||
|
border-left: 4px solid var(--signal);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bloc-erreurs p {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bouton-danger {
|
||||||
|
color: var(--signal);
|
||||||
|
border-color: var(--signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bouton-danger:hover {
|
||||||
|
color: var(--signal);
|
||||||
|
border-color: var(--signal);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
/**
|
||||||
|
* Formulaire profil de projet (création et édition).
|
||||||
|
* `brut` reçoit les valeurs soumises en cas d'erreur de validation (ré-affichage).
|
||||||
|
*/
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import { fr } from '$lib/i18n/fr';
|
||||||
|
import { cleConditionSchema, type CleCondition, type Profil } from '$lib/schemas';
|
||||||
|
|
||||||
|
type ProfilPartiel = Omit<Partial<Profil>, 'preferences'> & {
|
||||||
|
preferences?: Partial<Profil['preferences']>;
|
||||||
|
};
|
||||||
|
|
||||||
|
let {
|
||||||
|
profil = null,
|
||||||
|
brut = null,
|
||||||
|
erreurs = [],
|
||||||
|
edition = false,
|
||||||
|
actionSupprimer = null
|
||||||
|
}: {
|
||||||
|
profil?: Profil | null;
|
||||||
|
brut?: Record<string, unknown> | null;
|
||||||
|
erreurs?: string[];
|
||||||
|
edition?: boolean;
|
||||||
|
actionSupprimer?: string | null;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
const p = $derived((brut ?? profil ?? {}) as ProfilPartiel);
|
||||||
|
|
||||||
|
function s(v: unknown): string {
|
||||||
|
return typeof v === 'string' ? v : v == null ? '' : String(v);
|
||||||
|
}
|
||||||
|
|
||||||
|
function dansListe(v: unknown, cle: string): boolean {
|
||||||
|
return Array.isArray(v) && v.includes(cle);
|
||||||
|
}
|
||||||
|
|
||||||
|
const niveaux = ['obligatoire', 'fortement_prefere', 'prefere'] as const;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if erreurs.length > 0}
|
||||||
|
<div class="bloc-erreurs" role="alert">
|
||||||
|
<p>{fr.formulaires.erreurs_titre}</p>
|
||||||
|
<ul>
|
||||||
|
{#each erreurs as erreur (erreur)}
|
||||||
|
<li>{erreur}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<form method="post" use:enhance>
|
||||||
|
<fieldset>
|
||||||
|
<label class="champ">
|
||||||
|
<span>{fr.profils.champ_id}</span>
|
||||||
|
<input name="id" value={s(p.id)} required pattern="[a-z0-9][a-z0-9_\-]*" readonly={edition} />
|
||||||
|
</label>
|
||||||
|
<label class="champ">
|
||||||
|
<span>{fr.profils.champ_nom}</span>
|
||||||
|
<input name="nom" value={s(p.nom)} required />
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>{fr.profils.section_requiert}</legend>
|
||||||
|
<div class="choix">
|
||||||
|
{#each cleConditionSchema.options as cle (cle)}
|
||||||
|
<label class="case">
|
||||||
|
<input type="checkbox" name="requiert" value={cle} checked={dansListe(p.requiert, cle)} />
|
||||||
|
{fr.formulaires.cles[cle]}
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>{fr.profils.section_exclut}</legend>
|
||||||
|
<div class="choix">
|
||||||
|
{#each cleConditionSchema.options as cle (cle)}
|
||||||
|
<label class="case">
|
||||||
|
<input type="checkbox" name="exclut" value={cle} checked={dansListe(p.exclut, cle)} />
|
||||||
|
{fr.formulaires.cles[cle]}
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>{fr.profils.section_preferences}</legend>
|
||||||
|
<div class="rangee">
|
||||||
|
{#each cleConditionSchema.options as cle (cle)}
|
||||||
|
<label class="champ">
|
||||||
|
<span>{fr.formulaires.cles[cle]}</span>
|
||||||
|
<select name="pref_{cle}">
|
||||||
|
<option value="" selected={!(cle in (p.preferences ?? {}))}>
|
||||||
|
{fr.profils.pref_aucune}
|
||||||
|
</option>
|
||||||
|
{#each niveaux as niveau (niveau)}
|
||||||
|
<option value={niveau} selected={p.preferences?.[cle as CleCondition] === niveau}>
|
||||||
|
{fr.formulaires.niveaux[niveau]}
|
||||||
|
</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>{fr.profils.section_plateformes}</legend>
|
||||||
|
<div class="choix">
|
||||||
|
{#each Object.entries(fr.formulaires.plateformes) as [cle, libelle] (cle)}
|
||||||
|
<label class="case">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
name="plateformes"
|
||||||
|
value={cle}
|
||||||
|
checked={dansListe(p.plateformes, cle)}
|
||||||
|
/>
|
||||||
|
{libelle}
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<label class="champ">
|
||||||
|
<span>{fr.profils.champ_contraintes}</span>
|
||||||
|
<textarea name="contraintes_specifiques" rows="3">{s(p.contraintes_specifiques)}</textarea>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button class="bouton" type="submit">{fr.formulaires.enregistrer}</button>
|
||||||
|
{#if edition && actionSupprimer}
|
||||||
|
<button
|
||||||
|
class="bouton bouton-danger"
|
||||||
|
type="submit"
|
||||||
|
formaction={actionSupprimer}
|
||||||
|
formnovalidate
|
||||||
|
>
|
||||||
|
{fr.formulaires.supprimer}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
form,
|
||||||
|
fieldset {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
fieldset {
|
||||||
|
border: var(--border-card);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-3);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
legend {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: -0.01em;
|
||||||
|
padding-inline: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.champ {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.champ > span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--fg);
|
||||||
|
padding: 0.5em 0.75em;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[readonly] {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rangee {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rangee .champ {
|
||||||
|
flex: 1 1 220px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.choix {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.case {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4em;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bloc-erreurs {
|
||||||
|
border: 1px solid var(--signal);
|
||||||
|
border-left: 4px solid var(--signal);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-2) var(--space-3);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bloc-erreurs p {
|
||||||
|
font-weight: 600;
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.actions {
|
||||||
|
display: flex;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bouton-danger {
|
||||||
|
color: var(--signal);
|
||||||
|
border-color: var(--signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bouton-danger:hover {
|
||||||
|
color: var(--signal);
|
||||||
|
border-color: var(--signal);
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
+191
-1
@@ -15,6 +15,8 @@ export const fr = {
|
|||||||
registre: 'Registre',
|
registre: 'Registre',
|
||||||
profils: 'Profils',
|
profils: 'Profils',
|
||||||
recommander: 'Recommander',
|
recommander: 'Recommander',
|
||||||
|
exports: 'Exports',
|
||||||
|
workflows: 'Workflows',
|
||||||
alertes: 'Alertes',
|
alertes: 'Alertes',
|
||||||
navigation_principale: 'Navigation principale',
|
navigation_principale: 'Navigation principale',
|
||||||
aller_au_contenu: 'Aller au contenu'
|
aller_au_contenu: 'Aller au contenu'
|
||||||
@@ -37,6 +39,9 @@ export const fr = {
|
|||||||
carte_alertes_titre: 'Inbox alertes',
|
carte_alertes_titre: 'Inbox alertes',
|
||||||
carte_alertes_texte:
|
carte_alertes_texte:
|
||||||
'Événements classifiés (sorties, licences, classements) reçus depuis Node-RED.',
|
'Événements classifiés (sorties, licences, classements) reçus depuis Node-RED.',
|
||||||
|
carte_workflows_titre: 'Workflows ComfyUI',
|
||||||
|
carte_workflows_texte:
|
||||||
|
'Templates paramétrés, JSON concrets régénérés à chaque changement du registre, smoke test canari.',
|
||||||
phase_a_venir: 'Phase à venir'
|
phase_a_venir: 'Phase à venir'
|
||||||
},
|
},
|
||||||
erreurs: {
|
erreurs: {
|
||||||
@@ -60,9 +65,194 @@ export const fr = {
|
|||||||
badge: {
|
badge: {
|
||||||
a_verifier: 'à vérifier'
|
a_verifier: 'à vérifier'
|
||||||
},
|
},
|
||||||
|
formulaires: {
|
||||||
|
enregistrer: 'Enregistrer',
|
||||||
|
supprimer: 'Supprimer',
|
||||||
|
annuler: 'Annuler',
|
||||||
|
confirmer_suppression: 'Confirmer la suppression ?',
|
||||||
|
ouvert: 'oui',
|
||||||
|
ferme: 'non',
|
||||||
|
non_verifie: 'non vérifié',
|
||||||
|
erreurs_titre: 'Le formulaire contient des erreurs :',
|
||||||
|
modalites: {
|
||||||
|
image: 'image',
|
||||||
|
video: 'vidéo',
|
||||||
|
svg: 'SVG',
|
||||||
|
llm: 'LLM',
|
||||||
|
audio: 'audio'
|
||||||
|
},
|
||||||
|
statuts: {
|
||||||
|
production: 'production',
|
||||||
|
'early-access': 'early-access',
|
||||||
|
annonce: 'annonce',
|
||||||
|
mort: 'mort'
|
||||||
|
},
|
||||||
|
niveaux: {
|
||||||
|
obligatoire: 'obligatoire',
|
||||||
|
fortement_prefere: 'fortement préféré',
|
||||||
|
prefere: 'préféré'
|
||||||
|
},
|
||||||
|
cles: {
|
||||||
|
commercial_ok: 'usage commercial autorisé',
|
||||||
|
licence_outputs_commercialisables: 'outputs commercialisables',
|
||||||
|
attribution_requise: 'attribution requise',
|
||||||
|
poids_ouverts: 'poids ouverts',
|
||||||
|
nsfw_ok: 'NSFW autorisé',
|
||||||
|
comfyui_natif: 'support natif ComfyUI',
|
||||||
|
garde_fous_obligatoires: 'garde-fous obligatoires'
|
||||||
|
},
|
||||||
|
plateformes: {
|
||||||
|
steam: 'Steam',
|
||||||
|
itch_io: 'itch.io',
|
||||||
|
reseaux: 'réseaux sociaux',
|
||||||
|
site_perso: 'site personnel'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
registre: {
|
||||||
|
titre: 'Registre des modèles',
|
||||||
|
nouveau: 'Nouveau modèle',
|
||||||
|
modifier: 'Modifier',
|
||||||
|
colonne_nom: 'Modèle',
|
||||||
|
colonne_editeur: 'Éditeur',
|
||||||
|
colonne_modalites: 'Modalités',
|
||||||
|
colonne_commercial: 'Commercial',
|
||||||
|
colonne_statut: 'Statut',
|
||||||
|
colonne_verif: 'Dernière vérif.',
|
||||||
|
aucun: 'Aucun modèle dans le registre.',
|
||||||
|
titre_nouveau: 'Nouveau modèle',
|
||||||
|
titre_edition: 'Modifier le modèle',
|
||||||
|
champ_id: 'Identifiant (slug)',
|
||||||
|
champ_nom: 'Nom',
|
||||||
|
champ_editeur: 'Éditeur',
|
||||||
|
champ_modalites: 'Modalités',
|
||||||
|
section_licence: 'Licence',
|
||||||
|
champ_licence_nom: 'Nom de la licence',
|
||||||
|
champ_licence_url: 'URL de la licence',
|
||||||
|
champ_commercial_ok: 'Usage commercial',
|
||||||
|
champ_seuil: 'Seuil de licence (revenus, sièges…)',
|
||||||
|
champ_attribution: 'Attribution requise',
|
||||||
|
champ_poids_ouverts: 'Poids ouverts',
|
||||||
|
champ_garde_fous: 'Garde-fous obligatoires',
|
||||||
|
section_capacites: 'Capacités',
|
||||||
|
champ_nsfw: 'NSFW autorisé',
|
||||||
|
champ_vram: 'VRAM requise (Go)',
|
||||||
|
champ_resolution: 'Résolution max',
|
||||||
|
champ_comfyui: 'Support natif ComfyUI',
|
||||||
|
champ_outputs: 'Licence des outputs',
|
||||||
|
champ_checkpoint: 'Checkpoint ComfyUI (nom de fichier)',
|
||||||
|
outputs_commercialisables: 'commercialisables',
|
||||||
|
outputs_non_commercial: 'non commercial',
|
||||||
|
champ_scores: 'Scores (un par ligne : cle = valeur, ex. arena_image = 6)',
|
||||||
|
champ_statut: 'Statut',
|
||||||
|
champ_verif: 'Dernière vérification (AAAA-MM-JJ)',
|
||||||
|
champ_sources: 'Sources (une URL par ligne — obligatoire)',
|
||||||
|
champ_notes: 'Notes',
|
||||||
|
aide_tri_etat:
|
||||||
|
'« non vérifié » = null : affiché avec un badge « à vérifier » (règle anti-hallucination).'
|
||||||
|
},
|
||||||
|
profils: {
|
||||||
|
titre: 'Profils de projets',
|
||||||
|
nouveau: 'Nouveau profil',
|
||||||
|
modifier: 'Modifier',
|
||||||
|
aucun: 'Aucun profil.',
|
||||||
|
titre_nouveau: 'Nouveau profil',
|
||||||
|
titre_edition: 'Modifier le profil',
|
||||||
|
champ_id: 'Identifiant (slug)',
|
||||||
|
champ_nom: 'Nom',
|
||||||
|
section_requiert: 'Exigences (filtrage dur)',
|
||||||
|
section_exclut: 'Exclusions (filtrage dur)',
|
||||||
|
section_preferences: 'Préférences (scoring)',
|
||||||
|
pref_aucune: 'sans préférence',
|
||||||
|
section_plateformes: 'Plateformes de diffusion',
|
||||||
|
champ_contraintes: 'Contraintes spécifiques',
|
||||||
|
legende_requiert: 'exigé',
|
||||||
|
legende_exclut: 'exclu'
|
||||||
|
},
|
||||||
|
recommander: {
|
||||||
|
titre: 'Moteur de recommandation',
|
||||||
|
champ_profil: 'Profil de projet',
|
||||||
|
champ_modalite: 'Modalité (optionnel)',
|
||||||
|
toutes_modalites: 'toutes',
|
||||||
|
champ_vram: 'VRAM max (Go, optionnel)',
|
||||||
|
lancer: 'Recommander',
|
||||||
|
shortlist: 'Shortlist',
|
||||||
|
exclus: 'Modèles exclus',
|
||||||
|
score: 'Score',
|
||||||
|
justification: 'Justification',
|
||||||
|
warnings: 'Avertissements',
|
||||||
|
aucun_eligible: 'Aucun modèle éligible pour ce profil et ces filtres.',
|
||||||
|
aucun_exclu: 'Aucun modèle exclu.'
|
||||||
|
},
|
||||||
|
exports: {
|
||||||
|
titre: 'Exports markdown',
|
||||||
|
intro:
|
||||||
|
'Les guides sont générés depuis le registre. Les templates sont éditables ; chaque génération est commitée dans le dépôt git des données.',
|
||||||
|
section_templates: 'Templates',
|
||||||
|
section_generes: 'Documents générés',
|
||||||
|
generer: 'Générer les documents',
|
||||||
|
enregistrer_template: 'Enregistrer le template',
|
||||||
|
aucun_export: 'Aucun document généré pour le moment.',
|
||||||
|
genere_le: 'Généré le',
|
||||||
|
tokens_aide: 'Tokens disponibles : {{date}}, {{tableau_registre}}, {{tableau_classements}}'
|
||||||
|
},
|
||||||
|
workflows: {
|
||||||
|
titre: 'Workflows ComfyUI',
|
||||||
|
intro:
|
||||||
|
'Templates paramétrés : on substitue des variables, la topologie du graphe n’est jamais réécrite (PRD §4.6).',
|
||||||
|
section_generer: 'Générer un workflow concret',
|
||||||
|
champ_template: 'Template',
|
||||||
|
champ_modele: 'Modèle du registre',
|
||||||
|
champ_resolution: 'Résolution (LxH)',
|
||||||
|
champ_vram: 'Profil VRAM',
|
||||||
|
vram_auto: 'auto (depuis le registre)',
|
||||||
|
generer: 'Générer',
|
||||||
|
section_generes: 'Workflows générés',
|
||||||
|
aucun_genere: 'Aucun workflow généré pour le moment.',
|
||||||
|
colonne_fichier: 'Fichier',
|
||||||
|
colonne_template: 'Template',
|
||||||
|
colonne_modele: 'Modèle',
|
||||||
|
colonne_genere_le: 'Généré le',
|
||||||
|
telecharger: 'Télécharger',
|
||||||
|
regenere_auto: 'Régénérés automatiquement à chaque modification du registre.',
|
||||||
|
section_smoke: 'Smoke test ComfyUI',
|
||||||
|
smoke_intro:
|
||||||
|
'Envoie un prompt canari (graphe minimal sans modèle) sur l’API ComfyUI configurée.',
|
||||||
|
lancer_smoke: 'Lancer le smoke test',
|
||||||
|
smoke_ok: 'ComfyUI opérationnel',
|
||||||
|
smoke_ko: 'Smoke test en échec',
|
||||||
|
smoke_injoignable: 'ComfyUI injoignable',
|
||||||
|
endpoint: 'Endpoint',
|
||||||
|
erreur_generation: 'Génération impossible :'
|
||||||
|
},
|
||||||
|
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: 'Aucune donnée pour le moment : le registre sera initialisé en phase 1.'
|
registre_vide: 'Registre et profils initialisés depuis le PRD au premier démarrage.'
|
||||||
}
|
}
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schémas Zod des données persistées (YAML) — traduction stricte du PRD §4.
|
||||||
|
* Règle anti-hallucination gravée dans le modèle : tout champ non vérifiable
|
||||||
|
* publiquement est `null` et l'UI affiche un badge « à vérifier ».
|
||||||
|
* Aucune écriture dans le registre sans `sources` non vide.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const slugSchema = z
|
||||||
|
.string()
|
||||||
|
.regex(/^[a-z0-9][a-z0-9_-]*$/, 'slug attendu : minuscules, chiffres, tirets et underscores');
|
||||||
|
|
||||||
|
export const modaliteSchema = z.enum(['image', 'video', 'svg', 'llm', 'audio']);
|
||||||
|
export type Modalite = z.infer<typeof modaliteSchema>;
|
||||||
|
|
||||||
|
export const statutModeleSchema = z.enum(['production', 'early-access', 'annonce', 'mort']);
|
||||||
|
export type StatutModele = z.infer<typeof statutModeleSchema>;
|
||||||
|
|
||||||
|
const urlOuNull = z.string().url().nullable().default(null);
|
||||||
|
|
||||||
|
export const licenceSchema = z.object({
|
||||||
|
nom: z.string().nullable().default(null),
|
||||||
|
url: urlOuNull,
|
||||||
|
commercial_ok: z.boolean().nullable().default(null),
|
||||||
|
/** Ex. "équipes <50 sièges", "revenus <1M$" — présence déclenche un warning. */
|
||||||
|
seuil: z.string().nullable().default(null),
|
||||||
|
attribution_requise: z.boolean().nullable().default(null),
|
||||||
|
poids_ouverts: z.boolean().nullable().default(null),
|
||||||
|
garde_fous_obligatoires: z.boolean().nullable().default(null)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const capacitesSchema = z.object({
|
||||||
|
nsfw_ok: z.boolean().nullable().default(null),
|
||||||
|
vram_gb: z.number().positive().nullable().default(null),
|
||||||
|
resolution_max: z.string().nullable().default(null),
|
||||||
|
comfyui_natif: z.boolean().nullable().default(null),
|
||||||
|
/** Nom du fichier checkpoint tel qu'installé dans ComfyUI (ex. krea2turbo_fp8.safetensors). */
|
||||||
|
comfyui_checkpoint: z.string().nullable().default(null),
|
||||||
|
licence_outputs: z.enum(['commercialisables', 'non_commercial']).nullable().default(null)
|
||||||
|
});
|
||||||
|
|
||||||
|
export const modeleSchema = z.object({
|
||||||
|
id: slugSchema,
|
||||||
|
nom: z.string().min(1),
|
||||||
|
editeur: z.string().min(1),
|
||||||
|
modalites: z.array(modaliteSchema).min(1),
|
||||||
|
// prefault (et non default) : le {} est reparsé pour appliquer les défauts null internes.
|
||||||
|
licence: licenceSchema.prefault({}),
|
||||||
|
capacites: capacitesSchema.prefault({}),
|
||||||
|
/** Scores/classements publiés (ex. arena_image: rang Artificial Analysis). */
|
||||||
|
scores: z.record(z.string(), z.number().nullable()).default({}),
|
||||||
|
statut: statutModeleSchema,
|
||||||
|
/** Date ISO YYYY-MM-DD de dernière vérification humaine. */
|
||||||
|
derniere_verif: z
|
||||||
|
.string()
|
||||||
|
.regex(/^\d{4}-\d{2}-\d{2}$/, 'date ISO YYYY-MM-DD attendue')
|
||||||
|
.nullable()
|
||||||
|
.default(null),
|
||||||
|
sources: z.array(z.string().url()).min(1, 'au moins une source est obligatoire'),
|
||||||
|
notes: z.string().nullable().default(null)
|
||||||
|
});
|
||||||
|
export type Modele = z.infer<typeof modeleSchema>;
|
||||||
|
|
||||||
|
/** Clés de condition utilisables dans requiert/exclut/preferences d'un profil. */
|
||||||
|
export const cleConditionSchema = z.enum([
|
||||||
|
'commercial_ok',
|
||||||
|
'licence_outputs_commercialisables',
|
||||||
|
'attribution_requise',
|
||||||
|
'poids_ouverts',
|
||||||
|
'nsfw_ok',
|
||||||
|
'comfyui_natif',
|
||||||
|
'garde_fous_obligatoires'
|
||||||
|
]);
|
||||||
|
export type CleCondition = z.infer<typeof cleConditionSchema>;
|
||||||
|
|
||||||
|
export const niveauPreferenceSchema = z.enum(['obligatoire', 'fortement_prefere', 'prefere']);
|
||||||
|
export type NiveauPreference = z.infer<typeof niveauPreferenceSchema>;
|
||||||
|
|
||||||
|
export const profilSchema = z.object({
|
||||||
|
id: slugSchema,
|
||||||
|
nom: z.string().min(1),
|
||||||
|
/** Filtrage dur : la condition doit être vraie (null = échec, non vérifiable). */
|
||||||
|
requiert: z.array(cleConditionSchema).default([]),
|
||||||
|
/** Filtrage dur : la condition vraie exclut le modèle (null = non exclu, warning). */
|
||||||
|
exclut: z.array(cleConditionSchema).default([]),
|
||||||
|
preferences: z.partialRecord(cleConditionSchema, niveauPreferenceSchema).default({}),
|
||||||
|
plateformes: z.array(z.string()).default([]),
|
||||||
|
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>;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -12,6 +12,9 @@ export const config = {
|
|||||||
/** Endpoint Ollama. L'app doit fonctionner même s'il est injoignable (dégradation gracieuse). */
|
/** Endpoint Ollama. L'app doit fonctionner même s'il est injoignable (dégradation gracieuse). */
|
||||||
ollamaUrl: env.OLLAMA_URL ?? 'http://127.0.0.1:11434',
|
ollamaUrl: env.OLLAMA_URL ?? 'http://127.0.0.1:11434',
|
||||||
|
|
||||||
|
/** Endpoint ComfyUI (smoke tests, phase 3). Dégradation gracieuse si injoignable. */
|
||||||
|
comfyuiUrl: env.COMFYUI_URL ?? 'http://127.0.0.1:8188',
|
||||||
|
|
||||||
/** Token Bearer protégeant POST /api/alerts (généré à l'install YNH). Jamais de défaut. */
|
/** Token Bearer protégeant POST /api/alerts (généré à l'install YNH). Jamais de défaut. */
|
||||||
alertsToken: env.ALERTS_TOKEN ?? null,
|
alertsToken: env.ALERTS_TOKEN ?? null,
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
import type { Modele } from '../schemas';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Exporteur de documents markdown (PRD §4.5) — fonction pure, testable.
|
||||||
|
* Les guides sont des EXPORTS du registre, plus des fichiers maintenus à la main.
|
||||||
|
* Règle anti-hallucination : tout champ null est rendu « ⚠️ à vérifier ».
|
||||||
|
*/
|
||||||
|
|
||||||
|
const A_VERIFIER = '⚠️ à vérifier';
|
||||||
|
|
||||||
|
function cellule(valeur: string | number | null): string {
|
||||||
|
if (valeur === null) return A_VERIFIER;
|
||||||
|
return String(valeur);
|
||||||
|
}
|
||||||
|
|
||||||
|
function celluleBooleen(valeur: boolean | null): string {
|
||||||
|
if (valeur === null) return A_VERIFIER;
|
||||||
|
return valeur ? 'oui' : 'non';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Échappe les pipes pour ne pas casser les tableaux markdown. */
|
||||||
|
function e(texte: string): string {
|
||||||
|
return texte.replaceAll('|', '\\|');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tableauRegistre(modeles: Modele[]): string {
|
||||||
|
const lignes = [
|
||||||
|
'| Modèle | Éditeur | Modalités | Licence | Commercial | Attribution | Outputs | Statut | Vérifié le |',
|
||||||
|
'|---|---|---|---|---|---|---|---|---|'
|
||||||
|
];
|
||||||
|
for (const m of modeles) {
|
||||||
|
lignes.push(
|
||||||
|
`| ${e(m.nom)} | ${e(m.editeur)} | ${m.modalites.join(', ')} | ${cellule(m.licence.nom ? e(m.licence.nom) : null)} | ${celluleBooleen(m.licence.commercial_ok)} | ${celluleBooleen(m.licence.attribution_requise)} | ${cellule(m.capacites.licence_outputs)} | ${m.statut} | ${cellule(m.derniere_verif)} |`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return lignes.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function tableauClassements(modeles: Modele[]): string {
|
||||||
|
const avecScore = modeles
|
||||||
|
.flatMap((m) =>
|
||||||
|
Object.entries(m.scores)
|
||||||
|
.filter(([cle, valeur]) => cle.startsWith('arena') && typeof valeur === 'number')
|
||||||
|
.map(([cle, valeur]) => ({ modele: m, cle, rang: valeur as number }))
|
||||||
|
)
|
||||||
|
.sort((a, b) => a.rang - b.rang || a.modele.nom.localeCompare(b.modele.nom));
|
||||||
|
|
||||||
|
if (avecScore.length === 0) {
|
||||||
|
return '_Aucun score publié dans le registre pour le moment._';
|
||||||
|
}
|
||||||
|
|
||||||
|
const lignes = ['| Rang | Classement | Modèle | Éditeur | Statut |', '|---|---|---|---|---|'];
|
||||||
|
for (const { modele, cle, rang } of avecScore) {
|
||||||
|
lignes.push(
|
||||||
|
`| ${rang} | ${cle} | ${e(modele.nom)} | ${e(modele.editeur)} | ${modele.statut} |`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return lignes.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function genererDocument(template: string, modeles: Modele[], date: Date): string {
|
||||||
|
const dateIso = date.toISOString().slice(0, 10);
|
||||||
|
return template
|
||||||
|
.replaceAll('{{date}}', dateIso)
|
||||||
|
.replaceAll('{{tableau_registre}}', tableauRegistre(modeles))
|
||||||
|
.replaceAll('{{tableau_classements}}', tableauClassements(modeles));
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
import {
|
||||||
|
cleConditionSchema,
|
||||||
|
modaliteSchema,
|
||||||
|
niveauPreferenceSchema,
|
||||||
|
statutModeleSchema
|
||||||
|
} from '../schemas';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Conversion FormData → objets bruts validés ensuite par les schémas Zod.
|
||||||
|
* Convention UI des champs tri-état : '' = null (non vérifié — règle anti-hallucination).
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function chaineOuNull(valeur: FormDataEntryValue | null): string | null {
|
||||||
|
if (typeof valeur !== 'string') return null;
|
||||||
|
const v = valeur.trim();
|
||||||
|
return v === '' ? null : v;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function nombreOuNull(valeur: FormDataEntryValue | null): number | null {
|
||||||
|
const v = chaineOuNull(valeur);
|
||||||
|
if (v === null) return null;
|
||||||
|
const n = Number(v);
|
||||||
|
return Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function triEtat(valeur: FormDataEntryValue | null): boolean | null {
|
||||||
|
if (valeur === 'true') return true;
|
||||||
|
if (valeur === 'false') return false;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Textarea multi-lignes → liste (une entrée par ligne, lignes vides ignorées). */
|
||||||
|
export function listeLignes(valeur: FormDataEntryValue | null): string[] {
|
||||||
|
if (typeof valeur !== 'string') return [];
|
||||||
|
return valeur
|
||||||
|
.split('\n')
|
||||||
|
.map((l) => l.trim())
|
||||||
|
.filter((l) => l !== '');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function modeleDepuisFormulaire(data: FormData): unknown {
|
||||||
|
// Scores : textarea « cle = nombre » par ligne (ex. arena_image = 6).
|
||||||
|
const scores: Record<string, number | null> = {};
|
||||||
|
for (const ligne of listeLignes(data.get('scores'))) {
|
||||||
|
const [cle, brut] = ligne.split('=').map((s) => s.trim());
|
||||||
|
if (!cle) continue;
|
||||||
|
const n = Number(brut);
|
||||||
|
scores[cle] = brut === '' || brut === undefined ? null : Number.isFinite(n) ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: chaineOuNull(data.get('id')),
|
||||||
|
nom: chaineOuNull(data.get('nom')),
|
||||||
|
editeur: chaineOuNull(data.get('editeur')),
|
||||||
|
modalites: data.getAll('modalites').map(String),
|
||||||
|
licence: {
|
||||||
|
nom: chaineOuNull(data.get('licence_nom')),
|
||||||
|
url: chaineOuNull(data.get('licence_url')),
|
||||||
|
commercial_ok: triEtat(data.get('commercial_ok')),
|
||||||
|
seuil: chaineOuNull(data.get('seuil')),
|
||||||
|
attribution_requise: triEtat(data.get('attribution_requise')),
|
||||||
|
poids_ouverts: triEtat(data.get('poids_ouverts')),
|
||||||
|
garde_fous_obligatoires: triEtat(data.get('garde_fous_obligatoires'))
|
||||||
|
},
|
||||||
|
capacites: {
|
||||||
|
nsfw_ok: triEtat(data.get('nsfw_ok')),
|
||||||
|
vram_gb: nombreOuNull(data.get('vram_gb')),
|
||||||
|
resolution_max: chaineOuNull(data.get('resolution_max')),
|
||||||
|
comfyui_natif: triEtat(data.get('comfyui_natif')),
|
||||||
|
comfyui_checkpoint: chaineOuNull(data.get('comfyui_checkpoint')),
|
||||||
|
licence_outputs: chaineOuNull(data.get('licence_outputs'))
|
||||||
|
},
|
||||||
|
scores,
|
||||||
|
statut: chaineOuNull(data.get('statut')),
|
||||||
|
derniere_verif: chaineOuNull(data.get('derniere_verif')),
|
||||||
|
sources: listeLignes(data.get('sources')),
|
||||||
|
notes: chaineOuNull(data.get('notes'))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function profilDepuisFormulaire(data: FormData): unknown {
|
||||||
|
const preferences: Record<string, string> = {};
|
||||||
|
for (const cle of cleConditionSchema.options) {
|
||||||
|
const niveau = chaineOuNull(data.get(`pref_${cle}`));
|
||||||
|
if (niveau !== null) preferences[cle] = niveau;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: chaineOuNull(data.get('id')),
|
||||||
|
nom: chaineOuNull(data.get('nom')),
|
||||||
|
requiert: data.getAll('requiert').map(String),
|
||||||
|
exclut: data.getAll('exclut').map(String),
|
||||||
|
preferences,
|
||||||
|
plateformes: data.getAll('plateformes').map(String),
|
||||||
|
contraintes_specifiques: chaineOuNull(data.get('contraintes_specifiques'))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Met en forme les erreurs Zod pour affichage dans le formulaire. */
|
||||||
|
export function erreursZod(erreur: z.ZodError): string[] {
|
||||||
|
return erreur.issues.map((i) => `${i.path.join('.') || '(racine)'} : ${i.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export { cleConditionSchema, modaliteSchema, niveauPreferenceSchema, statutModeleSchema };
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { simpleGit, type SimpleGit } from 'simple-git';
|
||||||
|
import { config } from './config';
|
||||||
|
|
||||||
|
let git: SimpleGit | null = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Dépôt git du data_dir (versionning local de la source de vérité).
|
||||||
|
* Initialisé au premier accès ; identité locale dédiée à l'app.
|
||||||
|
* Le push vers une forge distante est volontairement hors périmètre (optionnel, manuel).
|
||||||
|
*/
|
||||||
|
export async function gitDonnees(): Promise<SimpleGit> {
|
||||||
|
if (git) return git;
|
||||||
|
const instance = simpleGit(config.dataDir);
|
||||||
|
if (!existsSync(join(config.dataDir, '.git'))) {
|
||||||
|
await instance.init();
|
||||||
|
}
|
||||||
|
await instance.addConfig('user.name', 'veille-ia');
|
||||||
|
await instance.addConfig('user.email', 'veille-ia@app.local');
|
||||||
|
await instance.addConfig('commit.gpgsign', 'false');
|
||||||
|
git = instance;
|
||||||
|
return git;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Commit atomique en français des chemins donnés (relatifs au data_dir). */
|
||||||
|
export async function committer(chemins: string[], message: string): Promise<void> {
|
||||||
|
const g = await gitDonnees();
|
||||||
|
await g.add(chemins);
|
||||||
|
// Ne rien faire si aucun changement (écriture idempotente).
|
||||||
|
const statut = await g.status();
|
||||||
|
if (statut.staged.length === 0) return;
|
||||||
|
await g.commit(message);
|
||||||
|
}
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import { recommander } from './recommandation';
|
||||||
|
import { modeleSchema, profilSchema, type Modele, type Profil } from '../schemas';
|
||||||
|
import { modelesSeed } from './seed/modeles';
|
||||||
|
import { profilsSeed } from './seed/profils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests du moteur de recommandation — cas nominaux + cas limites du prompt :
|
||||||
|
* licence null, seuil de licence, modèle statut « mort ».
|
||||||
|
* `maintenant` est injecté pour des tests déterministes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const MAINTENANT = new Date('2026-08-10T12:00:00Z');
|
||||||
|
|
||||||
|
function modele(surcharge: Record<string, unknown>): Modele {
|
||||||
|
return modeleSchema.parse({
|
||||||
|
id: 'modele-test',
|
||||||
|
nom: 'Modèle Test',
|
||||||
|
editeur: 'Éditeur Test',
|
||||||
|
modalites: ['image'],
|
||||||
|
statut: 'production',
|
||||||
|
derniere_verif: '2026-08-01',
|
||||||
|
sources: ['https://example.org/modele'],
|
||||||
|
...surcharge
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function profil(surcharge: Record<string, unknown>): Profil {
|
||||||
|
return profilSchema.parse({
|
||||||
|
id: 'profil-test',
|
||||||
|
nom: 'Profil Test',
|
||||||
|
...surcharge
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const krea = modelesSeed[0];
|
||||||
|
const vnRenpy = profilsSeed.find((p) => p.id === 'vn_renpy_adulte')!;
|
||||||
|
|
||||||
|
describe('cas nominal', () => {
|
||||||
|
it('le seed Krea est éligible pour vn_renpy_adulte avec justification et warnings', () => {
|
||||||
|
const { shortlist, exclus } = recommander([krea], vnRenpy, {}, { maintenant: MAINTENANT });
|
||||||
|
expect(exclus).toHaveLength(0);
|
||||||
|
expect(shortlist).toHaveLength(1);
|
||||||
|
|
||||||
|
const reco = shortlist[0];
|
||||||
|
expect(reco.score).toBeGreaterThan(0);
|
||||||
|
// Préférences honorées : poids ouverts (+20), comfyui (+10), rang arena 6 (+13), fraîcheur (+10)
|
||||||
|
expect(reco.justification.join(' ')).toContain('poids ouverts (+20)');
|
||||||
|
expect(reco.justification.join(' ')).toContain('rang 6 (+13)');
|
||||||
|
// Warnings attendus : seuil, garde-fous (nsfw demandé), déclarations plateformes
|
||||||
|
expect(reco.warnings.join(' ')).toContain('Seuil de licence');
|
||||||
|
expect(reco.warnings.join(' ')).toContain('Garde-fous');
|
||||||
|
expect(reco.warnings.join(' ')).toContain('Steam');
|
||||||
|
expect(reco.warnings.join(' ')).toContain('itch.io');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('les 5 profils du seed produisent une shortlist correcte sur le seed', () => {
|
||||||
|
for (const p of profilsSeed) {
|
||||||
|
const { shortlist } = recommander([krea], p, {}, { maintenant: MAINTENANT });
|
||||||
|
// Krea : commercial_ok true + outputs commercialisables + nsfw true → éligible partout
|
||||||
|
expect(shortlist.map((r) => r.modele.id)).toContain('krea-2-turbo');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cas limites — filtrage dur', () => {
|
||||||
|
it('licence null : commercial_ok non vérifiable exclut un profil qui le requiert', () => {
|
||||||
|
const m = modele({ licence: { commercial_ok: null } });
|
||||||
|
const { shortlist, exclus } = recommander(
|
||||||
|
[m],
|
||||||
|
profil({ requiert: ['commercial_ok'] }),
|
||||||
|
{},
|
||||||
|
{ maintenant: MAINTENANT }
|
||||||
|
);
|
||||||
|
expect(shortlist).toHaveLength(0);
|
||||||
|
expect(exclus[0].raisons.join(' ')).toContain('non vérifiable');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('modèle statut « mort » : exclu quoi qu’il arrive', () => {
|
||||||
|
const m = modele({ statut: 'mort' });
|
||||||
|
const { shortlist, exclus } = recommander([m], profil({}), {}, { maintenant: MAINTENANT });
|
||||||
|
expect(shortlist).toHaveLength(0);
|
||||||
|
expect(exclus[0].raisons.join(' ')).toContain('mort');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exclut attribution_requise : le modèle avec attribution est exclu pour prestation_images', () => {
|
||||||
|
const prestation = profilsSeed.find((p) => p.id === 'prestation_images')!;
|
||||||
|
const m = modele({
|
||||||
|
licence: { commercial_ok: true, attribution_requise: true },
|
||||||
|
capacites: { licence_outputs: 'commercialisables' }
|
||||||
|
});
|
||||||
|
const { shortlist, exclus } = recommander([m], prestation, {}, { maintenant: MAINTENANT });
|
||||||
|
expect(shortlist).toHaveLength(0);
|
||||||
|
expect(exclus[0].raisons.join(' ')).toContain('attribution');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('préférence « obligatoire » non remplie = exclusion', () => {
|
||||||
|
const m = modele({
|
||||||
|
licence: { commercial_ok: true },
|
||||||
|
capacites: { nsfw_ok: false }
|
||||||
|
});
|
||||||
|
const { exclus } = recommander([m], vnRenpy, {}, { maintenant: MAINTENANT });
|
||||||
|
expect(exclus[0].raisons.join(' ')).toContain('obligatoire');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filtre vram_max : au-dessus = exclu ; vram null = conservé avec warning', () => {
|
||||||
|
const lourd = modele({ capacites: { vram_gb: 24 } });
|
||||||
|
const { exclus } = recommander(
|
||||||
|
[lourd],
|
||||||
|
profil({}),
|
||||||
|
{ vram_max: 12 },
|
||||||
|
{ maintenant: MAINTENANT }
|
||||||
|
);
|
||||||
|
expect(exclus[0].raisons.join(' ')).toContain('VRAM');
|
||||||
|
|
||||||
|
const inconnu = modele({ capacites: { vram_gb: null } });
|
||||||
|
const { shortlist } = recommander(
|
||||||
|
[inconnu],
|
||||||
|
profil({}),
|
||||||
|
{ vram_max: 12 },
|
||||||
|
{ maintenant: MAINTENANT }
|
||||||
|
);
|
||||||
|
expect(shortlist[0].warnings.join(' ')).toContain('VRAM non vérifiée');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filtre modalité : modalité absente = exclu', () => {
|
||||||
|
const m = modele({ modalites: ['image'] });
|
||||||
|
const { exclus } = recommander(
|
||||||
|
[m],
|
||||||
|
profil({}),
|
||||||
|
{ modalite: 'video' },
|
||||||
|
{ maintenant: MAINTENANT }
|
||||||
|
);
|
||||||
|
expect(exclus[0].raisons.join(' ')).toContain('Modalité');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('cas limites — scoring', () => {
|
||||||
|
it('seuil de licence présent : éligible mais avec warning seuil', () => {
|
||||||
|
const m = modele({ licence: { seuil: 'revenus <1M$' } });
|
||||||
|
const { shortlist } = recommander([m], profil({}), {}, { maintenant: MAINTENANT });
|
||||||
|
expect(shortlist).toHaveLength(1);
|
||||||
|
expect(shortlist[0].warnings.join(' ')).toContain('Seuil de licence');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('les champs null sont pénalisés : un modèle renseigné bat un modèle à trous', () => {
|
||||||
|
const complet = krea;
|
||||||
|
const aTrous = modele({
|
||||||
|
id: 'modele-a-trous',
|
||||||
|
nom: 'Modèle À Trous',
|
||||||
|
licence: {
|
||||||
|
commercial_ok: true,
|
||||||
|
attribution_requise: null,
|
||||||
|
poids_ouverts: null
|
||||||
|
},
|
||||||
|
capacites: { nsfw_ok: true, vram_gb: null, licence_outputs: null },
|
||||||
|
scores: { arena_image: 6 },
|
||||||
|
derniere_verif: '2026-08-01'
|
||||||
|
});
|
||||||
|
const { shortlist } = recommander([aTrous, complet], vnRenpy, {}, { maintenant: MAINTENANT });
|
||||||
|
expect(shortlist[0].modele.id).toBe('krea-2-turbo');
|
||||||
|
const recoTrous = shortlist.find((r) => r.modele.id === 'modele-a-trous')!;
|
||||||
|
expect(recoTrous.justification.join(' ')).toContain('champ(s) non vérifié(s)');
|
||||||
|
expect(recoTrous.warnings.join(' ')).toContain('Champs à vérifier');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('statuts non-production : early-access et annonce pénalisés', () => {
|
||||||
|
const ea = modele({ id: 'm-ea', statut: 'early-access' });
|
||||||
|
const ann = modele({ id: 'm-ann', statut: 'annonce' });
|
||||||
|
const prod = modele({ id: 'm-prod', statut: 'production' });
|
||||||
|
const { shortlist } = recommander([ann, ea, prod], profil({}), {}, { maintenant: MAINTENANT });
|
||||||
|
const scores = Object.fromEntries(shortlist.map((r) => [r.modele.id, r.score]));
|
||||||
|
expect(scores['m-prod']).toBeGreaterThan(scores['m-ea']);
|
||||||
|
expect(scores['m-ea']).toBeGreaterThan(scores['m-ann']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fraîcheur : jamais vérifié pénalisé, vérification ancienne signalée', () => {
|
||||||
|
const jamais = modele({ id: 'm-jamais', derniere_verif: null });
|
||||||
|
const ancien = modele({ id: 'm-ancien', derniere_verif: '2026-01-01' });
|
||||||
|
const frais = modele({ id: 'm-frais', derniere_verif: '2026-08-05' });
|
||||||
|
const { shortlist } = recommander(
|
||||||
|
[jamais, ancien, frais],
|
||||||
|
profil({}),
|
||||||
|
{},
|
||||||
|
{ maintenant: MAINTENANT }
|
||||||
|
);
|
||||||
|
const parId = Object.fromEntries(shortlist.map((r) => [r.modele.id, r]));
|
||||||
|
expect(parId['m-frais'].score).toBeGreaterThan(parId['m-jamais'].score);
|
||||||
|
expect(parId['m-jamais'].warnings.join(' ')).toContain('jamais vérifiée');
|
||||||
|
expect(parId['m-ancien'].warnings.join(' ')).toContain('ancienne');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,262 @@
|
|||||||
|
import type { CleCondition, Modalite, Modele, Profil } from '../schemas';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Moteur de recommandation (PRD §4.3) — fonction PURE, sans I/O, testable.
|
||||||
|
*
|
||||||
|
* Logique : filtrage dur (requiert/exclut/obligatoire/statut mort/filtres ad hoc)
|
||||||
|
* puis scoring pondéré (préférences, rang arena, fraîcheur derniere_verif,
|
||||||
|
* pénalités statut et champs null).
|
||||||
|
*
|
||||||
|
* Règle anti-hallucination : un champ `null` n'est jamais traité comme une valeur
|
||||||
|
* — pour une exigence (requiert), null = échec (non vérifiable, juridiquement
|
||||||
|
* prudent) ; pour une exclusion, null = conservé avec warning ; au scoring,
|
||||||
|
* null = pénalité + badge « à vérifier ».
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface FiltresRecommandation {
|
||||||
|
modalite?: Modalite;
|
||||||
|
vram_max?: number;
|
||||||
|
/** Réservé — aucune donnée de prix n'est stockée par conception (PRD anti-hallucination). */
|
||||||
|
budget?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Exclusion {
|
||||||
|
modele: Modele;
|
||||||
|
raisons: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Recommandation {
|
||||||
|
modele: Modele;
|
||||||
|
score: number;
|
||||||
|
/** Contributions au score, dans l'ordre de calcul — affichées à l'utilisateur. */
|
||||||
|
justification: string[];
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ResultatRecommandation {
|
||||||
|
profil: Profil;
|
||||||
|
/** Modèles éligibles, triés par score décroissant (nom en cas d'égalité). */
|
||||||
|
shortlist: Recommandation[];
|
||||||
|
exclus: Exclusion[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const LIBELLES_CONDITIONS: Record<CleCondition, string> = {
|
||||||
|
commercial_ok: 'usage commercial autorisé',
|
||||||
|
licence_outputs_commercialisables: 'outputs commercialisables',
|
||||||
|
attribution_requise: 'attribution requise',
|
||||||
|
poids_ouverts: 'poids ouverts',
|
||||||
|
nsfw_ok: 'NSFW autorisé',
|
||||||
|
comfyui_natif: 'support natif ComfyUI',
|
||||||
|
garde_fous_obligatoires: 'garde-fous obligatoires'
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Valeur d'une condition pour un modèle : true, false, ou null (non vérifié). */
|
||||||
|
export function valeurCondition(modele: Modele, cle: CleCondition): boolean | null {
|
||||||
|
switch (cle) {
|
||||||
|
case 'commercial_ok':
|
||||||
|
return modele.licence.commercial_ok;
|
||||||
|
case 'licence_outputs_commercialisables':
|
||||||
|
return modele.capacites.licence_outputs === null
|
||||||
|
? null
|
||||||
|
: modele.capacites.licence_outputs === 'commercialisables';
|
||||||
|
case 'attribution_requise':
|
||||||
|
return modele.licence.attribution_requise;
|
||||||
|
case 'poids_ouverts':
|
||||||
|
return modele.licence.poids_ouverts;
|
||||||
|
case 'nsfw_ok':
|
||||||
|
return modele.capacites.nsfw_ok;
|
||||||
|
case 'comfyui_natif':
|
||||||
|
return modele.capacites.comfyui_natif;
|
||||||
|
case 'garde_fous_obligatoires':
|
||||||
|
return modele.licence.garde_fous_obligatoires;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Champs clés dont la nullité est pénalisée et signalée (règle anti-hallucination). */
|
||||||
|
const CHAMPS_CLES: Array<{ libelle: string; valeur: (m: Modele) => unknown }> = [
|
||||||
|
{ libelle: LIBELLES_CONDITIONS.commercial_ok, valeur: (m) => m.licence.commercial_ok },
|
||||||
|
{
|
||||||
|
libelle: LIBELLES_CONDITIONS.attribution_requise,
|
||||||
|
valeur: (m) => m.licence.attribution_requise
|
||||||
|
},
|
||||||
|
{ libelle: LIBELLES_CONDITIONS.poids_ouverts, valeur: (m) => m.licence.poids_ouverts },
|
||||||
|
{ libelle: LIBELLES_CONDITIONS.nsfw_ok, valeur: (m) => m.capacites.nsfw_ok },
|
||||||
|
{
|
||||||
|
libelle: LIBELLES_CONDITIONS.licence_outputs_commercialisables,
|
||||||
|
valeur: (m) => m.capacites.licence_outputs
|
||||||
|
},
|
||||||
|
{ libelle: 'VRAM requise', valeur: (m) => m.capacites.vram_gb }
|
||||||
|
];
|
||||||
|
|
||||||
|
const MS_PAR_JOUR = 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
function joursDepuis(dateIso: string, maintenant: Date): number {
|
||||||
|
const date = new Date(`${dateIso}T00:00:00Z`);
|
||||||
|
return Math.floor((maintenant.getTime() - date.getTime()) / MS_PAR_JOUR);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recommander(
|
||||||
|
modeles: Modele[],
|
||||||
|
profil: Profil,
|
||||||
|
filtres: FiltresRecommandation = {},
|
||||||
|
options: { maintenant?: Date } = {}
|
||||||
|
): ResultatRecommandation {
|
||||||
|
const maintenant = options.maintenant ?? new Date();
|
||||||
|
const shortlist: Recommandation[] = [];
|
||||||
|
const exclus: Exclusion[] = [];
|
||||||
|
|
||||||
|
for (const modele of modeles) {
|
||||||
|
const raisonsExclusion: string[] = [];
|
||||||
|
|
||||||
|
// ── Filtrage dur ──
|
||||||
|
if (modele.statut === 'mort') {
|
||||||
|
raisonsExclusion.push('Modèle au statut « mort » : non recommandable.');
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const cle of profil.requiert) {
|
||||||
|
const valeur = valeurCondition(modele, cle);
|
||||||
|
if (valeur !== true) {
|
||||||
|
raisonsExclusion.push(
|
||||||
|
valeur === null
|
||||||
|
? `Exigence « ${LIBELLES_CONDITIONS[cle]} » non vérifiable (champ à vérifier).`
|
||||||
|
: `Exigence non remplie : « ${LIBELLES_CONDITIONS[cle]} ».`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [cle, niveau] of Object.entries(profil.preferences)) {
|
||||||
|
if (niveau !== 'obligatoire') continue;
|
||||||
|
const valeur = valeurCondition(modele, cle as CleCondition);
|
||||||
|
if (valeur !== true) {
|
||||||
|
raisonsExclusion.push(
|
||||||
|
valeur === null
|
||||||
|
? `Préférence obligatoire « ${LIBELLES_CONDITIONS[cle as CleCondition]} » non vérifiable.`
|
||||||
|
: `Préférence obligatoire non remplie : « ${LIBELLES_CONDITIONS[cle as CleCondition]} ».`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const cle of profil.exclut) {
|
||||||
|
if (valeurCondition(modele, cle) === true) {
|
||||||
|
raisonsExclusion.push(`Critère d'exclusion présent : « ${LIBELLES_CONDITIONS[cle]} ».`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (filtres.modalite && !modele.modalites.includes(filtres.modalite)) {
|
||||||
|
raisonsExclusion.push(`Modalité « ${filtres.modalite} » absente du modèle.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const vram = modele.capacites.vram_gb;
|
||||||
|
if (filtres.vram_max !== undefined && vram !== null && vram > filtres.vram_max) {
|
||||||
|
raisonsExclusion.push(
|
||||||
|
`VRAM requise (${vram} Go) au-dessus de la limite (${filtres.vram_max} Go).`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raisonsExclusion.length > 0) {
|
||||||
|
exclus.push({ modele, raisons: raisonsExclusion });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Scoring pondéré ──
|
||||||
|
let score = 0;
|
||||||
|
const justification: string[] = [];
|
||||||
|
const warnings: string[] = [];
|
||||||
|
|
||||||
|
for (const [cle, niveau] of Object.entries(profil.preferences)) {
|
||||||
|
if (niveau === 'obligatoire') continue; // déjà traité en filtrage dur
|
||||||
|
const valeur = valeurCondition(modele, cle as CleCondition);
|
||||||
|
const libelle = LIBELLES_CONDITIONS[cle as CleCondition];
|
||||||
|
if (valeur === true) {
|
||||||
|
const points = niveau === 'fortement_prefere' ? 20 : 10;
|
||||||
|
score += points;
|
||||||
|
justification.push(`Préférence honorée : ${libelle} (+${points})`);
|
||||||
|
} else if (valeur === null) {
|
||||||
|
warnings.push(`« ${libelle} » non vérifié.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rang arena : premier score numérique trouvé (ex. arena_image: 6 → +13)
|
||||||
|
const entreeArena = Object.entries(modele.scores).find(
|
||||||
|
([cle, valeur]) => cle.startsWith('arena') && typeof valeur === 'number'
|
||||||
|
);
|
||||||
|
if (entreeArena) {
|
||||||
|
const rang = entreeArena[1] as number;
|
||||||
|
const points = Math.max(0, 25 - 2 * rang);
|
||||||
|
score += points;
|
||||||
|
justification.push(`Classement ${entreeArena[0]} : rang ${rang} (+${points})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fraîcheur de la dernière vérification humaine
|
||||||
|
if (modele.derniere_verif === null) {
|
||||||
|
score -= 10;
|
||||||
|
justification.push('Jamais vérifié (−10)');
|
||||||
|
warnings.push('Fiche jamais vérifiée.');
|
||||||
|
} else {
|
||||||
|
const jours = joursDepuis(modele.derniere_verif, maintenant);
|
||||||
|
if (jours <= 30) {
|
||||||
|
score += 10;
|
||||||
|
justification.push(`Vérifié il y a ${jours} j (+10)`);
|
||||||
|
} else if (jours <= 90) {
|
||||||
|
score += 5;
|
||||||
|
justification.push(`Vérifié il y a ${jours} j (+5)`);
|
||||||
|
} else if (jours > 180) {
|
||||||
|
score -= 5;
|
||||||
|
justification.push(
|
||||||
|
`Vérifié il y a ${jours} j : informations potentiellement périmées (−5)`
|
||||||
|
);
|
||||||
|
warnings.push(`Dernière vérification ancienne (${jours} jours).`);
|
||||||
|
} else {
|
||||||
|
justification.push(`Vérifié il y a ${jours} j (0)`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pénalité de statut (hors « mort », déjà exclu)
|
||||||
|
if (modele.statut === 'early-access') {
|
||||||
|
score -= 10;
|
||||||
|
justification.push('Statut early-access (−10)');
|
||||||
|
} else if (modele.statut === 'annonce') {
|
||||||
|
score -= 20;
|
||||||
|
justification.push('Statut annonce (−20)');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pénalité champs null (anti-hallucination) + warnings métier
|
||||||
|
const champsNull = CHAMPS_CLES.filter(({ valeur }) => valeur(modele) === null).map(
|
||||||
|
({ libelle }) => libelle
|
||||||
|
);
|
||||||
|
if (champsNull.length > 0) {
|
||||||
|
const penalite = 3 * champsNull.length;
|
||||||
|
score -= penalite;
|
||||||
|
justification.push(`${champsNull.length} champ(s) non vérifié(s) (−${penalite})`);
|
||||||
|
warnings.push(`Champs à vérifier : ${champsNull.join(', ')}.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modele.licence.attribution_requise === true) {
|
||||||
|
warnings.push('Attribution requise par la licence.');
|
||||||
|
}
|
||||||
|
if (modele.licence.seuil !== null) {
|
||||||
|
warnings.push(`Seuil de licence : « ${modele.licence.seuil} » — vérifier votre cas.`);
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
modele.licence.garde_fous_obligatoires === true &&
|
||||||
|
(profil.requiert.includes('nsfw_ok') || 'nsfw_ok' in profil.preferences)
|
||||||
|
) {
|
||||||
|
warnings.push('Garde-fous obligatoires (anti-NCII/CSAM) : NSFW en local uniquement.');
|
||||||
|
}
|
||||||
|
if (filtres.vram_max !== undefined && vram === null) {
|
||||||
|
warnings.push('VRAM non vérifiée : compatibilité avec la limite à vérifier.');
|
||||||
|
}
|
||||||
|
if (profil.plateformes.includes('steam')) {
|
||||||
|
warnings.push('Déclaration IA obligatoire sur Steam (formulaire éditeur).');
|
||||||
|
}
|
||||||
|
if (profil.plateformes.includes('itch_io')) {
|
||||||
|
warnings.push('Tag IA obligatoire sur itch.io.');
|
||||||
|
}
|
||||||
|
|
||||||
|
shortlist.push({ modele, score, justification, warnings });
|
||||||
|
}
|
||||||
|
|
||||||
|
shortlist.sort((a, b) => b.score - a.score || a.modele.nom.localeCompare(b.modele.nom));
|
||||||
|
|
||||||
|
return { profil, shortlist, exclus };
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { modeleSchema, type Modele } from '../../schemas';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Données d'exemple du registre — reprises EXACTEMENT du PRD §4.1.
|
||||||
|
* Écrites uniquement au premier démarrage (jamais d'écrasement de données existantes).
|
||||||
|
*/
|
||||||
|
const donneesSeed: unknown[] = [
|
||||||
|
{
|
||||||
|
id: 'krea-2-turbo',
|
||||||
|
nom: 'Krea 2 Turbo',
|
||||||
|
editeur: 'Krea AI',
|
||||||
|
modalites: ['image'],
|
||||||
|
licence: {
|
||||||
|
nom: 'Krea 2 Community License',
|
||||||
|
url: 'https://huggingface.co/krea/Krea-2-Turbo',
|
||||||
|
commercial_ok: true,
|
||||||
|
seuil: 'équipes <50 sièges',
|
||||||
|
attribution_requise: false,
|
||||||
|
poids_ouverts: true,
|
||||||
|
garde_fous_obligatoires: true
|
||||||
|
},
|
||||||
|
capacites: {
|
||||||
|
nsfw_ok: true,
|
||||||
|
vram_gb: 12,
|
||||||
|
resolution_max: '2K',
|
||||||
|
comfyui_natif: true,
|
||||||
|
licence_outputs: 'commercialisables'
|
||||||
|
},
|
||||||
|
scores: {
|
||||||
|
arena_image: 6
|
||||||
|
},
|
||||||
|
statut: 'production',
|
||||||
|
derniere_verif: '2026-08-01',
|
||||||
|
sources: ['https://huggingface.co/krea/Krea-2-Turbo'],
|
||||||
|
notes: 'Encodeur Qwen3-VL, VAE Qwen Image. LoRA-friendly.'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const modelesSeed: Modele[] = donneesSeed.map((m) => modeleSchema.parse(m));
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { profilSchema, type Profil } from '../../schemas';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Les 5 profils de projets du PRD §4.2 (contextes de production du porteur).
|
||||||
|
* Écrits uniquement au premier démarrage (jamais d'écrasement de données existantes).
|
||||||
|
*/
|
||||||
|
const donneesSeed: unknown[] = [
|
||||||
|
{
|
||||||
|
id: 'vn_renpy_adulte',
|
||||||
|
nom: "VN Ren'Py (CC BY-NC / BY-NC-ND)",
|
||||||
|
requiert: ['commercial_ok'],
|
||||||
|
exclut: [],
|
||||||
|
preferences: {
|
||||||
|
nsfw_ok: 'obligatoire',
|
||||||
|
poids_ouverts: 'fortement_prefere',
|
||||||
|
comfyui_natif: 'prefere'
|
||||||
|
},
|
||||||
|
plateformes: ['steam', 'itch_io'],
|
||||||
|
contraintes_specifiques:
|
||||||
|
'Déclaration IA Steam + tag itch.io obligatoires. NSFW => local uniquement.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'prestation_images',
|
||||||
|
nom: "Vente d'images (prestataire)",
|
||||||
|
requiert: ['commercial_ok', 'licence_outputs_commercialisables'],
|
||||||
|
exclut: ['attribution_requise'],
|
||||||
|
preferences: {
|
||||||
|
poids_ouverts: 'prefere',
|
||||||
|
comfyui_natif: 'prefere'
|
||||||
|
},
|
||||||
|
plateformes: [],
|
||||||
|
contraintes_specifiques:
|
||||||
|
'Facturation client : outputs commercialisables indispensables. Attribution à éviter sauf accord explicite du client. Vérifier le seuil de revenus de la licence avant facturation.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'clips_vendus',
|
||||||
|
nom: 'Clips vidéo vendus',
|
||||||
|
requiert: ['commercial_ok', 'licence_outputs_commercialisables'],
|
||||||
|
exclut: ['attribution_requise'],
|
||||||
|
preferences: {
|
||||||
|
comfyui_natif: 'prefere'
|
||||||
|
},
|
||||||
|
plateformes: [],
|
||||||
|
contraintes_specifiques:
|
||||||
|
'Revente de clips : vérifier les clauses de revente et les seuils de revenus de la licence.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'jeu_steam_itch',
|
||||||
|
nom: 'Jeu Steam / itch.io',
|
||||||
|
requiert: ['commercial_ok', 'licence_outputs_commercialisables'],
|
||||||
|
exclut: [],
|
||||||
|
preferences: {
|
||||||
|
poids_ouverts: 'prefere'
|
||||||
|
},
|
||||||
|
plateformes: ['steam', 'itch_io'],
|
||||||
|
contraintes_specifiques:
|
||||||
|
'Déclaration IA Steam (formulaire éditeur) + tag IA itch.io obligatoires.'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'diffusion_libre',
|
||||||
|
nom: 'Diffusion libre (CC)',
|
||||||
|
requiert: [],
|
||||||
|
exclut: [],
|
||||||
|
preferences: {
|
||||||
|
poids_ouverts: 'fortement_prefere'
|
||||||
|
},
|
||||||
|
plateformes: ['reseaux'],
|
||||||
|
contraintes_specifiques:
|
||||||
|
'Attribution selon la licence du modèle. Poids ouverts fortement préférés pour la redistribution.'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
export const profilsSeed: Profil[] = donneesSeed.map((p) => profilSchema.parse(p));
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* Templates markdown par défaut (PRD §4.5) — écrits au premier démarrage
|
||||||
|
* dans data/templates/, éditables ensuite dans l'UI.
|
||||||
|
* Tokens : {{date}}, {{tableau_registre}}, {{tableau_classements}}.
|
||||||
|
*/
|
||||||
|
export const TEMPLATES_SEED: Record<string, string> = {
|
||||||
|
'guide_licences.md': `# Guide des licences — veille-ia
|
||||||
|
|
||||||
|
> Généré le {{date}} depuis le registre. Document d'export : ne pas éditer à la main
|
||||||
|
> (modifier le template ou le registre, puis régénérer).
|
||||||
|
|
||||||
|
{{tableau_registre}}
|
||||||
|
|
||||||
|
## Rappels
|
||||||
|
|
||||||
|
- Tout champ « ⚠️ à vérifier » est non vérifié publiquement — ne jamais supposer une licence, un prix ou un benchmark.
|
||||||
|
- Vérifier le seuil de licence (revenus, sièges) avant tout usage commercial.
|
||||||
|
- L'attribution requise est bloquante pour certaines prestations : la traiter au cas par cas.
|
||||||
|
`,
|
||||||
|
|
||||||
|
'classements.md': `# Classements — veille-ia
|
||||||
|
|
||||||
|
> Généré le {{date}} depuis le registre.
|
||||||
|
|
||||||
|
{{tableau_classements}}
|
||||||
|
|
||||||
|
_Les rangs sont ceux publiés par les classements de référence (ex. Artificial Analysis), à la date de dernière vérification indiquée dans le registre._
|
||||||
|
`
|
||||||
|
};
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* Template de workflow ComfyUI par défaut (PRD §4.6) — écrit au premier
|
||||||
|
* démarrage dans data/workflows/templates/, éditable ensuite sur disque.
|
||||||
|
* Les templates sont des graphes API paramétrés : on substitue des variables,
|
||||||
|
* jamais la topologie.
|
||||||
|
*
|
||||||
|
* Variables disponibles dans les valeurs texte :
|
||||||
|
* {{modele_checkpoint}} fichier checkpoint du modèle (registre)
|
||||||
|
* {{resolution}} ex. "1024x1024"
|
||||||
|
* {{resolution_largeur}} ex. 1024 (nombre)
|
||||||
|
* {{resolution_hauteur}} ex. 1024 (nombre)
|
||||||
|
* {{vram_profile}} low | medium | high (dérivé de la VRAM du modèle)
|
||||||
|
*/
|
||||||
|
export const WORKFLOW_TEMPLATES_SEED: Record<string, string> = {
|
||||||
|
'txt2img_basique.json': JSON.stringify(
|
||||||
|
{
|
||||||
|
'1': { class_type: 'CheckpointLoaderSimple', inputs: { ckpt_name: '{{modele_checkpoint}}' } },
|
||||||
|
'2': { class_type: 'CLIPTextEncode', inputs: { text: 'a test image', clip: ['1', 1] } },
|
||||||
|
'3': { class_type: 'CLIPTextEncode', inputs: { text: '', clip: ['1', 1] } },
|
||||||
|
'4': {
|
||||||
|
class_type: 'EmptyLatentImage',
|
||||||
|
inputs: {
|
||||||
|
width: '{{resolution_largeur}}',
|
||||||
|
height: '{{resolution_hauteur}}',
|
||||||
|
batch_size: 1
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'5': {
|
||||||
|
class_type: 'KSampler',
|
||||||
|
inputs: {
|
||||||
|
model: ['1', 0],
|
||||||
|
positive: ['2', 0],
|
||||||
|
negative: ['3', 0],
|
||||||
|
latent_image: ['4', 0],
|
||||||
|
seed: 42,
|
||||||
|
steps: 4,
|
||||||
|
cfg: 1.0,
|
||||||
|
sampler_name: 'euler',
|
||||||
|
scheduler: 'simple',
|
||||||
|
denoise: 1.0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'6': { class_type: 'VAEDecode', inputs: { samples: ['5', 0], vae: ['1', 2] } },
|
||||||
|
'7': { class_type: 'PreviewImage', inputs: { images: ['6', 0] } },
|
||||||
|
'8': {
|
||||||
|
class_type: 'Note',
|
||||||
|
inputs: {
|
||||||
|
text: 'Généré par veille-ia — modèle : {{modele_checkpoint}} · résolution : {{resolution}} · profil VRAM : {{vram_profile}}'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
'\t'
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
export const WORKFLOW_INDEX_SEED = `# Index des templates de workflows ComfyUI (PRD §4.6).
|
||||||
|
# Variables : {{modele_checkpoint}} {{resolution}} {{resolution_largeur}}
|
||||||
|
# {{resolution_hauteur}} {{vram_profile}}
|
||||||
|
- fichier: txt2img_basique.json
|
||||||
|
nom: txt2img basique (canari)
|
||||||
|
modalite: image
|
||||||
|
description: Graphe minimal checkpoint → sampler → preview, utilisable comme canari de smoke test.
|
||||||
|
`;
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { dump as dumpYaml, load as loadYaml } from 'js-yaml';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { config } from './config';
|
||||||
|
import { committer } from './git';
|
||||||
|
import { modeleSchema, profilSchema, type Modele, type Profil } from '../schemas';
|
||||||
|
import { modelesSeed } from './seed/modeles';
|
||||||
|
import { profilsSeed } from './seed/profils';
|
||||||
|
import { TEMPLATES_SEED } from './seed/templates';
|
||||||
|
import { WORKFLOW_INDEX_SEED, WORKFLOW_TEMPLATES_SEED } from './seed/workflows';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persistance : fichiers YAML dans le data_dir + commit git à chaque écriture.
|
||||||
|
* Pas de base de données (PRD §8.2) — lisible, sauvegardable, diffable.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const FICHIER_REGISTRE = 'registre_modeles.yaml';
|
||||||
|
const FICHIER_PROFILS = 'profils_projets.yaml';
|
||||||
|
const DOSSIER_TEMPLATES = 'templates';
|
||||||
|
const DOSSIER_EXPORTS = 'exports';
|
||||||
|
|
||||||
|
let initialisation: Promise<void> | null = null;
|
||||||
|
|
||||||
|
/** Initialisation au premier accès : arborescence + seeds, commit initial. Idempotent. */
|
||||||
|
export function initialiserDonnees(): Promise<void> {
|
||||||
|
initialisation ??= (async () => {
|
||||||
|
await mkdir(config.dataDir, { recursive: true });
|
||||||
|
await mkdir(join(config.dataDir, DOSSIER_TEMPLATES), { recursive: true });
|
||||||
|
await mkdir(join(config.dataDir, DOSSIER_EXPORTS), { recursive: true });
|
||||||
|
|
||||||
|
const fichiersSeedes: string[] = [];
|
||||||
|
if (!existsSync(join(config.dataDir, FICHIER_REGISTRE))) {
|
||||||
|
await ecrireYaml(FICHIER_REGISTRE, modelesSeed);
|
||||||
|
fichiersSeedes.push(FICHIER_REGISTRE);
|
||||||
|
}
|
||||||
|
if (!existsSync(join(config.dataDir, FICHIER_PROFILS))) {
|
||||||
|
await ecrireYaml(FICHIER_PROFILS, profilsSeed);
|
||||||
|
fichiersSeedes.push(FICHIER_PROFILS);
|
||||||
|
}
|
||||||
|
for (const [nom, contenu] of Object.entries(TEMPLATES_SEED)) {
|
||||||
|
if (!existsSync(join(config.dataDir, DOSSIER_TEMPLATES, nom))) {
|
||||||
|
await writeFile(join(config.dataDir, DOSSIER_TEMPLATES, nom), contenu, 'utf8');
|
||||||
|
fichiersSeedes.push(join(DOSSIER_TEMPLATES, nom));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Templates ComfyUI (PRD §4.6) : data/workflows/templates/ + data/workflows/generes/
|
||||||
|
const dossierTemplatesWf = join(config.dataDir, 'workflows', 'templates');
|
||||||
|
await mkdir(dossierTemplatesWf, { recursive: true });
|
||||||
|
await mkdir(join(config.dataDir, 'workflows', 'generes'), { recursive: true });
|
||||||
|
for (const [nom, contenu] of Object.entries(WORKFLOW_TEMPLATES_SEED)) {
|
||||||
|
if (!existsSync(join(dossierTemplatesWf, nom))) {
|
||||||
|
await writeFile(join(dossierTemplatesWf, nom), contenu, 'utf8');
|
||||||
|
fichiersSeedes.push(join('workflows', 'templates', nom));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!existsSync(join(dossierTemplatesWf, 'index.yaml'))) {
|
||||||
|
await writeFile(join(dossierTemplatesWf, 'index.yaml'), WORKFLOW_INDEX_SEED, 'utf8');
|
||||||
|
fichiersSeedes.push(join('workflows', 'templates', 'index.yaml'));
|
||||||
|
}
|
||||||
|
if (fichiersSeedes.length > 0) {
|
||||||
|
await committer(
|
||||||
|
fichiersSeedes,
|
||||||
|
'chore : initialisation des données (registre, profils et templates du PRD)'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return initialisation;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function lireYaml<T>(fichier: string, schema: z.ZodType<T>): Promise<T> {
|
||||||
|
await initialiserDonnees();
|
||||||
|
const brut = await readFile(join(config.dataDir, fichier), 'utf8');
|
||||||
|
return schema.parse(loadYaml(brut));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ecrireYaml(fichier: string, donnees: unknown): Promise<void> {
|
||||||
|
await mkdir(config.dataDir, { recursive: true });
|
||||||
|
await writeFile(
|
||||||
|
join(config.dataDir, fichier),
|
||||||
|
dumpYaml(donnees, { lineWidth: 100, noRefs: true }),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const registreFichierSchema = z.array(modeleSchema);
|
||||||
|
const profilsFichierSchema = z.array(profilSchema);
|
||||||
|
|
||||||
|
export async function lireRegistre(): Promise<Modele[]> {
|
||||||
|
return lireYaml(FICHIER_REGISTRE, registreFichierSchema);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ecrireRegistre(modeles: Modele[], messageCommit: string): Promise<void> {
|
||||||
|
const valides = registreFichierSchema.parse(modeles);
|
||||||
|
await initialiserDonnees();
|
||||||
|
await ecrireYaml(FICHIER_REGISTRE, valides);
|
||||||
|
await committer([FICHIER_REGISTRE], messageCommit);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function lireProfils(): Promise<Profil[]> {
|
||||||
|
return lireYaml(FICHIER_PROFILS, profilsFichierSchema);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ecrireProfils(profils: Profil[], messageCommit: string): Promise<void> {
|
||||||
|
const valides = profilsFichierSchema.parse(profils);
|
||||||
|
await initialiserDonnees();
|
||||||
|
await ecrireYaml(FICHIER_PROFILS, valides);
|
||||||
|
await committer([FICHIER_PROFILS], messageCommit);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Templates et exports markdown (PRD §4.5) ──
|
||||||
|
|
||||||
|
export async function lireTemplate(nom: string): Promise<string> {
|
||||||
|
await initialiserDonnees();
|
||||||
|
return readFile(join(config.dataDir, DOSSIER_TEMPLATES, nom), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function templateExiste(nom: string): Promise<boolean> {
|
||||||
|
await initialiserDonnees();
|
||||||
|
return existsSync(join(config.dataDir, DOSSIER_TEMPLATES, nom));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ecrireTemplate(
|
||||||
|
nom: string,
|
||||||
|
contenu: string,
|
||||||
|
messageCommit: string
|
||||||
|
): Promise<void> {
|
||||||
|
await initialiserDonnees();
|
||||||
|
await writeFile(join(config.dataDir, DOSSIER_TEMPLATES, nom), contenu, 'utf8');
|
||||||
|
await committer([join(DOSSIER_TEMPLATES, nom)], messageCommit);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function ecrireExport(
|
||||||
|
nom: string,
|
||||||
|
contenu: string,
|
||||||
|
messageCommit: string
|
||||||
|
): Promise<void> {
|
||||||
|
await initialiserDonnees();
|
||||||
|
await writeFile(join(config.dataDir, DOSSIER_EXPORTS, nom), contenu, 'utf8');
|
||||||
|
await committer([join(DOSSIER_EXPORTS, nom)], messageCommit);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function lireExport(nom: string): Promise<string> {
|
||||||
|
await initialiserDonnees();
|
||||||
|
return readFile(join(config.dataDir, DOSSIER_EXPORTS, nom), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Liste les documents générés (exports/), du plus récent au plus ancien. */
|
||||||
|
export async function listerExports(): Promise<string[]> {
|
||||||
|
await initialiserDonnees();
|
||||||
|
const { readdir } = await import('node:fs/promises');
|
||||||
|
const fichiers = await readdir(join(config.dataDir, DOSSIER_EXPORTS));
|
||||||
|
return fichiers
|
||||||
|
.filter((f) => f.endsWith('.md'))
|
||||||
|
.sort()
|
||||||
|
.reverse();
|
||||||
|
}
|
||||||
@@ -0,0 +1,123 @@
|
|||||||
|
import { describe, expect, it } from 'vitest';
|
||||||
|
import {
|
||||||
|
PROMPT_CANARI,
|
||||||
|
profilVram,
|
||||||
|
substituerWorkflow,
|
||||||
|
testerComfyUI,
|
||||||
|
variablesPourModele
|
||||||
|
} from './workflows';
|
||||||
|
import { modeleSchema } from '../schemas';
|
||||||
|
|
||||||
|
describe('substituerWorkflow', () => {
|
||||||
|
it('remplace les variables et convertit les valeurs numériques', () => {
|
||||||
|
const template = JSON.stringify({
|
||||||
|
'1': {
|
||||||
|
class_type: 'A',
|
||||||
|
inputs: { ckpt: '{{modele_checkpoint}}', w: '{{resolution_largeur}}' }
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const resultat = JSON.parse(
|
||||||
|
substituerWorkflow(template, {
|
||||||
|
modele_checkpoint: 'flux.safetensors',
|
||||||
|
resolution_largeur: 1024
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(resultat['1'].inputs.ckpt).toBe('flux.safetensors');
|
||||||
|
expect(resultat['1'].inputs.w).toBe(1024);
|
||||||
|
expect(typeof resultat['1'].inputs.w).toBe('number');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ne touche pas à la topologie du graphe', () => {
|
||||||
|
const graphe = {
|
||||||
|
'1': { class_type: 'A', inputs: { x: '{{v}}' } },
|
||||||
|
'2': { class_type: 'B', inputs: { in: ['1', 0], y: 3 } }
|
||||||
|
};
|
||||||
|
const resultat = JSON.parse(substituerWorkflow(JSON.stringify(graphe), { v: 'ok' }));
|
||||||
|
expect(resultat['2'].inputs.in).toEqual(['1', 0]);
|
||||||
|
expect(resultat['2'].inputs.y).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejette une variable inconnue (jamais de valeur inventée)', () => {
|
||||||
|
expect(() => substituerWorkflow('{"a": "{{inconnue}}"}', {})).toThrow('inconnue');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('profilVram', () => {
|
||||||
|
it('mappe les paliers', () => {
|
||||||
|
expect(profilVram(4)).toBe('low');
|
||||||
|
expect(profilVram(12)).toBe('medium');
|
||||||
|
expect(profilVram(24)).toBe('high');
|
||||||
|
expect(profilVram(null)).toBe('medium');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('variablesPourModele', () => {
|
||||||
|
const base = {
|
||||||
|
id: 'm1',
|
||||||
|
nom: 'M1',
|
||||||
|
editeur: 'E',
|
||||||
|
modalites: ['image'],
|
||||||
|
statut: 'production',
|
||||||
|
sources: ['https://example.org']
|
||||||
|
};
|
||||||
|
|
||||||
|
it('construit les variables depuis le registre', () => {
|
||||||
|
const m = modeleSchema.parse({
|
||||||
|
...base,
|
||||||
|
capacites: { comfyui_checkpoint: 'm1.safetensors', vram_gb: 12 }
|
||||||
|
});
|
||||||
|
expect(variablesPourModele(m, '1024x768')).toEqual({
|
||||||
|
modele_checkpoint: 'm1.safetensors',
|
||||||
|
resolution: '1024x768',
|
||||||
|
resolution_largeur: 1024,
|
||||||
|
resolution_hauteur: 768,
|
||||||
|
vram_profile: 'medium'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuse de générer sans checkpoint renseigné (anti-hallucination)', () => {
|
||||||
|
const m = modeleSchema.parse({ ...base, capacites: { comfyui_checkpoint: null } });
|
||||||
|
expect(() => variablesPourModele(m, '1024x1024')).toThrow('comfyui_checkpoint');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejette une résolution malformée', () => {
|
||||||
|
const m = modeleSchema.parse({ ...base, capacites: { comfyui_checkpoint: 'x' } });
|
||||||
|
expect(() => variablesPourModele(m, '1024')).toThrow('Résolution invalide');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('testerComfyUI (smoke test)', () => {
|
||||||
|
it('canari accepté', async () => {
|
||||||
|
const fetchMock = async () =>
|
||||||
|
new Response(JSON.stringify({ prompt_id: 'abc' }), { status: 200 });
|
||||||
|
const statut = await testerComfyUI('http://localhost:8188', fetchMock as typeof fetch);
|
||||||
|
expect(statut).toEqual({
|
||||||
|
joignable: true,
|
||||||
|
accepte: true,
|
||||||
|
detail: 'Prompt canari accepté (id abc).'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('API joignable mais prompt refusé', async () => {
|
||||||
|
const fetchMock = async () => new Response('bad prompt', { status: 400 });
|
||||||
|
const statut = await testerComfyUI('http://localhost:8188', fetchMock as typeof fetch);
|
||||||
|
expect(statut.joignable).toBe(true);
|
||||||
|
expect(statut.accepte).toBe(false);
|
||||||
|
expect(statut.detail).toContain('400');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ComfyUI injoignable : dégradation gracieuse', async () => {
|
||||||
|
const fetchMock = async () => {
|
||||||
|
throw new Error('ECONNREFUSED');
|
||||||
|
};
|
||||||
|
const statut = await testerComfyUI('http://localhost:8188', fetchMock as typeof fetch);
|
||||||
|
expect(statut.joignable).toBe(false);
|
||||||
|
expect(statut.accepte).toBe(false);
|
||||||
|
expect(statut.detail).toContain('dégradation gracieuse');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('le canari est un graphe valide sans modèle', () => {
|
||||||
|
expect(PROMPT_CANARI['1'].class_type).toBe('EmptyImage');
|
||||||
|
expect(PROMPT_CANARI['2'].inputs.images).toEqual(['1', 0]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,290 @@
|
|||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { load as loadYaml, dump as dumpYaml } from 'js-yaml';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import type { Modele } from '../schemas';
|
||||||
|
import { committer } from './git';
|
||||||
|
import { config } from './config';
|
||||||
|
import { initialiserDonnees, lireRegistre } from './stockage';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Templates ComfyUI paramétriques (PRD §4.6).
|
||||||
|
* Principe : on PARAMÈTRE, on ne réécrit jamais la topologie du graphe.
|
||||||
|
* Les templates vivent dans data/workflows/templates/ (JSON API ComfyUI avec
|
||||||
|
* placeholders {{variable}}), les JSON concrets dans data/workflows/generes/.
|
||||||
|
*/
|
||||||
|
|
||||||
|
const DOSSIER_WORKFLOWS = 'workflows';
|
||||||
|
const DOSSIER_TEMPLATES = join(DOSSIER_WORKFLOWS, 'templates');
|
||||||
|
const DOSSIER_GENERES = join(DOSSIER_WORKFLOWS, 'generes');
|
||||||
|
const INDEX_TEMPLATES = join(DOSSIER_TEMPLATES, 'index.yaml');
|
||||||
|
const INDEX_GENERES = join(DOSSIER_GENERES, 'index.yaml');
|
||||||
|
|
||||||
|
const templateInfoSchema = z.object({
|
||||||
|
fichier: z.string().endsWith('.json'),
|
||||||
|
nom: z.string().min(1),
|
||||||
|
modalite: z.string().min(1),
|
||||||
|
description: z.string().default('')
|
||||||
|
});
|
||||||
|
export type TemplateInfo = z.infer<typeof templateInfoSchema>;
|
||||||
|
|
||||||
|
const genereInfoSchema = z.object({
|
||||||
|
fichier: z.string().endsWith('.json'),
|
||||||
|
template: z.string(),
|
||||||
|
modele_id: z.string(),
|
||||||
|
resolution: z.string().regex(/^\d+x\d+$/),
|
||||||
|
vram_profile: z.enum(['low', 'medium', 'high']),
|
||||||
|
genere_le: z.string()
|
||||||
|
});
|
||||||
|
export type GenereInfo = z.infer<typeof genereInfoSchema>;
|
||||||
|
|
||||||
|
// ── Substitution (fonction pure, testable) ──
|
||||||
|
|
||||||
|
const PLACEHOLDER = /\{\{([a-z_]+)\}\}/g;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Substitue les {{variables}} dans un template JSON (texte).
|
||||||
|
* - Toute variable inconnue du template doit être fournie, sinon erreur explicite.
|
||||||
|
* - Une valeur entièrement numérique après substitution devient un nombre JSON.
|
||||||
|
* Ne touche JAMAIS à la structure du graphe (topologie inchangée).
|
||||||
|
*/
|
||||||
|
export function substituerWorkflow(
|
||||||
|
templateJson: string,
|
||||||
|
variables: Record<string, string | number>
|
||||||
|
): string {
|
||||||
|
const graphe: unknown = JSON.parse(templateJson);
|
||||||
|
|
||||||
|
const substitue = (valeur: unknown): unknown => {
|
||||||
|
if (typeof valeur === 'string') {
|
||||||
|
if (!valeur.includes('{{')) return valeur;
|
||||||
|
const resultat = valeur.replace(PLACEHOLDER, (complet, cle: string) => {
|
||||||
|
if (!(cle in variables)) {
|
||||||
|
throw new Error(`Variable de template inconnue : {{${cle}}}`);
|
||||||
|
}
|
||||||
|
return String(variables[cle]);
|
||||||
|
});
|
||||||
|
// Conversion numérique : "{{resolution_largeur}}" → 1024 (nombre, pas chaîne)
|
||||||
|
return /^\d+$/.test(resultat) ? Number(resultat) : resultat;
|
||||||
|
}
|
||||||
|
if (Array.isArray(valeur)) return valeur.map(substitue);
|
||||||
|
if (valeur !== null && typeof valeur === 'object') {
|
||||||
|
return Object.fromEntries(Object.entries(valeur).map(([k, v]) => [k, substitue(v)]));
|
||||||
|
}
|
||||||
|
return valeur;
|
||||||
|
};
|
||||||
|
|
||||||
|
return JSON.stringify(substitue(graphe), null, '\t');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Profil VRAM dérivé du registre (null → medium, documenté). */
|
||||||
|
export function profilVram(vramGb: number | null): 'low' | 'medium' | 'high' {
|
||||||
|
if (vramGb === null) return 'medium';
|
||||||
|
if (vramGb <= 8) return 'low';
|
||||||
|
if (vramGb <= 16) return 'medium';
|
||||||
|
return 'high';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Variables de substitution pour un modèle + une résolution « LxH ». */
|
||||||
|
export function variablesPourModele(
|
||||||
|
modele: Modele,
|
||||||
|
resolution: string
|
||||||
|
): Record<string, string | number> {
|
||||||
|
const correspondance = resolution.match(/^(\d+)x(\d+)$/);
|
||||||
|
if (!correspondance) {
|
||||||
|
throw new Error(`Résolution invalide : « ${resolution} » (format attendu : 1024x1024)`);
|
||||||
|
}
|
||||||
|
// Règle anti-hallucination : jamais de nom de checkpoint inventé.
|
||||||
|
if (!modele.capacites.comfyui_checkpoint) {
|
||||||
|
throw new Error(
|
||||||
|
`Le modèle « ${modele.id} » n'a pas de comfyui_checkpoint renseigné — ` +
|
||||||
|
'compléter sa fiche registre avant de générer un workflow.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
modele_checkpoint: modele.capacites.comfyui_checkpoint,
|
||||||
|
resolution,
|
||||||
|
resolution_largeur: Number(correspondance[1]),
|
||||||
|
resolution_hauteur: Number(correspondance[2]),
|
||||||
|
vram_profile: profilVram(modele.capacites.vram_gb)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Persistance ──
|
||||||
|
|
||||||
|
export async function lireTemplates(): Promise<TemplateInfo[]> {
|
||||||
|
await initialiserDonnees();
|
||||||
|
const brut = await readFile(join(config.dataDir, INDEX_TEMPLATES), 'utf8');
|
||||||
|
return z.array(templateInfoSchema).parse(loadYaml(brut));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function lireGeneres(): Promise<GenereInfo[]> {
|
||||||
|
await initialiserDonnees();
|
||||||
|
const chemin = join(config.dataDir, INDEX_GENERES);
|
||||||
|
if (!existsSync(chemin)) return [];
|
||||||
|
const brut = await readFile(chemin, 'utf8');
|
||||||
|
return z.array(genereInfoSchema).parse(loadYaml(brut) ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function lireFichierGenere(fichier: string): Promise<string> {
|
||||||
|
await initialiserDonnees();
|
||||||
|
// Garde : pas de traversée de chemin.
|
||||||
|
if (!/^[a-z0-9][a-z0-9._-]*\.json$/.test(fichier)) throw new Error('Nom de fichier invalide');
|
||||||
|
return readFile(join(config.dataDir, DOSSIER_GENERES, fichier), 'utf8');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function ecrireIndexGeneres(entrees: GenereInfo[]): Promise<void> {
|
||||||
|
await writeFile(
|
||||||
|
join(config.dataDir, INDEX_GENERES),
|
||||||
|
dumpYaml(entrees, { lineWidth: 100, noRefs: true }),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Génère un workflow concret depuis un template + un modèle du registre. */
|
||||||
|
export async function genererWorkflow(
|
||||||
|
templateFichier: string,
|
||||||
|
modele: Modele,
|
||||||
|
resolution: string,
|
||||||
|
vramProfileOverride: 'low' | 'medium' | 'high' | null = null
|
||||||
|
): Promise<GenereInfo> {
|
||||||
|
await initialiserDonnees();
|
||||||
|
const variables = variablesPourModele(modele, resolution);
|
||||||
|
if (vramProfileOverride) variables.vram_profile = vramProfileOverride;
|
||||||
|
|
||||||
|
const templateBrut = await readFile(
|
||||||
|
join(config.dataDir, DOSSIER_TEMPLATES, templateFichier),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
const concret = substituerWorkflow(templateBrut, variables);
|
||||||
|
|
||||||
|
const base = templateFichier.replace(/\.json$/, '');
|
||||||
|
const info: GenereInfo = {
|
||||||
|
fichier: `${base}__${modele.id}.json`,
|
||||||
|
template: templateFichier,
|
||||||
|
modele_id: modele.id,
|
||||||
|
resolution,
|
||||||
|
vram_profile: variables.vram_profile as GenereInfo['vram_profile'],
|
||||||
|
genere_le: new Date().toISOString()
|
||||||
|
};
|
||||||
|
|
||||||
|
await mkdir(join(config.dataDir, DOSSIER_GENERES), { recursive: true });
|
||||||
|
await writeFile(join(config.dataDir, DOSSIER_GENERES, info.fichier), concret, 'utf8');
|
||||||
|
|
||||||
|
const entrees = (await lireGeneres()).filter((e) => e.fichier !== info.fichier);
|
||||||
|
entrees.push(info);
|
||||||
|
await ecrireIndexGeneres(entrees);
|
||||||
|
|
||||||
|
await committer(
|
||||||
|
[join(DOSSIER_GENERES, info.fichier), INDEX_GENERES],
|
||||||
|
`feat : workflow ${info.fichier} généré depuis ${templateFichier}`
|
||||||
|
);
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Régénère tous les workflows concrets dont le modèle a changé (PRD §4.6 :
|
||||||
|
* « quand registre change → régénération des JSON concrets + commit git »).
|
||||||
|
* Un workflow dont le modèle a disparu du registre est supprimé.
|
||||||
|
* À appeler après chaque écriture du registre.
|
||||||
|
*/
|
||||||
|
export async function regenererWorkflows(): Promise<{ regeneres: number; supprimes: number }> {
|
||||||
|
const entrees = await lireGeneres();
|
||||||
|
if (entrees.length === 0) return { regeneres: 0, supprimes: 0 };
|
||||||
|
|
||||||
|
const registre = await lireRegistre();
|
||||||
|
const cheminsModifies: string[] = [];
|
||||||
|
let regeneres = 0;
|
||||||
|
let supprimes = 0;
|
||||||
|
const restantes: GenereInfo[] = [];
|
||||||
|
|
||||||
|
for (const entree of entrees) {
|
||||||
|
const modele = registre.find((m) => m.id === entree.modele_id);
|
||||||
|
if (!modele) {
|
||||||
|
await rm(join(config.dataDir, DOSSIER_GENERES, entree.fichier), { force: true });
|
||||||
|
cheminsModifies.push(join(DOSSIER_GENERES, entree.fichier));
|
||||||
|
supprimes++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const variables = variablesPourModele(modele, entree.resolution);
|
||||||
|
variables.vram_profile = entree.vram_profile;
|
||||||
|
const templateBrut = await readFile(
|
||||||
|
join(config.dataDir, DOSSIER_TEMPLATES, entree.template),
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
const concret = substituerWorkflow(templateBrut, variables);
|
||||||
|
await writeFile(join(config.dataDir, DOSSIER_GENERES, entree.fichier), concret, 'utf8');
|
||||||
|
cheminsModifies.push(join(DOSSIER_GENERES, entree.fichier));
|
||||||
|
regeneres++;
|
||||||
|
restantes.push({ ...entree, genere_le: new Date().toISOString() });
|
||||||
|
} catch {
|
||||||
|
// Modèle incomplet (ex. checkpoint devenu null) : on conserve le fichier
|
||||||
|
// existant et on le signalera côté UI via l'index — pas de suppression silencieuse.
|
||||||
|
restantes.push(entree);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (supprimes > 0 || regeneres > 0) {
|
||||||
|
await ecrireIndexGeneres(restantes);
|
||||||
|
cheminsModifies.push(INDEX_GENERES);
|
||||||
|
await committer(
|
||||||
|
cheminsModifies,
|
||||||
|
`feat : régénération des workflows (${regeneres} régénérés, ${supprimes} supprimés)`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return { regeneres, supprimes };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Smoke test ComfyUI (PRD §4.6) ──
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prompt canari : graphe minimal sans modèle (EmptyImage → PreviewImage),
|
||||||
|
* exécutable sur toute installation ComfyUI.
|
||||||
|
*/
|
||||||
|
export const PROMPT_CANARI = {
|
||||||
|
'1': { class_type: 'EmptyImage', inputs: { width: 64, height: 64, batch_size: 1, color: 0 } },
|
||||||
|
'2': { class_type: 'PreviewImage', inputs: { images: ['1', 0] } }
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export interface StatutComfyUI {
|
||||||
|
joignable: boolean;
|
||||||
|
accepte: boolean;
|
||||||
|
detail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** POST du prompt canari sur /prompt — fetch injectable pour les tests. */
|
||||||
|
export async function testerComfyUI(
|
||||||
|
url: string,
|
||||||
|
fetchImpl: typeof fetch = fetch
|
||||||
|
): Promise<StatutComfyUI> {
|
||||||
|
const endpoint = `${url.replace(/\/$/, '')}/prompt`;
|
||||||
|
try {
|
||||||
|
const reponse = await fetchImpl(endpoint, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'content-type': 'application/json' },
|
||||||
|
body: JSON.stringify({ prompt: PROMPT_CANARI }),
|
||||||
|
signal: AbortSignal.timeout(10_000)
|
||||||
|
});
|
||||||
|
if (reponse.ok) {
|
||||||
|
const corps = (await reponse.json()) as { prompt_id?: string };
|
||||||
|
return {
|
||||||
|
joignable: true,
|
||||||
|
accepte: true,
|
||||||
|
detail: `Prompt canari accepté (id ${corps.prompt_id ?? 'inconnu'}).`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const texte = await reponse.text();
|
||||||
|
return {
|
||||||
|
joignable: true,
|
||||||
|
accepte: false,
|
||||||
|
detail: `API joignable mais prompt refusé (${reponse.status}) : ${texte.slice(0, 300)}`
|
||||||
|
};
|
||||||
|
} catch (erreur) {
|
||||||
|
return {
|
||||||
|
joignable: false,
|
||||||
|
accepte: false,
|
||||||
|
detail: `ComfyUI injoignable (${endpoint}) : ${erreur instanceof Error ? erreur.message : String(erreur)} — dégradation gracieuse, l'app fonctionne sans.`
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,13 +9,14 @@
|
|||||||
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.
|
||||||
// Les entrées "bientot" correspondent aux phases 1 et 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: true },
|
{ href: '/registre', libelle: fr.nav.registre, icone: 'registre', bientot: false },
|
||||||
{ href: '/profils', libelle: fr.nav.profils, icone: 'profils', bientot: true },
|
{ href: '/profils', libelle: fr.nav.profils, icone: 'profils', bientot: false },
|
||||||
{ href: '/recommander', libelle: fr.nav.recommander, icone: 'cible', bientot: true },
|
{ href: '/recommander', libelle: fr.nav.recommander, icone: 'cible', bientot: false },
|
||||||
{ href: '/alertes', libelle: fr.nav.alertes, icone: 'alerte', bientot: true }
|
{ href: '/exports', libelle: fr.nav.exports, icone: 'registre', bientot: false },
|
||||||
|
{ href: '/workflows', libelle: fr.nav.workflows, icone: 'workflow', bientot: false },
|
||||||
|
{ href: '/alertes', libelle: fr.nav.alertes, icone: 'alerte', bientot: false }
|
||||||
] as const;
|
] as const;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -10,22 +10,32 @@
|
|||||||
{
|
{
|
||||||
titre: fr.accueil.carte_registre_titre,
|
titre: fr.accueil.carte_registre_titre,
|
||||||
texte: fr.accueil.carte_registre_texte,
|
texte: fr.accueil.carte_registre_texte,
|
||||||
icone: 'registre'
|
icone: 'registre',
|
||||||
|
href: '/registre'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
titre: fr.accueil.carte_profils_titre,
|
titre: fr.accueil.carte_profils_titre,
|
||||||
texte: fr.accueil.carte_profils_texte,
|
texte: fr.accueil.carte_profils_texte,
|
||||||
icone: 'profils'
|
icone: 'profils',
|
||||||
|
href: '/profils'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
titre: fr.accueil.carte_recommander_titre,
|
titre: fr.accueil.carte_recommander_titre,
|
||||||
texte: fr.accueil.carte_recommander_texte,
|
texte: fr.accueil.carte_recommander_texte,
|
||||||
icone: 'cible'
|
icone: 'cible',
|
||||||
|
href: '/recommander'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
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: '/alertes'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
titre: fr.accueil.carte_workflows_titre,
|
||||||
|
texte: fr.accueil.carte_workflows_texte,
|
||||||
|
icone: 'workflow',
|
||||||
|
href: '/workflows'
|
||||||
}
|
}
|
||||||
] as const;
|
] as const;
|
||||||
</script>
|
</script>
|
||||||
@@ -44,10 +54,9 @@
|
|||||||
<article class="carte">
|
<article class="carte">
|
||||||
<h2>
|
<h2>
|
||||||
<Icon nom={carte.icone} taille={22} />
|
<Icon nom={carte.icone} taille={22} />
|
||||||
{carte.titre}
|
<a class="carte-lien" href={resolve(carte.href)}>{carte.titre}</a>
|
||||||
</h2>
|
</h2>
|
||||||
<p>{carte.texte}</p>
|
<p>{carte.texte}</p>
|
||||||
<span class="tag">{fr.accueil.phase_a_venir}</span>
|
|
||||||
</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 });
|
||||||
|
};
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { json, error } from '@sveltejs/kit';
|
||||||
|
import { z } from 'zod';
|
||||||
|
import { modaliteSchema } from '$lib/schemas';
|
||||||
|
import { recommander } from '$lib/server/recommandation';
|
||||||
|
import { lireProfils, lireRegistre } from '$lib/server/stockage';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/recommander (PRD §4.3)
|
||||||
|
* Corps JSON : { "profil_id": "vn_renpy_adulte", "filtres": { "modalite"?, "vram_max"?, "budget"? } }
|
||||||
|
* Authentification : SSO requis (hooks.server — route non publique).
|
||||||
|
*/
|
||||||
|
|
||||||
|
const corpsSchema = z.object({
|
||||||
|
profil_id: z.string().min(1),
|
||||||
|
filtres: z
|
||||||
|
.object({
|
||||||
|
modalite: modaliteSchema.optional(),
|
||||||
|
vram_max: z.number().positive().optional(),
|
||||||
|
// Réservé — aucune donnée de prix n'est stockée par conception.
|
||||||
|
budget: z.literal(0).optional()
|
||||||
|
})
|
||||||
|
.optional()
|
||||||
|
.default({})
|
||||||
|
});
|
||||||
|
|
||||||
|
export const POST: RequestHandler = async ({ request }) => {
|
||||||
|
let corpsBrut: unknown;
|
||||||
|
try {
|
||||||
|
corpsBrut = await request.json();
|
||||||
|
} catch {
|
||||||
|
error(400, 'Corps JSON invalide');
|
||||||
|
}
|
||||||
|
|
||||||
|
const corps = corpsSchema.safeParse(corpsBrut);
|
||||||
|
if (!corps.success) {
|
||||||
|
error(400, `Requête invalide : ${corps.error.issues.map((i) => i.message).join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const profils = await lireProfils();
|
||||||
|
const profil = profils.find((p) => p.id === corps.data.profil_id);
|
||||||
|
if (!profil) error(404, `Profil « ${corps.data.profil_id} » introuvable`);
|
||||||
|
|
||||||
|
const registre = await lireRegistre();
|
||||||
|
return json(recommander(registre, profil, corps.data.filtres));
|
||||||
|
};
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { fail } from '@sveltejs/kit';
|
||||||
|
import { genererDocument } from '$lib/server/exportateur';
|
||||||
|
import {
|
||||||
|
ecrireExport,
|
||||||
|
ecrireTemplate,
|
||||||
|
lireExport,
|
||||||
|
lireRegistre,
|
||||||
|
lireTemplate,
|
||||||
|
listerExports
|
||||||
|
} from '$lib/server/stockage';
|
||||||
|
import { chaineOuNull } from '$lib/server/formulaires';
|
||||||
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
const NOMS_TEMPLATES = ['guide_licences.md', 'classements.md'] as const;
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async () => {
|
||||||
|
const templates: Record<string, string> = {};
|
||||||
|
for (const nom of NOMS_TEMPLATES) {
|
||||||
|
templates[nom] = await lireTemplate(nom);
|
||||||
|
}
|
||||||
|
|
||||||
|
const nomsExports = await listerExports();
|
||||||
|
const exports: Record<string, string> = {};
|
||||||
|
for (const nom of nomsExports) {
|
||||||
|
exports[nom] = await lireExport(nom);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { templates, exports };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
enregistrerTemplate: async ({ request }) => {
|
||||||
|
const data = await request.formData();
|
||||||
|
const nom = chaineOuNull(data.get('nom'));
|
||||||
|
const contenu = data.get('contenu');
|
||||||
|
if (
|
||||||
|
!nom ||
|
||||||
|
!(NOMS_TEMPLATES as readonly string[]).includes(nom) ||
|
||||||
|
typeof contenu !== 'string'
|
||||||
|
) {
|
||||||
|
return fail(400, { erreur: 'Template invalide.' });
|
||||||
|
}
|
||||||
|
await ecrireTemplate(nom, contenu, `feat : mise à jour du template ${nom}`);
|
||||||
|
return { templateEnregistre: nom };
|
||||||
|
},
|
||||||
|
|
||||||
|
generer: async () => {
|
||||||
|
const registre = await lireRegistre();
|
||||||
|
const maintenant = new Date();
|
||||||
|
for (const nom of NOMS_TEMPLATES) {
|
||||||
|
const template = await lireTemplate(nom);
|
||||||
|
const document = genererDocument(template, registre, maintenant);
|
||||||
|
await ecrireExport(nom, document, `docs : régénération de ${nom} depuis le registre`);
|
||||||
|
}
|
||||||
|
return { genere: true };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import { fr } from '$lib/i18n/fr';
|
||||||
|
import type { ActionData, PageData } from './$types';
|
||||||
|
|
||||||
|
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||||
|
|
||||||
|
const nomsExports = $derived(Object.keys(data.exports));
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{fr.exports.titre} — {fr.app.nom}</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<h1>{fr.exports.titre}</h1>
|
||||||
|
<p class="intro">{fr.exports.intro}</p>
|
||||||
|
<p class="aide">{fr.exports.tokens_aide}</p>
|
||||||
|
|
||||||
|
<section aria-labelledby="titre-templates">
|
||||||
|
<h2 id="titre-templates">{fr.exports.section_templates}</h2>
|
||||||
|
{#each Object.entries(data.templates) as [nom, contenu] (nom)}
|
||||||
|
<form method="post" action="?/enregistrerTemplate" use:enhance class="template">
|
||||||
|
<h3>{nom}</h3>
|
||||||
|
<input type="hidden" name="nom" value={nom} />
|
||||||
|
<textarea name="contenu" rows="10" spellcheck="false">{contenu}</textarea>
|
||||||
|
<button class="bouton" type="submit">{fr.exports.enregistrer_template}</button>
|
||||||
|
{#if form && 'templateEnregistre' in form && form.templateEnregistre === nom}
|
||||||
|
<span class="tag tag-succes">{fr.formulaires.enregistrer} ✓</span>
|
||||||
|
{/if}
|
||||||
|
</form>
|
||||||
|
{/each}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-labelledby="titre-generes">
|
||||||
|
<div class="titre-section">
|
||||||
|
<h2 id="titre-generes">{fr.exports.section_generes}</h2>
|
||||||
|
<form method="post" action="?/generer" use:enhance>
|
||||||
|
<button class="bouton" type="submit">{fr.exports.generer}</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if nomsExports.length === 0}
|
||||||
|
<p>{fr.exports.aucun_export}</p>
|
||||||
|
{:else}
|
||||||
|
{#each nomsExports as nom (nom)}
|
||||||
|
<details class="export">
|
||||||
|
<summary>{nom}</summary>
|
||||||
|
<pre>{data.exports[nom]}</pre>
|
||||||
|
</details>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
h1 {
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin-block: var(--space-3) var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 1rem;
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro {
|
||||||
|
color: var(--muted);
|
||||||
|
max-width: 70ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.aide {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.template {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: start;
|
||||||
|
gap: var(--space-1);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.template textarea {
|
||||||
|
width: 100%;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--fg);
|
||||||
|
padding: 0.75em;
|
||||||
|
font-family: ui-monospace, monospace;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.titre-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.export {
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
summary {
|
||||||
|
cursor: pointer;
|
||||||
|
min-height: 44px;
|
||||||
|
align-content: center;
|
||||||
|
color: var(--action);
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
background: var(--surface);
|
||||||
|
border: var(--border-card);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
padding: var(--space-2);
|
||||||
|
overflow-x: auto;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { lireProfils } from '$lib/server/stockage';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async () => {
|
||||||
|
const profils = await lireProfils();
|
||||||
|
return { profils };
|
||||||
|
};
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import { fr } from '$lib/i18n/fr';
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
|
let { data }: PageProps = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{fr.profils.titre} — {fr.app.nom}</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="titre-page">
|
||||||
|
<h1>{fr.profils.titre}</h1>
|
||||||
|
<a class="bouton" href={resolve('/profils/nouveau')}>{fr.profils.nouveau}</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if data.profils.length === 0}
|
||||||
|
<p>{fr.profils.aucun}</p>
|
||||||
|
{:else}
|
||||||
|
<div class="grille">
|
||||||
|
{#each data.profils as profil (profil.id)}
|
||||||
|
<article class="carte">
|
||||||
|
<h2>{profil.nom}</h2>
|
||||||
|
{#if profil.requiert.length > 0}
|
||||||
|
<p>
|
||||||
|
<strong>{fr.profils.legende_requiert} :</strong>
|
||||||
|
{profil.requiert.map((c) => fr.formulaires.cles[c]).join(', ')}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{#if profil.exclut.length > 0}
|
||||||
|
<p>
|
||||||
|
<strong>{fr.profils.legende_exclut} :</strong>
|
||||||
|
{profil.exclut.map((c) => fr.formulaires.cles[c]).join(', ')}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{#if profil.contraintes_specifiques}
|
||||||
|
<p class="contraintes">{profil.contraintes_specifiques}</p>
|
||||||
|
{/if}
|
||||||
|
<a href={resolve(`/profils/${profil.id}`)}>{fr.profils.modifier}</a>
|
||||||
|
</article>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.titre-page {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.carte h2 {
|
||||||
|
font-size: 1.05rem;
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.carte p {
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.contraintes {
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { error, fail, redirect } from '@sveltejs/kit';
|
||||||
|
import { profilSchema } from '$lib/schemas';
|
||||||
|
import { erreursZod, profilDepuisFormulaire } from '$lib/server/formulaires';
|
||||||
|
import { ecrireProfils, lireProfils } from '$lib/server/stockage';
|
||||||
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async ({ params }) => {
|
||||||
|
const profils = await lireProfils();
|
||||||
|
const profil = profils.find((p) => p.id === params.id);
|
||||||
|
if (!profil) error(404, 'Profil introuvable');
|
||||||
|
return { profil };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
enregistrer: async ({ request, params }) => {
|
||||||
|
const data = await request.formData();
|
||||||
|
const brut = profilDepuisFormulaire(data);
|
||||||
|
// L'identifiant d'un profil existant n'est pas modifiable (champ readonly).
|
||||||
|
const resultat = profilSchema.safeParse({ ...(brut as object), id: params.id });
|
||||||
|
if (!resultat.success) {
|
||||||
|
return fail(400, { erreurs: erreursZod(resultat.error), brut });
|
||||||
|
}
|
||||||
|
|
||||||
|
const profils = await lireProfils();
|
||||||
|
const index = profils.findIndex((p) => p.id === params.id);
|
||||||
|
if (index === -1) error(404, 'Profil introuvable');
|
||||||
|
|
||||||
|
profils[index] = resultat.data;
|
||||||
|
await ecrireProfils(profils, `feat : mise à jour du profil ${params.id}`);
|
||||||
|
redirect(303, '/profils');
|
||||||
|
},
|
||||||
|
|
||||||
|
supprimer: async ({ params }) => {
|
||||||
|
const profils = await lireProfils();
|
||||||
|
const restant = profils.filter((p) => p.id !== params.id);
|
||||||
|
if (restant.length === profils.length) error(404, 'Profil introuvable');
|
||||||
|
|
||||||
|
await ecrireProfils(restant, `feat : suppression du profil ${params.id}`);
|
||||||
|
redirect(303, '/profils');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { fr } from '$lib/i18n/fr';
|
||||||
|
import ProfilForm from '$lib/components/ProfilForm.svelte';
|
||||||
|
import type { ActionData, PageData } from './$types';
|
||||||
|
|
||||||
|
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{fr.profils.titre_edition} : {data.profil.nom} — {fr.app.nom}</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<h1>{fr.profils.titre_edition} : {data.profil.nom}</h1>
|
||||||
|
|
||||||
|
<ProfilForm
|
||||||
|
profil={data.profil}
|
||||||
|
edition
|
||||||
|
actionSupprimer="?/supprimer"
|
||||||
|
erreurs={form?.erreurs ?? []}
|
||||||
|
brut={(form?.brut as Record<string, unknown> | undefined) ?? null}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { fail, redirect } from '@sveltejs/kit';
|
||||||
|
import { profilSchema } from '$lib/schemas';
|
||||||
|
import { erreursZod, profilDepuisFormulaire } from '$lib/server/formulaires';
|
||||||
|
import { ecrireProfils, lireProfils } from '$lib/server/stockage';
|
||||||
|
import type { Actions } from './$types';
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
default: async ({ request }) => {
|
||||||
|
const data = await request.formData();
|
||||||
|
const brut = profilDepuisFormulaire(data);
|
||||||
|
const resultat = profilSchema.safeParse(brut);
|
||||||
|
if (!resultat.success) {
|
||||||
|
return fail(400, { erreurs: erreursZod(resultat.error), brut });
|
||||||
|
}
|
||||||
|
|
||||||
|
const profils = await lireProfils();
|
||||||
|
if (profils.some((p) => p.id === resultat.data.id)) {
|
||||||
|
return fail(409, {
|
||||||
|
erreurs: [`Un profil avec l'identifiant « ${resultat.data.id} » existe déjà.`],
|
||||||
|
brut
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await ecrireProfils([...profils, resultat.data], `feat : ajout du profil ${resultat.data.id}`);
|
||||||
|
redirect(303, '/profils');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { fr } from '$lib/i18n/fr';
|
||||||
|
import ProfilForm from '$lib/components/ProfilForm.svelte';
|
||||||
|
import type { ActionData } from './$types';
|
||||||
|
|
||||||
|
let { form }: { form: ActionData } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{fr.profils.titre_nouveau} — {fr.app.nom}</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<h1>{fr.profils.titre_nouveau}</h1>
|
||||||
|
|
||||||
|
<ProfilForm
|
||||||
|
erreurs={form?.erreurs ?? []}
|
||||||
|
brut={(form?.brut as Record<string, unknown> | undefined) ?? null}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { fail } from '@sveltejs/kit';
|
||||||
|
import { modaliteSchema } from '$lib/schemas';
|
||||||
|
import { recommander } from '$lib/server/recommandation';
|
||||||
|
import { lireProfils, lireRegistre } from '$lib/server/stockage';
|
||||||
|
import { nombreOuNull, chaineOuNull } from '$lib/server/formulaires';
|
||||||
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async () => {
|
||||||
|
const profils = await lireProfils();
|
||||||
|
return { profils };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
default: async ({ request }) => {
|
||||||
|
const data = await request.formData();
|
||||||
|
const profilId = chaineOuNull(data.get('profil_id'));
|
||||||
|
const modaliteBrute = chaineOuNull(data.get('modalite'));
|
||||||
|
const vramMax = nombreOuNull(data.get('vram_max'));
|
||||||
|
|
||||||
|
const profils = await lireProfils();
|
||||||
|
const profil = profils.find((p) => p.id === profilId);
|
||||||
|
if (!profil) {
|
||||||
|
return fail(400, { erreur: 'Profil invalide.', profils });
|
||||||
|
}
|
||||||
|
|
||||||
|
const modalite = modaliteSchema.safeParse(modaliteBrute);
|
||||||
|
const registre = await lireRegistre();
|
||||||
|
const resultat = recommander(registre, profil, {
|
||||||
|
modalite: modalite.success ? modalite.data : undefined,
|
||||||
|
vram_max: vramMax ?? undefined
|
||||||
|
});
|
||||||
|
|
||||||
|
return { resultat, profilId: profil.id, modalite: modalite.data ?? '', vramMax };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { enhance } from '$app/forms';
|
||||||
|
import { fr } from '$lib/i18n/fr';
|
||||||
|
import BadgeAVerifier from '$lib/components/BadgeAVerifier.svelte';
|
||||||
|
import Icon from '$lib/components/Icon.svelte';
|
||||||
|
import { modaliteSchema } from '$lib/schemas';
|
||||||
|
import type { ActionData, PageData } from './$types';
|
||||||
|
|
||||||
|
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||||
|
|
||||||
|
const resultat = $derived(form && 'resultat' in form ? form.resultat : null);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{fr.recommander.titre} — {fr.app.nom}</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<h1>{fr.recommander.titre}</h1>
|
||||||
|
|
||||||
|
<form method="post" use:enhance class="filtres">
|
||||||
|
<label class="champ">
|
||||||
|
<span>{fr.recommander.champ_profil}</span>
|
||||||
|
<select name="profil_id" required>
|
||||||
|
{#each data.profils as profil (profil.id)}
|
||||||
|
<option
|
||||||
|
value={profil.id}
|
||||||
|
selected={form && 'profilId' in form && form.profilId === profil.id}
|
||||||
|
>
|
||||||
|
{profil.nom}
|
||||||
|
</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="champ">
|
||||||
|
<span>{fr.recommander.champ_modalite}</span>
|
||||||
|
<select name="modalite">
|
||||||
|
<option value="">{fr.recommander.toutes_modalites}</option>
|
||||||
|
{#each modaliteSchema.options as modalite (modalite)}
|
||||||
|
<option
|
||||||
|
value={modalite}
|
||||||
|
selected={form && 'modalite' in form && form.modalite === modalite}
|
||||||
|
>
|
||||||
|
{fr.formulaires.modalites[modalite]}
|
||||||
|
</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="champ">
|
||||||
|
<span>{fr.recommander.champ_vram}</span>
|
||||||
|
<input
|
||||||
|
name="vram_max"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
step="1"
|
||||||
|
value={form && 'vramMax' in form && form.vramMax !== null ? form.vramMax : ''}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button class="bouton" type="submit">{fr.recommander.lancer}</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{#if form && 'erreur' in form}
|
||||||
|
<p class="erreur" role="alert">{form.erreur}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if resultat}
|
||||||
|
<section aria-labelledby="titre-shortlist">
|
||||||
|
<h2 id="titre-shortlist">{fr.recommander.shortlist} — {resultat.profil.nom}</h2>
|
||||||
|
{#if resultat.shortlist.length === 0}
|
||||||
|
<p>{fr.recommander.aucun_eligible}</p>
|
||||||
|
{:else}
|
||||||
|
<div class="resultats">
|
||||||
|
{#each resultat.shortlist as reco (reco.modele.id)}
|
||||||
|
<article class="carte reco">
|
||||||
|
<header>
|
||||||
|
<h3>{reco.modele.nom}</h3>
|
||||||
|
<span class="score">{fr.recommander.score} : {reco.score}</span>
|
||||||
|
</header>
|
||||||
|
<p class="editeur">{reco.modele.editeur} — {reco.modele.modalites.join(', ')}</p>
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>{fr.recommander.justification}</summary>
|
||||||
|
<ul>
|
||||||
|
{#each reco.justification as ligne (ligne)}
|
||||||
|
<li>{ligne}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
{#if reco.warnings.length > 0}
|
||||||
|
<div class="warnings">
|
||||||
|
<p>
|
||||||
|
<Icon nom="avertissement" taille={16} />
|
||||||
|
{fr.recommander.warnings}
|
||||||
|
</p>
|
||||||
|
<ul>
|
||||||
|
{#each reco.warnings as warning (warning)}
|
||||||
|
<li>{warning}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if reco.modele.licence.nom === null}
|
||||||
|
<BadgeAVerifier texte="licence {fr.badge.a_verifier}" />
|
||||||
|
{/if}
|
||||||
|
</article>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-labelledby="titre-exclus">
|
||||||
|
<h2 id="titre-exclus">{fr.recommander.exclus}</h2>
|
||||||
|
{#if resultat.exclus.length === 0}
|
||||||
|
<p>{fr.recommander.aucun_exclu}</p>
|
||||||
|
{:else}
|
||||||
|
{#each resultat.exclus as exclu (exclu.modele.id)}
|
||||||
|
<details class="exclu">
|
||||||
|
<summary>{exclu.modele.nom}</summary>
|
||||||
|
<ul>
|
||||||
|
{#each exclu.raisons as raison (raison)}
|
||||||
|
<li>{raison}</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</details>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
h1 {
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin-block: var(--space-3) var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filtres {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: end;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-3);
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: var(--border-card);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.champ {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.champ > span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
select,
|
||||||
|
input {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--fg);
|
||||||
|
padding: 0.5em 0.75em;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.resultats {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reco header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reco h3 {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
color: var(--action);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editeur {
|
||||||
|
color: var(--muted);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
details {
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
summary {
|
||||||
|
cursor: pointer;
|
||||||
|
min-height: 44px;
|
||||||
|
align-content: center;
|
||||||
|
color: var(--action);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warnings {
|
||||||
|
border-left: 4px solid var(--action);
|
||||||
|
padding-left: var(--space-2);
|
||||||
|
margin-bottom: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.warnings p {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4em;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.exclu {
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
.erreur {
|
||||||
|
color: var(--signal);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { lireRegistre } from '$lib/server/stockage';
|
||||||
|
import type { PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async () => {
|
||||||
|
const modeles = await lireRegistre();
|
||||||
|
return { modeles };
|
||||||
|
};
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { resolve } from '$app/paths';
|
||||||
|
import { fr } from '$lib/i18n/fr';
|
||||||
|
import BadgeAVerifier from '$lib/components/BadgeAVerifier.svelte';
|
||||||
|
import type { PageProps } from './$types';
|
||||||
|
|
||||||
|
let { data }: PageProps = $props();
|
||||||
|
|
||||||
|
const tri = (v: boolean | null) =>
|
||||||
|
v === true ? fr.formulaires.ouvert : v === false ? fr.formulaires.ferme : null;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{fr.registre.titre} — {fr.app.nom}</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<div class="titre-page">
|
||||||
|
<h1>{fr.registre.titre}</h1>
|
||||||
|
<a class="bouton" href={resolve('/registre/nouveau')}>{fr.registre.nouveau}</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if data.modeles.length === 0}
|
||||||
|
<p>{fr.registre.aucun}</p>
|
||||||
|
{:else}
|
||||||
|
<div class="table-conteneur">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">{fr.registre.colonne_nom}</th>
|
||||||
|
<th scope="col">{fr.registre.colonne_editeur}</th>
|
||||||
|
<th scope="col">{fr.registre.colonne_modalites}</th>
|
||||||
|
<th scope="col">{fr.registre.colonne_commercial}</th>
|
||||||
|
<th scope="col">{fr.registre.colonne_statut}</th>
|
||||||
|
<th scope="col">{fr.registre.colonne_verif}</th>
|
||||||
|
<th scope="col"><span class="sr-only">{fr.registre.modifier}</span></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each data.modeles as modele (modele.id)}
|
||||||
|
<tr>
|
||||||
|
<th scope="row">
|
||||||
|
{modele.nom}
|
||||||
|
{#if modele.licence.nom === null}
|
||||||
|
<BadgeAVerifier />
|
||||||
|
{/if}
|
||||||
|
</th>
|
||||||
|
<td>{modele.editeur}</td>
|
||||||
|
<td>{modele.modalites.join(', ')}</td>
|
||||||
|
<td>
|
||||||
|
{#if tri(modele.licence.commercial_ok) !== null}
|
||||||
|
{tri(modele.licence.commercial_ok)}
|
||||||
|
{:else}
|
||||||
|
<BadgeAVerifier />
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<span
|
||||||
|
class="tag"
|
||||||
|
class:tag-succes={modele.statut === 'production'}
|
||||||
|
class:tag-signal={modele.statut === 'mort'}
|
||||||
|
>
|
||||||
|
{fr.formulaires.statuts[modele.statut]}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{#if modele.derniere_verif !== null}
|
||||||
|
<time datetime={modele.derniere_verif}>{modele.derniere_verif}</time>
|
||||||
|
{:else}
|
||||||
|
<BadgeAVerifier />
|
||||||
|
{/if}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href={resolve(`/registre/${modele.id}`)}>{fr.registre.modifier}</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.titre-page {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: var(--space-2);
|
||||||
|
margin-bottom: var(--space-3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-conteneur {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
text-align: left;
|
||||||
|
padding: var(--space-1);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
thead th {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody tr {
|
||||||
|
content-visibility: auto;
|
||||||
|
contain-intrinsic-size: 60px;
|
||||||
|
}
|
||||||
|
|
||||||
|
th :global(.tag) {
|
||||||
|
margin-left: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0 0 0 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
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 { regenererWorkflows } from '$lib/server/workflows';
|
||||||
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
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');
|
||||||
|
|
||||||
|
// 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 = {
|
||||||
|
enregistrer: async ({ request, params }) => {
|
||||||
|
const data = await request.formData();
|
||||||
|
const brut = modeleDepuisFormulaire(data);
|
||||||
|
// L'identifiant d'un modèle existant n'est pas modifiable (champ readonly).
|
||||||
|
const resultat = modeleSchema.safeParse({ ...(brut as object), id: params.id });
|
||||||
|
if (!resultat.success) {
|
||||||
|
return fail(400, { erreurs: erreursZod(resultat.error), brut });
|
||||||
|
}
|
||||||
|
|
||||||
|
const registre = await lireRegistre();
|
||||||
|
const index = registre.findIndex((m) => m.id === params.id);
|
||||||
|
if (index === -1) error(404, 'Modèle introuvable');
|
||||||
|
|
||||||
|
registre[index] = resultat.data;
|
||||||
|
await ecrireRegistre(registre, `feat : mise à jour du modèle ${params.id}`);
|
||||||
|
await regenererWorkflows();
|
||||||
|
redirect(303, '/registre');
|
||||||
|
},
|
||||||
|
|
||||||
|
supprimer: async ({ params }) => {
|
||||||
|
const registre = await lireRegistre();
|
||||||
|
const restant = registre.filter((m) => m.id !== params.id);
|
||||||
|
if (restant.length === registre.length) error(404, 'Modèle introuvable');
|
||||||
|
|
||||||
|
await ecrireRegistre(restant, `feat : suppression du modèle ${params.id}`);
|
||||||
|
await regenererWorkflows();
|
||||||
|
redirect(303, '/registre');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { fr } from '$lib/i18n/fr';
|
||||||
|
import ModeleForm from '$lib/components/ModeleForm.svelte';
|
||||||
|
import type { ActionData, PageData } from './$types';
|
||||||
|
|
||||||
|
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{fr.registre.titre_edition} : {data.modele.nom} — {fr.app.nom}</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<h1>{fr.registre.titre_edition} : {data.modele.nom}</h1>
|
||||||
|
|
||||||
|
<ModeleForm
|
||||||
|
modele={data.modele}
|
||||||
|
edition
|
||||||
|
actionSupprimer="?/supprimer"
|
||||||
|
erreurs={form?.erreurs ?? []}
|
||||||
|
brut={(form?.brut as Record<string, unknown> | undefined) ?? data.brut}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
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 { regenererWorkflows } from '$lib/server/workflows';
|
||||||
|
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 }) => {
|
||||||
|
const data = await request.formData();
|
||||||
|
const brut = modeleDepuisFormulaire(data);
|
||||||
|
const resultat = modeleSchema.safeParse(brut);
|
||||||
|
if (!resultat.success) {
|
||||||
|
return fail(400, { erreurs: erreursZod(resultat.error), brut });
|
||||||
|
}
|
||||||
|
|
||||||
|
const registre = await lireRegistre();
|
||||||
|
if (registre.some((m) => m.id === resultat.data.id)) {
|
||||||
|
return fail(409, {
|
||||||
|
erreurs: [`Un modèle avec l'identifiant « ${resultat.data.id} » existe déjà.`],
|
||||||
|
brut
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await ecrireRegistre(
|
||||||
|
[...registre, resultat.data],
|
||||||
|
`feat : ajout du modèle ${resultat.data.id} au registre`
|
||||||
|
);
|
||||||
|
await regenererWorkflows();
|
||||||
|
redirect(303, '/registre');
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { fr } from '$lib/i18n/fr';
|
||||||
|
import ModeleForm from '$lib/components/ModeleForm.svelte';
|
||||||
|
import type { ActionData, PageData } from './$types';
|
||||||
|
|
||||||
|
let { data, form }: { data: PageData; form: ActionData } = $props();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{fr.registre.titre_nouveau} — {fr.app.nom}</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<h1>{fr.registre.titre_nouveau}</h1>
|
||||||
|
|
||||||
|
<ModeleForm
|
||||||
|
erreurs={form?.erreurs ?? []}
|
||||||
|
brut={(form?.brut as Record<string, unknown> | undefined) ?? data.brut}
|
||||||
|
/>
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { fail } from '@sveltejs/kit';
|
||||||
|
import { chaineOuNull } from '$lib/server/formulaires';
|
||||||
|
import { lireRegistre } from '$lib/server/stockage';
|
||||||
|
import { config } from '$lib/server/config';
|
||||||
|
import {
|
||||||
|
genererWorkflow,
|
||||||
|
lireGeneres,
|
||||||
|
lireTemplates,
|
||||||
|
testerComfyUI,
|
||||||
|
type StatutComfyUI
|
||||||
|
} from '$lib/server/workflows';
|
||||||
|
import type { Actions, PageServerLoad } from './$types';
|
||||||
|
|
||||||
|
export const load: PageServerLoad = async () => {
|
||||||
|
const [templates, generes, registre] = await Promise.all([
|
||||||
|
lireTemplates(),
|
||||||
|
lireGeneres(),
|
||||||
|
lireRegistre()
|
||||||
|
]);
|
||||||
|
return { templates, generes, registre, comfyuiUrl: config.comfyuiUrl };
|
||||||
|
};
|
||||||
|
|
||||||
|
export const actions: Actions = {
|
||||||
|
generer: async ({ request }) => {
|
||||||
|
const data = await request.formData();
|
||||||
|
const templateFichier = chaineOuNull(data.get('template'));
|
||||||
|
const modeleId = chaineOuNull(data.get('modele_id'));
|
||||||
|
const resolution = chaineOuNull(data.get('resolution'));
|
||||||
|
const vramBrut = chaineOuNull(data.get('vram_profile'));
|
||||||
|
const vramOverride =
|
||||||
|
vramBrut === 'low' || vramBrut === 'medium' || vramBrut === 'high' ? vramBrut : null;
|
||||||
|
|
||||||
|
const registre = await lireRegistre();
|
||||||
|
const modele = registre.find((m) => m.id === modeleId);
|
||||||
|
if (!templateFichier || !modele || !resolution) {
|
||||||
|
return fail(400, { erreurGenerer: 'Template, modèle et résolution sont requis.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const info = await genererWorkflow(templateFichier, modele, resolution, vramOverride);
|
||||||
|
return { genere: info.fichier };
|
||||||
|
} catch (erreur) {
|
||||||
|
return fail(400, {
|
||||||
|
erreurGenerer: erreur instanceof Error ? erreur.message : String(erreur)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
smoke: async (): Promise<{ smoke: StatutComfyUI }> => {
|
||||||
|
return { smoke: await testerComfyUI(config.comfyuiUrl) };
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
<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();
|
||||||
|
|
||||||
|
const smoke = $derived(form && 'smoke' in form ? form.smoke : null);
|
||||||
|
const profilsVram = ['low', 'medium', 'high'] as const;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<svelte:head>
|
||||||
|
<title>{fr.workflows.titre} — {fr.app.nom}</title>
|
||||||
|
</svelte:head>
|
||||||
|
|
||||||
|
<h1>{fr.workflows.titre}</h1>
|
||||||
|
<p class="intro">{fr.workflows.intro}</p>
|
||||||
|
|
||||||
|
<section aria-labelledby="titre-generer">
|
||||||
|
<h2 id="titre-generer">{fr.workflows.section_generer}</h2>
|
||||||
|
|
||||||
|
{#if form && 'erreurGenerer' in form}
|
||||||
|
<p class="erreur" role="alert">{fr.workflows.erreur_generation} {form.erreurGenerer}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<form method="post" action="?/generer" use:enhance class="filtres">
|
||||||
|
<label class="champ">
|
||||||
|
<span>{fr.workflows.champ_template}</span>
|
||||||
|
<select name="template" required>
|
||||||
|
{#each data.templates as template (template.fichier)}
|
||||||
|
<option value={template.fichier} title={template.description}>{template.nom}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="champ">
|
||||||
|
<span>{fr.workflows.champ_modele}</span>
|
||||||
|
<select name="modele_id" required>
|
||||||
|
{#each data.registre as modele (modele.id)}
|
||||||
|
<option value={modele.id}>{modele.nom}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="champ">
|
||||||
|
<span>{fr.workflows.champ_resolution}</span>
|
||||||
|
<input
|
||||||
|
name="resolution"
|
||||||
|
value="1024x1024"
|
||||||
|
required
|
||||||
|
pattern="\d+x\d+"
|
||||||
|
placeholder="1024x1024"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<label class="champ">
|
||||||
|
<span>{fr.workflows.champ_vram}</span>
|
||||||
|
<select name="vram_profile">
|
||||||
|
<option value="">{fr.workflows.vram_auto}</option>
|
||||||
|
{#each profilsVram as profil (profil)}
|
||||||
|
<option value={profil}>{profil}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<button class="bouton" type="submit">{fr.workflows.generer}</button>
|
||||||
|
</form>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-labelledby="titre-generes">
|
||||||
|
<h2 id="titre-generes">{fr.workflows.section_generes}</h2>
|
||||||
|
<p class="aide">{fr.workflows.regenere_auto}</p>
|
||||||
|
|
||||||
|
{#if data.generes.length === 0}
|
||||||
|
<p>{fr.workflows.aucun_genere}</p>
|
||||||
|
{:else}
|
||||||
|
<div class="table-conteneur">
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col">{fr.workflows.colonne_fichier}</th>
|
||||||
|
<th scope="col">{fr.workflows.colonne_template}</th>
|
||||||
|
<th scope="col">{fr.workflows.colonne_modele}</th>
|
||||||
|
<th scope="col">{fr.workflows.colonne_genere_le}</th>
|
||||||
|
<th scope="col"><span class="sr-only">{fr.workflows.telecharger}</span></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{#each data.generes as genere (genere.fichier)}
|
||||||
|
<tr>
|
||||||
|
<th scope="row">{genere.fichier}</th>
|
||||||
|
<td>{genere.template}</td>
|
||||||
|
<td>{genere.modele_id}</td>
|
||||||
|
<td>
|
||||||
|
<time datetime={genere.genere_le}>{genere.genere_le.slice(0, 10)}</time>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<a href={resolve(`/workflows/fichier/${genere.fichier}`)} download>
|
||||||
|
{fr.workflows.telecharger}
|
||||||
|
</a>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section aria-labelledby="titre-smoke">
|
||||||
|
<h2 id="titre-smoke">{fr.workflows.section_smoke}</h2>
|
||||||
|
<p class="aide">
|
||||||
|
{fr.workflows.smoke_intro}
|
||||||
|
{fr.workflows.endpoint} : <code>{data.comfyuiUrl}</code>
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form method="post" action="?/smoke" use:enhance>
|
||||||
|
<button class="bouton" type="submit">{fr.workflows.lancer_smoke}</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{#if smoke}
|
||||||
|
<p class="statut" class:ok={smoke.accepte} class:ko={smoke.joignable && !smoke.accepte}>
|
||||||
|
<Icon nom={smoke.accepte ? 'verifie' : 'avertissement'} taille={18} />
|
||||||
|
<strong>
|
||||||
|
{smoke.accepte
|
||||||
|
? fr.workflows.smoke_ok
|
||||||
|
: smoke.joignable
|
||||||
|
? fr.workflows.smoke_ko
|
||||||
|
: fr.workflows.smoke_injoignable}
|
||||||
|
</strong>
|
||||||
|
{smoke.detail}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
h1 {
|
||||||
|
margin-bottom: var(--space-1);
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin-block: var(--space-3) var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro,
|
||||||
|
.aide {
|
||||||
|
color: var(--muted);
|
||||||
|
max-width: 75ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filtres {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: end;
|
||||||
|
gap: var(--space-2);
|
||||||
|
padding: var(--space-3);
|
||||||
|
background: var(--card-bg);
|
||||||
|
border: var(--border-card);
|
||||||
|
border-radius: var(--radius-md);
|
||||||
|
}
|
||||||
|
|
||||||
|
.champ {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.3em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.champ > span {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
select,
|
||||||
|
input {
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--fg);
|
||||||
|
padding: 0.5em 0.75em;
|
||||||
|
min-height: 44px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-conteneur {
|
||||||
|
overflow-x: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
}
|
||||||
|
|
||||||
|
th,
|
||||||
|
td {
|
||||||
|
text-align: left;
|
||||||
|
padding: var(--space-1);
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
}
|
||||||
|
|
||||||
|
thead th {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.erreur {
|
||||||
|
color: var(--signal);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.statut {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5em;
|
||||||
|
margin-top: var(--space-2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statut.ok :global(svg),
|
||||||
|
.statut.ok strong {
|
||||||
|
color: var(--succes);
|
||||||
|
}
|
||||||
|
|
||||||
|
.statut.ko :global(svg),
|
||||||
|
.statut.ko strong {
|
||||||
|
color: var(--signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sr-only {
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0 0 0 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { lireFichierGenere } from '$lib/server/workflows';
|
||||||
|
import type { RequestHandler } from './$types';
|
||||||
|
|
||||||
|
/** GET /workflows/fichier/[nom] — téléchargement d'un workflow JSON généré. */
|
||||||
|
export const GET: RequestHandler = async ({ params }) => {
|
||||||
|
try {
|
||||||
|
const contenu = await lireFichierGenere(params.nom);
|
||||||
|
return new Response(contenu, {
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json; charset=utf-8',
|
||||||
|
'content-disposition': `attachment; filename="${params.nom}"`
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return new Response('Workflow introuvable', { status: 404 });
|
||||||
|
}
|
||||||
|
};
|
||||||
@@ -39,4 +39,11 @@
|
|||||||
<symbol id="verifie" viewBox="0 0 24 24">
|
<symbol id="verifie" viewBox="0 0 24 24">
|
||||||
<path d="M3 13l6 6L21 5" />
|
<path d="M3 13l6 6L21 5" />
|
||||||
</symbol>
|
</symbol>
|
||||||
|
<symbol id="workflow" viewBox="0 0 24 24">
|
||||||
|
<!-- Graphe de nœuds -->
|
||||||
|
<rect x="2" y="9" width="6" height="6" />
|
||||||
|
<rect x="16" y="2" width="6" height="6" />
|
||||||
|
<rect x="16" y="16" width="6" height="6" />
|
||||||
|
<path d="M8 12h4M12 12l4-7M12 12l4 7" />
|
||||||
|
</symbol>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.4 KiB After Width: | Height: | Size: 1.7 KiB |
@@ -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