feat(app) : phase 1 — registre, profils, moteur de recommandation, export markdown

- 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
This commit is contained in:
cyber-mawonaj
2026-08-01 10:19:42 -04:00
parent 4a108c1fda
commit 0c9f83d3f5
36 changed files with 2814 additions and 34 deletions
+5 -1
View File
@@ -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)
+99 -22
View File
@@ -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"
}
}
}
}
+6
View File
@@ -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"
}
}
@@ -0,0 +1,15 @@
<script lang="ts">
/**
* Badge « à vérifier » — règle anti-hallucination du PRD : tout champ null
* est signalé, jamais masqué. Ne repose pas sur la couleur seule (picto + texte).
*/
import { fr } from '$lib/i18n/fr';
import Icon from './Icon.svelte';
let { texte = null }: { texte?: string | null } = $props();
</script>
<span class="tag">
<Icon nom="avertissement" taille={14} />
{#if texte}{texte}{:else}{fr.badge.a_verifier}{/if}
</span>
+331
View File
@@ -0,0 +1,331 @@
<script lang="ts">
/**
* Formulaire modèle (création et édition).
* Champs tri-état : '' = null = « non vérifié » (règle anti-hallucination).
* `brut` reçoit les valeurs soumises en cas d'erreur de validation (ré-affichage).
*/
import { enhance } from '$app/forms';
import { fr } from '$lib/i18n/fr';
import { modaliteSchema, statutModeleSchema, type Modele } from '$lib/schemas';
type ModelePartiel = {
[K in keyof Modele]?: K extends 'licence'
? Partial<Modele['licence']>
: K extends 'capacites'
? Partial<Modele['capacites']>
: Modele[K];
};
let {
modele = null,
brut = null,
erreurs = [],
edition = false,
actionSupprimer = null
}: {
modele?: Modele | null;
brut?: Record<string, unknown> | null;
erreurs?: string[];
edition?: boolean;
actionSupprimer?: string | null;
} = $props();
const m = $derived((brut ?? modele ?? {}) as ModelePartiel);
const licence = $derived(m.licence ?? {});
const capacites = $derived(m.capacites ?? {});
function s(v: unknown): string {
return typeof v === 'string' ? v : v == null ? '' : String(v);
}
function tri(v: unknown): string {
return v === true ? 'true' : v === false ? 'false' : '';
}
function scoresTexte(v: unknown): string {
if (!v || typeof v !== 'object') return '';
return Object.entries(v as Record<string, number | null>)
.map(([cle, val]) => `${cle} = ${val ?? ''}`)
.join('\n');
}
const libellesModalites = fr.formulaires.modalites;
</script>
{#if erreurs.length > 0}
<div class="bloc-erreurs" role="alert">
<p>{fr.formulaires.erreurs_titre}</p>
<ul>
{#each erreurs as erreur (erreur)}
<li>{erreur}</li>
{/each}
</ul>
</div>
{/if}
{#snippet triEtat(nom: string, libelle: string, valeur: unknown)}
<label class="champ">
<span>{libelle}</span>
<select name={nom}>
<option value="" selected={tri(valeur) === ''}>{fr.formulaires.non_verifie}</option>
<option value="true" selected={tri(valeur) === 'true'}>{fr.formulaires.ouvert}</option>
<option value="false" selected={tri(valeur) === 'false'}>{fr.formulaires.ferme}</option>
</select>
</label>
{/snippet}
<form method="post" use:enhance>
<fieldset>
<label class="champ">
<span>{fr.registre.champ_id}</span>
<input name="id" value={s(m.id)} required pattern="[a-z0-9][a-z0-9_\-]*" readonly={edition} />
</label>
<label class="champ">
<span>{fr.registre.champ_nom}</span>
<input name="nom" value={s(m.nom)} required />
</label>
<label class="champ">
<span>{fr.registre.champ_editeur}</span>
<input name="editeur" value={s(m.editeur)} required />
</label>
</fieldset>
<fieldset>
<legend>{fr.registre.champ_modalites}</legend>
<div class="choix">
{#each modaliteSchema.options as modalite (modalite)}
<label class="case">
<input
type="checkbox"
name="modalites"
value={modalite}
checked={m.modalites?.includes(modalite)}
/>
{libellesModalites[modalite]}
</label>
{/each}
</div>
</fieldset>
<fieldset>
<legend>{fr.registre.section_licence}</legend>
<p class="aide">{fr.registre.aide_tri_etat}</p>
<label class="champ">
<span>{fr.registre.champ_licence_nom}</span>
<input name="licence_nom" value={s(licence.nom)} />
</label>
<label class="champ">
<span>{fr.registre.champ_licence_url}</span>
<input name="licence_url" type="url" value={s(licence.url)} />
</label>
<div class="rangee">
{@render triEtat('commercial_ok', fr.registre.champ_commercial_ok, licence.commercial_ok)}
{@render triEtat(
'attribution_requise',
fr.registre.champ_attribution,
licence.attribution_requise
)}
{@render triEtat('poids_ouverts', fr.registre.champ_poids_ouverts, licence.poids_ouverts)}
{@render triEtat(
'garde_fous_obligatoires',
fr.registre.champ_garde_fous,
licence.garde_fous_obligatoires
)}
</div>
<label class="champ">
<span>{fr.registre.champ_seuil}</span>
<input name="seuil" value={s(licence.seuil)} placeholder="ex. équipes <50 sièges" />
</label>
</fieldset>
<fieldset>
<legend>{fr.registre.section_capacites}</legend>
<div class="rangee">
{@render triEtat('nsfw_ok', fr.registre.champ_nsfw, capacites.nsfw_ok)}
{@render triEtat('comfyui_natif', fr.registre.champ_comfyui, capacites.comfyui_natif)}
<label class="champ">
<span>{fr.registre.champ_vram}</span>
<input name="vram_gb" type="number" min="1" step="1" value={s(capacites.vram_gb)} />
</label>
<label class="champ">
<span>{fr.registre.champ_resolution}</span>
<input name="resolution_max" value={s(capacites.resolution_max)} placeholder="ex. 2K" />
</label>
<label class="champ">
<span>{fr.registre.champ_outputs}</span>
<select name="licence_outputs">
<option value="" selected={s(capacites.licence_outputs) === ''}>
{fr.formulaires.non_verifie}
</option>
<option
value="commercialisables"
selected={s(capacites.licence_outputs) === 'commercialisables'}
>
{fr.registre.outputs_commercialisables}
</option>
<option
value="non_commercial"
selected={s(capacites.licence_outputs) === 'non_commercial'}
>
{fr.registre.outputs_non_commercial}
</option>
</select>
</label>
</div>
</fieldset>
<fieldset>
<label class="champ">
<span>{fr.registre.champ_scores}</span>
<textarea name="scores" rows="3">{scoresTexte(m.scores)}</textarea>
</label>
<div class="rangee">
<label class="champ">
<span>{fr.registre.champ_statut}</span>
<select name="statut">
{#each statutModeleSchema.options as statut (statut)}
<option value={statut} selected={s(m.statut) === statut}>
{fr.formulaires.statuts[statut]}
</option>
{/each}
</select>
</label>
<label class="champ">
<span>{fr.registre.champ_verif}</span>
<input
name="derniere_verif"
value={s(m.derniere_verif)}
placeholder="AAAA-MM-JJ"
pattern="\d{4}-\d{2}-\d{2}"
/>
</label>
</div>
<label class="champ">
<span>{fr.registre.champ_sources}</span>
<textarea name="sources" rows="3" required>{(m.sources ?? []).join('\n')}</textarea>
</label>
<label class="champ">
<span>{fr.registre.champ_notes}</span>
<textarea name="notes" rows="3">{s(m.notes)}</textarea>
</label>
</fieldset>
<div class="actions">
<button class="bouton" type="submit">{fr.formulaires.enregistrer}</button>
{#if edition && actionSupprimer}
<button
class="bouton bouton-danger"
type="submit"
formaction={actionSupprimer}
formnovalidate
>
{fr.formulaires.supprimer}
</button>
{/if}
</div>
</form>
<style>
form,
fieldset {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
fieldset {
border: var(--border-card);
border-radius: var(--radius-md);
padding: var(--space-3);
margin-bottom: var(--space-3);
}
legend {
font-family: var(--font-display);
text-transform: uppercase;
letter-spacing: -0.01em;
padding-inline: var(--space-1);
}
.champ {
display: flex;
flex-direction: column;
gap: 0.3em;
}
.champ > span {
color: var(--muted);
font-size: 0.9rem;
}
input,
select,
textarea {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
color: var(--fg);
padding: 0.5em 0.75em;
}
input[readonly] {
opacity: 0.6;
}
.rangee {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.rangee .champ {
flex: 1 1 180px;
}
.choix {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.case {
display: inline-flex;
align-items: center;
gap: 0.4em;
min-height: 44px;
}
.aide {
color: var(--muted);
font-size: 0.85rem;
}
.bloc-erreurs {
border: 1px solid var(--signal);
border-left: 4px solid var(--signal);
border-radius: var(--radius-md);
padding: var(--space-2) var(--space-3);
margin-bottom: var(--space-3);
}
.bloc-erreurs p {
font-weight: 600;
margin-bottom: var(--space-1);
}
.actions {
display: flex;
gap: var(--space-2);
}
.bouton-danger {
color: var(--signal);
border-color: var(--signal);
}
.bouton-danger:hover {
color: var(--signal);
border-color: var(--signal);
opacity: 0.8;
}
</style>
+246
View File
@@ -0,0 +1,246 @@
<script lang="ts">
/**
* Formulaire profil de projet (création et édition).
* `brut` reçoit les valeurs soumises en cas d'erreur de validation (ré-affichage).
*/
import { enhance } from '$app/forms';
import { fr } from '$lib/i18n/fr';
import { cleConditionSchema, type CleCondition, type Profil } from '$lib/schemas';
type ProfilPartiel = Omit<Partial<Profil>, 'preferences'> & {
preferences?: Partial<Profil['preferences']>;
};
let {
profil = null,
brut = null,
erreurs = [],
edition = false,
actionSupprimer = null
}: {
profil?: Profil | null;
brut?: Record<string, unknown> | null;
erreurs?: string[];
edition?: boolean;
actionSupprimer?: string | null;
} = $props();
const p = $derived((brut ?? profil ?? {}) as ProfilPartiel);
function s(v: unknown): string {
return typeof v === 'string' ? v : v == null ? '' : String(v);
}
function dansListe(v: unknown, cle: string): boolean {
return Array.isArray(v) && v.includes(cle);
}
const niveaux = ['obligatoire', 'fortement_prefere', 'prefere'] as const;
</script>
{#if erreurs.length > 0}
<div class="bloc-erreurs" role="alert">
<p>{fr.formulaires.erreurs_titre}</p>
<ul>
{#each erreurs as erreur (erreur)}
<li>{erreur}</li>
{/each}
</ul>
</div>
{/if}
<form method="post" use:enhance>
<fieldset>
<label class="champ">
<span>{fr.profils.champ_id}</span>
<input name="id" value={s(p.id)} required pattern="[a-z0-9][a-z0-9_\-]*" readonly={edition} />
</label>
<label class="champ">
<span>{fr.profils.champ_nom}</span>
<input name="nom" value={s(p.nom)} required />
</label>
</fieldset>
<fieldset>
<legend>{fr.profils.section_requiert}</legend>
<div class="choix">
{#each cleConditionSchema.options as cle (cle)}
<label class="case">
<input type="checkbox" name="requiert" value={cle} checked={dansListe(p.requiert, cle)} />
{fr.formulaires.cles[cle]}
</label>
{/each}
</div>
</fieldset>
<fieldset>
<legend>{fr.profils.section_exclut}</legend>
<div class="choix">
{#each cleConditionSchema.options as cle (cle)}
<label class="case">
<input type="checkbox" name="exclut" value={cle} checked={dansListe(p.exclut, cle)} />
{fr.formulaires.cles[cle]}
</label>
{/each}
</div>
</fieldset>
<fieldset>
<legend>{fr.profils.section_preferences}</legend>
<div class="rangee">
{#each cleConditionSchema.options as cle (cle)}
<label class="champ">
<span>{fr.formulaires.cles[cle]}</span>
<select name="pref_{cle}">
<option value="" selected={!(cle in (p.preferences ?? {}))}>
{fr.profils.pref_aucune}
</option>
{#each niveaux as niveau (niveau)}
<option value={niveau} selected={p.preferences?.[cle as CleCondition] === niveau}>
{fr.formulaires.niveaux[niveau]}
</option>
{/each}
</select>
</label>
{/each}
</div>
</fieldset>
<fieldset>
<legend>{fr.profils.section_plateformes}</legend>
<div class="choix">
{#each Object.entries(fr.formulaires.plateformes) as [cle, libelle] (cle)}
<label class="case">
<input
type="checkbox"
name="plateformes"
value={cle}
checked={dansListe(p.plateformes, cle)}
/>
{libelle}
</label>
{/each}
</div>
</fieldset>
<fieldset>
<label class="champ">
<span>{fr.profils.champ_contraintes}</span>
<textarea name="contraintes_specifiques" rows="3">{s(p.contraintes_specifiques)}</textarea>
</label>
</fieldset>
<div class="actions">
<button class="bouton" type="submit">{fr.formulaires.enregistrer}</button>
{#if edition && actionSupprimer}
<button
class="bouton bouton-danger"
type="submit"
formaction={actionSupprimer}
formnovalidate
>
{fr.formulaires.supprimer}
</button>
{/if}
</div>
</form>
<style>
form,
fieldset {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
fieldset {
border: var(--border-card);
border-radius: var(--radius-md);
padding: var(--space-3);
margin-bottom: var(--space-3);
}
legend {
font-family: var(--font-display);
text-transform: uppercase;
letter-spacing: -0.01em;
padding-inline: var(--space-1);
}
.champ {
display: flex;
flex-direction: column;
gap: 0.3em;
}
.champ > span {
color: var(--muted);
font-size: 0.9rem;
}
input,
select,
textarea {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
color: var(--fg);
padding: 0.5em 0.75em;
}
input[readonly] {
opacity: 0.6;
}
.rangee {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.rangee .champ {
flex: 1 1 220px;
}
.choix {
display: flex;
flex-wrap: wrap;
gap: var(--space-2);
}
.case {
display: inline-flex;
align-items: center;
gap: 0.4em;
min-height: 44px;
}
.bloc-erreurs {
border: 1px solid var(--signal);
border-left: 4px solid var(--signal);
border-radius: var(--radius-md);
padding: var(--space-2) var(--space-3);
margin-bottom: var(--space-3);
}
.bloc-erreurs p {
font-weight: 600;
margin-bottom: var(--space-1);
}
.actions {
display: flex;
gap: var(--space-2);
}
.bouton-danger {
color: var(--signal);
border-color: var(--signal);
}
.bouton-danger:hover {
color: var(--signal);
border-color: var(--signal);
opacity: 0.8;
}
</style>
+131 -1
View File
@@ -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;
+89
View File
@@ -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<typeof modaliteSchema>;
export const statutModeleSchema = z.enum(['production', 'early-access', 'annonce', 'mort']);
export type StatutModele = z.infer<typeof statutModeleSchema>;
const urlOuNull = z.string().url().nullable().default(null);
export const licenceSchema = z.object({
nom: z.string().nullable().default(null),
url: urlOuNull,
commercial_ok: z.boolean().nullable().default(null),
/** Ex. "équipes <50 sièges", "revenus <1M$" — présence déclenche un warning. */
seuil: z.string().nullable().default(null),
attribution_requise: z.boolean().nullable().default(null),
poids_ouverts: z.boolean().nullable().default(null),
garde_fous_obligatoires: z.boolean().nullable().default(null)
});
export const capacitesSchema = z.object({
nsfw_ok: z.boolean().nullable().default(null),
vram_gb: z.number().positive().nullable().default(null),
resolution_max: z.string().nullable().default(null),
comfyui_natif: z.boolean().nullable().default(null),
licence_outputs: z.enum(['commercialisables', 'non_commercial']).nullable().default(null)
});
export const modeleSchema = z.object({
id: slugSchema,
nom: z.string().min(1),
editeur: z.string().min(1),
modalites: z.array(modaliteSchema).min(1),
// prefault (et non default) : le {} est reparsé pour appliquer les défauts null internes.
licence: licenceSchema.prefault({}),
capacites: capacitesSchema.prefault({}),
/** Scores/classements publiés (ex. arena_image: rang Artificial Analysis). */
scores: z.record(z.string(), z.number().nullable()).default({}),
statut: statutModeleSchema,
/** Date ISO YYYY-MM-DD de dernière vérification humaine. */
derniere_verif: z
.string()
.regex(/^\d{4}-\d{2}-\d{2}$/, 'date ISO YYYY-MM-DD attendue')
.nullable()
.default(null),
sources: z.array(z.string().url()).min(1, 'au moins une source est obligatoire'),
notes: z.string().nullable().default(null)
});
export type Modele = z.infer<typeof modeleSchema>;
/** Clés de condition utilisables dans requiert/exclut/preferences d'un profil. */
export const cleConditionSchema = z.enum([
'commercial_ok',
'licence_outputs_commercialisables',
'attribution_requise',
'poids_ouverts',
'nsfw_ok',
'comfyui_natif',
'garde_fous_obligatoires'
]);
export type CleCondition = z.infer<typeof cleConditionSchema>;
export const niveauPreferenceSchema = z.enum(['obligatoire', 'fortement_prefere', 'prefere']);
export type NiveauPreference = z.infer<typeof niveauPreferenceSchema>;
export const profilSchema = z.object({
id: slugSchema,
nom: z.string().min(1),
/** Filtrage dur : la condition doit être vraie (null = échec, non vérifiable). */
requiert: z.array(cleConditionSchema).default([]),
/** Filtrage dur : la condition vraie exclut le modèle (null = non exclu, warning). */
exclut: z.array(cleConditionSchema).default([]),
preferences: z.partialRecord(cleConditionSchema, niveauPreferenceSchema).default({}),
plateformes: z.array(z.string()).default([]),
contraintes_specifiques: z.string().nullable().default(null)
});
export type Profil = z.infer<typeof profilSchema>;
+67
View File
@@ -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));
}
+104
View File
@@ -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<string, number | null> = {};
for (const ligne of listeLignes(data.get('scores'))) {
const [cle, brut] = ligne.split('=').map((s) => s.trim());
if (!cle) continue;
const n = Number(brut);
scores[cle] = brut === '' || brut === undefined ? null : Number.isFinite(n) ? n : null;
}
return {
id: chaineOuNull(data.get('id')),
nom: chaineOuNull(data.get('nom')),
editeur: chaineOuNull(data.get('editeur')),
modalites: data.getAll('modalites').map(String),
licence: {
nom: chaineOuNull(data.get('licence_nom')),
url: chaineOuNull(data.get('licence_url')),
commercial_ok: triEtat(data.get('commercial_ok')),
seuil: chaineOuNull(data.get('seuil')),
attribution_requise: triEtat(data.get('attribution_requise')),
poids_ouverts: triEtat(data.get('poids_ouverts')),
garde_fous_obligatoires: triEtat(data.get('garde_fous_obligatoires'))
},
capacites: {
nsfw_ok: triEtat(data.get('nsfw_ok')),
vram_gb: nombreOuNull(data.get('vram_gb')),
resolution_max: chaineOuNull(data.get('resolution_max')),
comfyui_natif: triEtat(data.get('comfyui_natif')),
licence_outputs: chaineOuNull(data.get('licence_outputs'))
},
scores,
statut: chaineOuNull(data.get('statut')),
derniere_verif: chaineOuNull(data.get('derniere_verif')),
sources: listeLignes(data.get('sources')),
notes: chaineOuNull(data.get('notes'))
};
}
export function profilDepuisFormulaire(data: FormData): unknown {
const preferences: Record<string, string> = {};
for (const cle of cleConditionSchema.options) {
const niveau = chaineOuNull(data.get(`pref_${cle}`));
if (niveau !== null) preferences[cle] = niveau;
}
return {
id: chaineOuNull(data.get('id')),
nom: chaineOuNull(data.get('nom')),
requiert: data.getAll('requiert').map(String),
exclut: data.getAll('exclut').map(String),
preferences,
plateformes: data.getAll('plateformes').map(String),
contraintes_specifiques: chaineOuNull(data.get('contraintes_specifiques'))
};
}
/** Met en forme les erreurs Zod pour affichage dans le formulaire. */
export function erreursZod(erreur: z.ZodError): string[] {
return erreur.issues.map((i) => `${i.path.join('.') || '(racine)'} : ${i.message}`);
}
export { cleConditionSchema, modaliteSchema, niveauPreferenceSchema, statutModeleSchema };
+34
View File
@@ -0,0 +1,34 @@
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { simpleGit, type SimpleGit } from 'simple-git';
import { config } from './config';
let git: SimpleGit | null = null;
/**
* Dépôt git du data_dir (versionning local de la source de vérité).
* Initialisé au premier accès ; identité locale dédiée à l'app.
* Le push vers une forge distante est volontairement hors périmètre (optionnel, manuel).
*/
export async function gitDonnees(): Promise<SimpleGit> {
if (git) return git;
const instance = simpleGit(config.dataDir);
if (!existsSync(join(config.dataDir, '.git'))) {
await instance.init();
}
await instance.addConfig('user.name', 'veille-ia');
await instance.addConfig('user.email', 'veille-ia@app.local');
await instance.addConfig('commit.gpgsign', 'false');
git = instance;
return git;
}
/** Commit atomique en français des chemins donnés (relatifs au data_dir). */
export async function committer(chemins: string[], message: string): Promise<void> {
const g = await gitDonnees();
await g.add(chemins);
// Ne rien faire si aucun changement (écriture idempotente).
const statut = await g.status();
if (statut.staged.length === 0) return;
await g.commit(message);
}
+192
View File
@@ -0,0 +1,192 @@
import { describe, expect, it } from 'vitest';
import { recommander } from './recommandation';
import { modeleSchema, profilSchema, type Modele, type Profil } from '../schemas';
import { modelesSeed } from './seed/modeles';
import { profilsSeed } from './seed/profils';
/**
* Tests du moteur de recommandation — cas nominaux + cas limites du prompt :
* licence null, seuil de licence, modèle statut « mort ».
* `maintenant` est injecté pour des tests déterministes.
*/
const MAINTENANT = new Date('2026-08-10T12:00:00Z');
function modele(surcharge: Record<string, unknown>): Modele {
return modeleSchema.parse({
id: 'modele-test',
nom: 'Modèle Test',
editeur: 'Éditeur Test',
modalites: ['image'],
statut: 'production',
derniere_verif: '2026-08-01',
sources: ['https://example.org/modele'],
...surcharge
});
}
function profil(surcharge: Record<string, unknown>): Profil {
return profilSchema.parse({
id: 'profil-test',
nom: 'Profil Test',
...surcharge
});
}
const krea = modelesSeed[0];
const vnRenpy = profilsSeed.find((p) => p.id === 'vn_renpy_adulte')!;
describe('cas nominal', () => {
it('le seed Krea est éligible pour vn_renpy_adulte avec justification et warnings', () => {
const { shortlist, exclus } = recommander([krea], vnRenpy, {}, { maintenant: MAINTENANT });
expect(exclus).toHaveLength(0);
expect(shortlist).toHaveLength(1);
const reco = shortlist[0];
expect(reco.score).toBeGreaterThan(0);
// Préférences honorées : poids ouverts (+20), comfyui (+10), rang arena 6 (+13), fraîcheur (+10)
expect(reco.justification.join(' ')).toContain('poids ouverts (+20)');
expect(reco.justification.join(' ')).toContain('rang 6 (+13)');
// Warnings attendus : seuil, garde-fous (nsfw demandé), déclarations plateformes
expect(reco.warnings.join(' ')).toContain('Seuil de licence');
expect(reco.warnings.join(' ')).toContain('Garde-fous');
expect(reco.warnings.join(' ')).toContain('Steam');
expect(reco.warnings.join(' ')).toContain('itch.io');
});
it('les 5 profils du seed produisent une shortlist correcte sur le seed', () => {
for (const p of profilsSeed) {
const { shortlist } = recommander([krea], p, {}, { maintenant: MAINTENANT });
// Krea : commercial_ok true + outputs commercialisables + nsfw true → éligible partout
expect(shortlist.map((r) => r.modele.id)).toContain('krea-2-turbo');
}
});
});
describe('cas limites — filtrage dur', () => {
it('licence null : commercial_ok non vérifiable exclut un profil qui le requiert', () => {
const m = modele({ licence: { commercial_ok: null } });
const { shortlist, exclus } = recommander(
[m],
profil({ requiert: ['commercial_ok'] }),
{},
{ maintenant: MAINTENANT }
);
expect(shortlist).toHaveLength(0);
expect(exclus[0].raisons.join(' ')).toContain('non vérifiable');
});
it('modèle statut « mort » : exclu quoi quil 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');
});
});
+262
View File
@@ -0,0 +1,262 @@
import type { CleCondition, Modalite, Modele, Profil } from '../schemas';
/**
* Moteur de recommandation (PRD §4.3) — fonction PURE, sans I/O, testable.
*
* Logique : filtrage dur (requiert/exclut/obligatoire/statut mort/filtres ad hoc)
* puis scoring pondéré (préférences, rang arena, fraîcheur derniere_verif,
* pénalités statut et champs null).
*
* Règle anti-hallucination : un champ `null` n'est jamais traité comme une valeur
* — pour une exigence (requiert), null = échec (non vérifiable, juridiquement
* prudent) ; pour une exclusion, null = conservé avec warning ; au scoring,
* null = pénalité + badge « à vérifier ».
*/
export interface FiltresRecommandation {
modalite?: Modalite;
vram_max?: number;
/** Réservé — aucune donnée de prix n'est stockée par conception (PRD anti-hallucination). */
budget?: number;
}
export interface Exclusion {
modele: Modele;
raisons: string[];
}
export interface Recommandation {
modele: Modele;
score: number;
/** Contributions au score, dans l'ordre de calcul — affichées à l'utilisateur. */
justification: string[];
warnings: string[];
}
export interface ResultatRecommandation {
profil: Profil;
/** Modèles éligibles, triés par score décroissant (nom en cas d'égalité). */
shortlist: Recommandation[];
exclus: Exclusion[];
}
export const LIBELLES_CONDITIONS: Record<CleCondition, string> = {
commercial_ok: 'usage commercial autorisé',
licence_outputs_commercialisables: 'outputs commercialisables',
attribution_requise: 'attribution requise',
poids_ouverts: 'poids ouverts',
nsfw_ok: 'NSFW autorisé',
comfyui_natif: 'support natif ComfyUI',
garde_fous_obligatoires: 'garde-fous obligatoires'
};
/** Valeur d'une condition pour un modèle : true, false, ou null (non vérifié). */
export function valeurCondition(modele: Modele, cle: CleCondition): boolean | null {
switch (cle) {
case 'commercial_ok':
return modele.licence.commercial_ok;
case 'licence_outputs_commercialisables':
return modele.capacites.licence_outputs === null
? null
: modele.capacites.licence_outputs === 'commercialisables';
case 'attribution_requise':
return modele.licence.attribution_requise;
case 'poids_ouverts':
return modele.licence.poids_ouverts;
case 'nsfw_ok':
return modele.capacites.nsfw_ok;
case 'comfyui_natif':
return modele.capacites.comfyui_natif;
case 'garde_fous_obligatoires':
return modele.licence.garde_fous_obligatoires;
}
}
/** Champs clés dont la nullité est pénalisée et signalée (règle anti-hallucination). */
const CHAMPS_CLES: Array<{ libelle: string; valeur: (m: Modele) => unknown }> = [
{ libelle: LIBELLES_CONDITIONS.commercial_ok, valeur: (m) => m.licence.commercial_ok },
{
libelle: LIBELLES_CONDITIONS.attribution_requise,
valeur: (m) => m.licence.attribution_requise
},
{ libelle: LIBELLES_CONDITIONS.poids_ouverts, valeur: (m) => m.licence.poids_ouverts },
{ libelle: LIBELLES_CONDITIONS.nsfw_ok, valeur: (m) => m.capacites.nsfw_ok },
{
libelle: LIBELLES_CONDITIONS.licence_outputs_commercialisables,
valeur: (m) => m.capacites.licence_outputs
},
{ libelle: 'VRAM requise', valeur: (m) => m.capacites.vram_gb }
];
const MS_PAR_JOUR = 24 * 60 * 60 * 1000;
function joursDepuis(dateIso: string, maintenant: Date): number {
const date = new Date(`${dateIso}T00:00:00Z`);
return Math.floor((maintenant.getTime() - date.getTime()) / MS_PAR_JOUR);
}
export function recommander(
modeles: Modele[],
profil: Profil,
filtres: FiltresRecommandation = {},
options: { maintenant?: Date } = {}
): ResultatRecommandation {
const maintenant = options.maintenant ?? new Date();
const shortlist: Recommandation[] = [];
const exclus: Exclusion[] = [];
for (const modele of modeles) {
const raisonsExclusion: string[] = [];
// ── Filtrage dur ──
if (modele.statut === 'mort') {
raisonsExclusion.push('Modèle au statut « mort » : non recommandable.');
}
for (const cle of profil.requiert) {
const valeur = valeurCondition(modele, cle);
if (valeur !== true) {
raisonsExclusion.push(
valeur === null
? `Exigence « ${LIBELLES_CONDITIONS[cle]} » non vérifiable (champ à vérifier).`
: `Exigence non remplie : « ${LIBELLES_CONDITIONS[cle]} ».`
);
}
}
for (const [cle, niveau] of Object.entries(profil.preferences)) {
if (niveau !== 'obligatoire') continue;
const valeur = valeurCondition(modele, cle as CleCondition);
if (valeur !== true) {
raisonsExclusion.push(
valeur === null
? `Préférence obligatoire « ${LIBELLES_CONDITIONS[cle as CleCondition]} » non vérifiable.`
: `Préférence obligatoire non remplie : « ${LIBELLES_CONDITIONS[cle as CleCondition]} ».`
);
}
}
for (const cle of profil.exclut) {
if (valeurCondition(modele, cle) === true) {
raisonsExclusion.push(`Critère d'exclusion présent : « ${LIBELLES_CONDITIONS[cle]} ».`);
}
}
if (filtres.modalite && !modele.modalites.includes(filtres.modalite)) {
raisonsExclusion.push(`Modalité « ${filtres.modalite} » absente du modèle.`);
}
const vram = modele.capacites.vram_gb;
if (filtres.vram_max !== undefined && vram !== null && vram > filtres.vram_max) {
raisonsExclusion.push(
`VRAM requise (${vram} Go) au-dessus de la limite (${filtres.vram_max} Go).`
);
}
if (raisonsExclusion.length > 0) {
exclus.push({ modele, raisons: raisonsExclusion });
continue;
}
// ── Scoring pondéré ──
let score = 0;
const justification: string[] = [];
const warnings: string[] = [];
for (const [cle, niveau] of Object.entries(profil.preferences)) {
if (niveau === 'obligatoire') continue; // déjà traité en filtrage dur
const valeur = valeurCondition(modele, cle as CleCondition);
const libelle = LIBELLES_CONDITIONS[cle as CleCondition];
if (valeur === true) {
const points = niveau === 'fortement_prefere' ? 20 : 10;
score += points;
justification.push(`Préférence honorée : ${libelle} (+${points})`);
} else if (valeur === null) {
warnings.push(`« ${libelle} » non vérifié.`);
}
}
// Rang arena : premier score numérique trouvé (ex. arena_image: 6 → +13)
const entreeArena = Object.entries(modele.scores).find(
([cle, valeur]) => cle.startsWith('arena') && typeof valeur === 'number'
);
if (entreeArena) {
const rang = entreeArena[1] as number;
const points = Math.max(0, 25 - 2 * rang);
score += points;
justification.push(`Classement ${entreeArena[0]} : rang ${rang} (+${points})`);
}
// Fraîcheur de la dernière vérification humaine
if (modele.derniere_verif === null) {
score -= 10;
justification.push('Jamais vérifié (10)');
warnings.push('Fiche jamais vérifiée.');
} else {
const jours = joursDepuis(modele.derniere_verif, maintenant);
if (jours <= 30) {
score += 10;
justification.push(`Vérifié il y a ${jours} j (+10)`);
} else if (jours <= 90) {
score += 5;
justification.push(`Vérifié il y a ${jours} j (+5)`);
} else if (jours > 180) {
score -= 5;
justification.push(
`Vérifié il y a ${jours} j : informations potentiellement périmées (5)`
);
warnings.push(`Dernière vérification ancienne (${jours} jours).`);
} else {
justification.push(`Vérifié il y a ${jours} j (0)`);
}
}
// Pénalité de statut (hors « mort », déjà exclu)
if (modele.statut === 'early-access') {
score -= 10;
justification.push('Statut early-access (10)');
} else if (modele.statut === 'annonce') {
score -= 20;
justification.push('Statut annonce (20)');
}
// Pénalité champs null (anti-hallucination) + warnings métier
const champsNull = CHAMPS_CLES.filter(({ valeur }) => valeur(modele) === null).map(
({ libelle }) => libelle
);
if (champsNull.length > 0) {
const penalite = 3 * champsNull.length;
score -= penalite;
justification.push(`${champsNull.length} champ(s) non vérifié(s) (${penalite})`);
warnings.push(`Champs à vérifier : ${champsNull.join(', ')}.`);
}
if (modele.licence.attribution_requise === true) {
warnings.push('Attribution requise par la licence.');
}
if (modele.licence.seuil !== null) {
warnings.push(`Seuil de licence : « ${modele.licence.seuil} » — vérifier votre cas.`);
}
if (
modele.licence.garde_fous_obligatoires === true &&
(profil.requiert.includes('nsfw_ok') || 'nsfw_ok' in profil.preferences)
) {
warnings.push('Garde-fous obligatoires (anti-NCII/CSAM) : NSFW en local uniquement.');
}
if (filtres.vram_max !== undefined && vram === null) {
warnings.push('VRAM non vérifiée : compatibilité avec la limite à vérifier.');
}
if (profil.plateformes.includes('steam')) {
warnings.push('Déclaration IA obligatoire sur Steam (formulaire éditeur).');
}
if (profil.plateformes.includes('itch_io')) {
warnings.push('Tag IA obligatoire sur itch.io.');
}
shortlist.push({ modele, score, justification, warnings });
}
shortlist.sort((a, b) => b.score - a.score || a.modele.nom.localeCompare(b.modele.nom));
return { profil, shortlist, exclus };
}
+39
View File
@@ -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));
+73
View File
@@ -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));
+29
View File
@@ -0,0 +1,29 @@
/**
* Templates markdown par défaut (PRD §4.5) — écrits au premier démarrage
* dans data/templates/, éditables ensuite dans l'UI.
* Tokens : {{date}}, {{tableau_registre}}, {{tableau_classements}}.
*/
export const TEMPLATES_SEED: Record<string, string> = {
'guide_licences.md': `# Guide des licences — veille-ia
> Généré le {{date}} depuis le registre. Document d'export : ne pas éditer à la main
> (modifier le template ou le registre, puis régénérer).
{{tableau_registre}}
## Rappels
- Tout champ « ⚠️ à vérifier » est non vérifié publiquement — ne jamais supposer une licence, un prix ou un benchmark.
- Vérifier le seuil de licence (revenus, sièges) avant tout usage commercial.
- L'attribution requise est bloquante pour certaines prestations : la traiter au cas par cas.
`,
'classements.md': `# Classements — veille-ia
> Généré le {{date}} depuis le registre.
{{tableau_classements}}
_Les rangs sont ceux publiés par les classements de référence (ex. Artificial Analysis), à la date de dernière vérification indiquée dans le registre._
`
};
+143
View File
@@ -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<void> | null = null;
/** Initialisation au premier accès : arborescence + seeds, commit initial. Idempotent. */
export function initialiserDonnees(): Promise<void> {
initialisation ??= (async () => {
await mkdir(config.dataDir, { recursive: true });
await mkdir(join(config.dataDir, DOSSIER_TEMPLATES), { recursive: true });
await mkdir(join(config.dataDir, DOSSIER_EXPORTS), { recursive: true });
const fichiersSeedes: string[] = [];
if (!existsSync(join(config.dataDir, FICHIER_REGISTRE))) {
await ecrireYaml(FICHIER_REGISTRE, modelesSeed);
fichiersSeedes.push(FICHIER_REGISTRE);
}
if (!existsSync(join(config.dataDir, FICHIER_PROFILS))) {
await ecrireYaml(FICHIER_PROFILS, profilsSeed);
fichiersSeedes.push(FICHIER_PROFILS);
}
for (const [nom, contenu] of Object.entries(TEMPLATES_SEED)) {
if (!existsSync(join(config.dataDir, DOSSIER_TEMPLATES, nom))) {
await writeFile(join(config.dataDir, DOSSIER_TEMPLATES, nom), contenu, 'utf8');
fichiersSeedes.push(join(DOSSIER_TEMPLATES, nom));
}
}
if (fichiersSeedes.length > 0) {
await committer(
fichiersSeedes,
'chore : initialisation des données (registre, profils et templates du PRD)'
);
}
})();
return initialisation;
}
async function lireYaml<T>(fichier: string, schema: z.ZodType<T>): Promise<T> {
await initialiserDonnees();
const brut = await readFile(join(config.dataDir, fichier), 'utf8');
return schema.parse(loadYaml(brut));
}
async function ecrireYaml(fichier: string, donnees: unknown): Promise<void> {
await mkdir(config.dataDir, { recursive: true });
await writeFile(
join(config.dataDir, fichier),
dumpYaml(donnees, { lineWidth: 100, noRefs: true }),
'utf8'
);
}
const registreFichierSchema = z.array(modeleSchema);
const profilsFichierSchema = z.array(profilSchema);
export async function lireRegistre(): Promise<Modele[]> {
return lireYaml(FICHIER_REGISTRE, registreFichierSchema);
}
export async function ecrireRegistre(modeles: Modele[], messageCommit: string): Promise<void> {
const valides = registreFichierSchema.parse(modeles);
await initialiserDonnees();
await ecrireYaml(FICHIER_REGISTRE, valides);
await committer([FICHIER_REGISTRE], messageCommit);
}
export async function lireProfils(): Promise<Profil[]> {
return lireYaml(FICHIER_PROFILS, profilsFichierSchema);
}
export async function ecrireProfils(profils: Profil[], messageCommit: string): Promise<void> {
const valides = profilsFichierSchema.parse(profils);
await initialiserDonnees();
await ecrireYaml(FICHIER_PROFILS, valides);
await committer([FICHIER_PROFILS], messageCommit);
}
// ── Templates et exports markdown (PRD §4.5) ──
export async function lireTemplate(nom: string): Promise<string> {
await initialiserDonnees();
return readFile(join(config.dataDir, DOSSIER_TEMPLATES, nom), 'utf8');
}
export async function templateExiste(nom: string): Promise<boolean> {
await initialiserDonnees();
return existsSync(join(config.dataDir, DOSSIER_TEMPLATES, nom));
}
export async function ecrireTemplate(
nom: string,
contenu: string,
messageCommit: string
): Promise<void> {
await initialiserDonnees();
await writeFile(join(config.dataDir, DOSSIER_TEMPLATES, nom), contenu, 'utf8');
await committer([join(DOSSIER_TEMPLATES, nom)], messageCommit);
}
export async function ecrireExport(
nom: string,
contenu: string,
messageCommit: string
): Promise<void> {
await initialiserDonnees();
await writeFile(join(config.dataDir, DOSSIER_EXPORTS, nom), contenu, 'utf8');
await committer([join(DOSSIER_EXPORTS, nom)], messageCommit);
}
export async function lireExport(nom: string): Promise<string> {
await initialiserDonnees();
return readFile(join(config.dataDir, DOSSIER_EXPORTS, nom), 'utf8');
}
/** Liste les documents générés (exports/), du plus récent au plus ancien. */
export async function listerExports(): Promise<string[]> {
await initialiserDonnees();
const { readdir } = await import('node:fs/promises');
const fichiers = await readdir(join(config.dataDir, DOSSIER_EXPORTS));
return fichiers
.filter((f) => f.endsWith('.md'))
.sort()
.reverse();
}
+5 -4
View File
@@ -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;
</script>
+14 -4
View File
@@ -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;
</script>
@@ -44,10 +48,16 @@
<article class="carte">
<h2>
<Icon nom={carte.icone} taille={22} />
{#if carte.href}
<a class="carte-lien" href={resolve(carte.href)}>{carte.titre}</a>
{:else}
{carte.titre}
{/if}
</h2>
<p>{carte.texte}</p>
{#if !carte.href}
<span class="tag">{fr.accueil.phase_a_venir}</span>
{/if}
</article>
{/each}
</div>
+46
View File
@@ -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));
};
+57
View File
@@ -0,0 +1,57 @@
import { fail } from '@sveltejs/kit';
import { genererDocument } from '$lib/server/exportateur';
import {
ecrireExport,
ecrireTemplate,
lireExport,
lireRegistre,
lireTemplate,
listerExports
} from '$lib/server/stockage';
import { chaineOuNull } from '$lib/server/formulaires';
import type { Actions, PageServerLoad } from './$types';
const NOMS_TEMPLATES = ['guide_licences.md', 'classements.md'] as const;
export const load: PageServerLoad = async () => {
const templates: Record<string, string> = {};
for (const nom of NOMS_TEMPLATES) {
templates[nom] = await lireTemplate(nom);
}
const nomsExports = await listerExports();
const exports: Record<string, string> = {};
for (const nom of nomsExports) {
exports[nom] = await lireExport(nom);
}
return { templates, exports };
};
export const actions: Actions = {
enregistrerTemplate: async ({ request }) => {
const data = await request.formData();
const nom = chaineOuNull(data.get('nom'));
const contenu = data.get('contenu');
if (
!nom ||
!(NOMS_TEMPLATES as readonly string[]).includes(nom) ||
typeof contenu !== 'string'
) {
return fail(400, { erreur: 'Template invalide.' });
}
await ecrireTemplate(nom, contenu, `feat : mise à jour du template ${nom}`);
return { templateEnregistre: nom };
},
generer: async () => {
const registre = await lireRegistre();
const maintenant = new Date();
for (const nom of NOMS_TEMPLATES) {
const template = await lireTemplate(nom);
const document = genererDocument(template, registre, maintenant);
await ecrireExport(nom, document, `docs : régénération de ${nom} depuis le registre`);
}
return { genere: true };
}
};
+125
View File
@@ -0,0 +1,125 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { fr } from '$lib/i18n/fr';
import type { ActionData, PageData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
const nomsExports = $derived(Object.keys(data.exports));
</script>
<svelte:head>
<title>{fr.exports.titre}{fr.app.nom}</title>
</svelte:head>
<h1>{fr.exports.titre}</h1>
<p class="intro">{fr.exports.intro}</p>
<p class="aide">{fr.exports.tokens_aide}</p>
<section aria-labelledby="titre-templates">
<h2 id="titre-templates">{fr.exports.section_templates}</h2>
{#each Object.entries(data.templates) as [nom, contenu] (nom)}
<form method="post" action="?/enregistrerTemplate" use:enhance class="template">
<h3>{nom}</h3>
<input type="hidden" name="nom" value={nom} />
<textarea name="contenu" rows="10" spellcheck="false">{contenu}</textarea>
<button class="bouton" type="submit">{fr.exports.enregistrer_template}</button>
{#if form && 'templateEnregistre' in form && form.templateEnregistre === nom}
<span class="tag tag-succes">{fr.formulaires.enregistrer}</span>
{/if}
</form>
{/each}
</section>
<section aria-labelledby="titre-generes">
<div class="titre-section">
<h2 id="titre-generes">{fr.exports.section_generes}</h2>
<form method="post" action="?/generer" use:enhance>
<button class="bouton" type="submit">{fr.exports.generer}</button>
</form>
</div>
{#if nomsExports.length === 0}
<p>{fr.exports.aucun_export}</p>
{:else}
{#each nomsExports as nom (nom)}
<details class="export">
<summary>{nom}</summary>
<pre>{data.exports[nom]}</pre>
</details>
{/each}
{/if}
</section>
<style>
h1 {
margin-bottom: var(--space-2);
}
h2 {
margin-block: var(--space-3) var(--space-2);
}
h3 {
font-size: 1rem;
margin-bottom: var(--space-1);
}
.intro {
color: var(--muted);
max-width: 70ch;
}
.aide {
color: var(--muted);
font-size: 0.85rem;
margin-bottom: var(--space-2);
}
.template {
display: flex;
flex-direction: column;
align-items: start;
gap: var(--space-1);
margin-bottom: var(--space-3);
}
.template textarea {
width: 100%;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
color: var(--fg);
padding: 0.75em;
font-family: ui-monospace, monospace;
font-size: 0.85rem;
}
.titre-section {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
}
.export {
border-bottom: 1px solid var(--line);
margin-bottom: var(--space-1);
}
summary {
cursor: pointer;
min-height: 44px;
align-content: center;
color: var(--action);
}
pre {
background: var(--surface);
border: var(--border-card);
border-radius: var(--radius-md);
padding: var(--space-2);
overflow-x: auto;
font-size: 0.85rem;
}
</style>
+7
View File
@@ -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 };
};
+68
View File
@@ -0,0 +1,68 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { fr } from '$lib/i18n/fr';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
</script>
<svelte:head>
<title>{fr.profils.titre}{fr.app.nom}</title>
</svelte:head>
<div class="titre-page">
<h1>{fr.profils.titre}</h1>
<a class="bouton" href={resolve('/profils/nouveau')}>{fr.profils.nouveau}</a>
</div>
{#if data.profils.length === 0}
<p>{fr.profils.aucun}</p>
{:else}
<div class="grille">
{#each data.profils as profil (profil.id)}
<article class="carte">
<h2>{profil.nom}</h2>
{#if profil.requiert.length > 0}
<p>
<strong>{fr.profils.legende_requiert} :</strong>
{profil.requiert.map((c) => fr.formulaires.cles[c]).join(', ')}
</p>
{/if}
{#if profil.exclut.length > 0}
<p>
<strong>{fr.profils.legende_exclut} :</strong>
{profil.exclut.map((c) => fr.formulaires.cles[c]).join(', ')}
</p>
{/if}
{#if profil.contraintes_specifiques}
<p class="contraintes">{profil.contraintes_specifiques}</p>
{/if}
<a href={resolve(`/profils/${profil.id}`)}>{fr.profils.modifier}</a>
</article>
{/each}
</div>
{/if}
<style>
.titre-page {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
margin-bottom: var(--space-3);
}
.carte h2 {
font-size: 1.05rem;
margin-bottom: var(--space-1);
}
.carte p {
color: var(--muted);
margin-bottom: var(--space-1);
}
.contraintes {
font-style: italic;
}
</style>
@@ -0,0 +1,41 @@
import { error, fail, redirect } from '@sveltejs/kit';
import { profilSchema } from '$lib/schemas';
import { erreursZod, profilDepuisFormulaire } from '$lib/server/formulaires';
import { ecrireProfils, lireProfils } from '$lib/server/stockage';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params }) => {
const profils = await lireProfils();
const profil = profils.find((p) => p.id === params.id);
if (!profil) error(404, 'Profil introuvable');
return { profil };
};
export const actions: Actions = {
enregistrer: async ({ request, params }) => {
const data = await request.formData();
const brut = profilDepuisFormulaire(data);
// L'identifiant d'un profil existant n'est pas modifiable (champ readonly).
const resultat = profilSchema.safeParse({ ...(brut as object), id: params.id });
if (!resultat.success) {
return fail(400, { erreurs: erreursZod(resultat.error), brut });
}
const profils = await lireProfils();
const index = profils.findIndex((p) => p.id === params.id);
if (index === -1) error(404, 'Profil introuvable');
profils[index] = resultat.data;
await ecrireProfils(profils, `feat : mise à jour du profil ${params.id}`);
redirect(303, '/profils');
},
supprimer: async ({ params }) => {
const profils = await lireProfils();
const restant = profils.filter((p) => p.id !== params.id);
if (restant.length === profils.length) error(404, 'Profil introuvable');
await ecrireProfils(restant, `feat : suppression du profil ${params.id}`);
redirect(303, '/profils');
}
};
+21
View File
@@ -0,0 +1,21 @@
<script lang="ts">
import { fr } from '$lib/i18n/fr';
import ProfilForm from '$lib/components/ProfilForm.svelte';
import type { ActionData, PageData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
</script>
<svelte:head>
<title>{fr.profils.titre_edition} : {data.profil.nom}{fr.app.nom}</title>
</svelte:head>
<h1>{fr.profils.titre_edition} : {data.profil.nom}</h1>
<ProfilForm
profil={data.profil}
edition
actionSupprimer="?/supprimer"
erreurs={form?.erreurs ?? []}
brut={(form?.brut as Record<string, unknown> | undefined) ?? null}
/>
@@ -0,0 +1,27 @@
import { fail, redirect } from '@sveltejs/kit';
import { profilSchema } from '$lib/schemas';
import { erreursZod, profilDepuisFormulaire } from '$lib/server/formulaires';
import { ecrireProfils, lireProfils } from '$lib/server/stockage';
import type { Actions } from './$types';
export const actions: Actions = {
default: async ({ request }) => {
const data = await request.formData();
const brut = profilDepuisFormulaire(data);
const resultat = profilSchema.safeParse(brut);
if (!resultat.success) {
return fail(400, { erreurs: erreursZod(resultat.error), brut });
}
const profils = await lireProfils();
if (profils.some((p) => p.id === resultat.data.id)) {
return fail(409, {
erreurs: [`Un profil avec l'identifiant « ${resultat.data.id} » existe déjà.`],
brut
});
}
await ecrireProfils([...profils, resultat.data], `feat : ajout du profil ${resultat.data.id}`);
redirect(303, '/profils');
}
};
@@ -0,0 +1,18 @@
<script lang="ts">
import { fr } from '$lib/i18n/fr';
import ProfilForm from '$lib/components/ProfilForm.svelte';
import type { ActionData } from './$types';
let { form }: { form: ActionData } = $props();
</script>
<svelte:head>
<title>{fr.profils.titre_nouveau}{fr.app.nom}</title>
</svelte:head>
<h1>{fr.profils.titre_nouveau}</h1>
<ProfilForm
erreurs={form?.erreurs ?? []}
brut={(form?.brut as Record<string, unknown> | undefined) ?? null}
/>
@@ -0,0 +1,35 @@
import { fail } from '@sveltejs/kit';
import { modaliteSchema } from '$lib/schemas';
import { recommander } from '$lib/server/recommandation';
import { lireProfils, lireRegistre } from '$lib/server/stockage';
import { nombreOuNull, chaineOuNull } from '$lib/server/formulaires';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async () => {
const profils = await lireProfils();
return { profils };
};
export const actions: Actions = {
default: async ({ request }) => {
const data = await request.formData();
const profilId = chaineOuNull(data.get('profil_id'));
const modaliteBrute = chaineOuNull(data.get('modalite'));
const vramMax = nombreOuNull(data.get('vram_max'));
const profils = await lireProfils();
const profil = profils.find((p) => p.id === profilId);
if (!profil) {
return fail(400, { erreur: 'Profil invalide.', profils });
}
const modalite = modaliteSchema.safeParse(modaliteBrute);
const registre = await lireRegistre();
const resultat = recommander(registre, profil, {
modalite: modalite.success ? modalite.data : undefined,
vram_max: vramMax ?? undefined
});
return { resultat, profilId: profil.id, modalite: modalite.data ?? '', vramMax };
}
};
+233
View File
@@ -0,0 +1,233 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { fr } from '$lib/i18n/fr';
import BadgeAVerifier from '$lib/components/BadgeAVerifier.svelte';
import Icon from '$lib/components/Icon.svelte';
import { modaliteSchema } from '$lib/schemas';
import type { ActionData, PageData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
const resultat = $derived(form && 'resultat' in form ? form.resultat : null);
</script>
<svelte:head>
<title>{fr.recommander.titre}{fr.app.nom}</title>
</svelte:head>
<h1>{fr.recommander.titre}</h1>
<form method="post" use:enhance class="filtres">
<label class="champ">
<span>{fr.recommander.champ_profil}</span>
<select name="profil_id" required>
{#each data.profils as profil (profil.id)}
<option
value={profil.id}
selected={form && 'profilId' in form && form.profilId === profil.id}
>
{profil.nom}
</option>
{/each}
</select>
</label>
<label class="champ">
<span>{fr.recommander.champ_modalite}</span>
<select name="modalite">
<option value="">{fr.recommander.toutes_modalites}</option>
{#each modaliteSchema.options as modalite (modalite)}
<option
value={modalite}
selected={form && 'modalite' in form && form.modalite === modalite}
>
{fr.formulaires.modalites[modalite]}
</option>
{/each}
</select>
</label>
<label class="champ">
<span>{fr.recommander.champ_vram}</span>
<input
name="vram_max"
type="number"
min="1"
step="1"
value={form && 'vramMax' in form && form.vramMax !== null ? form.vramMax : ''}
/>
</label>
<button class="bouton" type="submit">{fr.recommander.lancer}</button>
</form>
{#if form && 'erreur' in form}
<p class="erreur" role="alert">{form.erreur}</p>
{/if}
{#if resultat}
<section aria-labelledby="titre-shortlist">
<h2 id="titre-shortlist">{fr.recommander.shortlist}{resultat.profil.nom}</h2>
{#if resultat.shortlist.length === 0}
<p>{fr.recommander.aucun_eligible}</p>
{:else}
<div class="resultats">
{#each resultat.shortlist as reco (reco.modele.id)}
<article class="carte reco">
<header>
<h3>{reco.modele.nom}</h3>
<span class="score">{fr.recommander.score} : {reco.score}</span>
</header>
<p class="editeur">{reco.modele.editeur}{reco.modele.modalites.join(', ')}</p>
<details>
<summary>{fr.recommander.justification}</summary>
<ul>
{#each reco.justification as ligne (ligne)}
<li>{ligne}</li>
{/each}
</ul>
</details>
{#if reco.warnings.length > 0}
<div class="warnings">
<p>
<Icon nom="avertissement" taille={16} />
{fr.recommander.warnings}
</p>
<ul>
{#each reco.warnings as warning (warning)}
<li>{warning}</li>
{/each}
</ul>
</div>
{/if}
{#if reco.modele.licence.nom === null}
<BadgeAVerifier texte="licence {fr.badge.a_verifier}" />
{/if}
</article>
{/each}
</div>
{/if}
</section>
<section aria-labelledby="titre-exclus">
<h2 id="titre-exclus">{fr.recommander.exclus}</h2>
{#if resultat.exclus.length === 0}
<p>{fr.recommander.aucun_exclu}</p>
{:else}
{#each resultat.exclus as exclu (exclu.modele.id)}
<details class="exclu">
<summary>{exclu.modele.nom}</summary>
<ul>
{#each exclu.raisons as raison (raison)}
<li>{raison}</li>
{/each}
</ul>
</details>
{/each}
{/if}
</section>
{/if}
<style>
h1 {
margin-bottom: var(--space-3);
}
h2 {
margin-block: var(--space-3) var(--space-2);
}
.filtres {
display: flex;
flex-wrap: wrap;
align-items: end;
gap: var(--space-2);
padding: var(--space-3);
background: var(--card-bg);
border: var(--border-card);
border-radius: var(--radius-md);
margin-bottom: var(--space-3);
}
.champ {
display: flex;
flex-direction: column;
gap: 0.3em;
}
.champ > span {
color: var(--muted);
font-size: 0.9rem;
}
select,
input {
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-sm);
color: var(--fg);
padding: 0.5em 0.75em;
min-height: 44px;
}
.resultats {
display: flex;
flex-direction: column;
gap: var(--space-2);
}
.reco header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
}
.reco h3 {
font-size: 1.1rem;
}
.score {
font-family: var(--font-display);
color: var(--action);
font-weight: 700;
}
.editeur {
color: var(--muted);
margin-bottom: var(--space-2);
}
details {
margin-bottom: var(--space-2);
}
summary {
cursor: pointer;
min-height: 44px;
align-content: center;
color: var(--action);
}
.warnings {
border-left: 4px solid var(--action);
padding-left: var(--space-2);
margin-bottom: var(--space-2);
}
.warnings p {
display: flex;
align-items: center;
gap: 0.4em;
font-weight: 600;
}
.exclu {
border-bottom: 1px solid var(--line);
}
.erreur {
color: var(--signal);
font-weight: 600;
}
</style>
+7
View File
@@ -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 };
};
+133
View File
@@ -0,0 +1,133 @@
<script lang="ts">
import { resolve } from '$app/paths';
import { fr } from '$lib/i18n/fr';
import BadgeAVerifier from '$lib/components/BadgeAVerifier.svelte';
import type { PageProps } from './$types';
let { data }: PageProps = $props();
const tri = (v: boolean | null) =>
v === true ? fr.formulaires.ouvert : v === false ? fr.formulaires.ferme : null;
</script>
<svelte:head>
<title>{fr.registre.titre}{fr.app.nom}</title>
</svelte:head>
<div class="titre-page">
<h1>{fr.registre.titre}</h1>
<a class="bouton" href={resolve('/registre/nouveau')}>{fr.registre.nouveau}</a>
</div>
{#if data.modeles.length === 0}
<p>{fr.registre.aucun}</p>
{:else}
<div class="table-conteneur">
<table>
<thead>
<tr>
<th scope="col">{fr.registre.colonne_nom}</th>
<th scope="col">{fr.registre.colonne_editeur}</th>
<th scope="col">{fr.registre.colonne_modalites}</th>
<th scope="col">{fr.registre.colonne_commercial}</th>
<th scope="col">{fr.registre.colonne_statut}</th>
<th scope="col">{fr.registre.colonne_verif}</th>
<th scope="col"><span class="sr-only">{fr.registre.modifier}</span></th>
</tr>
</thead>
<tbody>
{#each data.modeles as modele (modele.id)}
<tr>
<th scope="row">
{modele.nom}
{#if modele.licence.nom === null}
<BadgeAVerifier />
{/if}
</th>
<td>{modele.editeur}</td>
<td>{modele.modalites.join(', ')}</td>
<td>
{#if tri(modele.licence.commercial_ok) !== null}
{tri(modele.licence.commercial_ok)}
{:else}
<BadgeAVerifier />
{/if}
</td>
<td>
<span
class="tag"
class:tag-succes={modele.statut === 'production'}
class:tag-signal={modele.statut === 'mort'}
>
{fr.formulaires.statuts[modele.statut]}
</span>
</td>
<td>
{#if modele.derniere_verif !== null}
<time datetime={modele.derniere_verif}>{modele.derniere_verif}</time>
{:else}
<BadgeAVerifier />
{/if}
</td>
<td>
<a href={resolve(`/registre/${modele.id}`)}>{fr.registre.modifier}</a>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
{/if}
<style>
.titre-page {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-2);
margin-bottom: var(--space-3);
}
.table-conteneur {
overflow-x: auto;
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
text-align: left;
padding: var(--space-1);
border-bottom: 1px solid var(--line);
vertical-align: middle;
}
thead th {
font-family: var(--font-display);
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.02em;
color: var(--muted);
}
tbody tr {
content-visibility: auto;
contain-intrinsic-size: 60px;
}
th :global(.tag) {
margin-left: 0.5em;
}
.sr-only {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip: rect(0 0 0 0);
white-space: nowrap;
}
</style>
@@ -0,0 +1,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');
}
};
+21
View File
@@ -0,0 +1,21 @@
<script lang="ts">
import { fr } from '$lib/i18n/fr';
import ModeleForm from '$lib/components/ModeleForm.svelte';
import type { ActionData, PageData } from './$types';
let { data, form }: { data: PageData; form: ActionData } = $props();
</script>
<svelte:head>
<title>{fr.registre.titre_edition} : {data.modele.nom}{fr.app.nom}</title>
</svelte:head>
<h1>{fr.registre.titre_edition} : {data.modele.nom}</h1>
<ModeleForm
modele={data.modele}
edition
actionSupprimer="?/supprimer"
erreurs={form?.erreurs ?? []}
brut={(form?.brut as Record<string, unknown> | undefined) ?? null}
/>
@@ -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');
}
};
@@ -0,0 +1,18 @@
<script lang="ts">
import { fr } from '$lib/i18n/fr';
import ModeleForm from '$lib/components/ModeleForm.svelte';
import type { ActionData } from './$types';
let { form }: { form: ActionData } = $props();
</script>
<svelte:head>
<title>{fr.registre.titre_nouveau}{fr.app.nom}</title>
</svelte:head>
<h1>{fr.registre.titre_nouveau}</h1>
<ModeleForm
erreurs={form?.erreurs ?? []}
brut={(form?.brut as Record<string, unknown> | undefined) ?? null}
/>