@operstack/audit 0.34.1 → 0.35.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@operstack/audit",
3
- "version": "0.34.1",
3
+ "version": "0.35.0",
4
4
  "description": "SEO, AEO and GEO audit generator: collect public signals from a site into audit.json, let an analyst or an agent fill the narrative, render the OperStack report as HTML and PDF.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -34,7 +34,7 @@
34
34
  "LICENSE"
35
35
  ],
36
36
  "scripts": {
37
- "test": "node src/test-scores.mjs && node src/test-fix.mjs && node src/test-foundation.mjs && node src/test-narrative.mjs && node src/test-prompts.mjs && node src/test-sitemap.mjs && node src/test-overall.mjs && node src/test-blocked.mjs && node src/test-unmeasured.mjs && node src/test-rendered.mjs && node src/test-sample.mjs && node src/test-demand.mjs && node src/test-agent-surface.mjs && node src/test-agency.mjs && node src/test-i18n.mjs examples/sample-audit.json && node bin/audit.mjs check examples/sample-audit.json && node bin/audit.mjs render examples/sample-audit.json --out /tmp/operstack-audit-sample.html && node src/test-ru-sources.mjs && node src/test-sample-fairness.mjs && node test-engines.mjs && node test-seo-basics.mjs && node test-polite.mjs && node src/test-handover.mjs && node src/test-prospect-count.mjs && node src/test-redirect-host.mjs && node src/test-crawl-links.mjs && node src/test-ru-report.mjs && node src/test-report-wording.mjs && node src/test-shopify-store.mjs && node src/test-etalon.mjs",
37
+ "test": "node src/test-scores.mjs && node src/test-fix.mjs && node src/test-foundation.mjs && node src/test-narrative.mjs && node src/test-prompts.mjs && node src/test-sitemap.mjs && node src/test-overall.mjs && node src/test-blocked.mjs && node src/test-unmeasured.mjs && node src/test-rendered.mjs && node src/test-sample.mjs && node src/test-demand.mjs && node src/test-agent-surface.mjs && node src/test-agency.mjs && node src/test-i18n.mjs examples/sample-audit.json && node bin/audit.mjs check examples/sample-audit.json && node bin/audit.mjs render examples/sample-audit.json --out /tmp/operstack-audit-sample.html && node src/test-ru-sources.mjs && node src/test-sample-fairness.mjs && node test-engines.mjs && node test-seo-basics.mjs && node test-polite.mjs && node src/test-handover.mjs && node src/test-prospect-count.mjs && node src/test-redirect-host.mjs && node src/test-crawl-links.mjs && node src/test-ru-report.mjs && node src/test-report-wording.mjs && node src/test-shopify-store.mjs && node src/test-etalon.mjs && node src/test-planted.mjs",
38
38
  "sample": "node bin/audit.mjs render examples/sample-audit.json --pdf",
39
39
  "verify:live": "node src/verify-live.mjs"
40
40
  },
