mirror of
https://github.com/titanwings/colleague-skill.git
synced 2026-10-29 20:54:43 +08:00
四条缺陷,全部让「效果层盲测」这条线落空:
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 未回退
115 lines
4.4 KiB
JavaScript
115 lines
4.4 KiB
JavaScript
#!/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.");
|