From 0c9f83d3f59a82418aa17bc95cd2e80f2fc325de Mon Sep 17 00:00:00 2001 From: cyber-mawonaj Date: Sat, 1 Aug 2026 10:19:42 -0400 Subject: [PATCH] =?UTF-8?q?feat(app)=20:=20phase=201=20=E2=80=94=20registr?= =?UTF-8?q?e,=20profils,=20moteur=20de=20recommandation,=20export=20markdo?= =?UTF-8?q?wn?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Schémas Zod partagés ($lib/schemas) : modèle, profil (PRD §4), null = « à vérifier », sources[] obligatoires à chaque écriture - Persistance YAML dans DATA_DIR + commits git automatiques (simple-git) ; seeds PRD au premier démarrage (1 modèle §4.1, 5 profils, 2 templates) - CRUD registre et profils : pages liste/édition/création, form actions, champs tri-état, messages d'erreur Zod, commit par écriture - Moteur de recommandation pur et testé (12 cas : nominal, licence null, seuil, statut mort, vram, modalité, fraîcheur, pénalités) : filtrage dur (requiert/exclut/obligatoire/mort) puis scoring pondéré (préférences, rang arena, fraîcheur, pénalités statut et champs null), justification et warnings (attribution, seuil, garde-fous, déclarations plateformes) - POST /api/recommander (SSO requis) + page /recommander - Exporteur markdown /exports : templates éditables, génération depuis le registre (tableaux registre + classements), commit par document - Vérifié : check/lint/test (22) /build verts, test fumée HTTP complet (seed + git init, CRUD par form action, exports générés, filtres API, 401/404), svelte-autofixer propre sur les 11 composants --- README.md | 6 +- app/package-lock.json | 121 +++++-- app/package.json | 6 + app/src/lib/components/BadgeAVerifier.svelte | 15 + app/src/lib/components/ModeleForm.svelte | 331 ++++++++++++++++++ app/src/lib/components/ProfilForm.svelte | 246 +++++++++++++ app/src/lib/i18n/fr.ts | 132 ++++++- app/src/lib/schemas.ts | 89 +++++ app/src/lib/server/exportateur.ts | 67 ++++ app/src/lib/server/formulaires.ts | 104 ++++++ app/src/lib/server/git.ts | 34 ++ app/src/lib/server/recommandation.test.ts | 192 ++++++++++ app/src/lib/server/recommandation.ts | 262 ++++++++++++++ app/src/lib/server/seed/modeles.ts | 39 +++ app/src/lib/server/seed/profils.ts | 73 ++++ app/src/lib/server/seed/templates.ts | 29 ++ app/src/lib/server/stockage.ts | 143 ++++++++ app/src/routes/+layout.svelte | 9 +- app/src/routes/+page.svelte | 22 +- app/src/routes/api/recommander/+server.ts | 46 +++ app/src/routes/exports/+page.server.ts | 57 +++ app/src/routes/exports/+page.svelte | 125 +++++++ app/src/routes/profils/+page.server.ts | 7 + app/src/routes/profils/+page.svelte | 68 ++++ app/src/routes/profils/[id]/+page.server.ts | 41 +++ app/src/routes/profils/[id]/+page.svelte | 21 ++ .../routes/profils/nouveau/+page.server.ts | 27 ++ app/src/routes/profils/nouveau/+page.svelte | 18 + app/src/routes/recommander/+page.server.ts | 35 ++ app/src/routes/recommander/+page.svelte | 233 ++++++++++++ app/src/routes/registre/+page.server.ts | 7 + app/src/routes/registre/+page.svelte | 133 +++++++ app/src/routes/registre/[id]/+page.server.ts | 41 +++ app/src/routes/registre/[id]/+page.svelte | 21 ++ .../routes/registre/nouveau/+page.server.ts | 30 ++ app/src/routes/registre/nouveau/+page.svelte | 18 + 36 files changed, 2814 insertions(+), 34 deletions(-) create mode 100644 app/src/lib/components/BadgeAVerifier.svelte create mode 100644 app/src/lib/components/ModeleForm.svelte create mode 100644 app/src/lib/components/ProfilForm.svelte create mode 100644 app/src/lib/schemas.ts create mode 100644 app/src/lib/server/exportateur.ts create mode 100644 app/src/lib/server/formulaires.ts create mode 100644 app/src/lib/server/git.ts create mode 100644 app/src/lib/server/recommandation.test.ts create mode 100644 app/src/lib/server/recommandation.ts create mode 100644 app/src/lib/server/seed/modeles.ts create mode 100644 app/src/lib/server/seed/profils.ts create mode 100644 app/src/lib/server/seed/templates.ts create mode 100644 app/src/lib/server/stockage.ts create mode 100644 app/src/routes/api/recommander/+server.ts create mode 100644 app/src/routes/exports/+page.server.ts create mode 100644 app/src/routes/exports/+page.svelte create mode 100644 app/src/routes/profils/+page.server.ts create mode 100644 app/src/routes/profils/+page.svelte create mode 100644 app/src/routes/profils/[id]/+page.server.ts create mode 100644 app/src/routes/profils/[id]/+page.svelte create mode 100644 app/src/routes/profils/nouveau/+page.server.ts create mode 100644 app/src/routes/profils/nouveau/+page.svelte create mode 100644 app/src/routes/recommander/+page.server.ts create mode 100644 app/src/routes/recommander/+page.svelte create mode 100644 app/src/routes/registre/+page.server.ts create mode 100644 app/src/routes/registre/+page.svelte create mode 100644 app/src/routes/registre/[id]/+page.server.ts create mode 100644 app/src/routes/registre/[id]/+page.svelte create mode 100644 app/src/routes/registre/nouveau/+page.server.ts create mode 100644 app/src/routes/registre/nouveau/+page.svelte diff --git a/README.md b/README.md index 64c7970..677871c 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,11 @@ Optionnel. Si le paquet `ollama_ynh` est indisponible : Ollama sur la machine lo ## État d'avancement - [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 + - 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` + - CRUD registre (`/registre`) et profils (`/profils`) : form actions, validation Zod, commit git à chaque écriture, champs tri-état (null = « à vérifier ») + - Moteur de recommandation : page `/recommander` + API `POST /api/recommander` (JSON `{profil_id, filtres: {modalite?, vram_max?}}`, SSO requis) — filtrage dur puis scoring pondéré, justification et warnings par modèle + - Exporteur markdown (`/exports`) : templates éditables, génération des guides depuis le registre, commit git par document - [ ] **Phase 2** — inbox alertes + flows Node-RED - [ ] **Phase 3** — templates ComfyUI + smoke tests - [ ] **Phase 4** — durcissement (i18n gcf/en, niveau 8 package_check) diff --git a/app/package-lock.json b/app/package-lock.json index 92e3bc6..626280e 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -1,17 +1,23 @@ { - "name": "app", + "name": "veille-ia", "version": "0.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "app", + "name": "veille-ia", "version": "0.0.1", + "dependencies": { + "js-yaml": "^5.2.3", + "simple-git": "^3.36.0", + "zod": "^4.4.3" + }, "devDependencies": { "@eslint/js": "^10.0.1", "@sveltejs/adapter-node": "^5.5.4", "@sveltejs/kit": "^2.63.0", "@sveltejs/vite-plugin-svelte": "^7.1.2", + "@types/js-yaml": "^4.0.9", "@types/node": "^22", "eslint": "^10.4.1", "eslint-config-prettier": "^10.1.8", @@ -305,6 +311,21 @@ "@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": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", @@ -1090,6 +1111,21 @@ "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": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", @@ -1246,6 +1282,13 @@ "dev": true, "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": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -1670,6 +1713,12 @@ "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": { "version": "5.3.1", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.1.tgz", @@ -1815,7 +1864,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2390,6 +2438,28 @@ "dev": true, "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": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", @@ -2796,7 +2866,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -3309,6 +3378,23 @@ "dev": true, "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": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz", @@ -3884,24 +3970,6 @@ "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": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", @@ -3921,6 +3989,15 @@ "integrity": "sha512-B58NGBEoc8Y9MWWCQGl/gq9xBCe4IiKM0a2x7GZdQKOW5Exr8S1W24J6OgM1njK8xCRGvAJIL/MxXHf6SkmQKQ==", "dev": true, "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" + } } } } diff --git a/app/package.json b/app/package.json index 5662837..d46215e 100644 --- a/app/package.json +++ b/app/package.json @@ -20,6 +20,7 @@ "@sveltejs/adapter-node": "^5.5.4", "@sveltejs/kit": "^2.63.0", "@sveltejs/vite-plugin-svelte": "^7.1.2", + "@types/js-yaml": "^4.0.9", "@types/node": "^22", "eslint": "^10.4.1", "eslint-config-prettier": "^10.1.8", @@ -33,5 +34,10 @@ "typescript-eslint": "^8.60.1", "vite": "^8.0.16", "vitest": "^4.1.8" + }, + "dependencies": { + "js-yaml": "^5.2.3", + "simple-git": "^3.36.0", + "zod": "^4.4.3" } } diff --git a/app/src/lib/components/BadgeAVerifier.svelte b/app/src/lib/components/BadgeAVerifier.svelte new file mode 100644 index 0000000..1bd1e59 --- /dev/null +++ b/app/src/lib/components/BadgeAVerifier.svelte @@ -0,0 +1,15 @@ + + + + + {#if texte}{texte}{:else}{fr.badge.a_verifier}{/if} + diff --git a/app/src/lib/components/ModeleForm.svelte b/app/src/lib/components/ModeleForm.svelte new file mode 100644 index 0000000..823ec88 --- /dev/null +++ b/app/src/lib/components/ModeleForm.svelte @@ -0,0 +1,331 @@ + + +{#if erreurs.length > 0} + +{/if} + +{#snippet triEtat(nom: string, libelle: string, valeur: unknown)} + +{/snippet} + +
+
+ + + +
+ +
+ {fr.registre.champ_modalites} +
+ {#each modaliteSchema.options as modalite (modalite)} + + {/each} +
+
+ +
+ {fr.registre.section_licence} +

{fr.registre.aide_tri_etat}

+ + +
+ {@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 + )} +
+ +
+ +
+ {fr.registre.section_capacites} +
+ {@render triEtat('nsfw_ok', fr.registre.champ_nsfw, capacites.nsfw_ok)} + {@render triEtat('comfyui_natif', fr.registre.champ_comfyui, capacites.comfyui_natif)} + + + +
+
+ +
+ +
+ + +
+ + +
+ +
+ + {#if edition && actionSupprimer} + + {/if} +
+
+ + diff --git a/app/src/lib/components/ProfilForm.svelte b/app/src/lib/components/ProfilForm.svelte new file mode 100644 index 0000000..2b4b259 --- /dev/null +++ b/app/src/lib/components/ProfilForm.svelte @@ -0,0 +1,246 @@ + + +{#if erreurs.length > 0} + +{/if} + +
+
+ + +
+ +
+ {fr.profils.section_requiert} +
+ {#each cleConditionSchema.options as cle (cle)} + + {/each} +
+
+ +
+ {fr.profils.section_exclut} +
+ {#each cleConditionSchema.options as cle (cle)} + + {/each} +
+
+ +
+ {fr.profils.section_preferences} +
+ {#each cleConditionSchema.options as cle (cle)} + + {/each} +
+
+ +
+ {fr.profils.section_plateformes} +
+ {#each Object.entries(fr.formulaires.plateformes) as [cle, libelle] (cle)} + + {/each} +
+
+ +
+ +
+ +
+ + {#if edition && actionSupprimer} + + {/if} +
+
+ + diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index f08d1c1..dcfaebe 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -15,6 +15,7 @@ export const fr = { registre: 'Registre', profils: 'Profils', recommander: 'Recommander', + exports: 'Exports', alertes: 'Alertes', navigation_principale: 'Navigation principale', aller_au_contenu: 'Aller au contenu' @@ -60,9 +61,138 @@ export const fr = { badge: { 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', + 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}}' + }, pied: { 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; diff --git a/app/src/lib/schemas.ts b/app/src/lib/schemas.ts new file mode 100644 index 0000000..0143e6c --- /dev/null +++ b/app/src/lib/schemas.ts @@ -0,0 +1,89 @@ +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; + +export const statutModeleSchema = z.enum(['production', 'early-access', 'annonce', 'mort']); +export type StatutModele = z.infer; + +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), + 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; + +/** 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; + +export const niveauPreferenceSchema = z.enum(['obligatoire', 'fortement_prefere', 'prefere']); +export type NiveauPreference = z.infer; + +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; diff --git a/app/src/lib/server/exportateur.ts b/app/src/lib/server/exportateur.ts new file mode 100644 index 0000000..8adad39 --- /dev/null +++ b/app/src/lib/server/exportateur.ts @@ -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)); +} diff --git a/app/src/lib/server/formulaires.ts b/app/src/lib/server/formulaires.ts new file mode 100644 index 0000000..da6943b --- /dev/null +++ b/app/src/lib/server/formulaires.ts @@ -0,0 +1,104 @@ +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 = {}; + 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')), + 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 = {}; + 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 }; diff --git a/app/src/lib/server/git.ts b/app/src/lib/server/git.ts new file mode 100644 index 0000000..8c9c5e5 --- /dev/null +++ b/app/src/lib/server/git.ts @@ -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 { + 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 { + 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); +} diff --git a/app/src/lib/server/recommandation.test.ts b/app/src/lib/server/recommandation.test.ts new file mode 100644 index 0000000..b6fdc5b --- /dev/null +++ b/app/src/lib/server/recommandation.test.ts @@ -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): 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): 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'); + }); +}); diff --git a/app/src/lib/server/recommandation.ts b/app/src/lib/server/recommandation.ts new file mode 100644 index 0000000..7503229 --- /dev/null +++ b/app/src/lib/server/recommandation.ts @@ -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 = { + 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 }; +} diff --git a/app/src/lib/server/seed/modeles.ts b/app/src/lib/server/seed/modeles.ts new file mode 100644 index 0000000..4424a02 --- /dev/null +++ b/app/src/lib/server/seed/modeles.ts @@ -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)); diff --git a/app/src/lib/server/seed/profils.ts b/app/src/lib/server/seed/profils.ts new file mode 100644 index 0000000..f759aad --- /dev/null +++ b/app/src/lib/server/seed/profils.ts @@ -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)); diff --git a/app/src/lib/server/seed/templates.ts b/app/src/lib/server/seed/templates.ts new file mode 100644 index 0000000..e224f01 --- /dev/null +++ b/app/src/lib/server/seed/templates.ts @@ -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 = { + '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._ +` +}; diff --git a/app/src/lib/server/stockage.ts b/app/src/lib/server/stockage.ts new file mode 100644 index 0000000..adac994 --- /dev/null +++ b/app/src/lib/server/stockage.ts @@ -0,0 +1,143 @@ +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'; + +/** + * 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 | null = null; + +/** Initialisation au premier accès : arborescence + seeds, commit initial. Idempotent. */ +export function initialiserDonnees(): Promise { + 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)); + } + } + if (fichiersSeedes.length > 0) { + await committer( + fichiersSeedes, + 'chore : initialisation des données (registre, profils et templates du PRD)' + ); + } + })(); + return initialisation; +} + +async function lireYaml(fichier: string, schema: z.ZodType): Promise { + await initialiserDonnees(); + const brut = await readFile(join(config.dataDir, fichier), 'utf8'); + return schema.parse(loadYaml(brut)); +} + +async function ecrireYaml(fichier: string, donnees: unknown): Promise { + 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 { + return lireYaml(FICHIER_REGISTRE, registreFichierSchema); +} + +export async function ecrireRegistre(modeles: Modele[], messageCommit: string): Promise { + const valides = registreFichierSchema.parse(modeles); + await initialiserDonnees(); + await ecrireYaml(FICHIER_REGISTRE, valides); + await committer([FICHIER_REGISTRE], messageCommit); +} + +export async function lireProfils(): Promise { + return lireYaml(FICHIER_PROFILS, profilsFichierSchema); +} + +export async function ecrireProfils(profils: Profil[], messageCommit: string): Promise { + 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 { + await initialiserDonnees(); + return readFile(join(config.dataDir, DOSSIER_TEMPLATES, nom), 'utf8'); +} + +export async function templateExiste(nom: string): Promise { + await initialiserDonnees(); + return existsSync(join(config.dataDir, DOSSIER_TEMPLATES, nom)); +} + +export async function ecrireTemplate( + nom: string, + contenu: string, + messageCommit: string +): Promise { + 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 { + 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 { + 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 { + 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(); +} diff --git a/app/src/routes/+layout.svelte b/app/src/routes/+layout.svelte index df0f536..addf5fa 100644 --- a/app/src/routes/+layout.svelte +++ b/app/src/routes/+layout.svelte @@ -9,12 +9,13 @@ let { data, children }: LayoutProps = $props(); // Navigation privée : visible uniquement avec un utilisateur SSO. - // Les entrées "bientot" correspondent aux phases 1 et 2 du PRD. + // L'entrée "bientot" correspond à la phase 2 du PRD. const entrees = [ { href: '/', libelle: fr.nav.accueil, icone: 'accueil', bientot: false }, - { href: '/registre', libelle: fr.nav.registre, icone: 'registre', bientot: true }, - { href: '/profils', libelle: fr.nav.profils, icone: 'profils', bientot: true }, - { href: '/recommander', libelle: fr.nav.recommander, icone: 'cible', bientot: true }, + { href: '/registre', libelle: fr.nav.registre, icone: 'registre', bientot: false }, + { href: '/profils', libelle: fr.nav.profils, icone: 'profils', bientot: false }, + { href: '/recommander', libelle: fr.nav.recommander, icone: 'cible', bientot: false }, + { href: '/exports', libelle: fr.nav.exports, icone: 'registre', bientot: false }, { href: '/alertes', libelle: fr.nav.alertes, icone: 'alerte', bientot: true } ] as const; diff --git a/app/src/routes/+page.svelte b/app/src/routes/+page.svelte index 659ff32..15db3ce 100644 --- a/app/src/routes/+page.svelte +++ b/app/src/routes/+page.svelte @@ -10,22 +10,26 @@ { titre: fr.accueil.carte_registre_titre, texte: fr.accueil.carte_registre_texte, - icone: 'registre' + icone: 'registre', + href: '/registre' }, { titre: fr.accueil.carte_profils_titre, texte: fr.accueil.carte_profils_texte, - icone: 'profils' + icone: 'profils', + href: '/profils' }, { titre: fr.accueil.carte_recommander_titre, texte: fr.accueil.carte_recommander_texte, - icone: 'cible' + icone: 'cible', + href: '/recommander' }, { titre: fr.accueil.carte_alertes_titre, texte: fr.accueil.carte_alertes_texte, - icone: 'alerte' + icone: 'alerte', + href: null } ] as const; @@ -44,10 +48,16 @@

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