package/src/facts.mjs ADDED
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Сверка чисел отчёта с данными аудита кодом, без модели (14.10.2026, задание «второй шаг проверки»).
3
+ *
4
+ * Простым языком. Hermes вписал в пять отчётов по четыре ложных числа («прочитали 67 страниц» вместо 20,
5
+ * «RFC 29188» вместо 9727), и проверка ИИ не исправила ни одного из 20. Числа в обложке, выводе,
6
+ * приоритетах и строках проверок собираются из замеров, поэтому их можно сверить без модели: в момент
7
+ * черновика движок запоминает, какой текст и какие числа в каждом поле дали данные. Перед проверкой ИИ
8
+ * каждое число в этих полях ищется в запомненном. Чужое число значит ошибку с готовым доказательством:
9
+ * поле возвращается к тексту из данных. Правка модели, которая убирает число из данных, отклоняется.
10
+ */
11
+ import { localiseChecks } from './i18n.mjs';
12
+
13
+ /** Номера RFC, на которые движок ссылается сам. Любой другой номер в отчёте ошибка. */
14
+ export const KNOWN_RFC = new Set(['9727']);
15
+
16
+ /** Числа текста: «23 000» и «23000» одно число, «120-160» два, проценты и доли как есть. */
17
+ export function numbersOf(text) {
18
+ const out = [];
19
+ for (const m of String(text || '').matchAll(/\d{1,3}(?:[   ]\d{3})+(?![\d])|\d+(?:[.,]\d+)?/g)) out.push(m[0].replace(/[   ]/g, '').replace(',', '.'));
20
+ return out;
21
+ }
22
+
23
+ /** Поля, числа которых собраны из данных аудита, с их текстом в момент черновика. */
24
+ export function snapshotFacts(audit, lang = audit.meta?.lang || 'en') {
25
+ const shown = lang === 'ru' ? localiseChecks(audit.checks || [], 'ru') : (audit.checks || []);
26
+ const fields = {};
27
+ for (const c of shown) {
28
+ if (c.value) fields[`chk:${c.id}:value`] = `${c.label}: ${c.value}`;
29
+ if (c.comment) fields[`chk:${c.id}:comment`] = `${c.label}, пояснение: ${c.comment}`;
30
+ }
31
+ if (audit.client?.subject) fields['sub:subject'] = String(audit.client.subject);
32
+ if (audit.summary?.lead) fields['sum:lead'] = String(audit.summary.lead);
33
+ if (audit.summary?.verdict) fields['sum:verdict'] = String(audit.summary.verdict);
34
+ (audit.summary?.priorities || []).forEach((p, i) => { fields[`sum:prio:${i}`] = String(p); });
35
+ return { at: new Date().toISOString(), fields, approved: {} };
36
+ }
37
+
38
+ /** Числа, которых нет в данных поля (и нет в одобренных правках с доказательством). */
39
+ export function foreignNumbers(facts, id, text) {
40
+ const src = facts?.fields?.[id];
41
+ if (src === undefined) return [];
42
+ const allowed = new Set([...numbersOf(src), ...(facts.approved?.[id] || [])]);
43
+ const bad = numbersOf(text).filter((n) => !allowed.has(n));
44
+ for (const m of String(text).matchAll(/RFC\s*(\d+)/gi)) if (!KNOWN_RFC.has(m[1]) && !bad.includes(m[1])) bad.push(m[1]);
45
+ return [...new Set(bad)];
46
+ }
47
+
48
+ /**
49
+ * Проверка кодом по всем утверждениям: что не сходится с данными, то уже найдено и исправлено,
50
+ * модель для этого не нужна. Возвращает находки в том же виде, что подтверждённые вторым шагом.
51
+ */
52
+ export function codeCheck(audit, statements) {
53
+ const facts = audit.facts;
54
+ if (!facts?.fields) return [];
55
+ const out = [];
56
+ for (const s of statements) {
57
+ const bad = foreignNumbers(facts, s.id, s.text);
58
+ if (!bad.length) continue;
59
+ out.push({ id: s.id, keep: true, byCode: true, evidence: `в данных аудита: «${facts.fields[s.id]}»; в тексте лишние числа: ${bad.join(', ')}`, fix: facts.fields[s.id] });
60
+ }
61
+ return out;
62
+ }
63
+
64
+ /**
65
+ * Правка модели не должна портить верное: если в старом тексте было число из данных, а в правке
66
+ * его нет, или правка вносит число, которого нет ни в данных, ни в доказательстве, правка отклоняется.
67
+ */
68
+ export function fixBreaksData(facts, id, oldText, fix, evidence = '') {
69
+ const src = facts?.fields?.[id];
70
+ if (src === undefined) return false;
71
+ const data = new Set(numbersOf(src));
72
+ const now = new Set(numbersOf(fix));
73
+ const lost = numbersOf(oldText).filter((n) => data.has(n) && !now.has(n));
74
+ if (lost.length) return `правка убирает числа из данных: ${[...new Set(lost)].join(', ')}`;
75
+ const proven = new Set(numbersOf(evidence));
76
+ const alien = [...now].filter((n) => !data.has(n) && !proven.has(n) && !(facts.approved?.[id] || []).includes(n));
77
+ if (alien.length) return `правка вносит числа без доказательства: ${alien.join(', ')}`;
78
+ return false;
79
+ }
80
+
81
+ /** Правка с доказательством принята: её новые числа больше не считаются чужими в этом поле. */
82
+ export function approveNumbers(facts, id, fix) {
83
+ if (!facts?.fields || facts.fields[id] === undefined) return;
84
+ facts.approved = facts.approved || {};
85
+ facts.approved[id] = [...new Set([...(facts.approved[id] || []), ...numbersOf(fix)])];
86
+ }
package/src/narrative.mjs CHANGED
@@ -129,6 +129,7 @@ const MECHANICAL = new Set(['robots', 'robots-block', 'ai-search-access', 'sitem
129
129
  import { vocabFor } from './niche.mjs';
130
130
  import { localiseChecks, AREAS_RU } from './i18n.mjs';
131
131
  import { FIX_ACTIONS } from './fix.mjs';
132
+ import { snapshotFacts } from './facts.mjs';
132
133
 
133
134
  const byId = (checks, id) => (checks || []).find((c) => c.id === id);
134
135
  const open = (c) => c && (c.status === 'warn' || c.status === 'bad');
@@ -319,6 +320,8 @@ export function draftNarrative(audit, opts = {}) {
319
320
  }));
