Files
InfoRSI/eval/round-one/export.ts
T

127 lines
7.4 KiB
TypeScript
Raw 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.
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} 字)`;
}