{carte.texte}

- {fr.accueil.phase_a_venir} + {#if !carte.href} + {fr.accueil.phase_a_venir} + {/if}
{/each} diff --git a/app/src/routes/api/recommander/+server.ts b/app/src/routes/api/recommander/+server.ts new file mode 100644 index 0000000..6475783 --- /dev/null +++ b/app/src/routes/api/recommander/+server.ts @@ -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)); +}; diff --git a/app/src/routes/exports/+page.server.ts b/app/src/routes/exports/+page.server.ts new file mode 100644 index 0000000..57c538a --- /dev/null +++ b/app/src/routes/exports/+page.server.ts @@ -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 = {}; + for (const nom of NOMS_TEMPLATES) { + templates[nom] = await lireTemplate(nom); + } + + const nomsExports = await listerExports(); + const exports: Record = {}; + 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 }; + } +}; diff --git a/app/src/routes/exports/+page.svelte b/app/src/routes/exports/+page.svelte new file mode 100644 index 0000000..4254700 --- /dev/null +++ b/app/src/routes/exports/+page.svelte @@ -0,0 +1,125 @@ + + + + {fr.exports.titre} — {fr.app.nom} + + +

{fr.exports.titre}

+

{fr.exports.intro}

+

{fr.exports.tokens_aide}