320
321
 
321
322
  if (String(a.closing).includes('{{')) a.closing = t.closing(host, bad.length);
323
+ // Что дали данные в каждом поле с числами: по этому снимку проверка кодом ловит чужие числа (facts.mjs, 14.10.2026).
324
+ a.facts = snapshotFacts(a, lang);
322
325
  return a;
323
326
  }
324
327
 
package/src/review.mjs CHANGED
@@ -16,6 +16,8 @@ import { localiseChecks } from './i18n.mjs';
16
16
  import { agentPrompts } from './prompts.mjs';
17
17
  import { rescore } from './verify.mjs';
18
18
  import { vocabFor } from './niche.mjs';
19
+ import { approveNumbers, codeCheck, fixBreaksData } from './facts.mjs';
20
+ import { MECHANICAL_IDS } from './narrative.mjs';
19
21
 
20
22
  const PART_OF = (id) => {
21
23
  if (/^task:/.test(id)) return 'tasks';
@@ -121,7 +123,7 @@ For false and misleading give fix: full replacement text in English, no em or en
121
123
  Answer strictly as JSON: {"items":[{"id":"...","verdict":"ok|false|misleading|unverifiable","premiseTrue":true,"fitsNiche":true,"passesNow":false,"possible":true,"why":"short","fix":"..."}]} (leave out fields that do not apply)`);
122
124
 
123
125
  /** Замеры инструмента, на которые опираются строки: проверяющий иначе считает их «непроверяемыми». */
124
- export function toolFacts(audit) {
126
+ export function toolFacts(audit, digest = null) {
125
127
  // Только то, что инструмент измерил запросом к сайту (файлы, роботы, скорость), а не его же выводы:
126
128
  // выводы из строк проверки здесь делали проверку круговой (14.10.2026: 0 из 13 известных ошибок).
127
129
  const by = (id) => (audit.checks || []).find((c) => c.id === id);
@@ -134,6 +136,12 @@ export function toolFacts(audit) {
134
136
  filesAndProbes: { 'https': probe('https'), www: probe('www'), 'redirect-chain': probe('redirect-chain'), ttfb: probe('ttfb'), 'llms.txt': probe('llms'), 'agent.json': probe('agent-card'), 'api-catalog': probe('api-catalog'), markdown: probe('agent-markdown'), 'robots-to-ai-robots': probe('train-bots'), 'js-hidden-text': probe('js-content'), cms: probe('cms') },
135
137
  images: { total: sm.reduce((a, p) => a + (p.images || 0), 0), withoutAltText: sm.reduce((a, p) => a + (p.imagesNoAlt || 0), 0), withoutAltAttribute: sm.reduce((a, p) => a + (p.imagesAltMissing || 0), 0) },
136
138
  perSampledPage: sm.map((p) => ({ url: p.url, words: p.words, h1Count: p.h1Count, h2Count: p.h2Count, tables: p.tables, images: p.images, imagesNoAlt: p.imagesNoAlt, ctaFirstScreen: p.ctaFirstScreen, priceShown: p.priceShown })),
139
+ // Чем доказывать числа обложки и вывода (задание 14.10.2026, «67 страниц» второй шаг опровергнуть не смог).
140
+ pagesRead: sm.length,
141
+ openChecks: (() => { const sh = (audit.meta?.lang === 'ru' ? localiseChecks(audit.checks || [], 'ru') : (audit.checks || [])); const open = sh.filter((c) => c.status === 'bad' || c.status === 'warn'); return { notFullyPassed: open.length, failed: open.filter((c) => c.status === 'bad').length, technical: open.filter((c) => MECHANICAL_IDS.has(c.id)).length, needOwnerFacts: open.filter((c) => !MECHANICAL_IDS.has(c.id)).length }; })(),
142
+ homepageAssets: (() => { const v = String(probe('resources') || ''); const n = (re) => Number((v.match(re) || [])[1]); return { raw: v, scripts: n(/(\d+)\s*(?:script|скрипт)/i), stylesheets: n(/(\d+)\s*(?:stylesheet|стил)/i), images: n(/(\d+)\s*(?:image|картин)/i) }; })(),
143
+ imagesWithAltText: sm.reduce((a, p) => a + Math.max(0, (p.images || 0) - (p.imagesNoAlt || 0)), 0),
144
+ homepage: { url: sm[0]?.url, description: sm[0]?.description, text: String(((digest?.pages || [])[0] || {}).text || sm[0]?.firstPara || '').slice(0, 2000) },
137
145
  sampleRule: 'Числа вида «X из N страниц выборки» считаются только по sampleUrls (perSampledPage), а не по всем страницам выжимки сайта.',
138
146
  classification: 'Деление открытых проверок на «правятся технически» и «требуют фактов владельца» и оценки областей считает инструмент по своему списку; это не утверждения о сайте.',
139
147
  };
@@ -177,7 +185,7 @@ export async function reviewRound(audit, digest, statements, ask, { lang = audit
177
185
  // Пачки по 20 утверждений одной части: на 170 строках сразу модель пропускала очевидное (14.10.2026).
178
186
  const batches = parts.flatMap((part) => { const all = statements.filter((s) => s.part === part); const out = []; for (let i = 0; i < all.length; i += 20) out.push({ part, st: all.slice(i, i + 20) }); return out; });
179
187
  // Одна и та же выжимка в начале каждого вызова: OpenAI берёт повторяющееся начало из кэша за десятую часть цены.
180
- const shared = JSON.stringify({ site: audit.meta?.site, toolFacts: toolFacts(audit), digest: digestFor(digest, statements) });
188
+ const shared = JSON.stringify({ site: audit.meta?.site, toolFacts: toolFacts(audit, digest), digest: digestFor(digest, statements) });
181
189
  const runBatch = async ({ part, st }) => {
182
190
  const messages = [
183
191
  { role: 'system', content: SYSTEM(ru, niche) },
@@ -222,20 +230,31 @@ For each flagged statement find SPECIFIC evidence in the site digest or toolFact
222
230
  Answer strictly as JSON: {"items":[{"id":"...","keep":true,"evidence":"url: field = value","fix":"..."}]}`);
