Files
ki-fwi/tools/data/rapport-manques.mjs
OKIandClaude Opus 5 9e3c176411 feat(participatif): publier les manques et outiller la contribution
Le manque devient une information de première classe : mesuré, daté,
affiché, et accompagné de la marche à suivre pour le combler (ADR 0006).

Mesure
- rapport:manques : couverture des 5 vues pédagogiques dans les fonds
  publics, présence du kréyòl sur Wikidata, observations GBIF par île,
  manques internes du dataset → docs/manques.md.
- Le kréyòl est absent des communs : Wikidata enregistre 124 noms
  vernaculaires pour ces 13 espèces (zh, en, pt, sl) et aucun en kréyòl ;
  0 étiquette gcf sur 13 items.
- L'écorce est la vue manquante : 3 trous sur 65, tous des écorces —
  mansiniyé 0 fichier, aki 0, kénèt 1.
- Pl@ntNet publie en CC BY-SA là où iNaturalist est à ~93 % CC BY-NC :
  meilleur canal de contribution à effort égal.

Correction d'une mesure fausse avant publication
- Le premier rapport annonçait 28 vues manquantes sur 65. Le groupement OR
  de la recherche Commons ne se comporte pas comme attendu : « section »
  seul remonte 17 fichiers là où « (section OR sliced OR cut OR coupe) » en
  remonte 0. Un mot, une requête, avec gestion du débit — le chiffre réel
  est 3 sur 65. Publier les 28 aurait envoyé des bénévoles photographier
  ce qui existait déjà.

Documents
- docs/contribuer.md : six gestes, du nom kréyòl sur Wikidata (5 min) à
  l'enregistrement d'une voix, avec le tableau de choix de licence et la
  consigne de sécurité pour photographier le mancenillier.
- docs/adr/0006-projet-participatif.md : aucun dépôt interne, aucune
  requête tierce au runtime, la porte de validation reste inchangée.
- docs/courriers/appel-liberation-tramil.md : brouillon d'appel public,
  à n'envisager qu'après la demande privée. Publication au porteur.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-27 10:12:35 -04:00

