规则库 3.8.1 + 排盘修复 + 评测体系

- 规则库 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
This commit is contained in:
2026-10-13 12:34:36 +08:00
parent b20fd5c3c9
commit 1a0eed1999
338 changed files with 36533 additions and 30 deletions
+143
View File
@@ -0,0 +1,143 @@
#!/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())