feat(session-0): cadre juridique, porte de validation et dataset vérifié

Session 0 du plan — aucun code applicatif.

Cadre
- 5 ADR : licences, Phaser 4 hors budget initial, frontière Svelte/Phaser,
  filières A/B des assets, kréyòl langue principale (norme gwadloupéyenne).
- LICENSE (AGPL-3.0-or-later, texte canonique gnu.org), LICENSE-CONTENT
  (CC BY-SA 4.0). Le jeu est une œuvre collective : aucun compositing sur un
  média SA ou NC, contrainte vérifiée par script.
- Audit des 7 sources avec endpoints réellement appelés. TRAMIL vérifié en
  direct : « all rights reserved » et page de mentions légales en 404.
  iNaturalist mesuré à 93 % de CC BY-NC sur un échantillon de 305 photos.

Outillage (tools/data/, hors bundle, zéro appel réseau au runtime)
- verify:taxa, ingest:commons, build:dataset, validate:data, test:porte,
  build:credits, check:audio.
- validate.mjs est la porte exigée par le brief §2 : le build échoue si une
  épreuve s'appuie sur un média non validé, non photographique ou de filière B,
  sur une attribution sans auteur, sur un compositing SA/NC, ou sur une entrée
  publiée sans relecture kréyòl. test:porte prouve les 9 refus + 1 témoin.

Données
- 13 entrées (8 fruits + 5 plantes du module Pa touché) vérifiées contre
  Wikidata ET GBIF : 13/13 ACCEPTED, 0 divergence avec la graine.
- 130 candidats Commons sous licence libre, 39 pré-inscrits, 0 validé —
  aucun agent ne peut valider un média.

Écarts signalés, non corrigés en silence
- Le champ danger de la graine a deux formes (note vs resume) : normalisé.
- 3 identifiants divergent du nom kréyòl au-delà des accents : non tranchés.
- comestible:false sur akée et pomme-cajou : sous-estimation prudente assumée.
- Le mancenillier n'a aucune photographie moderne dans ses 3 premiers
  candidats — l'entrée la plus critique est la moins bien servie.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
