Files
ki-fwi/tools/data/verify-taxa.mjs
T
OKIandClaude Opus 5 945a524b44 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>
2026-07-26 23:47:27 -04:00

196 lines
7.5 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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);
});