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
+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);
});