import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { basename, dirname, join } from "node:path"; import type { StudyRecord } from "../../src/materials/index.ts"; import { ofKind, readLedger } from "../lib/ledger.ts"; import { newEvalDir } from "../lib/study.ts"; import { Neutral, render } from "./neutral.ts"; import { readOurs, readReference, type Json, type Prediction, type RoundData } from "./round-data.ts"; // 把几次运行的第 1 轮导出成同一种纯文本(任务书 14 第 3.2 节第 1 步),按固定小标题排,去掉来历。 // node eval/round-one/export.ts <检验名字> <来源>... // 来源是我们的研究目录(里面有 ledger.sqlite),或参考项目的 board.sqlite(只读,先复制到输出目录)。 // 每次运行写进 <检验目录>/runs/<名字>/:data.json(检验用的中间数据,带原编号)、text.txt(给审阅者的文本)、 // names.json(原编号 → 中性名的映射,审阅者看不到)。之后用 blind.ts 随机编成"运行 A、B……"。 const [name, ...sources] = process.argv.slice(2); if (name === undefined || sources.length === 0) throw new Error("用法:node eval/round-one/export.ts <检验名字> <研究目录或 board.sqlite>..."); const out = newEvalDir(name); for (const source of sources) { const ours = existsSync(join(source, "ledger.sqlite")); // 名字取上一层目录名:我们的研究目录是 <检验目录>/study,取检验目录名;参考项目取运行名(board.sqlite 所在目录)。 const label = basename(dirname(source)); const dir = join(out, "runs", label); if (existsSync(dir)) throw new Error(`两个来源的名字都是 ${label},分不开`); mkdirSync(dir, { recursive: true }); let data: RoundData; if (ours) { const records = readLedger(source); const study = ofKind(records, "config")[0]?.content as StudyRecord | undefined; if (study === undefined) throw new Error(`${source} 的账本里没有研究配置记录`); data = readOurs(records, 1, source, (study.config as { studyId: string }).studyId); } else { data = readReference(source, join(dir, "board-copy")); } const nt = new Neutral(data.hiddenNames); writeFileSync(join(dir, "data.json"), `${JSON.stringify(data, null, 2)}\n`); writeFileSync(join(dir, "text.txt"), renderRound(data, nt)); writeFileSync(join(dir, "names.json"), `${JSON.stringify(nt.table(), null, 2)}\n`); console.log(`${label}:提案 ${data.proposals.length}、候选 ${data.dedup.groups.length}、挑刺 ${data.critiques.length}、退回 ${data.rejections.length} → ${dir}`); } console.log(`检验目录:${out}`); // --------------------------------------------------------------------------- // 文本 // --------------------------------------------------------------------------- function renderRound(d: RoundData, nt: Neutral): string { const out: string[] = ["# 第 1 轮记录(开轮到选批)", ""]; const range = (r: { q1: number; q5: number; q9: number }) => `q1 = ${r.q1},q5 = ${r.q5},q9 = ${r.q9}`; const candidates = d.dedup.groups.map((g) => g.candidate); const candidateOf = new Map(d.dedup.groups.flatMap((g) => g.proposals.map((p) => [p, g.candidate] as const))); // 先按"提案 → 候选 → 挑刺"的顺序给编号起中性名,正文里引用到的编号就和小标题一致。 for (const p of d.proposals) { nt.name("研究者", p.author); nt.name("提案", p.id); } for (const g of d.dedup.groups) nt.name("候选", g.candidate); for (const p of d.proposals) for (const c of d.critiques.filter((x) => x.proposal === p.id)) nt.name("挑刺", c.id); out.push("## 一、提案全文", ""); for (const p of d.proposals) { out.push(`### ${nt.text(p.id)}(${nt.text(p.author)})`, ""); out.push(`作者自评:${p.authorEstimate === null ? "无" : range(p.authorEstimate)}`, ""); out.push(render(p.content, nt), ""); } out.push("## 二、判重结果", ""); for (const g of d.dedup.groups) out.push(`- ${nt.text(g.candidate)}:${g.proposals.map((p) => nt.text(p)).join("、")}`); out.push("", "并进留池候选的:", render(d.dedup.carried, nt), "", "判重时每一对的判断(原样):", render(d.dedup.judgments, nt), ""); out.push("## 三、挑刺和回应", ""); for (const p of d.proposals) { const these = d.critiques.filter((c) => c.proposal === p.id); const candidate = candidateOf.get(p.id); out.push(`### 对 ${nt.text(p.id)} 的挑刺${candidate === undefined ? "" : `(${nt.text(candidate)})`}`, ""); if (these.length === 0) out.push("(没有挑刺)", ""); for (const c of these) { out.push(`#### ${nt.text(c.id)}(${nt.text(c.author)})`, "", render(c.content, nt), ""); const replies = d.responses.flatMap((r) => r.replies.filter((x) => x.critique === c.id).map((x) => ({ ...x, author: r.author }))); if (replies.length === 0) out.push("作者回应:(没有找到对这条挑刺的回应)", ""); for (const x of replies) out.push(`作者回应(${nt.text(x.author)}):`, render(x.text, nt, " "), ""); } } for (const r of d.responses) { out.push(`### ${nt.text(r.candidate)} 回应里逐条答复以外的部分(${nt.text(r.author)},${r.kind === "unification" ? "统一" : "回应"})`, ""); out.push(render(r.rest, nt), ""); } out.push("## 四、冻结协议", ""); for (const p of d.protocols) out.push(`### ${nt.text(p.candidate)}`, "", render(p.content, nt), ""); out.push("## 五、两次预测的三个数", ""); const predictions = (title: string, list: Prediction[] | null, missing: string) => { out.push(`### ${title}`, ""); if (list === null) return out.push(missing, ""); const order = (p: Prediction) => [candidates.indexOf(p.candidate), nt.text(p.researcher)] as const; const sorted = [...list].sort((a, b) => order(a)[0] - order(b)[0] || order(a)[1].localeCompare(order(b)[1])); for (const p of sorted) { out.push(`- ${nt.text(p.candidate)},${nt.text(p.researcher)}:${range(p.range)}`); if (p.note !== null) out.push(render(p.note as Json, nt, " ")); } out.push(""); }; predictions("第一次预测", d.firstPrediction, "无此阶段"); predictions("第二次预测", d.secondPrediction, "不适用:这次运行用投票选批,不是范围预测。投票原样如下:"); if (d.votes !== null) out.push(render(d.votes, nt), ""); out.push("## 六、选批结果", ""); out.push(`选中:${d.batch.chosen.length === 0 ? "无" : d.batch.chosen.map((c) => nt.text(c)).join("、")}`, "", "选批记录(原样):", render(d.batch.detail, nt), ""); out.push("## 七、退回的次数和原因", ""); const byStage = new Map(); for (const r of d.rejections) byStage.set(r.stage, (byStage.get(r.stage) ?? 0) + 1); out.push(`共 ${d.rejections.length} 次${byStage.size === 0 ? "" : `:${[...byStage].map(([s, n]) => `${s} ${n} 次`).join(",")}`}`, ""); d.rejections.forEach((r, i) => { out.push(`${i + 1}. ${r.stage},${nt.text(r.author)}${r.target === null ? "" : `,对象 ${nt.text(r.target)}`}:`, render(clip(r.reason), nt, " "), ""); }); return `${out.join("\n")}\n`; } /** 退回原因只留开头 800 字:形状不合格的退回会把整份交上来的内容附在后面,全文在 data.json 里。 */ function clip(s: string): string { const keep = 800; return s.length <= keep ? s : `${s.slice(0, keep)}\n(以下省略 ${s.length - keep} 字)`; }