- 规则库 3.6→3.8.1(542→548 条):剪枝上锁、R-杂-38 从格临界双轨、 R-事-10~13 冲刑方向判别、R-禁-23 自造必要条件之禁 - 排盘修复随 3.6.2 已发:港澳时区夏令时双向误拨、晚子时双重换日 - 新增 评测/ 目录(BaziQA 题答分离、判分工具、记分卡、with/without-skill 对照) 与 scripts/query_rules.py 条款检索;ZIPEC_EVAL 评测额度豁免 - manifest 重建并重新签名;VERSION 保持 3.6.2,不发版不打 tag
144 lines
6.3 KiB
Python
144 lines
6.3 KiB
Python
#!/usr/bin/env python3
|
||
"""试题原始包 → questions/(盲版)+ answers/(答案键)分离生成器。
|
||
|
||
输入为含官方答案的赛事原始 JSON(contest8_*.json 结构:第 0 项元数据,
|
||
其后为命主数组,每命主 questions 列表,每题含 answer 字段)。输出:
|
||
|
||
- <out>/questions/<file>.json:与原版同构但剥离每题 answer 的盲版(求解端只准读这里);
|
||
- <out>/answers/<file>.json:答案键,仅 [元数据, {person_id, answers: [
|
||
{question_index, answer, dimension}, ...]} ...];维度标签可由 --scores
|
||
指向的官方跑分目录(details 含 dimension 字段)合并,缺省标「未标注」。
|
||
|
||
用法(在本仓库根目录之外同样可用,路径全部参数化):
|
||
python make_blind.py --src <原始包目录> [--out <赛事目录>]
|
||
[--scores <官方跑分目录>]
|
||
|
||
校验:
|
||
1. 结构断言:盲版任何位置(递归)不存在 "answer" 键;
|
||
2. 文本扫描:盲版文件原文不出现 `"answer"` 字样;
|
||
3. 等价性:盲版 + 答案键可无损还原原文件(确认只删了 answer);
|
||
4. 题数:每届实际题数与元数据 total_questions 一致;
|
||
5. 维度覆盖:打印未标注维度计数(有 --scores 时应为 0)。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import copy
|
||
import json
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
ROOT = Path(__file__).resolve().parents[1] # 评测/
|
||
DEFAULT_OUT = ROOT / "试题" / "BaziQA-全球算命师大赛"
|
||
|
||
|
||
def strip_answers(node, path: str = "$"):
|
||
"""递归剥离题目里的 answer 键;返回剥离数量。非题目位置本就不该有 answer,发现即报错。"""
|
||
n = 0
|
||
if isinstance(node, dict):
|
||
if "options" in node and "question_id" in node:
|
||
assert "answer" in node, f"{path}: 题目缺少 answer 字段"
|
||
del node["answer"]
|
||
n += 1
|
||
else:
|
||
assert "answer" not in node, f"{path}: 非题目位置出现 answer 键"
|
||
for k, v in node.items():
|
||
n += strip_answers(v, f"{path}.{k}")
|
||
elif isinstance(node, list):
|
||
for i, v in enumerate(node):
|
||
n += strip_answers(v, f"{path}[{i}]")
|
||
return n
|
||
|
||
|
||
def has_answer_key(node) -> bool:
|
||
if isinstance(node, dict):
|
||
return "answer" in node or any(has_answer_key(v) for v in node.values())
|
||
if isinstance(node, list):
|
||
return any(has_answer_key(v) for v in node)
|
||
return False
|
||
|
||
|
||
def load_dimensions(scores_dir: Path | None) -> dict[tuple[str, str, int], str]:
|
||
"""从官方跑分 details 复用维度标签,键 (dataset, person_id, question_index)。"""
|
||
dim = {}
|
||
if not scores_dir or not scores_dir.is_dir():
|
||
return dim
|
||
for f in sorted(scores_dir.glob("contest8_*.json")):
|
||
try:
|
||
s = json.loads(f.read_text(encoding="utf-8"))
|
||
except Exception:
|
||
continue
|
||
for d in s.get("details", []):
|
||
dim[(d["dataset"], d["person_id"], d["question_index"])] = \
|
||
d.get("dimension", "未标注")
|
||
return dim
|
||
|
||
|
||
def main() -> int:
|
||
ap = argparse.ArgumentParser(description="试题原始包 → questions/+answers/ 分离")
|
||
ap.add_argument("--src", type=Path, required=True, help="含 answer 的原始 contest8_*.json 目录")
|
||
ap.add_argument("--out", type=Path, default=DEFAULT_OUT, help="赛事目录(其下建 questions/ 与 answers/)")
|
||
ap.add_argument("--scores", type=Path, default=None, help="官方跑分目录(取 dimension 维度标签,可省)")
|
||
a = ap.parse_args()
|
||
|
||
q_dir = a.out / "questions"
|
||
k_dir = a.out / "answers"
|
||
q_dir.mkdir(parents=True, exist_ok=True)
|
||
k_dir.mkdir(parents=True, exist_ok=True)
|
||
dims = load_dimensions(a.scores)
|
||
|
||
total = 0
|
||
for src in sorted(a.src.glob("contest8_*.json")):
|
||
original = json.loads(src.read_text(encoding="utf-8"))
|
||
contest = original[0]["contest_id"]
|
||
blind = copy.deepcopy(original)
|
||
n = strip_answers(blind)
|
||
|
||
# 结构断言:盲版任何位置无 answer 键
|
||
assert not has_answer_key(blind), f"{src.name}: 盲版仍含 answer 键"
|
||
|
||
# 答案键:仅 person_id + 题序 + 答案字母 + 维度标签
|
||
missing_dim = 0
|
||
key = [{
|
||
"contest_id": contest,
|
||
"total_questions": original[0].get("total_questions"),
|
||
"note": "答案键:person_id + 题序 + 标准答案字母 + 维度标签;求解端禁读",
|
||
}]
|
||
for p in original[1:]:
|
||
answers = []
|
||
for i, q in enumerate(p["questions"], start=1):
|
||
d = dims.get((contest, p["person_id"], i))
|
||
if d is None:
|
||
d, missing_dim = "未标注", missing_dim + 1
|
||
answers.append({"question_index": i, "answer": q["answer"],
|
||
"dimension": d})
|
||
key.append({"person_id": p["person_id"], "answers": answers})
|
||
|
||
# 等价性:盲版 + 答案键 == 原版(确认除 answer 外未动任何字段)
|
||
rebuilt = copy.deepcopy(blind)
|
||
for p_o, p_r in zip(original[1:], rebuilt[1:]):
|
||
for q_o, q_r in zip(p_o["questions"], p_r["questions"]):
|
||
q_r["answer"] = q_o["answer"]
|
||
assert rebuilt == original, f"{src.name}: 剥离 answer 后其余字段不一致"
|
||
|
||
# 题数与元数据一致
|
||
actual = sum(len(p["questions"]) for p in original[1:])
|
||
assert original[0].get("total_questions") in (None, actual), \
|
||
f"{src.name}: 元数据 total_questions={original[0].get('total_questions')} 与实际 {actual} 不符"
|
||
|
||
q_text = json.dumps(blind, ensure_ascii=False, indent=1) + "\n"
|
||
# 文本扫描:盲版原文不得出现 "answer" 字样
|
||
assert '"answer"' not in q_text, f"{src.name}: 盲版文本含 answer 字样"
|
||
(q_dir / src.name).write_text(q_text, encoding="utf-8")
|
||
(k_dir / src.name).write_text(
|
||
json.dumps(key, ensure_ascii=False, indent=1) + "\n", encoding="utf-8")
|
||
total += n
|
||
print(f"{src.name}: {len(original) - 1} 命主 {n} 题 -> "
|
||
f"questions/(盲版)+ answers/(答案键,维度未标注 {missing_dim})")
|
||
print(f"OK: 共分离 {total} 题,questions/ 与 answers/ 已生成并校验通过")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|