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