+ +
+

{fr.exports.section_templates}

+ {#each Object.entries(data.templates) as [nom, contenu] (nom)} +
+

{nom}

+ + + + {#if form && 'templateEnregistre' in form && form.templateEnregistre === nom} + {fr.formulaires.enregistrer} ✓ + {/if} +
+ {/each} +
+ +
+
+

{fr.exports.section_generes}

+
+ +
+
+ + {#if nomsExports.length === 0} +

{fr.exports.aucun_export}

+ {:else} + {#each nomsExports as nom (nom)} +
+ {nom} +
{data.exports[nom]}
+
+ {/each} + {/if} +
+ + diff --git a/app/src/routes/profils/+page.server.ts b/app/src/routes/profils/+page.server.ts new file mode 100644 index 0000000..4fe0089 --- /dev/null +++ b/app/src/routes/profils/+page.server.ts @@ -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 }; +}; diff --git a/app/src/routes/profils/+page.svelte b/app/src/routes/profils/+page.svelte new file mode 100644 index 0000000..6c9d0b5 --- /dev/null +++ b/app/src/routes/profils/+page.svelte @@ -0,0 +1,68 @@ + + + + {fr.profils.titre} — {fr.app.nom} + + +
+

{fr.profils.titre}

+ {fr.profils.nouveau} +
+ +{#if data.profils.length === 0} +

{fr.profils.aucun}

+{:else} +
+ {#each data.profils as profil (profil.id)} +
+

{profil.nom}

+ {#if profil.requiert.length > 0} +

+ {fr.profils.legende_requiert} : + {profil.requiert.map((c) => fr.formulaires.cles[c]).join(', ')} +

+ {/if} + {#if profil.exclut.length > 0} +

+ {fr.profils.legende_exclut} : + {profil.exclut.map((c) => fr.formulaires.cles[c]).join(', ')} +

+ {/if} + {#if profil.contraintes_specifiques} +

{profil.contraintes_specifiques}

+ {/if} + {fr.profils.modifier} +
+ {/each} +
+{/if} + + diff --git a/app/src/routes/profils/[id]/+page.server.ts b/app/src/routes/profils/[id]/+page.server.ts new file mode 100644 index 0000000..e730845 --- /dev/null +++ b/app/src/routes/profils/[id]/+page.server.ts @@ -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'); + } +}; diff --git a/app/src/routes/profils/[id]/+page.svelte b/app/src/routes/profils/[id]/+page.svelte new file mode 100644 index 0000000..624a909 --- /dev/null +++ b/app/src/routes/profils/[id]/+page.svelte @@ -0,0 +1,21 @@ + + + + {fr.profils.titre_edition} : {data.profil.nom} — {fr.app.nom} + + +

{fr.profils.titre_edition} : {data.profil.nom}

+ + | undefined) ?? null} +/> diff --git a/app/src/routes/profils/nouveau/+page.server.ts b/app/src/routes/profils/nouveau/+page.server.ts new file mode 100644 index 0000000..44ccb3a --- /dev/null +++ b/app/src/routes/profils/nouveau/+page.server.ts @@ -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'); + } +}; diff --git a/app/src/routes/profils/nouveau/+page.svelte b/app/src/routes/profils/nouveau/+page.svelte new file mode 100644 index 0000000..efd6381 --- /dev/null +++ b/app/src/routes/profils/nouveau/+page.svelte @@ -0,0 +1,18 @@ + + + + {fr.profils.titre_nouveau} — {fr.app.nom} + + +

{fr.profils.titre_nouveau}

+ + | undefined) ?? null} +/> diff --git a/app/src/routes/recommander/+page.server.ts b/app/src/routes/recommander/+page.server.ts new file mode 100644 index 0000000..8b4f1b2 --- /dev/null +++ b/app/src/routes/recommander/+page.server.ts @@ -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 }; + } +}; diff --git a/app/src/routes/recommander/+page.svelte b/app/src/routes/recommander/+page.svelte new file mode 100644 index 0000000..ba8f632 --- /dev/null +++ b/app/src/routes/recommander/+page.svelte @@ -0,0 +1,233 @@ + + + + {fr.recommander.titre} — {fr.app.nom} + + +

