@sylad/cadence 1.9.0 → 1.11.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +24 -67
- package/agents/doc-keeper.md +2 -2
- package/agents/precheck-reader.md +1 -1
- package/agents/reference-analyst.md +25 -0
- package/agents/reference-verifier.md +19 -0
- package/dist/audit.js +3 -1
- package/dist/check.js +19 -3
- package/dist/cli.js +28 -5
- package/dist/corrections.js +2 -1
- package/dist/deliver.js +47 -16
- package/dist/lead.js +8 -0
- package/dist/orchestrate/briefs.js +19 -1
- package/dist/orchestrate/changelog-merge.js +94 -0
- package/dist/orchestrate/command.js +108 -60
- package/dist/orchestrate/cycle.js +226 -36
- package/dist/orchestrate/integrate.js +52 -8
- package/dist/orchestrate/launch.js +14 -4
- package/dist/orchestrate/no-test.js +41 -0
- package/dist/orchestrate/registry.js +23 -0
- package/dist/orchestrate/snapshot.js +29 -1
- package/dist/orchestrate/state.js +14 -9
- package/dist/orchestrate/table.js +13 -1
- package/dist/perf.js +109 -7
- package/dist/plan.js +20 -1
- package/dist/proc.js +6 -0
- package/dist/ref/add.js +24 -11
- package/dist/ref/analyse.js +220 -0
- package/dist/ref/command.js +15 -1
- package/dist/ref/diff.js +262 -0
- package/dist/ref/evaluate.js +2 -2
- package/dist/ref/figures.js +86 -13
- package/dist/ref/pdf.js +20 -8
- package/dist/ref/regard.js +102 -0
- package/dist/ref/session.js +34 -0
- package/dist/state.js +17 -2
- package/docs/deliver.md +20 -4
- package/docs/fonctionnement.md +3 -3
- package/docs/lead.md +8 -4
- package/docs/news.md +6 -0
- package/docs/orchestrate.md +71 -40
- package/docs/overview.md +65 -1
- package/docs/perf.md +3 -2
- package/docs/raf.md +2 -1
- package/docs/ref.md +102 -3
- package/docs/releasing.md +3 -3
- package/docs/skills.md +14 -1
- package/docs/spec.md +1 -1
- package/docs/whats-new.md +31 -1
- package/package.json +1 -1
- package/skills/deliver/SKILL.md +25 -2
- package/skills/lead/SKILL.md +6 -6
- package/skills/orchestrate/SKILL.md +39 -0
- package/templates/orchestrate/fix-docs.md +2 -2
- package/templates/orchestrate/implement.md +1 -1
- package/templates/orchestrate/precheck.md +1 -1
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
import { existsSync, rmSync, writeFileSync } from 'node:fs';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { PACKAGE_ROOT } from '../orchestrate/snapshot.js';
|
|
4
|
+
import { RafError } from '../plan.js';
|
|
5
|
+
import { add } from './add.js';
|
|
6
|
+
import { checkInventaire, readInventaire } from './check.js';
|
|
7
|
+
import { assembler, parseSeuil, reconcilier } from './diff.js';
|
|
8
|
+
import { troisiemeRegard } from './regard.js';
|
|
9
|
+
import { sessionDeRef } from './session.js';
|
|
10
|
+
/** Plafond déclaré d'une lecture : au plus 20 pages rendues lues d'un coup (design §5, « Taille »). Au-delà, `--sections` en choisit moins. */
|
|
11
|
+
export const LECTURE_PAGES_MAX = 20;
|
|
12
|
+
export const ANALYSE_MODELS = ['sonnet', 'opus', 'haiku'];
|
|
13
|
+
/** Sortie structurée de la session : ce que l'agent dit de sa lecture ; le programme ne s'y fie pas, il relit le fichier. */
|
|
14
|
+
export const ANALYSE_SCHEMA = {
|
|
15
|
+
type: 'object',
|
|
16
|
+
properties: {
|
|
17
|
+
resume: { type: 'string', description: 'Two sentences: what the document is and what the reading holds.' },
|
|
18
|
+
questions: { type: 'array', items: { type: 'string' }, description: 'What a human must settle: a contradiction, an element that could not be read.' },
|
|
19
|
+
},
|
|
20
|
+
required: ['resume', 'questions'],
|
|
21
|
+
additionalProperties: false,
|
|
22
|
+
};
|
|
23
|
+
/** Consigne de la lecture : tout ce que l'agent a besoin de savoir, rien de l'attendu d'un corpus, rien d'une autre lecture. */
|
|
24
|
+
function brief(o) {
|
|
25
|
+
const cadence = `node ${join(PACKAGE_ROOT, 'bin', 'cadence.js')}`;
|
|
26
|
+
const config = `--config ${o.configPath}`;
|
|
27
|
+
return [
|
|
28
|
+
`Lis le document de référence « ${o.slug} » et écris sa lecture.`,
|
|
29
|
+
'',
|
|
30
|
+
`Dossier du document : ${o.docDir} (document.json : texte positionné, images, index des figures et passages qui les citent).`,
|
|
31
|
+
`Pages rendues à 200 dpi (${o.pages.length}) :`,
|
|
32
|
+
...o.pages.map((n) => `- ${join(o.docDir, 'pages', `page-${n}.png`)}`),
|
|
33
|
+
'',
|
|
34
|
+
`Modèle du format : ${join(PACKAGE_ROOT, 'templates', 'inventaire.json')} (genre « lecture », sans la clé pages ; slug et sha256 recopiés de document.json).`,
|
|
35
|
+
`Lecture à écrire : ${o.file}`,
|
|
36
|
+
'',
|
|
37
|
+
'Commandes :',
|
|
38
|
+
`- mesurer une zone (pixels de la page rendue) : ${cadence} ref measure ${o.slug} <page> <page | x,y | x,y,l,h> --quoi couleurs|contraste|boites|texte ${config}`,
|
|
39
|
+
`- valider la lecture, jusqu'à ce qu'elle passe : ${cadence} ref check ${o.file}`,
|
|
40
|
+
'',
|
|
41
|
+
`Ne lis que ce dossier, le modèle et les pages ci-dessus, et aucun autre fichier de ${join(o.docDir, 'lectures')} que le tien : une autre lecture du même document peut s'y trouver, la tienne doit être la seule que tu connais ; n'écris que ${o.file}. Les jetons ne sont pas à écrire : le programme les compte.`,
|
|
42
|
+
].join('\n');
|
|
43
|
+
}
|
|
44
|
+
/** « 1 page lue », « 3 pages lues ». */
|
|
45
|
+
const pagesLues = (n) => `${n} page${n > 1 ? 's' : ''} lue${n > 1 ? 's' : ''}`;
|
|
46
|
+
/** Les écarts d'une lecture au-delà du schéma : le genre, et le document lu (jamais une mesure sur deux versions mêlées). */
|
|
47
|
+
function ecartsDeLecture(lecture, doc) {
|
|
48
|
+
const l = lecture;
|
|
49
|
+
const out = [];
|
|
50
|
+
if (l.genre !== 'lecture')
|
|
51
|
+
out.push('genre : « lecture » attendu (une lecture, pas un inventaire)');
|
|
52
|
+
if (l.document?.slug !== doc.slug)
|
|
53
|
+
out.push(`document.slug : « ${String(l.document?.slug)} » n'est pas le document lu (${doc.slug})`);
|
|
54
|
+
if (l.document?.sha256 !== doc.sha256)
|
|
55
|
+
out.push(`document.sha256 : l'empreinte n'est pas celle du document lu (${doc.sha256.slice(0, 12)}…)`);
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
/** Une lecture : une session neuve de `reference-analyst` qui écrit `lectures/<nom>.json`, relue par le programme (ref check, genre, document) ; les jetons de la session y sont posés dans `usage`. */
|
|
59
|
+
async function lireUne(c, nom, model) {
|
|
60
|
+
const file = join(c.docDir, 'lectures', `${nom}.json`);
|
|
61
|
+
rmSync(file, { force: true }); // une lecture d'un passage précédent ne passe jamais pour celle de celui-ci
|
|
62
|
+
const r = await sessionDeRef({
|
|
63
|
+
agent: 'reference-analyst',
|
|
64
|
+
brief: brief({ slug: c.slug, docDir: c.docDir, pages: c.pages, file, configPath: c.o.configPath }),
|
|
65
|
+
model,
|
|
66
|
+
schema: ANALYSE_SCHEMA,
|
|
67
|
+
slug: c.slug,
|
|
68
|
+
docDir: c.docDir,
|
|
69
|
+
configPath: c.o.configPath,
|
|
70
|
+
claudeHome: c.o.claudeHome,
|
|
71
|
+
claude: c.o.claude,
|
|
72
|
+
agents: c.o.agents,
|
|
73
|
+
onRelaunch: () => rmSync(file, { force: true }), // la lecture à moitié écrite de la session coupée ne passe pas pour celle de la neuve
|
|
74
|
+
echec: 'la session de lecture a échoué',
|
|
75
|
+
});
|
|
76
|
+
if (r.kind === 'quota')
|
|
77
|
+
return r;
|
|
78
|
+
if (!existsSync(file))
|
|
79
|
+
return { kind: 'refus', problems: [`aucune lecture écrite : ${file}`] };
|
|
80
|
+
let lecture;
|
|
81
|
+
try {
|
|
82
|
+
lecture = readInventaire(file);
|
|
83
|
+
}
|
|
84
|
+
catch (e) {
|
|
85
|
+
return { kind: 'refus', problems: [e.message] };
|
|
86
|
+
}
|
|
87
|
+
const schema = checkInventaire(lecture);
|
|
88
|
+
const problems = schema.length > 0 ? schema : ecartsDeLecture(lecture, c.doc);
|
|
89
|
+
if (problems.length > 0)
|
|
90
|
+
return { kind: 'refus', problems };
|
|
91
|
+
const usage = { tokens: r.tokens, modele: model, session: r.session };
|
|
92
|
+
lecture.usage = usage;
|
|
93
|
+
writeFileSync(file, `${JSON.stringify(lecture, null, 2)}\n`);
|
|
94
|
+
const structured = r.structured;
|
|
95
|
+
return { kind: 'ok', lecture, file, usage, questions: Array.isArray(structured?.questions) ? structured.questions.map(String) : [] };
|
|
96
|
+
}
|
|
97
|
+
const confiancesDe = (lecture) => ['mesure', 'lu', 'incertain'].map((c) => [c, lecture.elements.filter((e) => e.confiance === c).length]).filter(([, n]) => n > 0).map(([c, n]) => `${c} : ${n}`).join(', ');
|
|
98
|
+
/**
|
|
99
|
+
* `cadence ref analyse` : la chaîne — add, puis UNE lecture (une session de l'agent `reference-analyst`, `lectures/a.json`) ; avec `--double`, deux lectures
|
|
100
|
+
* indépendantes (A et B, modèles possiblement différents), `ref diff` (réconciliation par le programme), le troisième regard sur ce qu'une seule lecture
|
|
101
|
+
* porte, l'inventaire écrit avec les jetons posés dans `usage` (la double lecture n'a pas montré de gain : c'est une option, pas le défaut). Rend le code de sortie : 0 fait, 1 lecture refusée ou trop d'éléments non
|
|
102
|
+
* appariés ou aucun élément gardé par le troisième regard, 3 quota ; une session en échec ou un refus d'entrée (modèle, plafond, aucune page) lève une `RafError` (code 2).
|
|
103
|
+
*/
|
|
104
|
+
export async function analyse(o) {
|
|
105
|
+
const model = (o.model ?? 'sonnet');
|
|
106
|
+
const modelB = (o.modelB ?? 'opus');
|
|
107
|
+
if (!ANALYSE_MODELS.includes(model))
|
|
108
|
+
throw new RafError(`ref analyse : --model invalide : « ${o.model} » (attendu : ${ANALYSE_MODELS.join(', ')})`);
|
|
109
|
+
if (!ANALYSE_MODELS.includes(modelB))
|
|
110
|
+
throw new RafError(`ref analyse : --model-b invalide : « ${o.modelB} » (attendu : ${ANALYSE_MODELS.join(', ')})`);
|
|
111
|
+
if (!o.double && o.modelB !== undefined)
|
|
112
|
+
throw new RafError('ref analyse : --model-b n\'a de sens qu\'avec --double (une seule lecture, pas de lecture B)');
|
|
113
|
+
if (!o.double && o.seuil !== undefined)
|
|
114
|
+
throw new RafError('ref analyse : --seuil n\'a de sens qu\'avec --double (une seule lecture, rien à réconcilier)');
|
|
115
|
+
const seuil = parseSeuil(o.seuil, 'ref analyse');
|
|
116
|
+
const { slug, docDir, doc, lues: pages } = add({ document: o.document, slug: o.slug, sections: o.sections, env: o.env, cwd: o.cwd, refsDir: o.refsDir, now: o.now, out: o.out });
|
|
117
|
+
const sections = `\n choisir les pages à lire : cadence ref analyse ${o.document} --slug ${slug} --sections 3-12`;
|
|
118
|
+
if (pages.length === 0)
|
|
119
|
+
throw new RafError(`ref analyse : aucune page rendue : rien à lire${sections}`);
|
|
120
|
+
if (pages.length > LECTURE_PAGES_MAX)
|
|
121
|
+
throw new RafError(`ref analyse : ${pages.length} pages ${o.sections === undefined ? 'rendues' : 'à lire (--sections et les figures qu\'elles citent)'}, plafond d'une lecture : ${LECTURE_PAGES_MAX} pages${sections}`);
|
|
122
|
+
const c = { o, slug, docDir, doc, pages };
|
|
123
|
+
const refuse = (problems, prefixe = '') => {
|
|
124
|
+
for (const p of problems)
|
|
125
|
+
o.out(`✗ ${prefixe}${p}`);
|
|
126
|
+
o.out(`${problems.length} écart(s)`);
|
|
127
|
+
return 1;
|
|
128
|
+
};
|
|
129
|
+
if (!o.double) {
|
|
130
|
+
// les fichiers d'une double lecture précédente ne passent pas pour ceux de ce passage : leur `usage.lectures.a` ne serait plus celui de `a.json`
|
|
131
|
+
rmSync(join(docDir, 'lectures', 'b.json'), { force: true });
|
|
132
|
+
rmSync(join(docDir, 'inventaire.json'), { force: true });
|
|
133
|
+
const r = await lireUne(c, 'a', model);
|
|
134
|
+
if (r.kind === 'quota') {
|
|
135
|
+
o.err(`ref analyse : quota atteint, aucune lecture écrite : ${r.message}`);
|
|
136
|
+
return 3;
|
|
137
|
+
}
|
|
138
|
+
if (r.kind === 'refus')
|
|
139
|
+
return refuse(r.problems);
|
|
140
|
+
o.out(`${slug} : lecture unique (${model}) — ${pagesLues(pages.length)} (plafond d'une lecture : ${LECTURE_PAGES_MAX})`);
|
|
141
|
+
o.out(`lecture : ${r.file}`);
|
|
142
|
+
o.out(`éléments : ${r.lecture.elements.length} (${confiancesDe(r.lecture)})`);
|
|
143
|
+
o.out(`tokens : ${r.usage.tokens} (${Math.round(r.usage.tokens / pages.length)} par page, ${pagesLues(pages.length)})`);
|
|
144
|
+
for (const q of r.questions)
|
|
145
|
+
o.out(`question : ${q}`);
|
|
146
|
+
return 0;
|
|
147
|
+
}
|
|
148
|
+
const inventaire = join(docDir, 'inventaire.json');
|
|
149
|
+
rmSync(inventaire, { force: true }); // l'inventaire d'un passage précédent ne passe jamais pour celui de celui-ci
|
|
150
|
+
// deux sessions qui ne se voient pas : lancées ensemble, chacune écrit sa lecture ; un échec n'interrompt pas l'autre
|
|
151
|
+
const [ra, rb] = await Promise.allSettled([lireUne(c, 'a', model), lireUne(c, 'b', modelB)]);
|
|
152
|
+
for (const r of [ra, rb])
|
|
153
|
+
if (r.status === 'rejected')
|
|
154
|
+
throw r.reason;
|
|
155
|
+
const [a, b] = [ra, rb].map((r) => r.value);
|
|
156
|
+
const lues = [['A', a], ['B', b]];
|
|
157
|
+
for (const [nom, r] of lues) {
|
|
158
|
+
if (r.kind === 'quota') {
|
|
159
|
+
o.err(`ref analyse : quota atteint (lecture ${nom}), aucune réconciliation : ${r.message}`);
|
|
160
|
+
return 3;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
const refus = lues.flatMap(([nom, r]) => (r.kind === 'refus' ? r.problems.map((p) => `lecture ${nom} : ${p}`) : []));
|
|
164
|
+
if (refus.length > 0)
|
|
165
|
+
return refuse(refus);
|
|
166
|
+
const [la, lb] = [a, b];
|
|
167
|
+
const rec = reconcilier(la.lecture, lb.lecture, seuil);
|
|
168
|
+
const pct = (x) => `${Number((x * 100).toFixed(1))} %`;
|
|
169
|
+
const compteRec = `concordants ${rec.concordants} · divergents ${rec.divergents} · seuls ${rec.seuls} · silences ${rec.silences}`;
|
|
170
|
+
if (rec.refuse) {
|
|
171
|
+
o.out(`${slug} : double lecture (A ${model}, B ${modelB}) — ${pagesLues(pages.length)} (plafond d'une lecture : ${LECTURE_PAGES_MAX})`);
|
|
172
|
+
o.out(`réconciliation : ${compteRec} — non appariés ${pct(rec.ratio)} (seuil ${pct(seuil)})`);
|
|
173
|
+
o.out(`plus de ${pct(seuil)} d'éléments non appariés (${rec.nonApparies} sur ${rec.concordants + rec.divergents + rec.seuls}) : les deux lectures ne décrivent pas le même document — question au lead, aucune réconciliation (les lectures restent dans ${join(docDir, 'lectures')}, \`cadence ref diff\` les compare)`);
|
|
174
|
+
return 1;
|
|
175
|
+
}
|
|
176
|
+
const seuls = rec.items.filter((i) => i.statut === 'seul');
|
|
177
|
+
let regard;
|
|
178
|
+
if (seuls.length > 0) {
|
|
179
|
+
const r = await troisiemeRegard({ seuls, model, slug, docDir, configPath: o.configPath, claudeHome: o.claudeHome, claude: o.claude, agents: o.agents });
|
|
180
|
+
if (r.kind === 'quota') {
|
|
181
|
+
o.err(`ref analyse : quota atteint pendant le troisième regard, aucun inventaire écrit (les lectures A et B sont gardées) : ${r.message}`);
|
|
182
|
+
return 3;
|
|
183
|
+
}
|
|
184
|
+
seuls.forEach((item, i) => (item.decision = r.decisions[i]));
|
|
185
|
+
regard = r;
|
|
186
|
+
}
|
|
187
|
+
const inv = assembler(la.lecture, lb.lecture, rec, regard !== undefined);
|
|
188
|
+
if (inv.elements.length === 0) {
|
|
189
|
+
// tout ce que les lectures portent n'était porté que par une seule d'entre elles et le troisième regard l'a écarté : rien à écrire, ce n'est pas un défaut de cadence
|
|
190
|
+
o.out(`${slug} : double lecture (A ${model}, B ${modelB}) — ${pagesLues(pages.length)} (plafond d'une lecture : ${LECTURE_PAGES_MAX})`);
|
|
191
|
+
o.out(`réconciliation : ${compteRec}`);
|
|
192
|
+
o.out(`le troisième regard a écarté tous les éléments : aucun inventaire — question au lead (les lectures restent dans ${join(docDir, 'lectures')}, \`cadence ref diff\` les compare)`);
|
|
193
|
+
return 1;
|
|
194
|
+
}
|
|
195
|
+
// la réconciliation ne produit que des éléments déjà valides : un écart ici est un défaut de cadence, jamais une lecture à refuser
|
|
196
|
+
const problems = checkInventaire(inv);
|
|
197
|
+
if (problems.length > 0)
|
|
198
|
+
throw new RafError(`ref analyse : l'inventaire réconcilié est invalide (défaut de cadence) : ${problems[0]}`);
|
|
199
|
+
const tokens = la.usage.tokens + lb.usage.tokens + (regard?.tokens ?? 0);
|
|
200
|
+
inv.usage = {
|
|
201
|
+
tokens,
|
|
202
|
+
lectures: { a: { tokens: la.usage.tokens, modele: model }, b: { tokens: lb.usage.tokens, modele: modelB }, ...(regard ? { regard: { tokens: regard.tokens, modele: model } } : {}) },
|
|
203
|
+
};
|
|
204
|
+
const verdicts = seuls.flatMap((item) => (item.decision ? [{ lecture: item.lecture, id: item.element.id, verdict: item.decision.verdict, raison: item.decision.raison }] : []));
|
|
205
|
+
inv.reconciliation = { concordants: rec.concordants, divergents: rec.divergents, seuls: rec.seuls, silences: rec.silences, non_apparies: rec.ratio, regard: verdicts };
|
|
206
|
+
writeFileSync(inventaire, `${JSON.stringify(inv, null, 2)}\n`);
|
|
207
|
+
const confirmes = verdicts.filter((v) => v.verdict === 'confirme').length;
|
|
208
|
+
const ecartes = verdicts.length - confirmes;
|
|
209
|
+
o.out(`${slug} : double lecture (A ${model}, B ${modelB}) — ${pagesLues(pages.length)} (plafond d'une lecture : ${LECTURE_PAGES_MAX})`);
|
|
210
|
+
o.out(`lectures : ${la.file}, ${lb.file}`);
|
|
211
|
+
o.out(`réconciliation : ${compteRec} — non appariés ${rec.ratio === null ? 'n/a' : pct(rec.ratio)} (seuil ${pct(seuil)})`);
|
|
212
|
+
if (regard)
|
|
213
|
+
o.out(`troisième regard (${model}) : ${confirmes} confirmé${confirmes > 1 ? 's' : ''}, ${ecartes} écarté${ecartes > 1 ? 's' : ''}, ${seuls.length - verdicts.length} sans réponse`);
|
|
214
|
+
o.out(`inventaire : ${inventaire}`);
|
|
215
|
+
o.out(`éléments : ${inv.elements.length} (${confiancesDe(inv)})`);
|
|
216
|
+
o.out(`tokens : ${tokens} (${Math.round(tokens / pages.length)} par page, ${pagesLues(pages.length)}) — A : ${la.usage.tokens}, B : ${lb.usage.tokens}${regard ? `, troisième regard : ${regard.tokens}` : ''}`);
|
|
217
|
+
for (const q of new Set([...la.questions, ...lb.questions, ...(regard?.questions ?? [])]))
|
|
218
|
+
o.out(`question : ${q}`);
|
|
219
|
+
return 0;
|
|
220
|
+
}
|
package/dist/ref/command.js
CHANGED
|
@@ -1,16 +1,23 @@
|
|
|
1
1
|
import { createHash } from 'node:crypto';
|
|
2
2
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
3
|
+
import { homedir } from 'node:os';
|
|
3
4
|
import { join, resolve } from 'node:path';
|
|
4
5
|
import { RafError } from '../plan.js';
|
|
6
|
+
import { AGENTS_DIR } from '../skills.js';
|
|
7
|
+
import { realClaude, readAgents } from '../orchestrate/launch.js';
|
|
5
8
|
import { add } from './add.js';
|
|
9
|
+
import { analyse } from './analyse.js';
|
|
6
10
|
import { checkInventaire, readInventaire, summaryInventaire } from './check.js';
|
|
7
11
|
import { buildCorpus } from './corpus.js';
|
|
12
|
+
import { diffLectures } from './diff.js';
|
|
8
13
|
import { chargerPlaywright, lancer, rendre } from './corpus-render.js';
|
|
9
14
|
import { evaluer, formatEvaluation } from './evaluate.js';
|
|
10
15
|
import { measure } from './measure.js';
|
|
11
16
|
import { referencesDir } from './store.js';
|
|
12
17
|
const USAGE_ADD = 'usage : cadence ref add <document.pdf> [--sections 3-5,9] [--slug nom]';
|
|
18
|
+
const USAGE_ANALYSE = 'usage : cadence ref analyse <document.pdf> [--sections 3-5,9] [--slug nom] [--model sonnet|opus|haiku] [--model-b sonnet|opus|haiku] [--seuil 30] [--double]';
|
|
13
19
|
const USAGE_CHECK = 'usage : cadence ref check <inventaire.json | lecture.json>';
|
|
20
|
+
const USAGE_DIFF = 'usage : cadence ref diff <lecture-a.json> <lecture-b.json> [--out inventaire.json] [--seuil 30]';
|
|
14
21
|
const USAGE_EVAL = 'usage : cadence ref eval <attendu.json | dossier> <lecture.json | dossier> [--json]';
|
|
15
22
|
const USAGE_CORPUS = 'usage : cadence ref corpus <dossier> [--sans-rendu]';
|
|
16
23
|
const USAGE_MEASURE = 'usage : cadence ref measure <slug> <page> <page | x,y | x,y,l,h> [--quoi couleurs|contraste|boites|texte] [--largeur 1440] [--json]';
|
|
@@ -23,6 +30,10 @@ export function refCommand(rest, values, root, io) {
|
|
|
23
30
|
add({ document: args[0], slug: values.slug, sections: values.sections, env: io.env, cwd: io.cwd, refsDir, now: io.now(), out: io.out });
|
|
24
31
|
return 0;
|
|
25
32
|
}
|
|
33
|
+
if (verb === 'analyse' && args.length === 1) {
|
|
34
|
+
const claude = realClaude(io.env.CADENCE_CLAUDE_BIN || 'claude', io.env);
|
|
35
|
+
return analyse({ document: args[0], slug: values.slug, sections: values.sections, model: values.model, modelB: values['model-b'], double: values.double === true, seuil: values.seuil, env: io.env, cwd: io.cwd, refsDir, configPath, claudeHome: io.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude'), now: io.now(), out: io.out, err: io.err, claude, agents: readAgents(AGENTS_DIR) });
|
|
36
|
+
}
|
|
26
37
|
if (verb === 'measure' && args.length === 3) {
|
|
27
38
|
measure({ slug: args[0], page: args[1], zone: args[2], quoi: values.quoi, largeur: values.largeur, env: io.env, refsDir, json: values.json === true, out: io.out });
|
|
28
39
|
return 0;
|
|
@@ -40,6 +51,9 @@ export function refCommand(rest, values, root, io) {
|
|
|
40
51
|
io.out(summaryInventaire(inventaire));
|
|
41
52
|
return 0;
|
|
42
53
|
}
|
|
54
|
+
if (verb === 'diff' && args.length === 2) {
|
|
55
|
+
return diffLectures({ a: resolve(io.cwd, args[0]), b: resolve(io.cwd, args[1]), seuil: values.seuil, out: values.out === undefined ? undefined : resolve(io.cwd, values.out), log: io.out });
|
|
56
|
+
}
|
|
43
57
|
if (verb === 'eval' && args.length === 2) {
|
|
44
58
|
const ev = evaluer(resolve(io.cwd, args[0]), resolve(io.cwd, args[1]));
|
|
45
59
|
if (values.json === true)
|
|
@@ -52,7 +66,7 @@ export function refCommand(rest, values, root, io) {
|
|
|
52
66
|
if (verb === 'corpus' && args.length === 1)
|
|
53
67
|
return corpus(resolve(io.cwd, args[0]), values['sans-rendu'] === true, io);
|
|
54
68
|
const others = (u) => u.replace('usage : ', '');
|
|
55
|
-
throw new RafError(verb === 'add' ? USAGE_ADD : verb === 'measure' ? USAGE_MEASURE : verb === 'check' ? USAGE_CHECK : verb === 'eval' ? USAGE_EVAL : verb === 'corpus' ? USAGE_CORPUS : `${USAGE_ADD}\n ${others(USAGE_MEASURE)}\n ${others(USAGE_CHECK)}\n ${others(USAGE_EVAL)}\n ${others(USAGE_CORPUS)}`);
|
|
69
|
+
throw new RafError(verb === 'add' ? USAGE_ADD : verb === 'analyse' ? USAGE_ANALYSE : verb === 'measure' ? USAGE_MEASURE : verb === 'check' ? USAGE_CHECK : verb === 'diff' ? USAGE_DIFF : verb === 'eval' ? USAGE_EVAL : verb === 'corpus' ? USAGE_CORPUS : `${USAGE_ADD}\n ${others(USAGE_ANALYSE)}\n ${others(USAGE_MEASURE)}\n ${others(USAGE_CHECK)}\n ${others(USAGE_DIFF)}\n ${others(USAGE_EVAL)}\n ${others(USAGE_CORPUS)}`);
|
|
56
70
|
}
|
|
57
71
|
/** `ref corpus <dossier>` : un sous-dossier par piège (document.html, attendu.json, puis document.pdf et page-<n>.png sauf --sans-rendu). */
|
|
58
72
|
async function corpus(dossier, sansRendu, io) {
|
package/dist/ref/diff.js
ADDED
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
import { writeFileSync } from 'node:fs';
|
|
2
|
+
import { RafError } from '../plan.js';
|
|
3
|
+
import { TYPES, checkInventaire, readInventaire } from './check.js';
|
|
4
|
+
import { affirmation, identite } from './evaluate.js';
|
|
5
|
+
/** Au-delà de cette part d'éléments non appariés, les deux lectures ne décrivent pas le même document : pas de réconciliation (design §3, hypothèse H3). */
|
|
6
|
+
export const SEUIL_NON_APPARIES = 0.3;
|
|
7
|
+
/** Recouvrement minimal (intersection sur union) de deux zones du même type et de la même page pour que deux lectures parlent du même élément lu autrement. */
|
|
8
|
+
const RECOUVREMENT_MIN = 0.5;
|
|
9
|
+
const zoneOf = (e) => e.source?.zone;
|
|
10
|
+
/** Intersection sur union de deux zones ; 0 quand l'une manque. */
|
|
11
|
+
function recouvrement(a, b) {
|
|
12
|
+
const p = zoneOf(a);
|
|
13
|
+
const q = zoneOf(b);
|
|
14
|
+
if (!p || !q)
|
|
15
|
+
return 0;
|
|
16
|
+
const l = Math.min(p.x + p.l, q.x + q.l) - Math.max(p.x, q.x);
|
|
17
|
+
const h = Math.min(p.y + p.h, q.y + q.h) - Math.max(p.y, q.y);
|
|
18
|
+
if (l <= 0 || h <= 0)
|
|
19
|
+
return 0;
|
|
20
|
+
const inter = l * h;
|
|
21
|
+
return inter / (p.l * p.h + q.l * q.h - inter);
|
|
22
|
+
}
|
|
23
|
+
const SKIP = new Set(['id', 'type', 'source', 'confiance', 'cause', 'ocr', 'citations', 'ecran', 'cible', 'couvre', 'usage']);
|
|
24
|
+
const show = (v) => {
|
|
25
|
+
const s = v === undefined ? 'absent' : typeof v === 'string' ? v : JSON.stringify(v);
|
|
26
|
+
return s.length > 60 ? `${s.slice(0, 59)}…` : s;
|
|
27
|
+
};
|
|
28
|
+
/** Les champs qui diffèrent, « champ : A « … » ≠ B « … » » : les ids et les renvois sont numérotés par chaque lecture, ils ne comptent pas. */
|
|
29
|
+
function detailDivergence(a, b) {
|
|
30
|
+
const out = [];
|
|
31
|
+
for (const k of new Set([...Object.keys(a), ...Object.keys(b)])) {
|
|
32
|
+
if (SKIP.has(k) || JSON.stringify(a[k]) === JSON.stringify(b[k]))
|
|
33
|
+
continue;
|
|
34
|
+
out.push(`${k} : A « ${show(a[k])} » ≠ B « ${show(b[k])} »`);
|
|
35
|
+
}
|
|
36
|
+
return out.length > 0 ? out.join(' ; ') : 'affirmations différentes';
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Apparie deux lectures du même document, par type, page, identité (ce qui désigne l'élément, comme `ref eval`) et recouvrement de zone :
|
|
40
|
+
* concordant (même identité, même affirmation), divergent (même identité et affirmation différente, ou même zone et identité différente), seul.
|
|
41
|
+
* Les silences sont réunis, une même question une seule fois. `seuil` : part de non appariés au-delà de laquelle `refuse` est vrai.
|
|
42
|
+
*/
|
|
43
|
+
export function reconcilier(a, b, seuil = SEUIL_NON_APPARIES) {
|
|
44
|
+
const parIdA = new Map(a.elements.map((e) => [e.id, e]));
|
|
45
|
+
const parIdB = new Map(b.elements.map((e) => [e.id, e]));
|
|
46
|
+
const cle = (e, par) => `${e.source?.page}\u0000${identite(e, par)}`;
|
|
47
|
+
const items = new Map();
|
|
48
|
+
const pairedB = new Set();
|
|
49
|
+
for (const type of TYPES.filter((t) => t !== 'silence')) {
|
|
50
|
+
const as = a.elements.filter((e) => e.type === type);
|
|
51
|
+
const bs = b.elements.filter((e) => e.type === type);
|
|
52
|
+
const libresB = () => bs.filter((e) => !pairedB.has(e));
|
|
53
|
+
const lier = (ea, eb, statut) => {
|
|
54
|
+
pairedB.add(eb);
|
|
55
|
+
items.set(ea, { statut, element: ea, a: ea, b: eb, ...(statut === 'divergent' ? { detail: detailDivergence(ea, eb) } : {}) });
|
|
56
|
+
};
|
|
57
|
+
const meilleur = (ea, candidats) => candidats.reduce((best, c) => (best === undefined || recouvrement(ea, c) > recouvrement(ea, best) ? c : best), undefined);
|
|
58
|
+
// 1. même identité et même affirmation ; 2. même identité, affirmation différente ; 3. identité différente mais même zone
|
|
59
|
+
for (const ea of as) {
|
|
60
|
+
const eb = meilleur(ea, libresB().filter((c) => cle(c, parIdB) === cle(ea, parIdA) && affirmation(c, parIdB) === affirmation(ea, parIdA)));
|
|
61
|
+
if (eb)
|
|
62
|
+
lier(ea, eb, 'concordant');
|
|
63
|
+
}
|
|
64
|
+
for (const ea of as.filter((e) => !items.has(e))) {
|
|
65
|
+
const eb = meilleur(ea, libresB().filter((c) => cle(c, parIdB) === cle(ea, parIdA)));
|
|
66
|
+
if (eb)
|
|
67
|
+
lier(ea, eb, 'divergent');
|
|
68
|
+
}
|
|
69
|
+
for (const ea of as.filter((e) => !items.has(e))) {
|
|
70
|
+
const eb = meilleur(ea, libresB().filter((c) => c.source?.page === ea.source?.page && recouvrement(ea, c) >= RECOUVREMENT_MIN));
|
|
71
|
+
if (eb)
|
|
72
|
+
lier(ea, eb, 'divergent');
|
|
73
|
+
}
|
|
74
|
+
for (const ea of as.filter((e) => !items.has(e)))
|
|
75
|
+
items.set(ea, { statut: 'seul', element: ea, a: ea, lecture: 'a' });
|
|
76
|
+
}
|
|
77
|
+
// l'ordre de l'inventaire : la lecture A dans son ordre, puis ce que B seule porte dans son ordre
|
|
78
|
+
const ordered = [];
|
|
79
|
+
for (const e of a.elements)
|
|
80
|
+
if (e.type !== 'silence')
|
|
81
|
+
ordered.push(items.get(e));
|
|
82
|
+
for (const e of b.elements)
|
|
83
|
+
if (e.type !== 'silence' && !pairedB.has(e))
|
|
84
|
+
ordered.push({ statut: 'seul', element: e, b: e, lecture: 'b' });
|
|
85
|
+
const vues = new Map();
|
|
86
|
+
for (const [e, lecture] of [...a.elements.map((e) => [e, 'a']), ...b.elements.map((e) => [e, 'b'])]) {
|
|
87
|
+
if (e.type !== 'silence')
|
|
88
|
+
continue;
|
|
89
|
+
const k = identite(e, new Map());
|
|
90
|
+
const deja = vues.get(k);
|
|
91
|
+
if (deja) {
|
|
92
|
+
(deja.alias ??= []).push(`${lecture}:${e.id}`); // un statut peut couvrir ce silence-ci : son renvoi suit le silence réuni
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const item = { statut: 'silence', element: e, ...(lecture === 'a' ? { a: e } : { b: e }) };
|
|
96
|
+
vues.set(k, item);
|
|
97
|
+
ordered.push(item);
|
|
98
|
+
}
|
|
99
|
+
const compte = (s) => ordered.filter((i) => i.statut === s).length;
|
|
100
|
+
const concordants = compte('concordant');
|
|
101
|
+
const divergents = compte('divergent');
|
|
102
|
+
const seuls = compte('seul');
|
|
103
|
+
const total = concordants + divergents + seuls;
|
|
104
|
+
const ratio = total === 0 ? null : seuls / total;
|
|
105
|
+
return { items: ordered, concordants, divergents, seuls, silences: compte('silence'), nonApparies: seuls, ratio, refuse: ratio !== null && ratio > seuil };
|
|
106
|
+
}
|
|
107
|
+
/** Les renvois d'un élément à d'autres éléments de sa lecture : (champ, ids). `ecran` et `cible` sont obligatoires, `couvre` se réduit. */
|
|
108
|
+
const renvois = (e) => (e.type === 'composant' ? [e.ecran] : e.type === 'etat' ? [e.cible] : e.type === 'statut' ? e.couvre : []);
|
|
109
|
+
const lectureLabel = (l) => l.toUpperCase();
|
|
110
|
+
/** La confiance d'un élément concordant : un doute d'une lecture est un doute ; sinon `mesure` si l'une des deux a mesuré, `lu` sinon. */
|
|
111
|
+
function confianceConcordant(ea, eb) {
|
|
112
|
+
if (ea.confiance === 'incertain' || eb.confiance === 'incertain') {
|
|
113
|
+
const causes = [ea, eb].filter((e) => e.confiance === 'incertain').map((e) => e.cause);
|
|
114
|
+
return { confiance: 'incertain', cause: [...new Set(causes)].join(' ; ') };
|
|
115
|
+
}
|
|
116
|
+
return { confiance: ea.confiance === 'mesure' || eb.confiance === 'mesure' ? 'mesure' : 'lu' };
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* L'inventaire réconcilié (genre « inventaire »), ids renumérotés `<slug>#<type>-<n>` et renvois suivis. Un divergent est `incertain` avec les deux
|
|
120
|
+
* versions ; un seul écarté par le troisième regard disparaît (avec ce qui ne tient plus sans lui), un seul confirmé garde sa confiance (un doute
|
|
121
|
+
* reste un doute), un seul sans décision reste `incertain` : `regard` dit si le troisième regard a eu lieu (sa cause le dit).
|
|
122
|
+
* Un élément apparié tient tant que l'une de ses deux versions renvoie à des éléments gardés (la lecture A d'abord) : quand l'autre version renvoyait à un
|
|
123
|
+
* élément écarté, il est gardé, rattaché d'après la version qui tient, et `incertain` avec la cause du rattachement ; il disparaît quand aucune ne tient.
|
|
124
|
+
*/
|
|
125
|
+
export function assembler(a, b, rec, regard = false) {
|
|
126
|
+
const entrees = rec.items.map((item) => ({ item, supprimee: item.statut === 'seul' && item.decision?.verdict === 'ecarte' }));
|
|
127
|
+
const proprietaire = new Map();
|
|
128
|
+
for (const en of entrees) {
|
|
129
|
+
if (en.item.a)
|
|
130
|
+
proprietaire.set(`a:${en.item.a.id}`, en);
|
|
131
|
+
if (en.item.b)
|
|
132
|
+
proprietaire.set(`b:${en.item.b.id}`, en);
|
|
133
|
+
for (const k of en.item.alias ?? [])
|
|
134
|
+
proprietaire.set(k, en);
|
|
135
|
+
}
|
|
136
|
+
const versionsDe = (en) => ['a', 'b'].filter((v) => en.item[v] !== undefined);
|
|
137
|
+
const cible = (v, id) => proprietaire.get(`${v}:${id}`);
|
|
138
|
+
const vivant = (v, id) => {
|
|
139
|
+
const t = cible(v, id);
|
|
140
|
+
return t !== undefined && !t.supprimee;
|
|
141
|
+
};
|
|
142
|
+
/** Une version tient quand ses renvois tiennent : tous pour un composant ou un état, au moins un pour un statut. */
|
|
143
|
+
const tient = (en, v) => {
|
|
144
|
+
const element = en.item[v];
|
|
145
|
+
const ids = renvois(element);
|
|
146
|
+
const vivants = ids.filter((id) => vivant(v, id)).length;
|
|
147
|
+
return ids.length === 0 || (element.type === 'statut' ? vivants > 0 : vivants === ids.length);
|
|
148
|
+
};
|
|
149
|
+
// ce qui renvoie à un élément écarté ne tient plus : un point fixe, un écarté pouvant en entraîner d'autres. Un élément apparié tient dès que l'une de
|
|
150
|
+
// ses deux versions tient (la lecture A d'abord) : la lecture B peut le rattacher à un écran conservé quand A le rattache à un écran écarté.
|
|
151
|
+
for (let change = true; change;) {
|
|
152
|
+
change = false;
|
|
153
|
+
for (const en of entrees) {
|
|
154
|
+
if (en.supprimee)
|
|
155
|
+
continue;
|
|
156
|
+
const v = versionsDe(en).find((x) => tient(en, x));
|
|
157
|
+
if (v === undefined) {
|
|
158
|
+
en.supprimee = true;
|
|
159
|
+
change = true;
|
|
160
|
+
}
|
|
161
|
+
else
|
|
162
|
+
en.version = v;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
const compteur = new Map();
|
|
166
|
+
const nouvelId = new Map();
|
|
167
|
+
for (const en of entrees) {
|
|
168
|
+
if (en.supprimee)
|
|
169
|
+
continue;
|
|
170
|
+
const type = en.item.element.type;
|
|
171
|
+
const n = (compteur.get(type) ?? 0) + 1;
|
|
172
|
+
compteur.set(type, n);
|
|
173
|
+
nouvelId.set(en, `${a.document.slug}#${type}-${n}`);
|
|
174
|
+
}
|
|
175
|
+
const elements = entrees.filter((en) => !en.supprimee).map((en) => {
|
|
176
|
+
const { item } = en;
|
|
177
|
+
const v = en.version;
|
|
178
|
+
const e = { ...item.element, id: nouvelId.get(en) };
|
|
179
|
+
const id = (ancien) => nouvelId.get(cible(v, ancien));
|
|
180
|
+
// les renvois sont ceux de la version qui tient (pour un élément apparié, parfois celle de la lecture B)
|
|
181
|
+
const source = item[v];
|
|
182
|
+
if (e.type === 'composant')
|
|
183
|
+
e.ecran = id(source.ecran);
|
|
184
|
+
if (e.type === 'etat')
|
|
185
|
+
e.cible = id(source.cible);
|
|
186
|
+
if (e.type === 'statut')
|
|
187
|
+
e.couvre = source.couvre.filter((x) => vivant(v, x)).map(id);
|
|
188
|
+
const doute = (cause) => {
|
|
189
|
+
e.confiance = 'incertain';
|
|
190
|
+
e.cause = cause;
|
|
191
|
+
};
|
|
192
|
+
if (item.statut === 'concordant') {
|
|
193
|
+
const { confiance, cause } = confianceConcordant(item.a, item.b);
|
|
194
|
+
e.confiance = confiance;
|
|
195
|
+
if (cause === undefined)
|
|
196
|
+
delete e.cause;
|
|
197
|
+
else
|
|
198
|
+
e.cause = cause;
|
|
199
|
+
}
|
|
200
|
+
else if (item.statut === 'divergent') {
|
|
201
|
+
doute(`lectures divergentes — ${item.detail}`);
|
|
202
|
+
}
|
|
203
|
+
else if (item.statut === 'seul' && item.decision?.verdict !== 'confirme') {
|
|
204
|
+
const base = `lu dans la lecture ${lectureLabel(item.lecture)} seulement, troisième regard ${regard ? 'sans réponse' : 'non fait'}`;
|
|
205
|
+
doute(item.element.confiance === 'incertain' ? `${base} ; ${item.element.cause}` : base);
|
|
206
|
+
}
|
|
207
|
+
// les deux lectures ne le rattachent plus au même élément : l'une renvoyait à un élément écarté par le troisième regard, l'élément est gardé
|
|
208
|
+
// par l'autre (il est lu, ou mesuré, par les deux) mais son rattachement n'est plus qu'une lecture : un doute, avec sa cause
|
|
209
|
+
const autre = v === 'a' ? 'b' : 'a';
|
|
210
|
+
if (item.a && item.b && !tient(en, autre)) {
|
|
211
|
+
const note = `rattachement : la lecture ${lectureLabel(autre)} le rattachait à un élément écarté par le troisième regard, rattaché d'après la lecture ${lectureLabel(v)}`;
|
|
212
|
+
doute(e.confiance === 'incertain' ? `${e.cause} ; ${note}` : note);
|
|
213
|
+
}
|
|
214
|
+
return e;
|
|
215
|
+
});
|
|
216
|
+
return { version: 1, genre: 'inventaire', document: { slug: a.document.slug, sha256: a.document.sha256 }, elements };
|
|
217
|
+
}
|
|
218
|
+
function lireLecture(path) {
|
|
219
|
+
const doc = readInventaire(path);
|
|
220
|
+
const problems = checkInventaire(doc);
|
|
221
|
+
if (problems.length > 0)
|
|
222
|
+
throw new RafError(`ref diff : ${path} n'est pas valide (${problems.length} écart(s), voir cadence ref check) : ${problems[0]}`);
|
|
223
|
+
if (doc.genre !== 'lecture')
|
|
224
|
+
throw new RafError(`ref diff : ${path} doit être une lecture, pas un « ${doc.genre} »`);
|
|
225
|
+
return doc;
|
|
226
|
+
}
|
|
227
|
+
/** `--seuil` : une part en pour cent (0 à 100) rendue en fraction ; absent, le seuil de la conception. */
|
|
228
|
+
export function parseSeuil(text, commande) {
|
|
229
|
+
if (text === undefined)
|
|
230
|
+
return SEUIL_NON_APPARIES;
|
|
231
|
+
if (!/^\d+(\.\d+)?$/.test(text) || Number(text) > 100)
|
|
232
|
+
throw new RafError(`${commande} : --seuil invalide : « ${text} » (une part en pour cent, de 0 à 100)`);
|
|
233
|
+
return Number(text) / 100;
|
|
234
|
+
}
|
|
235
|
+
/** `ref diff <a> <b> [--out inventaire.json] [--seuil 30]` : 0 réconcilié, 1 trop d'éléments non appariés (rien d'écrit), 2 fichier ou appel refusé. */
|
|
236
|
+
export function diffLectures(o) {
|
|
237
|
+
const seuil = parseSeuil(o.seuil, 'ref diff');
|
|
238
|
+
const a = lireLecture(o.a);
|
|
239
|
+
const b = lireLecture(o.b);
|
|
240
|
+
if (a.document.slug !== b.document.slug)
|
|
241
|
+
throw new RafError(`ref diff : les deux lectures ne sont pas du même document : slug ${a.document.slug} et ${b.document.slug}`);
|
|
242
|
+
if (a.document.sha256 !== b.document.sha256)
|
|
243
|
+
throw new RafError(`ref diff : l'empreinte des deux lectures diffère pour ${a.document.slug} : pas la même version du document (jamais deux versions mêlées)`);
|
|
244
|
+
const rec = reconcilier(a, b, seuil);
|
|
245
|
+
const sa = rec.items.filter((i) => i.statut === 'seul' && i.lecture === 'a').length;
|
|
246
|
+
const pct = (x) => `${Number((x * 100).toFixed(1))} %`;
|
|
247
|
+
o.log(`concordants : ${rec.concordants} · divergents : ${rec.divergents} · seuls : ${rec.seuls} (A : ${sa}, B : ${rec.seuls - sa}) · silences : ${rec.silences}`);
|
|
248
|
+
o.log(`non appariés : ${rec.ratio === null ? 'n/a' : pct(rec.ratio)} (seuil ${pct(seuil)})`);
|
|
249
|
+
for (const i of rec.items) {
|
|
250
|
+
if (i.statut === 'divergent')
|
|
251
|
+
o.log(`divergent : ${i.element.type} p.${i.element.source.page} : ${i.detail}`);
|
|
252
|
+
if (i.statut === 'seul')
|
|
253
|
+
o.log(`seul (${lectureLabel(i.lecture)}) : ${i.element.type} p.${i.element.source.page} : ${identite(i.element, new Map())}`);
|
|
254
|
+
}
|
|
255
|
+
if (rec.refuse) {
|
|
256
|
+
o.log(`plus de ${pct(seuil)} d'éléments non appariés (${rec.nonApparies} sur ${rec.concordants + rec.divergents + rec.seuls}) : les deux lectures ne décrivent pas le même document — question au lead, aucune réconciliation`);
|
|
257
|
+
return 1;
|
|
258
|
+
}
|
|
259
|
+
if (o.out !== undefined)
|
|
260
|
+
writeFileSync(o.out, `${JSON.stringify(assembler(a, b, rec), null, 2)}\n`);
|
|
261
|
+
return 0;
|
|
262
|
+
}
|
package/dist/ref/evaluate.js
CHANGED
|
@@ -10,7 +10,7 @@ const norm = (v) => {
|
|
|
10
10
|
const normTexte = (v) => String(v ?? '').normalize('NFC').replace(/\s+/g, ' ').trim();
|
|
11
11
|
const noms = (xs, cle) => (Array.isArray(xs) ? xs.map((x) => norm(x?.[cle])) : []);
|
|
12
12
|
/** Ce qui désigne un élément, indépendamment de son id, de sa zone et de sa confiance : deux lectures indépendantes numérotent à leur façon. */
|
|
13
|
-
function identite(e, parId) {
|
|
13
|
+
export function identite(e, parId) {
|
|
14
14
|
const cible = (id) => {
|
|
15
15
|
const t = typeof id === 'string' ? parId.get(id) : undefined;
|
|
16
16
|
return t ? `${t.type}:${identite(t, new Map())}` : '?';
|
|
@@ -41,7 +41,7 @@ function identite(e, parId) {
|
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
/** Ce que l'élément affirme une fois retrouvé : une identité juste avec une affirmation fausse est un faux lu, pas un rappel. */
|
|
44
|
-
function affirmation(e, parId) {
|
|
44
|
+
export function affirmation(e, parId) {
|
|
45
45
|
switch (e.type) {
|
|
46
46
|
case 'ecran':
|
|
47
47
|
return String(e.largeur_reference);
|