Files
colleague-skill/scripts/split-corpus.mjs
zhoutianyi 8ce68b01f1 fix(blind-test/render): 盲测装置真的产出可读页面;声明为缺口的段落可以渲染
四条缺陷,全部让「效果层盲测」这条线落空:

1. **`visual-check.mjs` 没有入口守卫**(scripts):`main()` 在 import 时就执行,
   而 `tests/visual-check-rule.test.mjs` 正是 import 它来用它的检查函数 ——
   一次 import 就启动浏览器并打印用法,于是整个**文件**作为一条测试失败,
   而不是某个断言失败。现在与 `bin/distilly.mjs`、`blind-test.mjs` 一样用
   `isEntryPoint` 守卫。
2. **`split_by` 的词汇与全系统不一致**:回执写 `"time"`,而同一份回执里的
   `cut.timecode`、字幕解析器的 `meta.timecodes` 都用 timecode。
3. **`prepare` 默认不产出页面**:只在 `--baseline` 时才渲染 `profile.html`,
   于是拿着一张 `judge-prompt.md` 的评委**没有任何东西可读** —— 这一臂可以在
   「什么都没产出」的情况下"通过"。现在默认渲染(`--skeleton-only` 保留旧的
   「只写骨架、由模型自己写视图」行为)。
4. **声明为缺口的段落渲染不出来**(views/schema):机械基线对没有证据的段落写
   `{gap: true, items:[{anchors: []}]}`,而 `view check` 要求每个条目至少一个
   锚点、时间线条目必须有 `at` —— 于是「诚实地标注缺证据」反而让页面校验失败。
   `allowMissing` 正是为这种情形存在的,现在对 `gap: true` 的段落逐段生效
   (引用**不存在**的锚点仍然是错误)。

另外 `finalizeView` 现在回读渲染结果并报告 `anchors` 数:页面把视图数据以 JSON
内嵌、由 viewer 在运行时生成 `data-anchor-ref` 标记,所以文件里是
`"anchor": "k00NN"` 而不是 `[k00NN]` —— 先前的方括号统计对一页满是引用的页面
报 0。

验证:
- `node --test tests/blind-test.test.mjs` → 8/8(改动前 6/8)
- `node --test tests/visual-check-rule.test.mjs` → 3/3(改动前整文件失败)
- 手工核对:`prepare` 产出 `profile.html`(53011 B,`external_links: none`,
  26 个不同锚点),缺口段落渲染为「本节证据不足:…」
- 全量 → 365 通过 / 10 失败(改动前 363 / 13);acceptance 12/12 未回退
2026-09-15 17:22:20 +08:00

115 lines
4.4 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
/**
* Split a corpus by time into the A (distillation) and B (held-out) halves that
* `docs/v2/ACCEPTANCE.md` requires, and write a receipt so the split cannot be
* quietly re-rolled after seeing the results.
*
* node scripts/split-corpus.mjs --in <corpus.srt|.vtt|.txt> --out <dir> [--ratio 0.7]
*
* Subtitles are split on the cue timeline (B starts where A stops). Anything else
* is split by paragraph count, and the receipt records `by: "paragraphs"` so the
* weaker guarantee is visible.
*/
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { createHash } from "node:crypto";
import { basename, extname, join } from "node:path";
import { SourceFile } from "../src/parse/common.mjs";
import { detectSubtitleFormat, formatTimecode, splitCues } from "../src/parse/subtitle.mjs";
function arg(name, fallback) {
const index = process.argv.indexOf(`--${name}`);
return index === -1 ? fallback : process.argv[index + 1];
}
const input = arg("in");
const outDir = arg("out");
const ratio = Number(arg("ratio", "0.7"));
if (!input || !outDir) {
console.error("usage: node scripts/split-corpus.mjs --in <corpus> --out <dir> [--ratio 0.7]");
process.exit(2);
}
if (!(ratio > 0.1 && ratio < 0.95)) {
console.error(`ratio must be between 0.1 and 0.95 (got ${ratio})`);
process.exit(2);
}
const sha256 = (text) => createHash("sha256").update(text, "utf8").digest("hex");
const extension = extname(input).toLowerCase();
const raw = readFileSync(input);
const source = new SourceFile({ path: basename(input), raw });
mkdirSync(outDir, { recursive: true });
let by;
let aText;
let bText;
let aMeta;
let bMeta;
let cut = null;
if (extension === ".srt" || extension === ".vtt") {
const { format } = detectSubtitleFormat(source);
const { cues } = splitCues(source, format);
if (cues.length < 4) throw new Error(`need at least four cues to split ${input}`);
const total = cues[cues.length - 1].end;
const cutAt = total * ratio;
let boundary = cues.findIndex((cue) => cue.start >= cutAt);
if (boundary < 2) boundary = 2;
if (boundary > cues.length - 2) boundary = cues.length - 2;
const render = (list) =>
list
.map((cue, index) => {
const body = cue.speaker ? `${cue.speaker}:${cue.text}` : cue.text;
return `${index + 1}\n${formatTimecode(cue.start)} --> ${formatTimecode(cue.end)}\n${body}\n`;
})
.join("\n");
const a = cues.slice(0, boundary);
const b = cues.slice(boundary);
aText = render(a);
bText = render(b);
// The vocabulary is "timecode" everywhere else (`cut.timecode`, the subtitle
// parser's `meta.timecodes`), so the receipt says the same thing.
by = "timecode";
cut = {
index: boundary,
timecode: formatTimecode(cues[boundary].start),
a_end_timecode: formatTimecode(a[a.length - 1].end),
};
aMeta = { cues: a.length, start: formatTimecode(a[0].start), end: formatTimecode(a[a.length - 1].end) };
bMeta = { cues: b.length, start: formatTimecode(b[0].start), end: formatTimecode(b[b.length - 1].end) };
} else {
const paragraphs = source.text.split(/\n{2,}/).map((part) => part.trim()).filter(Boolean);
if (paragraphs.length < 4) throw new Error(`need at least four paragraphs to split ${input}`);
const boundary = Math.min(Math.max(Math.round(paragraphs.length * ratio), 1), paragraphs.length - 1);
aText = `${paragraphs.slice(0, boundary).join("\n\n")}\n`;
bText = `${paragraphs.slice(boundary).join("\n\n")}\n`;
by = "paragraphs";
cut = { index: boundary, timecode: null, a_end_timecode: null };
aMeta = { paragraphs: boundary };
bMeta = { paragraphs: paragraphs.length - boundary };
}
const aPath = join(outDir, `A${extension || ".txt"}`);
const bPath = join(outDir, `B${extension || ".txt"}`);
writeFileSync(aPath, aText, "utf8");
writeFileSync(bPath, bText, "utf8");
const receipt = {
source: input,
split_by: by,
ratio,
cut,
a: { file: basename(aPath), sha256: sha256(aText), bytes: Buffer.byteLength(aText), ...aMeta },
b: { file: basename(bPath), sha256: sha256(bText), bytes: Buffer.byteLength(bText), ...bMeta },
};
writeFileSync(join(outDir, "split.json"), `${JSON.stringify(receipt, null, 2)}\n`, "utf8");
console.log(`split (${by}, ratio ${ratio}) → ${aPath} / ${bPath}`);
console.log(` A: ${JSON.stringify(aMeta)}`);
console.log(` B: ${JSON.stringify(bMeta)}`);
console.log(" keep B away from the distiller and the judge; only the checker reads it.");