350 lines
14 KiB
JavaScript
Raw Permalink 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
/**
* rapport-manques.mjs — mesure ce qui manque, et où le trouver.
*
* Le projet est participatif : un manque n'est pas un défaut à masquer, c'est une tâche à
* publier. Ce script produit l'inventaire des trous — dans les données du projet ET dans
* les fonds publics — pour que chacun puisse en combler un.
*
* Deux mesures distinctes :
*
* 1. MANQUES INTERNES — ce que le dataset n'a pas : photos validées, vues assignées,
* enregistrement kréyòl, sources factuelles, relecture, saisonnalité sourcée.
*
* 2. DISPONIBILITÉ EXTERNE — ce que les fonds publics contiennent pour chaque vue
* pédagogique. Un trou ici veut dire : « personne au monde n'a publié cette photo
* sous licence libre », donc c'est une photo à aller prendre.
*
* LIMITE À CONNAÎTRE : la recherche Commons porte sur le TEXTE des pages de fichier, pas
* sur le contenu des images. Une photo de feuille dont la description ne dit pas « leaf »
* est comptée comme absente. Ce n'est pas un défaut de la mesure : une photo que personne
* ne peut trouver est presque aussi inutile qu'une photo qui n'existe pas — et la
* redécrire sur Commons est justement une contribution utile.
*
* Sortie : docs/manques.md
*/
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 UA = 'ki-fwi-dataset/0.1 (https://o-k-i.net ; projet educatif ORGANISATION KA INTERNATIONALE)';
/**
* Les vues pédagogiques, avec les mots par lesquels on les cherche dans plusieurs langues.
*
* Chaque mot est interrogé SÉPARÉMENT. Une première version groupait les mots par `OR` —
* la syntaxe est acceptée par l'API mais ne se comporte pas comme attendu : sur
* Annona muricata, `section` seul remonte 17 fichiers alors que
* `(section OR sliced OR cut OR coupe)` en remonte 0. Le rapport affichait donc des
* manques inexistants. Un mot, une requête.
*/
const VUES = {
fruit_entier: ['fruit', 'fruits', 'fruta', 'frutas'],
coupe: ['section', 'sliced', 'pulp', 'cut'],
feuille: ['leaf', 'leaves', 'feuille', 'hoja'],
ecorce: ['bark', 'trunk', 'ecorce', 'corteza'],
port: ['tree', 'arbre', 'arbol', 'habit']
};
/** Seuil en dessous duquel une vue est déclarée manquante dans les fonds publics. */
const SEUIL_VUE = 3;
const ILES = { gp: 'GP', mq: 'MQ', gf: 'GF', re: 'RE' };
const NOM_ILE = { gp: 'Guadeloupe', mq: 'Martinique', gf: 'Guyane', re: 'La Réunion' };
const pause = (ms) => new Promise((r) => setTimeout(r, ms));
/**
* Les API Wikimedia limitent le débit et répondent alors autre chose que du JSON.
* Une réponse illisible comptée comme « 0 fichier » fabriquerait un manque imaginaire :
* on réessaie, et on échoue franchement plutôt que de renvoyer un chiffre faux.
*/
async function getJson(url, params, essais = 4) {
const u = new URL(url);
for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v);
let derniere;
for (let i = 0; i < essais; i++) {
try {
const res = await fetch(u, { headers: { 'User-Agent': UA, Accept: 'application/json' } });
if (!res.ok) throw new Error(`${res.status} ${res.statusText}`);
return await res.json();
} catch (e) {
derniere = e;
await pause(600 * (i + 1));
}
}
throw new Error(`${u.host} injoignable après ${essais} essais : ${derniere?.message}`);
}
async function commonsCompte(recherche) {
const json = await getJson('https://commons.wikimedia.org/w/api.php', {
action: 'query',
format: 'json',
list: 'search',
srnamespace: '6',
srlimit: '1',
srinfo: 'totalhits',
srsearch: recherche
});
return json.query?.searchinfo?.totalhits ?? 0;
}
/**
* Nombre de fichiers trouvables pour une vue : le maximum obtenu sur les mots de la vue.
* Le maximum et non la somme — un même fichier peut répondre à plusieurs mots, et une
* somme surestimerait la couverture, donc sous-estimerait le manque.
*/
async function commonsPourVue(nomSci, mots) {
let meilleur = 0;
let motRetenu = null;
for (const mot of mots) {
const n = await commonsCompte(`"${nomSci}" ${mot} filetype:bitmap`);
if (n > meilleur) {
meilleur = n;
motRetenu = mot;
}
await pause(350);
}
return { n: meilleur, mot: motRetenu };
}
const commonsTotal = (nomSci) => commonsCompte(`"${nomSci}" filetype:bitmap`);
/**
* Le kréyòl est-il présent dans les communs numériques, ou seulement dans ce dépôt ?
*
* Deux endroits où un nom kréyòl a sa place sur Wikidata :
* — l'étiquette de langue `gcf` (kréyòl gwadloupéyen) ;
* — la propriété P1843, « nom vernaculaire du taxon », avec sa langue.
*
* Un nom qui n'existe que dans ce jeu reste un nom perdu ailleurs. C'est la contribution
* la moins coûteuse du projet, et l'une des plus durables.
*/
async function wikidataKreyol(qids) {
const labels = await getJson('https://www.wikidata.org/w/api.php', {
action: 'wbgetentities',
ids: qids.join('|'),
props: 'labels',
languages: 'gcf',
format: 'json'
});
const etat = {};
for (const q of qids) {
etat[q] = { etiquette: labels.entities?.[q]?.labels?.gcf?.value ?? null, vernaculaires: null, gcfVern: false };
await pause(250);
try {
const cl = await getJson('https://www.wikidata.org/w/api.php', {
action: 'wbgetclaims',
entity: q,
property: 'P1843',
format: 'json'
});
const noms = cl.claims?.P1843 ?? [];
etat[q].vernaculaires = noms.length;
etat[q].gcfVern = noms.some((c) => c.mainsnak?.datavalue?.value?.language === 'gcf');
} catch {
/* le compte reste null : mieux vaut un trou qu'un chiffre inventé */
}
}
return etat;
}
/** Observations GBIF pour ce taxon dans cette île, et combien portent une image. */
async function gbifIle(taxonKey, pays) {
const [tout, avecImage] = await Promise.all([
getJson('https://api.gbif.org/v1/occurrence/search', { taxonKey: String(taxonKey), country: pays, limit: '0' }),
getJson('https://api.gbif.org/v1/occurrence/search', {
taxonKey: String(taxonKey),
country: pays,
mediaType: 'StillImage',
limit: '0'
})
]);
return { total: tout.count ?? 0, avecImage: avecImage.count ?? 0 };
}
async function main() {
const fichiers = (await readdir(DATA)).filter((f) => f.endsWith('.json')).sort();
const rapports = [];
for (const f of fichiers) {
const e = JSON.parse(await readFile(join(DATA, f), 'utf8'));
process.stdout.write(`→ ${e.id}… `);
// ── 1. Manques internes
const photos = e.medias.filter((m) => m.type_media === 'photo');
const planches = e.medias.filter((m) => m.type_media === 'planche_botanique');
const validees = e.medias.filter((m) => m.valide);
const vuesAssignees = new Set(e.medias.map((m) => m.vue).filter(Boolean));
const internes = [];
if (validees.length < 2) internes.push(`${validees.length} média validé sur ${e.medias.length} — il en faut au moins 2`);
if (photos.length < 2) internes.push(`${photos.length} photographie(s) parmi les candidats, ${planches.length} planche(s) ancienne(s)`);
if (!vuesAssignees.size) internes.push('aucune vue assignée — personne na encore regardé les images');
if (!e.audio.gcf) internes.push('aucun enregistrement du nom kréyòl');
if (!e.sources_factuelles.length) internes.push('aucune source factuelle citée pour les indices');
if (e.orthographe.statut !== 'relu') internes.push('orthographe kréyòl non relue');
if (e.categorie === 'fruit' && e.saison_mois?.length) internes.push('saisonnalité non sourcée et non ventilée par île');
// ── 2. Disponibilité externe
const total = await commonsTotal(e.taxon.scientifique);
await pause(350);
const vues = {};
for (const [vue, mots] of Object.entries(VUES)) {
vues[vue] = await commonsPourVue(e.taxon.scientifique, mots);
}
const iles = {};
for (const code of e.iles) {
if (!ILES[code]) continue;
iles[code] = await gbifIle(e.taxon.gbif, ILES[code]);
await pause(200);
}
const vuesManquantes = Object.entries(vues)
.filter(([, v]) => v.n < SEUIL_VUE)
.map(([v]) => v);
console.log(`${total} fichiers Commons · ${vuesManquantes.length} vue(s) sous le seuil`);
rapports.push({ e, internes, total, vues, vuesManquantes, iles, photos: photos.length, planches: planches.length });
}
process.stdout.write('→ Wikidata : présence du kréyòl… ');
const kreyol = await wikidataKreyol(rapports.map((r) => r.e.taxon.wikidata));
const avecEtiquette = Object.values(kreyol).filter((k) => k.etiquette).length;
const avecVernaculaire = Object.values(kreyol).filter((k) => k.gcfVern).length;
console.log(`${avecEtiquette} étiquette(s), ${avecVernaculaire} nom(s) vernaculaire(s)`);
// ── Écriture du rapport, du plus démuni au mieux servi
rapports.sort((a, b) => b.vuesManquantes.length - a.vuesManquantes.length || b.internes.length - a.internes.length);
const l = [
'# Ce qui manque — et comment le combler',
'',
`> Généré par \`npm run rapport:manques\` le ${new Date().toISOString().slice(0, 10)}.`,
'> Sortie de script : ne pas éditer à la main.',
'',
'Ce projet est participatif. Un manque nest pas un défaut à masquer : cest une tâche',
'publiée. Chaque ligne ci-dessous est quelque chose quune personne peut aller combler,',
'et dont le bénéfice dépasse ce jeu — une photo versée à Wikimedia Commons sert à tous.',
'',
'**Comment contribuer :** [`docs/contribuer.md`](contribuer.md).',
'',
'## Comment lire ce rapport',
'',
`Une vue est déclarée **manquante** quand les fonds publics contiennent moins de ${SEUIL_VUE} fichiers`,
'trouvables pour elle. La mesure porte sur le **texte** des pages de fichier Commons, pas sur',
'le contenu des images : une photo de feuille dont la description ne dit pas « leaf » est',
'comptée absente. Cest volontaire — une photo que personne ne peut trouver est presque',
'aussi inutile quune photo qui nexiste pas, et **la redécrire est déjà une contribution**.',
'',
'---',
'',
'## Vue densemble',
'',
'| entrée | fichiers Commons | fruit | coupe | feuille | écorce | port | médias validés |',
'|---|---|---|---|---|---|---|---|'
];
const mark = (n) => (n < SEUIL_VUE ? `**${n}** ⚠` : String(n));
for (const r of rapports) {
l.push(
`| \`${r.e.id}\` | ${r.total} | ${mark(r.vues.fruit_entier.n)} | ${mark(r.vues.coupe.n)} | ${mark(r.vues.feuille.n)} | ${mark(r.vues.ecorce.n)} | ${mark(r.vues.port.n)} | ${r.e.medias.filter((m) => m.valide).length}/${r.e.medias.length} |`
);
}
const totalVuesManquantes = rapports.reduce((n, r) => n + r.vuesManquantes.length, 0);
l.push(
'',
`**${totalVuesManquantes} vues manquantes sur ${rapports.length * Object.keys(VUES).length}** dans les fonds publics libres.`,
'',
'---',
'',
'## Le kréyòl dans les communs numériques',
'',
'Un nom qui nexiste que dans ce jeu reste un nom perdu partout ailleurs. Wikidata offre',
'deux emplacements pour linscrire durablement : l**étiquette** de langue `gcf`, et la',
'propriété **P1843**, « nom vernaculaire du taxon ».',
'',
`**${avecEtiquette} étiquette(s) kréyòl sur ${rapports.length}** · **${avecVernaculaire} nom(s) vernaculaire(s) kréyòl sur ${rapports.length}**.`,
'',
'| entrée | nom kréyòl | étiquette `gcf` | noms vernaculaires enregistrés | dont kréyòl |',
'|---|---|---|---|---|'
);
for (const r of rapports) {
const k = kreyol[r.e.taxon.wikidata] ?? {};
l.push(
`| [\`${r.e.id}\`](https://www.wikidata.org/wiki/${r.e.taxon.wikidata}) | ${r.e.noms.gcf} | ${k.etiquette ? '✔ ' + k.etiquette : '**absente** ⚠'} | ${k.vernaculaires ?? '?'} | ${k.gcfVern ? '✔' : '**non** ⚠'} |`
);
}
l.push(
'',
'Cest la contribution la moins coûteuse du projet et lune des plus durables : quelques',
'minutes par entrée, un bénéfice qui dépasse largement ce jeu. Marche à suivre dans',
'[`contribuer.md`](contribuer.md).',
'',
'---',
''
);
for (const r of rapports) {
const e = r.e;
l.push(`## ${e.noms.gcf} — *${e.taxon.scientifique}*`, '');
l.push(
`\`${e.id}\` · ${e.categorie === 'fruit' ? 'fruit' : '⚠ plante dangereuse'} · ${e.iles.map((i) => NOM_ILE[i] ?? i).join(', ')}`,
''
);
if (r.vuesManquantes.length) {
l.push(`### 📷 Photographies à prendre (${r.vuesManquantes.length} vue(s))`, '');
for (const v of r.vuesManquantes) {
const indice = e.indices[v === 'fruit_entier' ? 'fruit' : v];
l.push(`- **${v}** — ${r.vues[v].n} fichier(s) trouvable(s) sur Commons.`);
if (indice) l.push(` Ce quil faut montrer : ${indice}`);
}
l.push('');
} else {
l.push('### 📷 Photographies', '', 'Les cinq vues sont couvertes dans les fonds publics. Reste à les choisir et les valider.', '');
}
if (Object.keys(r.iles).length) {
l.push('### 🗺️ Où lespèce est déjà observée', '');
l.push('| île | observations GBIF | dont avec image |', '|---|---|---|');
for (const [code, v] of Object.entries(r.iles))
l.push(`| ${NOM_ILE[code] ?? code} | ${v.total} | ${v.avecImage} |`);
l.push(
'',
'Peu dobservations sur une île ne veut pas dire que lespèce y est rare : cela veut',
'souvent dire que personne ne la encore documentée. Cest aussi une contribution.',
''
);
}
if (r.internes.length) {
l.push('### 📝 Manques dans les données du projet', '');
for (const m of r.internes) l.push(`- ${m}`);
l.push('');
}
l.push(
'### 🔗 Aller voir',
'',
`- [Commons — tous les fichiers](https://commons.wikimedia.org/w/index.php?search=${encodeURIComponent(`"${e.taxon.scientifique}" filetype:bitmap`)}&ns6=1)`,
`- [Wikidata ${e.taxon.wikidata}](https://www.wikidata.org/wiki/${e.taxon.wikidata}) · [GBIF ${e.taxon.gbif}](https://www.gbif.org/species/${e.taxon.gbif})`,
`- [iNaturalist](https://www.inaturalist.org/search?q=${encodeURIComponent(e.taxon.scientifique)}) · [Pl@ntNet](https://identify.plantnet.org/)`,
''
);
}
await writeFile(join(ROOT, 'docs', 'manques.md'), l.join('\n'));
console.log(`\n✔ docs/manques.md — ${totalVuesManquantes} vues manquantes sur ${rapports.length * Object.keys(VUES).length}.`);
}
main().catch((e) => {
console.error('✘', e.stack ?? e.message);
process.exit(1);
});