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}
+
+
{fr.formulaires.erreurs_titre}
+
+ {#each erreurs as erreur (erreur)}
+ - {erreur}
+ {/each}
+
+
+{/if}
+
+{#snippet triEtat(nom: string, libelle: string, valeur: unknown)}
+
+{/snippet}
+
+
+
+
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}
+
+
{fr.formulaires.erreurs_titre}
+
+ {#each erreurs as erreur (erreur)}
+ - {erreur}
+ {/each}
+
+
+{/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)}
+
+ {/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}
+ {form.erreur}
+{/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}
+
+
+
+
+ | {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} |
+
+
+
+ {#each data.modeles as modele (modele.id)}
+
+ |
+ {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}
+ |
+
+ {/each}
+
+
+
+{/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}
+/>