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:
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user