OKI
2026-07-26 23:47:27 -04:00
co-authored by Claude Opus 5
parent f0b3ce8484
commit 945a524b44
45 changed files with 8599 additions and 0 deletions
+131
View File
@@ -0,0 +1,131 @@
#!/usr/bin/env node
/**
* build-credits.mjs — génère CREDITS.md depuis les données.
*
* Le brief §3 exige un CREDITS.md « généré depuis les données ». Un fichier de crédits
* tenu à la main se désynchronise le jour où l'on remplace une photo, et une attribution
* fausse est une violation de licence. Ce fichier n'est donc jamais édité : il se rejoue.
*
* Les médias sous clause non commerciale sont isolés dans leur propre section, comme
* l'ADR 0001 l'impose.
*/
import { readFile, readdir, writeFile } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const DATA = join(ROOT, 'src', 'lib', 'data', 'fruits');
const LIBELLE = {
'CC0-1.0': 'CC0 1.0 (domaine public volontaire)',
PD: 'domaine public',
'CC-BY-2.0': 'CC BY 2.0',
'CC-BY-3.0': 'CC BY 3.0',
'CC-BY-4.0': 'CC BY 4.0',
'CC-BY-SA-2.0': 'CC BY-SA 2.0',
'CC-BY-SA-2.5': 'CC BY-SA 2.5',
'CC-BY-SA-3.0': 'CC BY-SA 3.0',
'CC-BY-SA-4.0': 'CC BY-SA 4.0',
'CC-BY-NC-4.0': 'CC BY-NC 4.0',
'CC-BY-NC-SA-4.0': 'CC BY-NC-SA 4.0',
'OKI-CC-BY-SA-4.0': 'ORGANISATION KA INTERNATIONALE, CC BY-SA 4.0'
};
const echapper = (s) => String(s ?? '—').replace(/\|/g, '\\|').replace(/\n/g, ' ');
async function main() {
const fichiers = (await readdir(DATA)).filter((f) => f.endsWith('.json')).sort();
const entrees = [];
for (const f of fichiers) entrees.push(JSON.parse(await readFile(join(DATA, f), 'utf8')));
const tous = entrees.flatMap((e) => e.medias.map((m) => ({ ...m, entree: e })));
const nc = tous.filter((m) => m.usage_non_commercial);
const sa = tous.filter((m) => m.partage_identique);
const l = [
'# Crédits — KI FWI SA YÉ ?',
'',
'> **Fichier généré.** Ne pas éditer à la main : `npm run build:credits`.',
`> Dernière génération : ${new Date().toISOString().slice(0, 10)}.`,
'',
'## Ce que couvre ce fichier',
'',
'Chaque média embarqué dans le jeu ou dans lencyclopédie est listé ci-dessous avec son',
'auteur, sa licence et le lien vers sa page de description dorigine. Rien nest',
'« libre de droit » : chaque ligne est une obligation contractuelle.',
'',
'Le code du projet est sous **AGPL-3.0-or-later** (`LICENSE`). Les textes rédigés par',
'ORGANISATION KA INTERNATIONALE sont sous **CC BY-SA 4.0** (`LICENSE-CONTENT`). Les médias',
'tiers restent sous leur licence propre : le jeu est une **œuvre collective**, pas une',
'œuvre dérivée — voir `docs/adr/0001-licences.md`.',
'',
`## Chiffres`,
'',
`- ${entrees.length} entrées · ${tous.length} médias`,
`- ${tous.filter((m) => m.valide).length} médias validés par un humain`,
`- ${sa.length} médias en partage à lidentique (CC BY-SA) — **aucun compositing autorisé**`,
`- ${nc.length} médias sous clause non commerciale (CC BY-NC)`,
''
];
if (nc.length) {
l.push(
'## ⚠ Médias sous clause non commerciale',
'',
'La présence de ces médias engage le projet à ne **jamais** être distribué à titre',
'commercial — ni vente, ni version payante, ni licence à un éditeur. Décision prise et',
'documentée dans `docs/adr/0001-licences.md`. Retirer ces médias est la seule façon de',
'revenir en arrière.',
'',
'| entrée | média | auteur | licence | source |',
'|---|---|---|---|---|'
);
for (const m of nc)
l.push(
`| \`${m.entree.id}\` | ${echapper(m.id)} | ${echapper(m.auteur)} | ${LIBELLE[m.licence] ?? m.licence} | [page](${m.url_description}) |`
);
l.push('');
}
l.push('## Médias par entrée', '');
for (const e of entrees) {
l.push(`### ${e.noms.gcf} — *${e.taxon.scientifique}* (\`${e.id}\`)`, '');
l.push(
`Taxonomie vérifiée le ${e.taxon.verifie_le} contre [Wikidata ${e.taxon.wikidata}](https://www.wikidata.org/wiki/${e.taxon.wikidata})`,
`et [GBIF ${e.taxon.gbif}](https://www.gbif.org/species/${e.taxon.gbif}).`,
''
);
if (!e.medias.length) {
l.push('_Aucun média._', '');
continue;
}
l.push('| média | vue | auteur | licence | validé | page dorigine |');
l.push('|---|---|---|---|---|---|');
for (const m of e.medias)
l.push(
`| ${echapper(m.id)} | ${m.vue ?? '—'} | ${echapper(m.auteur)} | ${LIBELLE[m.licence] ?? m.licence} | ${m.valide ? `oui (${echapper(m.valide_par)})` : 'non'} | [voir](${m.url_description}) |`
);
l.push('');
}
l.push(
'## Sources factuelles',
'',
'- **Wikidata** — identifiants et taxonomie (CC0). https://www.wikidata.org',
'- **GBIF** — noms scientifiques acceptés et clés dusage. https://www.gbif.org',
'- **Wikimedia Commons** — photographies. https://commons.wikimedia.org',
'',
'Voir `SOURCES.md` pour le détail des régimes de licence et `docs/audit-sources.md`',
'pour laudit complet, TRAMIL et CIRAD compris.',
''
);
await writeFile(join(ROOT, 'CREDITS.md'), l.join('\n'));
console.log(`✔ CREDITS.md — ${entrees.length} entrées, ${tous.length} médias, ${nc.length} sous clause NC.`);
}
main().catch((e) => {
console.error('✘', e.stack ?? e.message);
process.exit(1);
});
+200
View File
@@ -0,0 +1,200 @@
#!/usr/bin/env node
/**
* build-dataset.mjs — compose le dataset à partir de la graine et des sorties vérifiées.
*
* fruits.seed.json (contenu éditorial d'amorçage)
* + tools/data/out/taxa-verified.json (Wikidata + GBIF, réponses d'API)
* + tools/data/out/commons-candidats.json (médias sous licence libre)
* → src/lib/data/fruits/<id>.json
*
* AMORÇAGE SEUL. Une fois les fichiers écrits, ils sont édités à la main par le porteur
* (choix des photos, assignation des vues, passage de `valide` à true, corrections kréyòl).
* Le script refuse donc d'écraser un fichier existant sans `--force`, pour ne jamais
* détruire une validation humaine.
*/
import { readFile, writeFile, mkdir, access } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const DEST = join(ROOT, 'src', 'lib', 'data', 'fruits');
const FORCE = process.argv.includes('--force');
/** Nombre de médias candidats pré-inscrits par entrée. Le brief §8 en exige au moins deux. */
const MEDIAS_PAR_ENTREE = 3;
/** Licences à attribution — recopié de la classification d'ingest-commons.mjs. */
const SANS_ATTRIBUTION = new Set(['CC0-1.0', 'PD']);
const AVEC_SA = new Set(['CC-BY-SA-2.0', 'CC-BY-SA-2.5', 'CC-BY-SA-3.0', 'CC-BY-SA-4.0', 'CC-BY-NC-SA-4.0']);
const AVEC_NC = new Set(['CC-BY-NC-4.0', 'CC-BY-NC-SA-4.0']);
const slug = (s) =>
s
.replace(/^File:/, '')
.replace(/\.[a-z0-9]+$/i, '')
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 60)
.replace(/-+$/, '');
/**
* Une planche gravée du XVIIIe siècle est exacte mais stylisée : elle ne peut pas servir
* d'épreuve de reconnaissance du réel. On la détecte grossièrement — c'est un pré-tri,
* pas un verdict : le porteur corrige le champ à la relecture.
*/
const INDICES_PLANCHE = /blanco|merian|k[oö]hler|flora|sylva|botanical|plate|lithograph|engrav|descourtilz|1[6-9]\d\d/i;
function typeMedia(c) {
// La date de prise de vue fait partie du texte examiné : sans elle, la planche de
// Jacquin (1780) ressortait en « photo » — son titre et son auteur ne disent rien
// de son siècle. Constaté en session 0, corrigé ici.
const hay = `${c.titre} ${c.description ?? ''} ${c.auteur ?? ''} ${c.date_prise_de_vue ?? ''}`;
if (INDICES_PLANCHE.test(hay)) return 'planche_botanique';
// La photographie de terrain n'existe pas avant le XXe siècle dans ces fonds.
const annee = Number((c.date_prise_de_vue ?? '').match(/\b(1[5-9]\d\d)\b/)?.[1]);
if (annee && annee < 1900) return 'planche_botanique';
return 'photo';
}
/**
* Écart constaté dans la graine : les dangers de niveau « vigilance » (kénèt, pòm-kannèl)
* portent leur texte dans `note`, les niveaux « élevé » et « critique » dans `resume`.
* Le schéma impose un champ unique — on normalise vers `resume` sans rien réécrire.
* Écart signalé dans docs/sessions/00-cadre-et-donnees.md, conformément au brief §9.
*/
function normaliserDanger(d) {
if (!d) return null;
const { note, resume, ...reste } = d;
return { ...reste, resume: resume ?? note };
}
/** Deux planches Blanco tronquées au même slug : on désambiguïse plutôt que de perdre un média. */
function idsUniques(medias) {
const vus = new Map();
return medias.map((m) => {
const n = (vus.get(m.id) ?? 0) + 1;
vus.set(m.id, n);
return n === 1 ? m : { ...m, id: `${m.id}-${n}` };
});
}
async function existe(p) {
try {
await access(p);
return true;
} catch {
return false;
}
}
async function main() {
const seed = JSON.parse(await readFile(join(ROOT, 'fruits.seed.json'), 'utf8'));
const taxa = JSON.parse(await readFile(join(ROOT, 'tools/data/out/taxa-verified.json'), 'utf8'));
const medias = JSON.parse(await readFile(join(ROOT, 'tools/data/out/commons-candidats.json'), 'utf8'));
const parId = (arr) => new Map(arr.map((r) => [r.id, r]));
const T = parId(taxa.resultats);
const M = parId(medias.resultats);
const dateVerif = taxa.genere_le.slice(0, 10);
const entrees = [
...seed.fruits.map((f) => ({ ...f, categorie: 'fruit' })),
...seed.plantes_dangereuses.map((p) => ({ ...p, categorie: 'plante_dangereuse' }))
];
await mkdir(DEST, { recursive: true });
let ecrits = 0;
let ignores = 0;
for (const e of entrees) {
const chemin = join(DEST, `${e.id}.json`);
if (!FORCE && (await existe(chemin))) {
console.log(`· ${e.id} — existe déjà, conservé (--force pour écraser)`);
ignores++;
continue;
}
const t = T.get(e.id);
if (!t?.wikidata?.qid || !t?.gbif?.usageKey) {
console.error(`${e.id} — taxonomie non vérifiée, entrée non écrite`);
continue;
}
const candidats = idsUniques(
(M.get(e.id)?.candidats ?? []).slice(0, MEDIAS_PAR_ENTREE).map((c) => ({
id: slug(c.titre),
filiere: 'A',
type_media: typeMedia(c),
vue: null,
usages: [],
source: 'wikimedia_commons',
auteur: c.auteur,
licence: c.licence,
licence_url: c.licence_url ?? null,
url_fichier: c.url_fichier,
url_description: c.url_description,
largeur: c.largeur ?? null,
hauteur: c.hauteur ?? null,
mime: c.mime ?? null,
description: c.description ?? null,
date_prise_de_vue: c.date_prise_de_vue ?? null,
attribution_requise: !SANS_ATTRIBUTION.has(c.licence),
partage_identique: AVEC_SA.has(c.licence),
usage_non_commercial: AVEC_NC.has(c.licence),
composite: false,
valide: false,
valide_par: null,
valide_le: null,
note_validation: null
}))
);
const entree = {
$schema: '../../../../fruits.schema.json',
id: e.id,
categorie: e.categorie,
noms: e.noms,
orthographe: { norme: 'gwadloupeyen', statut: 'a_relire', relu_par: null },
taxon: {
scientifique: t.gbif.canonicalName ?? e.taxon.scientifique,
famille: t.gbif.famille ?? e.taxon.famille,
wikidata: t.wikidata.qid,
gbif: t.gbif.usageKey,
verifie_le: dateVerif,
verifie_contre: ['wikidata', 'gbif']
},
iles: e.iles,
...(e.categorie === 'fruit' ? { saison_mois: e.saison_mois } : {}),
// Les cinq plantes du module « Pa touché » sortent à comestible: false.
// C'est le défaut prudent, pas la vérité complète : l'akée mûre et la pomme-cajou
// se mangent sous conditions strictes. La nuance vit dans danger.resume et doit
// être arbitrée par un humain avant publication (voir docs/a-arbitrer.md).
comestible: e.categorie === 'fruit' ? (e.comestible ?? true) : false,
danger: normaliserDanger(e.danger),
indices: e.indices ?? {},
medias: candidats,
audio: { gcf: null, locuteur: null },
sources_factuelles: [],
valide: false
};
if (e.$comment) entree.$comment = e.$comment;
await writeFile(chemin, JSON.stringify(entree, null, '\t') + '\n');
console.log(`${e.id}${candidats.length} médias, ${entree.taxon.wikidata} / ${entree.taxon.gbif}`);
ecrits++;
}
console.log(`\n${ecrits} entrée(s) écrite(s), ${ignores} conservée(s).`);
}
main().catch((err) => {
console.error('✘', err.message);
process.exit(1);
});
+86
View File
@@ -0,0 +1,86 @@
#!/usr/bin/env node
/**
* check-audio.mjs — liste les enregistrements kréyòl manquants.
*
* Le brief §6 le demande explicitement : « prévois le contrat (audio.gcf, format .opus,
* repli silencieux si le fichier manque) et un script de vérification listant les
* enregistrements manquants. »
*
* Pas de synthèse vocale : ce script n'en fabrique aucune. Il produit une liste de courses
* pour la séance d'enregistrement — une après-midi de terrain vaut dix heures de code.
*
* Ce script n'échoue jamais : un enregistrement manquant est un travail à faire,
* pas un défaut de build. Le jeu doit se replier en silence.
*/
import { readFile, readdir, writeFile, access } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const DATA = join(ROOT, 'src', 'lib', 'data', 'fruits');
const STATIC = join(ROOT, 'static');
async function existe(p) {
try {
await access(p);
return true;
} catch {
return false;
}
}
async function main() {
const fichiers = (await readdir(DATA)).filter((f) => f.endsWith('.json')).sort();
const manquants = [];
const absents = [];
const presents = [];
for (const f of fichiers) {
const e = JSON.parse(await readFile(join(DATA, f), 'utf8'));
if (!e.audio.gcf) {
manquants.push(e);
continue;
}
// Le chemin est absolu depuis la racine du site : /audio/<id>.opus → static/audio/<id>.opus
if (await existe(join(STATIC, e.audio.gcf))) presents.push(e);
else absents.push(e);
}
const l = [
'# Enregistrements kréyòl à réaliser',
'',
'> Généré par `npm run check:audio`. Aucune synthèse vocale : ces noms doivent être',
'> prononcés par une voix humaine locale (brief §6).',
'',
"Format attendu : `.opus` — un fichier par entrée, déposé dans `static/audio/<id>.opus`.",
'',
`## À enregistrer (${manquants.length})`,
''
];
for (const e of manquants) l.push(`- [ ] **${e.noms.gcf}** — \`static/audio/${e.id}.opus\` _(${e.noms.fr ?? ''})_`);
l.push('');
if (absents.length) {
l.push(`## Déclarés mais fichier introuvable (${absents.length})`, '');
for (const e of absents) l.push(`- ⚠ \`${e.id}\`\`${e.audio.gcf}\` absent de \`static/\``);
l.push('');
}
if (presents.length) {
l.push(`## En place (${presents.length})`, '');
for (const e of presents) l.push(`- ✔ \`${e.id}\`${e.audio.locuteur ?? 'locuteur non renseigné'}`);
l.push('');
}
await writeFile(join(ROOT, 'docs', 'audio-a-enregistrer.md'), l.join('\n'));
console.log(`À enregistrer : ${manquants.length}`);
console.log(`Introuvables : ${absents.length}`);
console.log(`En place : ${presents.length}`);
console.log('\n docs/audio-a-enregistrer.md');
if (absents.length) console.log("\n⚠ Des chemins audio sont déclarés sans fichier : le jeu se repliera en silence.");
}
main().catch((e) => {
console.error('✘', e.stack ?? e.message);
process.exit(1);
});
+217
View File
@@ -0,0 +1,217 @@
#!/usr/bin/env node
/**
* ingest-commons.mjs — recherche de photographies candidates sur Wikimedia Commons.
*
* FILIÈRE A UNIQUEMENT (ADR 0004) : ce script ne produit que des photographies réelles,
* destinées aux épreuves d'identification. Aucun asset généré ne passe par ici.
*
* Ce que ce script fait : il interroge Commons, récupère pour chaque fichier l'auteur,
* la licence, l'URL du fichier et l'URL de sa page de description, et écarte tout ce
* dont la licence n'est pas explicitement libre.
*
* Ce que ce script NE fait PAS, et ne fera jamais :
* — il ne télécharge aucun fichier ;
* — il ne décide pas qu'une photo montre bien l'espèce annoncée. L'ordre des candidats
* est celui de la pertinence du moteur de recherche de Commons, ce qui n'est pas un
* jugement botanique. Tout candidat sort avec `valide: false` et le reste jusqu'à ce
* qu'un humain compétent tranche (brief §2, ADR 0004).
*
* Sorties :
* tools/data/out/commons-candidats.json — données brutes
* docs/planche-relecture-medias.md — planche de relecture pour le porteur
*/
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const UA = 'ki-fwi-dataset/0.1 (https://o-k-i.net ; projet educatif ORGANISATION KA INTERNATIONALE)';
const API = 'https://commons.wikimedia.org/w/api.php';
/** Nombre de candidats remontés par taxon. Le porteur en retiendra au moins deux. */
const CANDIDATS_PAR_TAXON = 10;
/**
* Licences acceptées, par préfixe de `LicenseShortName` normalisé.
* Commons n'héberge que du libre, mais on ne présume pas : on vérifie.
* Le champ `sa` marque le partage à l'identique, qui contamine les dérivés — d'où
* l'interdiction de compositing de l'ADR 0001.
*/
const LICENCES_OK = [
{ motif: /^cc0/i, id: 'CC0-1.0', attribution: false, sa: false },
{ motif: /^public domain|^pd-/i, id: 'PD', attribution: false, sa: false },
{ motif: /^cc by-sa 4/i, id: 'CC-BY-SA-4.0', attribution: true, sa: true },
{ motif: /^cc by-sa 3/i, id: 'CC-BY-SA-3.0', attribution: true, sa: true },
{ motif: /^cc by-sa 2\.5/i, id: 'CC-BY-SA-2.5', attribution: true, sa: true },
{ motif: /^cc by-sa 2/i, id: 'CC-BY-SA-2.0', attribution: true, sa: true },
{ motif: /^cc by 4/i, id: 'CC-BY-4.0', attribution: true, sa: false },
{ motif: /^cc by 3/i, id: 'CC-BY-3.0', attribution: true, sa: false },
{ motif: /^cc by 2/i, id: 'CC-BY-2.0', attribution: true, sa: false }
];
/** Retire le balisage HTML que Commons renvoie dans les champs d'attribution. */
const texte = (html) =>
html
? String(html)
.replace(/<[^>]+>/g, ' ')
.replace(/\s+/g, ' ')
.trim()
: null;
function classerLicence(nomCourt) {
if (!nomCourt) return null;
return LICENCES_OK.find((l) => l.motif.test(nomCourt.trim())) ?? null;
}
async function getJson(params) {
const u = new URL(API);
for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v);
const res = await fetch(u, { headers: { 'User-Agent': UA, Accept: 'application/json' } });
if (!res.ok) throw new Error(`Commons ${res.status} ${res.statusText}`);
return res.json();
}
async function chercher(nomScientifique) {
const json = await getJson({
action: 'query',
format: 'json',
formatversion: '2',
generator: 'search',
gsrsearch: `"${nomScientifique}" filetype:bitmap`,
gsrnamespace: '6',
gsrlimit: String(CANDIDATS_PAR_TAXON * 2),
prop: 'imageinfo',
iiprop: 'url|size|mime|extmetadata',
iiextmetadatafilter: 'LicenseShortName|LicenseUrl|Artist|Credit|ImageDescription|DateTimeOriginal'
});
const pages = json.query?.pages ?? [];
const retenus = [];
const ecartes = [];
for (const p of pages) {
const info = p.imageinfo?.[0];
if (!info) continue;
const meta = info.extmetadata ?? {};
const nomCourt = texte(meta.LicenseShortName?.value);
const licence = classerLicence(nomCourt);
const commun = {
titre: p.title,
auteur: texte(meta.Artist?.value) ?? texte(meta.Credit?.value),
licence_libelle: nomCourt,
url_fichier: info.url,
url_description: info.descriptionurl,
largeur: info.width,
hauteur: info.height,
mime: info.mime,
description: texte(meta.ImageDescription?.value)?.slice(0, 220) ?? null,
date_prise_de_vue: texte(meta.DateTimeOriginal?.value)
};
if (!licence) {
ecartes.push({ ...commun, raison: `licence non reconnue comme libre : ${nomCourt ?? 'absente'}` });
continue;
}
if (licence.attribution && !commun.auteur) {
ecartes.push({ ...commun, raison: 'licence à attribution mais auteur introuvable dans les métadonnées' });
continue;
}
retenus.push({
...commun,
licence: licence.id,
licence_url: texte(meta.LicenseUrl?.value),
attribution_requise: licence.attribution,
partage_identique: licence.sa
});
}
return { retenus: retenus.slice(0, CANDIDATS_PAR_TAXON), ecartes };
}
async function main() {
const seed = JSON.parse(await readFile(join(ROOT, 'fruits.seed.json'), 'utf8'));
const entrees = [
...seed.fruits.map((f) => ({ ...f, categorie: 'fruit' })),
...seed.plantes_dangereuses.map((p) => ({ ...p, categorie: 'plante_dangereuse' }))
];
const resultats = [];
for (const e of entrees) {
process.stdout.write(`→ Commons : ${e.taxon.scientifique}`);
const { retenus, ecartes } = await chercher(e.taxon.scientifique);
console.log(`${retenus.length} candidats, ${ecartes.length} écartés`);
resultats.push({
id: e.id,
categorie: e.categorie,
taxon: e.taxon.scientifique,
candidats: retenus,
ecartes
});
}
await mkdir(join(ROOT, 'tools', 'data', 'out'), { recursive: true });
await writeFile(
join(ROOT, 'tools', 'data', 'out', 'commons-candidats.json'),
JSON.stringify({ genere_le: new Date().toISOString(), resultats }, null, 2) + '\n'
);
const insuffisants = resultats.filter((r) => r.candidats.length < 2);
const l = [
'# Planche de relecture des médias — filière A',
'',
`> Généré par \`npm run ingest:commons\` le ${new Date().toISOString().slice(0, 10)}.`,
"> Sortie de script : ne pas éditer à la main, rejouer `npm run ingest:commons`.",
'',
'## Ce qui est demandé au porteur',
'',
"Pour chaque taxon, ouvrir les pages de description et retenir au moins deux photographies",
"qui montrent réellement l'espèce. Reporter le choix dans `src/lib/data/fruits/<id>.json`,",
'assigner à chaque média sa vue (`fruit_entier`, `coupe`, `feuille`, `ecorce`, `port`),',
'puis passer `valide` à `true` **et seulement alors**.',
'',
"Aucune vérification botanique n'a été faite par la machine : l'ordre ci-dessous est celui du",
'moteur de recherche de Commons, rien de plus.',
''
];
for (const r of resultats) {
l.push(`## \`${r.id}\` — *${r.taxon}* (${r.categorie})`, '');
if (!r.candidats.length) {
l.push('**Aucun candidat sous licence libre exploitable.** À photographier sur le terrain.', '');
continue;
}
l.push('| # | fichier | auteur | licence | dimensions | page de description |');
l.push('|---|---|---|---|---|---|');
r.candidats.forEach((c, i) => {
const titre = c.titre.replace(/^File:/, '').replace(/\|/g, '\\|');
const auteur = (c.auteur ?? '—').replace(/\|/g, '\\|').slice(0, 60);
l.push(
`| ${i + 1} | ${titre.slice(0, 70)} | ${auteur} | ${c.licence} | ${c.largeur}×${c.hauteur} | [voir](${c.url_description}) |`
);
});
l.push('');
if (r.ecartes.length) {
l.push(`<details><summary>${r.ecartes.length} fichier(s) écarté(s) automatiquement</summary>`, '');
for (const x of r.ecartes) l.push(`- ${x.titre}${x.raison}`);
l.push('', '</details>', '');
}
}
if (insuffisants.length) {
l.push('## Taxons sous le seuil de deux candidats', '');
for (const r of insuffisants) l.push(`- \`${r.id}\` (*${r.taxon}*) — ${r.candidats.length} candidat(s)`);
l.push('');
}
await writeFile(join(ROOT, 'docs', 'planche-relecture-medias.md'), l.join('\n'));
const total = resultats.reduce((n, r) => n + r.candidats.length, 0);
console.log(`\n${total} candidats sur ${resultats.length} taxons — 0 validé (état attendu).`);
if (insuffisants.length) console.log(`${insuffisants.length} taxon(s) sous deux candidats.`);
console.log(' tools/data/out/commons-candidats.json');
console.log(' docs/planche-relecture-medias.md');
}
main().catch((err) => {
console.error('✘', err.message);
process.exit(1);
});
File diff suppressed because it is too large Load Diff
+343
View File
@@ -0,0 +1,343 @@
{
"genere_le": "2026-07-27T03:46:04.075Z",
"resultats": [
{
"id": "kowosol",
"categorie": "fruit",
"graine": {
"scientifique": "Annona muricata",
"famille": "Annonaceae"
},
"wikidata": {
"qid": "Q1709343",
"famille": "Annonaceae",
"rang": "espèce"
},
"wikidata_pistes": null,
"gbif": {
"usageKey": 5407273,
"scientificName": "Annona muricata L.",
"canonicalName": "Annona muricata",
"rank": "SPECIES",
"status": "ACCEPTED",
"matchType": "EXACT",
"confidence": 97,
"famille": "Annonaceae",
"accepte": null
},
"ecarts": []
},
{
"id": "kenet",
"categorie": "fruit",
"graine": {
"scientifique": "Melicoccus bijugatus",
"famille": "Sapindaceae"
},
"wikidata": {
"qid": "Q279728",
"famille": "Sapindaceae",
"rang": "espèce"
},
"wikidata_pistes": null,
"gbif": {
"usageKey": 3189990,
"scientificName": "Melicoccus bijugatus Jacq.",
"canonicalName": "Melicoccus bijugatus",
"rank": "SPECIES",
"status": "ACCEPTED",
"matchType": "EXACT",
"confidence": 99,
"famille": "Sapindaceae",
"accepte": null
},
"ecarts": []
},
{
"id": "gouyav",
"categorie": "fruit",
"graine": {
"scientifique": "Psidium guajava",
"famille": "Myrtaceae"
},
"wikidata": {
"qid": "Q166843",
"famille": "Myrtaceae",
"rang": "espèce"
},
"wikidata_pistes": null,
"gbif": {
"usageKey": 5420380,
"scientificName": "Psidium guajava L.",
"canonicalName": "Psidium guajava",
"rank": "SPECIES",
"status": "ACCEPTED",
"matchType": "EXACT",
"confidence": 99,
"famille": "Myrtaceae",
"accepte": null
},
"ecarts": []
},
{
"id": "pom-site",
"categorie": "fruit",
"graine": {
"scientifique": "Spondias dulcis",
"famille": "Anacardiaceae"
},
"wikidata": {
"qid": "Q500383",
"famille": "Anacardiaceae",
"rang": "espèce"
},
"wikidata_pistes": null,
"gbif": {
"usageKey": 3190595,
"scientificName": "Spondias dulcis Parkinson",
"canonicalName": "Spondias dulcis",
"rank": "SPECIES",
"status": "ACCEPTED",
"matchType": "EXACT",
"confidence": 97,
"famille": "Anacardiaceae",
"accepte": null
},
"ecarts": []
},
{
"id": "zikak",
"categorie": "fruit",
"graine": {
"scientifique": "Chrysobalanus icaco",
"famille": "Chrysobalanaceae"
},
"wikidata": {
"qid": "Q577669",
"famille": "Chrysobalanaceae",
"rang": "espèce"
},
"wikidata_pistes": null,
"gbif": {
"usageKey": 2985082,
"scientificName": "Chrysobalanus icaco L.",
"canonicalName": "Chrysobalanus icaco",
"rank": "SPECIES",
"status": "ACCEPTED",
"matchType": "EXACT",
"confidence": 99,
"famille": "Chrysobalanaceae",
"accepte": null
},
"ecarts": []
},
{
"id": "pom-kannel",
"categorie": "fruit",
"graine": {
"scientifique": "Annona squamosa",
"famille": "Annonaceae"
},
"wikidata": {
"qid": "Q275620",
"famille": "Annonaceae",
"rang": "espèce"
},
"wikidata_pistes": null,
"gbif": {
"usageKey": 5407099,
"scientificName": "Annona squamosa L.",
"canonicalName": "Annona squamosa",
"rank": "SPECIES",
"status": "ACCEPTED",
"matchType": "EXACT",
"confidence": 97,
"famille": "Annonaceae",
"accepte": null
},
"ecarts": []
},
{
"id": "fwiyapen",
"categorie": "fruit",
"graine": {
"scientifique": "Artocarpus altilis",
"famille": "Moraceae"
},
"wikidata": {
"qid": "Q14677",
"famille": "Moraceae",
"rang": "espèce"
},
"wikidata_pistes": null,
"gbif": {
"usageKey": 2984573,
"scientificName": "Artocarpus altilis (Parkinson) Fosberg",
"canonicalName": "Artocarpus altilis",
"rank": "SPECIES",
"status": "ACCEPTED",
"matchType": "EXACT",
"confidence": 97,
"famille": "Moraceae",
"accepte": null
},
"ecarts": []
},
{
"id": "pom-dlo",
"categorie": "fruit",
"graine": {
"scientifique": "Syzygium samarangense",
"famille": "Myrtaceae"
},
"wikidata": {
"qid": "Q11643111",
"famille": "Myrtaceae",
"rang": "espèce"
},
"wikidata_pistes": null,
"gbif": {
"usageKey": 3183180,
"scientificName": "Syzygium samarangense (Blume) Merr. & L.M.Perry",
"canonicalName": "Syzygium samarangense",
"rank": "SPECIES",
"status": "ACCEPTED",
"matchType": "EXACT",
"confidence": 99,
"famille": "Myrtaceae",
"accepte": null
},
"ecarts": []
},
{
"id": "mansiniye",
"categorie": "plante_dangereuse",
"graine": {
"scientifique": "Hippomane mancinella",
"famille": "Euphorbiaceae"
},
"wikidata": {
"qid": "Q636480",
"famille": "Euphorbiaceae",
"rang": "espèce"
},
"wikidata_pistes": null,
"gbif": {
"usageKey": 3063705,
"scientificName": "Hippomane mancinella L.",
"canonicalName": "Hippomane mancinella",
"rank": "SPECIES",
"status": "ACCEPTED",
"matchType": "EXACT",
"confidence": 99,
"famille": "Euphorbiaceae",
"accepte": null
},
"ecarts": []
},
{
"id": "ake",
"categorie": "plante_dangereuse",
"graine": {
"scientifique": "Blighia sapida",
"famille": "Sapindaceae"
},
"wikidata": {
"qid": "Q417145",
"famille": "Sapindaceae",
"rang": "espèce"
},
"wikidata_pistes": null,
"gbif": {
"usageKey": 3189955,
"scientificName": "Blighia sapida K.D.Koenig",
"canonicalName": "Blighia sapida",
"rank": "SPECIES",
"status": "ACCEPTED",
"matchType": "EXACT",
"confidence": 99,
"famille": "Sapindaceae",
"accepte": null
},
"ecarts": []
},
{
"id": "pom-kajou",
"categorie": "plante_dangereuse",
"graine": {
"scientifique": "Anacardium occidentale",
"famille": "Anacardiaceae"
},
"wikidata": {
"qid": "Q34007",
"famille": "Anacardiaceae",
"rang": "espèce"
},
"wikidata_pistes": null,
"gbif": {
"usageKey": 5421368,
"scientificName": "Anacardium occidentale L.",
"canonicalName": "Anacardium occidentale",
"rank": "SPECIES",
"status": "ACCEPTED",
"matchType": "EXACT",
"confidence": 98,
"famille": "Anacardiaceae",
"accepte": null
},
"ecarts": []
},
{
"id": "medisinye",
"categorie": "plante_dangereuse",
"graine": {
"scientifique": "Jatropha curcas",
"famille": "Euphorbiaceae"
},
"wikidata": {
"qid": "Q74644",
"famille": "Euphorbiaceae",
"rang": "espèce"
},
"wikidata_pistes": null,
"gbif": {
"usageKey": 3072953,
"scientificName": "Jatropha curcas L.",
"canonicalName": "Jatropha curcas",
"rank": "SPECIES",
"status": "ACCEPTED",
"matchType": "EXACT",
"confidence": 98,
"famille": "Euphorbiaceae",
"accepte": null
},
"ecarts": []
},
{
"id": "lorye-jonn",
"categorie": "plante_dangereuse",
"graine": {
"scientifique": "Cascabela thevetia",
"famille": "Apocynaceae"
},
"wikidata": {
"qid": "Q10694475",
"famille": "Apocynaceae",
"rang": "espèce"
},
"wikidata_pistes": null,
"gbif": {
"usageKey": 3169652,
"scientificName": "Cascabela thevetia (L.) Lippold",
"canonicalName": "Cascabela thevetia",
"rank": "SPECIES",
"status": "ACCEPTED",
"matchType": "EXACT",
"confidence": 99,
"famille": "Apocynaceae",
"accepte": null
},
"ecarts": []
}
]
}
+194
View File
@@ -0,0 +1,194 @@
#!/usr/bin/env node
/**
* test-porte.mjs — prouve que la porte se ferme.
*
* Une règle de sécurité qu'on n'a jamais vue refuser quoi que ce soit n'est pas une règle,
* c'est une intention. Ce test fabrique les cas interdits par le brief et vérifie que
* `validate.mjs` les rejette bien avec un code de sortie non nul.
*
* Chaque cas correspond à une phrase du brief, citée en regard.
*/
import { readFile, writeFile, mkdir, rm } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
import { spawnSync } from 'node:child_process';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const BAC = join('tools', 'data', 'out', 'test-porte');
/** Applique une mutation à une copie profonde de l'entrée de référence. */
const muter = (base, fn) => {
const c = structuredClone(base);
fn(c);
return c;
};
/** Un média de référence, valide et utilisable comme épreuve. */
const mediaValide = (over = {}) => ({
id: 'photo-temoin',
filiere: 'A',
type_media: 'photo',
vue: 'fruit_entier',
usages: ['epreuve'],
source: 'wikimedia_commons',
auteur: 'Témoin',
licence: 'CC-BY-SA-4.0',
licence_url: null,
url_fichier: 'https://upload.wikimedia.org/temoin.jpg',
url_description: 'https://commons.wikimedia.org/wiki/File:temoin.jpg',
largeur: 800,
hauteur: 600,
mime: 'image/jpeg',
description: null,
date_prise_de_vue: null,
attribution_requise: true,
partage_identique: true,
usage_non_commercial: false,
composite: false,
valide: true,
valide_par: 'porteur',
valide_le: '2026-07-27',
note_validation: null
});
const CAS = [
{
nom: 'épreuve sur un média non validé',
regle: 'brief §2 — « Le build échoue si un média non validé est référencé par une épreuve. »',
muter: (e) => {
e.medias = [mediaValide({}), mediaValide()];
e.medias[0].id = 'photo-non-validee';
e.medias[0].valide = false;
e.medias[0].valide_par = null;
}
},
{
nom: 'épreuve sur un asset généré (filière B)',
regle: "brief §10 — « Aucune épreuve d'identification ne s'appuie sur un asset IA. »",
muter: (e) => {
e.medias = [mediaValide()];
e.medias[0].filiere = 'B';
e.medias[0].type_media = 'image_generee';
}
},
{
nom: 'épreuve sur une planche botanique',
regle: "brief §2 — filière A = photographie réelle ; une gravure documente mais n'entraîne pas la reconnaissance du réel.",
muter: (e) => {
e.medias = [mediaValide()];
e.medias[0].type_media = 'planche_botanique';
}
},
{
nom: 'épreuve portée par une plante dangereuse',
regle: 'brief §6 — « Pa touché : jamais chronométré, jamais scoré, jamais transformé en épreuve. »',
muter: (e) => {
e.categorie = 'plante_dangereuse';
e.danger = { niveau: 'critique', resume: 'Ne jamais toucher ni manger ce fruit.' };
delete e.saison_mois;
e.comestible = false;
e.medias = [mediaValide()];
}
},
{
nom: 'média à attribution sans auteur',
regle: 'brief §3 — « Récupérer auteur, licence, URL du fichier, URL de la page de description. »',
muter: (e) => {
e.medias = [mediaValide()];
e.medias[0].auteur = null;
}
},
{
nom: 'compositing sur un média en partage à lidentique',
regle: "ADR 0001 — le SA contamine les dérivés, le NC est incompatible avec le SA : aucun compositing.",
muter: (e) => {
e.medias = [mediaValide()];
e.medias[0].composite = true;
}
},
{
nom: 'entrée publiée avec une seule photo validée',
regle: 'brief §8 — « ≥ 2 photos Commons par fruit avec auteur, licence et URL. »',
muter: (e) => {
e.medias = [mediaValide()];
e.orthographe = { norme: 'gwadloupeyen', statut: 'relu', relu_par: 'relecteur' };
e.valide = true;
}
},
{
nom: 'entrée publiée sans relecture kréyòl',
regle: "brief §5 — « L'orthographe kréyòl n'est pas de ton ressort. »",
muter: (e) => {
e.medias = [mediaValide(), muter(mediaValide(), (m) => (m.id = 'photo-temoin-2'))];
e.valide = true;
}
},
{
nom: 'validation anonyme',
regle: 'porte du projet — une validation sans nom nengage personne.',
muter: (e) => {
e.medias = [mediaValide()];
e.medias[0].valide_par = null;
}
}
];
/** Cas témoin : doit passer. Un test qui échoue toujours ne prouve rien non plus. */
const TEMOIN = {
nom: 'TÉMOIN — entrée correcte, doit passer',
attendu: 0,
muter: (e) => {
e.medias = [mediaValide(), muter(mediaValide(), (m) => (m.id = 'photo-temoin-2'))];
e.orthographe = { norme: 'gwadloupeyen', statut: 'relu', relu_par: 'relecteur' };
e.valide = true;
}
};
async function jouer(cas, base, i) {
const dir = join(BAC, `cas-${i}`);
await mkdir(join(ROOT, dir), { recursive: true });
await writeFile(join(ROOT, dir, `${base.id}.json`), JSON.stringify(muter(base, cas.muter), null, '\t'));
const r = spawnSync(process.execPath, [join(ROOT, 'tools', 'data', 'validate.mjs')], {
cwd: ROOT,
env: { ...process.env, KI_FWI_DATA: dir },
encoding: 'utf8'
});
const attendu = cas.attendu ?? 1;
const obtenu = r.status;
const ok = obtenu === attendu;
console.log(`${ok ? '✔' : '✘'} ${cas.nom}`);
if (cas.regle) console.log(` ${cas.regle}`);
if (!ok) {
console.log(` attendu code ${attendu}, obtenu ${obtenu}`);
console.log((r.stderr || r.stdout).split('\n').slice(-8).join('\n'));
}
return ok;
}
async function main() {
const base = JSON.parse(await readFile(join(ROOT, 'src/lib/data/fruits/kowosol.json'), 'utf8'));
await rm(join(ROOT, BAC), { recursive: true, force: true });
console.log('\nTest de la porte de validation\n');
const resultats = [];
resultats.push(await jouer(TEMOIN, base, 0));
for (const [i, cas] of CAS.entries()) resultats.push(await jouer(cas, base, i + 1));
await rm(join(ROOT, BAC), { recursive: true, force: true });
const echecs = resultats.filter((r) => !r).length;
console.log(`\n${resultats.length - echecs}/${resultats.length} cas conformes.`);
if (echecs) {
console.error(`${echecs} cas non couvert(s) : la porte laisse passer ce qu'elle devrait refuser.`);
process.exit(1);
}
console.log('✔ La porte refuse tout ce que le brief interdit.');
}
main().catch((e) => {
console.error('✘', e.stack ?? e.message);
process.exit(1);
});
+163
View File
@@ -0,0 +1,163 @@
#!/usr/bin/env node
/**
* validate.mjs — LA PORTE. Ce script est la règle n°1 du projet écrite en code.
*
* Le brief §2 l'exige mot pour mot : « Le build échoue si un média non validé est
* référencé par une épreuve. Écris ce contrôle comme un script de build, pas comme
* une consigne. » Ce fichier est ce contrôle.
*
* Il vérifie deux couches :
* 1. la forme, contre fruits.schema.json (Ajv) ;
* 2. les règles que le schéma ne peut pas exprimer — filières, licences, sécurité.
*
* Sortie non nulle = build refusé. Aucune de ces règles ne se contourne par un flag.
*/
import { readFile, readdir } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { dirname, join, basename } from 'node:path';
import Ajv2020 from 'ajv/dist/2020.js';
import addFormats from 'ajv-formats';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
/** Surchargeable pour les tests de la porte (tools/data/test-porte.mjs). */
const DATA = process.env.KI_FWI_DATA ? join(ROOT, process.env.KI_FWI_DATA) : join(ROOT, 'src', 'lib', 'data', 'fruits');
/** Usages qui servent à identifier une espèce. Ce sont eux qui déclenchent la porte. */
const USAGES_IDENTIFIANTS = new Set(['epreuve', 'indice']);
const erreurs = [];
const alertes = [];
const err = (fichier, msg) => erreurs.push(`${fichier}${msg}`);
const warn = (fichier, msg) => alertes.push(`${fichier}${msg}`);
async function main() {
const schema = JSON.parse(await readFile(join(ROOT, 'fruits.schema.json'), 'utf8'));
const ajv = new Ajv2020({ allErrors: true, strict: false });
addFormats(ajv);
const valider = ajv.compile(schema);
const fichiers = (await readdir(DATA)).filter((f) => f.endsWith('.json')).sort();
if (!fichiers.length) {
console.error('✘ Aucune entrée dans src/lib/data/fruits/.');
process.exit(1);
}
const vus = new Map();
const entrees = [];
let nbMedias = 0;
let nbMediasValides = 0;
let nbNC = 0;
for (const f of fichiers) {
const brut = await readFile(join(DATA, f), 'utf8');
let e;
try {
e = JSON.parse(brut);
} catch (ex) {
err(f, `JSON illisible : ${ex.message}`);
continue;
}
// ── Couche 1 : la forme
if (!valider(e)) {
for (const d of valider.errors ?? []) err(f, `schéma : ${d.instancePath || '/'} ${d.message}`);
continue;
}
// ── Couche 2 : les règles métier
if (e.id !== basename(f, '.json')) err(f, `id « ${e.id} » ≠ nom de fichier`);
if (vus.has(e.id)) err(f, `id « ${e.id} » déjà utilisé par ${vus.get(e.id)}`);
vus.set(e.id, f);
const idsMedias = new Set();
for (const m of e.medias) {
nbMedias++;
if (m.valide) nbMediasValides++;
if (m.usage_non_commercial) nbNC++;
if (idsMedias.has(m.id)) err(f, `média « ${m.id} » en double`);
idsMedias.add(m.id);
const identifiant = m.usages.some((u) => USAGES_IDENTIFIANTS.has(u));
// LA PORTE — brief §2 et §10.
if (identifiant) {
if (m.filiere !== 'A')
err(f, `média « ${m.id} » sert à identifier mais relève de la filière ${m.filiere} : une image générée n'enseigne pas le réel`);
if (m.type_media !== 'photo')
err(f, `média « ${m.id} » sert à identifier mais n'est pas une photo (${m.type_media})`);
if (!m.valide)
err(f, `média « ${m.id} » sert à identifier mais n'est pas validé — aucun humain n'a confirmé qu'il montre bien ${e.taxon.scientifique}`);
if (!m.vue) err(f, `média « ${m.id} » sert à identifier mais sa vue n'est pas renseignée`);
}
// Cohérence des filières
if (m.filiere === 'B' && m.type_media !== 'image_generee')
err(f, `média « ${m.id} » : filière B impose type_media = image_generee`);
if (m.filiere === 'B' && m.usages.some((u) => u !== 'decor'))
err(f, `média « ${m.id} » : un média de filière B ne peut servir qu'au décor`);
if (m.filiere === 'A' && m.type_media === 'image_generee')
err(f, `média « ${m.id} » : filière A n'accepte aucune image générée`);
// Licences — brief §3
if (m.attribution_requise && !m.auteur)
err(f, `média « ${m.id} » : licence ${m.licence} à attribution, auteur manquant`);
if (m.composite && (m.partage_identique || m.usage_non_commercial))
err(f, `média « ${m.id} » : compositing interdit sur un média SA ou NC (conflit de licence, ADR 0001)`);
if (m.valide && !m.valide_par)
err(f, `média « ${m.id} » : validé sans nom de validateur — une validation anonyme n'en est pas une`);
// Signalement, pas blocage : l'ADR 0001 accepte le NC contre un engagement non commercial.
if (m.usage_non_commercial && m.source === 'wikimedia_commons')
warn(f, `média « ${m.id} » : NC annoncé sur une source Commons — vérifier, Commons n'héberge pas de NC`);
}
// Sécurité enfants — brief §6 : « Pa touché » n'est jamais une épreuve, jamais scoré.
if (e.categorie === 'plante_dangereuse') {
if (!e.danger) err(f, 'plante dangereuse sans bloc danger');
for (const m of e.medias)
if (m.usages.includes('epreuve'))
err(f, `média « ${m.id} » : une plante dangereuse ne peut pas porter d'épreuve — le module Pa touché n'est ni chronométré ni scoré`);
}
// Conditions de publication d'une entrée
if (e.valide) {
const photos = e.medias.filter((m) => m.valide && m.filiere === 'A' && m.type_media === 'photo');
if (photos.length < 2)
err(f, `entrée déclarée valide avec ${photos.length} photo(s) validée(s) — le brief §8 en exige au moins deux`);
if (e.orthographe.statut !== 'relu')
err(f, "entrée déclarée valide alors que l'orthographe kréyòl n'est pas relue (ADR 0005)");
} else {
warn(f, 'entrée non encore validée pour publication');
}
if (!e.audio.gcf) warn(f, 'aucun enregistrement kréyòl — le jeu devra se replier en silence');
if (e.orthographe.statut === 'a_relire') warn(f, 'orthographe kréyòl à relire');
entrees.push(e);
}
// ── Rapport
console.log(`\nEntrées : ${entrees.length}`);
console.log(`Médias : ${nbMedias} (${nbMediasValides} validés, ${nbNC} sous clause NC)`);
console.log(`Publiables : ${entrees.filter((e) => e.valide).length}`);
if (alertes.length) {
console.log(`\n${alertes.length} alerte(s) — n'empêchent pas le build :`);
for (const a of alertes) console.log(` · ${a}`);
}
if (erreurs.length) {
console.error(`\n${erreurs.length} erreur(s) — build refusé :`);
for (const e of erreurs) console.error(` · ${e}`);
process.exit(1);
}
console.log('\n✔ Dataset conforme. Aucune épreuve ne sappuie sur un média non validé.');
}
main().catch((e) => {
console.error('✘', e.stack ?? e.message);
process.exit(1);
});
+195
View File
@@ -0,0 +1,195 @@
#!/usr/bin/env node
/**
* verify-taxa.mjs — vérification taxonomique contre Wikidata et GBIF.
*
* Le brief §8 exige que chaque champ taxonomique soit vérifié contre Wikidata ET GBIF,
* « pas contre ta mémoire ». Ce script est ce contrôle : il lit les noms scientifiques
* d'amorçage, interroge les deux référentiels, et écrit un rapport de divergence.
*
* Il ne corrige rien tout seul : il constate. Les corrections sont reportées à la main
* dans src/lib/data/fruits/*.json, avec la trace de la réponse d'API en regard.
*
* Sorties :
* tools/data/out/taxa-verified.json — données brutes des deux API
* docs/verification-taxons.md — rapport lisible, versionné
*
* Hors ligne : ce script vit dans tools/, jamais dans le bundle. Aucun appel réseau
* ne subsiste au runtime du jeu (playbook P2 : bake au build).
*/
import { readFile, writeFile, mkdir } from 'node:fs/promises';
import { fileURLToPath } from 'node:url';
import { dirname, join } from 'node:path';
const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
const UA = 'ki-fwi-dataset/0.1 (https://o-k-i.net ; projet educatif ORGANISATION KA INTERNATIONALE)';
const WIKIDATA_SPARQL = 'https://query.wikidata.org/sparql';
const WIKIDATA_API = 'https://www.wikidata.org/w/api.php';
const GBIF_MATCH = 'https://api.gbif.org/v1/species/match';
const GBIF_SPECIES = 'https://api.gbif.org/v1/species';
/** Rang « famille » dans Wikidata. */
const Q_FAMILLE = 'wd:Q35409';
async function getJson(url, params) {
const u = new URL(url);
for (const [k, v] of Object.entries(params ?? {})) u.searchParams.set(k, v);
const res = await fetch(u, { headers: { 'User-Agent': UA, Accept: 'application/json' } });
if (!res.ok) throw new Error(`${res.status} ${res.statusText}${u}`);
return res.json();
}
/**
* Interroge Wikidata en une seule requête SPARQL : entité dont P225 (nom du taxon)
* vaut exactement le nom cherché, plus la famille remontée par P171+ filtrée sur P105 = famille.
*/
async function wikidataBatch(noms) {
const values = noms.map((n) => JSON.stringify(n)).join(' ');
const query = `SELECT ?taxon ?name ?fam ?rang WHERE {
VALUES ?name { ${values} }
?taxon wdt:P225 ?name .
OPTIONAL { ?taxon wdt:P105 ?r . ?r rdfs:label ?rang . FILTER(LANG(?rang) = "fr") }
OPTIONAL { ?taxon wdt:P171+ ?f . ?f wdt:P105 ${Q_FAMILLE} ; wdt:P225 ?fam . }
}`;
const u = new URL(WIKIDATA_SPARQL);
u.searchParams.set('query', query);
const res = await fetch(u, {
headers: { 'User-Agent': UA, Accept: 'application/sparql-results+json' }
});
if (!res.ok) throw new Error(`SPARQL ${res.status} ${res.statusText}`);
const json = await res.json();
const parNom = new Map();
for (const b of json.results.bindings) {
const nom = b.name.value;
if (!parNom.has(nom)) parNom.set(nom, { qid: null, famille: null, rang: null });
const e = parNom.get(nom);
e.qid = b.taxon.value.replace('http://www.wikidata.org/entity/', '');
if (b.fam) e.famille = b.fam.value;
if (b.rang) e.rang = b.rang.value;
}
return parNom;
}
/** Repli quand P225 ne correspond à rien : recherche plein texte, pour au moins pointer une piste. */
async function wikidataRecherche(nom) {
const json = await getJson(WIKIDATA_API, {
action: 'wbsearchentities',
search: nom,
language: 'en',
format: 'json',
limit: '3',
origin: '*'
});
return (json.search ?? []).map((r) => ({ qid: r.id, label: r.label, description: r.description }));
}
async function gbif(nom) {
const m = await getJson(GBIF_MATCH, { name: nom, strict: 'false' });
const out = {
usageKey: m.usageKey ?? null,
scientificName: m.scientificName ?? null,
canonicalName: m.canonicalName ?? null,
rank: m.rank ?? null,
status: m.status ?? null,
matchType: m.matchType ?? null,
confidence: m.confidence ?? null,
famille: m.family ?? null,
accepte: null
};
// Un synonyme est le piège classique de ce dataset : GBIF renvoie alors la clé du
// synonyme et il faut aller chercher le nom accepté pour ne pas figer un nom périmé.
if (out.status && out.status !== 'ACCEPTED' && out.usageKey) {
try {
const sp = await getJson(`${GBIF_SPECIES}/${out.usageKey}`, {});
out.accepte = {
usageKey: sp.acceptedKey ?? null,
scientificName: sp.accepted ?? null
};
} catch {
out.accepte = { erreur: 'lecture /species/{key} impossible' };
}
}
return out;
}
const ok = (a, b) => typeof a === 'string' && typeof b === 'string' && a.toLowerCase() === b.toLowerCase();
async function main() {
const seed = JSON.parse(await readFile(join(ROOT, 'fruits.seed.json'), 'utf8'));
const entrees = [
...seed.fruits.map((f) => ({ ...f, categorie: 'fruit' })),
...seed.plantes_dangereuses.map((p) => ({ ...p, categorie: 'plante_dangereuse' }))
];
const noms = entrees.map((e) => e.taxon.scientifique);
console.log(`→ Wikidata : ${noms.length} taxons en une requête SPARQL…`);
const wd = await wikidataBatch(noms);
const resultats = [];
for (const e of entrees) {
const nom = e.taxon.scientifique;
process.stdout.write(`→ GBIF : ${nom}`);
const g = await gbif(nom);
console.log(g.status ?? 'AUCUN MATCH');
let w = wd.get(nom) ?? null;
let pistes = null;
if (!w) pistes = await wikidataRecherche(nom);
const ecarts = [];
if (!w) ecarts.push('Wikidata : aucune entité avec P225 = ce nom exact');
if (w && !w.famille) ecarts.push('Wikidata : famille non remontée par P171+');
if (w?.famille && !ok(w.famille, e.taxon.famille))
ecarts.push(`famille Wikidata « ${w.famille} » ≠ graine « ${e.taxon.famille} »`);
if (g.famille && !ok(g.famille, e.taxon.famille))
ecarts.push(`famille GBIF « ${g.famille} » ≠ graine « ${e.taxon.famille} »`);
if (g.status && g.status !== 'ACCEPTED')
ecarts.push(`GBIF : statut ${g.status} — nom accepté ${g.accepte?.scientificName ?? '?'}`);
if (g.matchType && g.matchType !== 'EXACT') ecarts.push(`GBIF : matchType ${g.matchType}`);
resultats.push({
id: e.id,
categorie: e.categorie,
graine: { scientifique: nom, famille: e.taxon.famille },
wikidata: w,
wikidata_pistes: pistes,
gbif: g,
ecarts
});
}
await mkdir(join(ROOT, 'tools', 'data', 'out'), { recursive: true });
await writeFile(
join(ROOT, 'tools', 'data', 'out', 'taxa-verified.json'),
JSON.stringify({ genere_le: new Date().toISOString(), resultats }, null, 2) + '\n'
);
const lignes = [
'# Vérification taxonomique — Wikidata × GBIF',
'',
`> Généré par \`npm run verify:taxa\` le ${new Date().toISOString().slice(0, 10)}.`,
'> Ne pas éditer à la main : rejouer le script quand la graine change.',
'',
'| id | nom de la graine | Wikidata | famille WD | GBIF | statut GBIF | famille GBIF | écarts |',
'|---|---|---|---|---|---|---|---|'
];
for (const r of resultats) {
lignes.push(
`| \`${r.id}\` | *${r.graine.scientifique}* | ${r.wikidata?.qid ?? '—'} | ${r.wikidata?.famille ?? '—'} | ${r.gbif.usageKey ?? '—'} | ${r.gbif.status ?? '—'} | ${r.gbif.famille ?? '—'} | ${r.ecarts.length ? r.ecarts.join(' · ') : 'aucun'} |`
);
}
const nbEcarts = resultats.filter((r) => r.ecarts.length).length;
lignes.push('', `**${nbEcarts} entrée(s) sur ${resultats.length} présentent au moins un écart.**`, '');
await writeFile(join(ROOT, 'docs', 'verification-taxons.md'), lignes.join('\n'));
console.log(`\n${resultats.length} taxons vérifiés, ${nbEcarts} avec écart.`);
console.log(' tools/data/out/taxa-verified.json');
console.log(' docs/verification-taxons.md');
}
main().catch((err) => {
console.error('✘', err.message);
process.exit(1);
});