{fr.recommander.titre}

+ +
+ + + + +
+ +{#if form && 'erreur' in form} + +{/if} + +{#if resultat} +
+

{fr.recommander.shortlist} — {resultat.profil.nom}

+ {#if resultat.shortlist.length === 0} +

{fr.recommander.aucun_eligible}

+ {:else} +
+ {#each resultat.shortlist as reco (reco.modele.id)} +
+
+

{reco.modele.nom}

+ {fr.recommander.score} : {reco.score} +
+

{reco.modele.editeur} — {reco.modele.modalites.join(', ')}

+ +
+ {fr.recommander.justification} +
    + {#each reco.justification as ligne (ligne)} +
  • {ligne}
  • + {/each} +
+
+ + {#if reco.warnings.length > 0} +
+

+ + {fr.recommander.warnings} +

+
    + {#each reco.warnings as warning (warning)} +
  • {warning}
  • + {/each} +
+
+ {/if} + + {#if reco.modele.licence.nom === null} + + {/if} +
+ {/each} +
+ {/if} +
+ +
+

{fr.recommander.exclus}

+ {#if resultat.exclus.length === 0} +

{fr.recommander.aucun_exclu}

+ {:else} + {#each resultat.exclus as exclu (exclu.modele.id)} +
+ {exclu.modele.nom} +
    + {#each exclu.raisons as raison (raison)} +
  • {raison}
  • + {/each} +
+
+ {/each} + {/if} +
+{/if} + + diff --git a/app/src/routes/registre/+page.server.ts b/app/src/routes/registre/+page.server.ts new file mode 100644 index 0000000..228c237 --- /dev/null +++ b/app/src/routes/registre/+page.server.ts @@ -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 }; +}; diff --git a/app/src/routes/registre/+page.svelte b/app/src/routes/registre/+page.svelte new file mode 100644 index 0000000..1663dc8 --- /dev/null +++ b/app/src/routes/registre/+page.svelte @@ -0,0 +1,133 @@ + + + + {fr.registre.titre} — {fr.app.nom} + + +
+

{fr.registre.titre}

+ {fr.registre.nouveau} +
+ +{#if data.modeles.length === 0} +

{fr.registre.aucun}

+{:else} +
+ + + + + + + + + + + + + + {#each data.modeles as modele (modele.id)} + + + + + + + + + + {/each} + +
{fr.registre.colonne_nom}{fr.registre.colonne_editeur}{fr.registre.colonne_modalites}{fr.registre.colonne_commercial}{fr.registre.colonne_statut}{fr.registre.colonne_verif}{fr.registre.modifier}
+ {modele.nom} + {#if modele.licence.nom === null} + + {/if} + {modele.editeur}{modele.modalites.join(', ')} + {#if tri(modele.licence.commercial_ok) !== null} + {tri(modele.licence.commercial_ok)} + {:else} + + {/if} + + + {fr.formulaires.statuts[modele.statut]} + + + {#if modele.derniere_verif !== null} + + {:else} + + {/if} + + {fr.registre.modifier} +
+
+{/if} + + diff --git a/app/src/routes/registre/[id]/+page.server.ts b/app/src/routes/registre/[id]/+page.server.ts new file mode 100644 index 0000000..17c7bb2 --- /dev/null +++ b/app/src/routes/registre/[id]/+page.server.ts @@ -0,0 +1,41 @@ +import { error, fail, redirect } from '@sveltejs/kit'; +import { modeleSchema } from '$lib/schemas'; +import { erreursZod, modeleDepuisFormulaire } from '$lib/server/formulaires'; +import { ecrireRegistre, lireRegistre } from '$lib/server/stockage'; +import type { Actions, PageServerLoad } from './$types'; + +export const load: PageServerLoad = async ({ params }) => { + const registre = await lireRegistre(); + const modele = registre.find((m) => m.id === params.id); + if (!modele) error(404, 'Modèle introuvable'); + return { modele }; +}; + +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}`); + 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}`); + redirect(303, '/registre'); + } +}; diff --git a/app/src/routes/registre/[id]/+page.svelte b/app/src/routes/registre/[id]/+page.svelte new file mode 100644 index 0000000..812f000 --- /dev/null +++ b/app/src/routes/registre/[id]/+page.svelte @@ -0,0 +1,21 @@ + + + + {fr.registre.titre_edition} : {data.modele.nom} — {fr.app.nom} + + +

{fr.registre.titre_edition} : {data.modele.nom}

+ + | undefined) ?? null} +/> diff --git a/app/src/routes/registre/nouveau/+page.server.ts b/app/src/routes/registre/nouveau/+page.server.ts new file mode 100644 index 0000000..45226be --- /dev/null +++ b/app/src/routes/registre/nouveau/+page.server.ts @@ -0,0 +1,30 @@ +import { fail, redirect } from '@sveltejs/kit'; +import { modeleSchema } from '$lib/schemas'; +import { erreursZod, modeleDepuisFormulaire } from '$lib/server/formulaires'; +import { ecrireRegistre, lireRegistre } from '$lib/server/stockage'; +import type { Actions } from './$types'; + +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` + ); + redirect(303, '/registre'); + } +}; diff --git a/app/src/routes/registre/nouveau/+page.svelte b/app/src/routes/registre/nouveau/+page.svelte new file mode 100644 index 0000000..c0f6217 --- /dev/null +++ b/app/src/routes/registre/nouveau/+page.svelte @@ -0,0 +1,18 @@ + + + + {fr.registre.titre_nouveau} — {fr.app.nom} + + +

{fr.registre.titre_nouveau}

+ + | undefined) ?? null} +/>