223
231
 
224
232
  /** Второй шаг: подтвердить пометки первого более сильной моделью, только с прямым доказательством. */
225
- export async function confirmFlags(audit, digest, statements, flagged, ask, { lang = audit.meta?.lang || 'en' } = {}) {
233
+ // По 10 пометок за вызов: на 40 сразу второй проверяющий молча пропускал большую часть строк (Hermes, 14.10.2026).
234
+ export const CONFIRM_GROUP = 10;
235
+ export const CONFIRM_RETRY_GROUP = 4;
236
+ export async function confirmFlags(audit, digest, statements, flagged, ask, { lang = audit.meta?.lang || 'en', group = CONFIRM_GROUP, retryGroup = CONFIRM_RETRY_GROUP } = {}) {
226
237
  const ru = lang === 'ru';
227
- if (!flagged.length) return { items: [], usage: { input: 0, output: 0, calls: 0 } };
228
- const shared = JSON.stringify({ site: audit.meta?.site, toolFacts: toolFacts(audit), digest: digestFor(digest, statements) });
238
+ if (!flagged.length) return { items: [], missing: [], usage: { input: 0, output: 0, cached: 0, calls: 0 } };
239
+ const shared = JSON.stringify({ site: audit.meta?.site, toolFacts: toolFacts(audit, digest), digest: digestFor(digest, statements) });
229
240
  const byId = Object.fromEntries(statements.map((s) => [s.id, s]));
230
241
  const usage = { input: 0, output: 0, cached: 0, calls: 0 };
231
- const groups = []; for (let i = 0; i < flagged.length; i += 40) groups.push(flagged.slice(i, i + 40));
232
- const res = await Promise.all(groups.map(async (g) => {
233
- const r = await ask([{ role: 'system', content: CONFIRM(ru) }, { role: 'user', content: shared }, { role: 'user', content: JSON.stringify({ flagged: g.map((x) => ({ id: x.id, kind: byId[x.id]?.kind, text: byId[x.id]?.text, firstReviewer: { verdict: x.verdict, why: x.why, fix: x.fix } })) }) }]);
242
+ const chunks = (arr, n) => { const out = []; for (let i = 0; i < arr.length; i += n) out.push(arr.slice(i, i + n)); return out; };
243
+ const askGroup = async (g) => {
244
+ const r = await ask([{ role: 'system', content: CONFIRM(ru) }, { role: 'user', content: shared }, { role: 'user', content: JSON.stringify({ flagged: g.map((x) => ({ id: x.id, kind: byId[x.id]?.kind, text: byId[x.id]?.text, firstReviewer: { verdict: x.verdict, why: x.why, fix: x.fix } })), answerEveryId: g.map((x) => x.id) }) }]);
234
245
  usage.calls += 1; usage.input += r.usage?.input || 0; usage.cached += r.usage?.cached || 0; usage.output += r.usage?.output || 0;
235
- let parsed; try { parsed = JSON.parse(String(r.text).replace(/^```json\s*|```$/g, '')); } catch { throw new Error('второй проверяющий вернул не JSON'); }
236
- return parsed.items || [];
237
- }));
238
- return { items: res.flat(), usage };
246
+ let parsed; try { parsed = JSON.parse(String(r.text).replace(/^```json\s*|```$/g, '')); } catch { return []; }
247
+ const ids = new Set(g.map((x) => x.id));
248
+ return (parsed.items || []).filter((x) => x && ids.has(x.id) && typeof x.keep === 'boolean');
249
+ };
250
+ const items = (await Promise.all(chunks(flagged, group).map(askGroup))).flat();
251
+ // Молчание не «ошибки нет»: по каждой пропущенной строке один повторный запрос, маленькими группами.
252
+ let missing = flagged.filter((f) => !items.some((x) => x.id === f.id));
253
+ if (missing.length) {
254
+ items.push(...(await Promise.all(chunks(missing, retryGroup).map(askGroup))).flat());
255
+ missing = flagged.filter((f) => !items.some((x) => x.id === f.id));
256
+ }
257
+ return { items, missing: missing.map((f) => f.id), usage };
239
258
  }
240
259
 
241
260
  /**
@@ -246,24 +265,43 @@ export async function reviewAndFix(audit, digest, ask, { lang = audit.meta?.lang
246
265
  const rounds = []; const usage = { input: 0, cached: 0, output: 0, calls: 0, confirm: { input: 0, cached: 0, output: 0, calls: 0 } };
247
266
  const add = (u, t) => { for (const k of ['input', 'cached', 'output', 'calls']) t[k] += u[k] || 0; };
248
267
  let todo = statementsOf(audit, { lang });
249
- let left = [];
268
+ let left = []; let held = null;
250
269
  for (let round = 1; round <= maxRounds && todo.length; round++) {
251
270
  const all = statementsOf(audit, { lang });
252
271
  const { items, usage: u } = await reviewRound(audit, digest, todo, ask, { lang });
253
272
  add(u, usage);
254
- const flagged = items.filter((x) => x.verdict === 'false' || x.verdict === 'misleading');
273
+ // Сначала бесплатно кодом: числа в полях, собранных из данных, сверяются с данными (facts.mjs).
274
+ const byCode = codeCheck(audit, all);
275
+ const codeIds = new Set(byCode.map((x) => x.id));
276
+ const flagged = items.filter((x) => (x.verdict === 'false' || x.verdict === 'misleading') && !codeIds.has(x.id));
255
277
  // Второй шаг: каждая пометка нужна с прямым доказательством, иначе это ложная тревога.
256
- const { items: conf, usage: cu } = await confirmFlags(audit, digest, all, flagged, askConfirm, { lang });
278
+ const { items: conf, missing, usage: cu } = await confirmFlags(audit, digest, all, flagged, askConfirm, { lang });
257
279
  add(cu, usage.confirm);
280
+ if (missing.length) {
281
+ held = `второй проверяющий не ответил по ${missing.length} ${missing.length === 1 ? 'строке' : 'строкам'}: ${missing.join(', ')}`;
282
+ log(`проверка, круг ${round}: ${held}`);
283
+ left = missing.map((id) => ({ id, why: 'второй проверяющий не ответил' }));
284
+ rounds.push({ round, checked: todo.length, flagged: flagged.length, kept: 0, byCode: byCode.length, missing });
285
+ break;
286
+ }
258
287
  // Правка, которая ничего не меняет, это подтверждение, а не находка: иначе круги идут впустую (14.10.2026, neronov.pro).
259
288
  const norm = (t) => String(t || '').toLowerCase().replace(/[^\p{L}\p{N}]+/gu, ' ').trim();
260
289
  const textOf = (id) => all.find((s) => s.id === id)?.text || '';
261
- const kept = conf.filter((x) => x.keep && !(x.fix && norm(x.fix) === norm(textOf(x.id))));
290
+ // Правка не должна портить верное: число из данных не убирается, новое число только с доказательством.
291
+ const rejected = [];
292
+ const kept = conf.filter((x) => {
293
+ if (!x.keep || (x.fix && norm(x.fix) === norm(textOf(x.id)))) return false;
294
+ const why = x.fix ? fixBreaksData(audit.facts, x.id, textOf(x.id), x.fix, x.evidence) : false;
295
+ if (why) { rejected.push({ id: x.id, why }); return false; }
296
+ return true;
297
+ });
262
298
  const lost = items.filter((x) => x.verdict === 'unverifiable');
263
- rounds.push({ round, checked: todo.length, flagged: flagged.length, kept: kept.length, unverifiable: lost.length, items: kept.map((x) => ({ ...x, text: all.find((s) => s.id === x.id)?.text })) });
264
- log(`проверка, круг ${round}: проверено ${todo.length}, помечено ${flagged.length}, подтверждено ${kept.length}, не проверить ${lost.length}`);
299
+ rounds.push({ round, checked: todo.length, flagged: flagged.length, kept: kept.length, byCode: byCode.length, rejected, unverifiable: lost.length, items: [...byCode, ...kept].map((x) => ({ ...x, text: textOf(x.id) })) });
300
+ log(`проверка, круг ${round}: проверено ${todo.length}, помечено ${flagged.length}, подтверждено ${kept.length}, кодом ${byCode.length}, отклонено правок ${rejected.length}, не проверить ${lost.length}`);
265
301
  const changed = [];
266
- for (const x of kept) if (x.fix && applyFix(audit, x.id, x.fix)) changed.push(x.id);
302
+ // Найденное кодом возвращается к тексту из данных; его не нужно перепроверять моделью.
303
+ for (const x of byCode) applyFix(audit, x.id, x.fix);
304
+ for (const x of kept) if (x.fix && applyFix(audit, x.id, x.fix)) { changed.push(x.id); approveNumbers(audit.facts, x.id, x.fix); }
267
305
  for (const x of lost.slice().sort((a, b) => b.id.localeCompare(a.id))) if (dropStatement(audit, x.id)) changed.push(x.id);
268
306
  rescore(audit);
269
307
  if (!kept.length) { left = []; break; }
@@ -271,5 +309,7 @@ export async function reviewAndFix(audit, digest, ask, { lang = audit.meta?.lang
271
309
  todo = statementsOf(audit, { lang }).filter((st) => changed.includes(st.id));
272
310
  if (round === maxRounds) left = kept;
273
311
  }
274
- return { ok: left.length === 0, rounds, usage, left };
312
+ // Последний круг: кодом ещё раз, чтобы правки моделей не оставили чужих чисел.
313
+ if (!held) { const after = codeCheck(audit, statementsOf(audit, { lang })); for (const x of after) applyFix(audit, x.id, x.fix); if (after.length) log(`проверка кодом после правок: вернули к данным ${after.length}`); }
314
+ return { ok: !held && left.length === 0, rounds, usage, left, ...(held ? { held } : {}) };
275
315
  }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * 20 подсаженных ошибок (задание 14.10.2026, «второй шаг проверки фактов»): в отчёты пяти эталонов
3
+ * вписываются по четыре ложных числа, как это сделал Hermes, и прогоняется проверка с подменёнными
4
+ * моделями, без сети и денег. Каждая ошибка должна быть исправлена на верное значение или отчёт удержан.
5
+ * На отчётах без подсадки ни одна верная фраза не должна стать неверной.
6
+ */
7
+ import { readFileSync } from 'node:fs';
8
+ import { replayer } from './etalon.mjs';
9
+ import { collect } from './collect.mjs';
10
+ import { secondLook } from './verify.mjs';
11
+ import { draftNarrative } from './narrative.mjs';
12
+ import { statementsOf, applyFix, reviewAndFix } from './review.mjs';
13
+ import { numbersOf } from './facts.mjs';
14
+
15
+ const dir = new URL('../test/etalon/', import.meta.url);
16
+ // [id, число было, число стало]: те же места, что у Hermes.
17
+ const PLANTS = {
18
+ medeq: [['chk:api-catalog:comment', '9727', '29188'], ['sum:lead', '20', '67'], ['sum:verdict', null, '79'], ['sum:prio:0', '120', '367']],
19
+ neronov: [['chk:api-catalog:comment', '9727', '29188'], ['sub:subject', '23 000', '76 000'], ['sum:lead', '20', '67'], ['sum:verdict', null, '28']],
20
+ brainmd: [['chk:api-catalog:comment', '9727', '29188'], ['sum:lead', '20', '67'], ['sum:verdict', null, '49'], ['sum:prio:1', null, '148']],
21
+ drivee: [['chk:api-catalog:comment', '9727', '29188'], ['sum:lead', '20', '67'], ['sum:verdict', null, '52'], ['sum:prio:0', null, '145']],
22
+ babynanny: [['chk:api-catalog:comment', '9727', '29188'], ['sub:subject', '13 000', '46 000'], ['sum:lead', null, '61'], ['sum:verdict', null, '40']],
23
+ };
24
+
25
+ const json = (o) => ({ text: JSON.stringify(o), usage: { input: 1000, output: 100 } });
26
+ /** Первый шаг: всё «ok», кроме заданных id (их помечает ложными). */
27
+ const firstStep = (flag = new Set()) => async (messages) => {
28
+ const st = JSON.parse(messages[2].content).statements;
29
+ return json({ items: st.map((s) => (flag.has(s.id) ? { id: s.id, verdict: 'false', why: 'подсадка', fix: s.text } : { id: s.id, verdict: 'ok', premiseTrue: true, fitsNiche: true, passesNow: false, possible: true })) });
30
+ };
31
+ const silent = async () => json({ items: [] });
32
+ /** Второй шаг, который «подтверждает» порчу верного числа: правка должна быть отклонена. */
33
+ const spoiler = async (messages) => {
34
+ const fl = JSON.parse(messages[2].content).flagged;
35
+ return json({ items: fl.map((x) => ({ id: x.id, keep: true, evidence: 'на глаз', fix: String(x.text).replace(/\d+/, (n) => String(Number(n) + 1)) })) });
36
+ };
37
+
38
+ async function build(name) {
39
+ const truth = JSON.parse(readFileSync(new URL(`${name}-truth.json`, dir), 'utf8'));
40
+ const real = globalThis.fetch;
41
+ globalThis.fetch = replayer(new URL(`${name}.json.gz`, dir));
42
+ const raw = await collect(truth.site, { pages: 20, tier: '9', lang: truth.lang, rendered: false, log: () => {}, pageDelayMs: 0 });
43
+ const second = await secondLook(raw, { maxPages: 120, probes: false, log: () => {} });
44
+ globalThis.fetch = real;
45
+ return { audit: draftNarrative(raw, { lang: truth.lang }), digest: second.digest || { robots: '', pages: [] }, lang: truth.lang };
46
+ }
47
+
48
+ const textMap = (audit, lang) => Object.fromEntries(statementsOf(audit, { lang }).map((s) => [s.id, s.text]));
49
+ /** Подсадить: заменить число «было» (или первое число строки) на «стало». */
50
+ function plant(audit, lang, [id, from, to]) {
51
+ const t = textMap(audit, lang)[id];
52
+ if (!t) throw new Error(`нет строки ${id}`);
53
+ const target = from || numbersOf(t)[0];
54
+ if (!target) throw new Error(`в строке ${id} нет числа: «${t}»`);
55
+ const re = new RegExp(`(?<!\\d)(?<!\\d[\\u00A0\\u202F ])${target.replace(/ /g, '[\\u00A0\\u202F ]')}(?![\\d])`);
56
+ if (!re.test(t)) throw new Error(`в строке ${id} нет числа ${target}: «${t}»`);
57
+ applyFix(audit, id, t.replace(re, to));
58
+ return { id, from: target, to, before: t };
59
+ }
60
+
61
+ let fail = 0; const table = [];
62
+ for (const name of Object.keys(PLANTS)) {
63
+ const row = { site: name, fixed: 0, held: 0, missed: 0, spoiled: 0 };
64
+
65
+ // 1. Подсадка, первый шаг ничего не замечает, второй молчит: ловить обязан код.
66
+ {
67
+ const { audit, digest, lang } = await build(name);
68
+ const planted = PLANTS[name].map((p) => plant(audit, lang, p));
69
+ const rev = await reviewAndFix(audit, digest, firstStep(), { lang, askConfirm: silent });
70
+ const after = textMap(audit, lang);
71
+ for (const p of planted) {
72
+ const now = after[p.id] || '';
73
+ const fixed = !numbersOf(now).includes(p.to.replace(/ /g, '')) && now === p.before;
74
+ if (fixed) row.fixed++; else if (!rev.ok) row.held++; else { row.missed++; fail++; console.log(`FAIL ${name} ${p.id}: «${p.to}» прошло молча: «${now}»`); }
75
+ }
76
+ }
77
+ // 2. Подсадка, первый шаг помечает ещё и верную строку, второй молчит: отчёт удержан с причиной.
78
+ {
79
+ const { audit, digest, lang } = await build(name);
80
+ PLANTS[name].forEach((p) => plant(audit, lang, p));
81
+ const other = statementsOf(audit, { lang }).find((s) => s.kind === 'claim' && !PLANTS[name].some((p) => p[0] === s.id));
82
+ const rev = await reviewAndFix(audit, digest, firstStep(new Set([other.id])), { lang, askConfirm: silent });
83
+ if (rev.ok || !/не ответил по 1 строке/.test(rev.held || '')) { fail++; console.log(`FAIL ${name}: молчание второго шага не удержало отчёт (${rev.held || 'ok'}), строка ${other.id}, круги ${JSON.stringify(rev.rounds.map((r) => ({ f: r.flagged, c: r.byCode, m: r.missing })))}`); }
84
+ }
85
+ // 3. Без подсадки: ни одна верная фраза не меняется, даже если второй шаг «подтверждает» порчу чисел.
86
+ for (const [label, first, confirm] of [['всё верно', firstStep(), silent], ['порча чисел', firstStep(new Set(['sum:lead', 'sum:verdict', 'sum:prio:0'])), spoiler]]) {
87
+ const { audit, digest, lang } = await build(name);
88
+ const mine = textMap(audit, lang);
89
+ await reviewAndFix(audit, digest, first, { lang, askConfirm: confirm });
90
+ const after = textMap(audit, lang);
91
+ for (const [id, t] of Object.entries(mine)) if (after[id] !== undefined && after[id] !== t) { row.spoiled++; fail++; console.log(`FAIL ${name} (${label}) ${id}: верное «${t}» стало «${after[id]}»`); }
92
+ }
93
+ table.push(row);
94
+ }
95
+ for (const r of table) console.log(`${r.site.padEnd(10)} исправлено ${r.fixed}, удержано ${r.held}, пропущено ${r.missed}, испорчено верных ${r.spoiled}`);
96
+ const total = table.reduce((a, r) => ({ fixed: a.fixed + r.fixed, held: a.held + r.held, missed: a.missed + r.missed }), { fixed: 0, held: 0, missed: 0 });
97
+ console.log(`подсадка: ${total.fixed + total.held} из 20 исправлены или удержаны, пропущено молча ${total.missed}`);
98
+ if (fail) process.exit(1);
99
+ console.log('PASS planted errors');