a2a1392f01
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
127 lines
7.4 KiB
TypeScript
127 lines
7.4 KiB
TypeScript
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<string, number>();
|
||
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} 字)`;
|
||
}
|