规则库 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
+9
View File
@@ -18,12 +18,17 @@ from __future__ import annotations
import hashlib
import json
import os
import secrets
from datetime import date
from pathlib import Path
_ENABLED = True # 软锁总开关:False = 完全放行
# 评测豁免:环境变量 ZIPEC_EVAL=1 时 check/consume 直接放行——
# 跑测试集复测不签发、不扣次、不创建任何文件(见 评测/测试规则.md)。
_EVAL_ENV = "ZIPEC_EVAL"
_SALT = "lingyao-miaojie-ziping-eightchar"
_COMPANY = "四川灵爻妙解文化传播有限公司"
STATE_REL = Path("data") / ".license.json"
@@ -78,6 +83,8 @@ def check(root: Path) -> str | None:
"""返回 None 表示放行;返回字符串表示拒跑原因(由调用方打印到 stderr,退出码 4)。"""
if not _ENABLED:
return None
if os.environ.get(_EVAL_ENV) == "1":
return None # 评测豁免:不签发、不扣次、不创建任何文件
st = _load(root)
if st is None:
if (root / PUBLIC_MARKER_REL).is_file():
@@ -101,6 +108,8 @@ def consume(root: Path) -> None:
"""成功排盘一次后扣减一次。仅在 check 放行后调用。"""
if not _ENABLED:
return
if os.environ.get(_EVAL_ENV) == "1":
return # 评测豁免:不扣次、不写状态文件
st = _load(root)
if st is None:
return
+166
View File
@@ -0,0 +1,166 @@
#!/usr/bin/env python3
"""规则条款检索 —— 按关键词查 rules/ 条款全文,模型按需取规则。
用法:
python query_rules.py 从格 破格 [--limit 8] [--and]
python query_rules.py --id R-运-32
python query_rules.py --list
规则库(rules/[0-9]*.md)为单一事实源:启动时现解析条款索引,无缓存文件。
条款体例:`### R-运-32 标题` 起头,正文到下一个 `##`/`###` 标题或文件尾。
关键词空格分隔,中文按子串匹配(不分词)。默认排序:全部关键词都命中的
条款排前,部分命中按命中词数 + 相关度分排序(罕见词加权威力更大,
标题命中加权、正文命中位置靠前加权);--and 只保留全部关键词都命中的条款。
退出码:0 检索完成;1 无命中条款;2 输入不可用;3 内部错误。
"""
import argparse
import math
import re
import sys
import traceback
from dataclasses import dataclass
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import _runtime_guard # noqa: F401 import 即检查:Python < 3.10 时中文报错退出(码 3)
RULES_DIR = Path(__file__).resolve().parents[1] / "rules"
CLAUSE_RE = re.compile(r"^### (R-\S+)\s+(.+?)\s*$")
HEADING_RE = re.compile(r"^##+ ")
@dataclass
class Clause:
rid: str # 条款编号,如 R-运-32
title: str # 标题
body: str # 正文(不含头部行,尾部空行与 --- 分隔线已剥除)
file: str # 所属文件名
line: int # 头部行号(1 起)
order: int # 解析序,排序兜底
def load_index(rules_dir: Path = RULES_DIR) -> list[Clause]:
clauses: list[Clause] = []
for path in sorted(rules_dir.glob("[0-9]*.md")):
lines = path.read_text(encoding="utf-8").splitlines()
starts = [(i, CLAUSE_RE.match(ln)) for i, ln in enumerate(lines)]
heads = [(i, m) for i, m in starts if m]
for k, (i, m) in enumerate(heads):
end = len(lines)
for j in range(i + 1, len(lines)):
if HEADING_RE.match(lines[j]):
end = j
break
body = lines[i + 1:end]
while body and (not body[-1].strip() or body[-1].strip() == "---"):
body.pop()
clauses.append(Clause(rid=m.group(1), title=m.group(2),
body="\n".join(body), file=path.name,
line=i + 1, order=len(clauses)))
return clauses
def score_clause(c: Clause, keywords: list[str],
idf: dict[str, float]) -> tuple[int, float]:
"""返回 (命中关键词数, 相关度分);一个关键词未命中则 hits 少一。"""
hits, total = 0, 0.0
text = c.title + "\n" + c.body
for kw in keywords:
n_title, n_body = c.title.count(kw), c.body.count(kw)
if n_title + n_body == 0:
continue
hits += 1
pos = text.find(kw)
total += idf[kw] * (5.0 * n_title + n_body + (1.0 - pos / len(text)))
return hits, total
def search(clauses: list[Clause], keywords: list[str],
strict_and: bool = False) -> list[Clause]:
idf = {}
for kw in keywords:
df = sum(1 for c in clauses if kw in c.title or kw in c.body)
idf[kw] = math.log(1 + len(clauses) / (1 + df))
scored = []
for c in clauses:
hits, score = score_clause(c, keywords, idf)
if hits == 0 or (strict_and and hits < len(keywords)):
continue
scored.append((hits, score, c))
scored.sort(key=lambda t: (-t[0], -t[1], t[2].order))
return [c for _, _, c in scored]
def print_clause(c: Clause) -> None:
print(f"## {c.rid} {c.title}")
print(f"{c.file}:{c.line}")
print()
print(c.body)
print()
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(
prog="query_rules.py",
description="规则条款检索:按关键词查 rules/ 条款全文(编号 R-XX-NN)",
)
ap.add_argument("keywords", nargs="*", help="检索关键词,空格分隔,子串匹配")
ap.add_argument("--limit", type=int, default=8, help="最多输出条款数(默认 8,0 为不限)")
ap.add_argument("--and", dest="strict_and", action="store_true",
help="严格 AND:只保留全部关键词都命中的条款")
ap.add_argument("--id", default=None, help="按编号精确取单条,如 R-运-32")
ap.add_argument("--list", action="store_true", help="列出全部条款标题")
a = ap.parse_args(argv)
keywords = [kw for kw in (kw.strip() for kw in a.keywords) if kw]
if not keywords and a.id is None and not a.list:
print("ERROR: 至少给一个关键词,或用 --id / --list。", file=sys.stderr)
return 2
try:
clauses = load_index()
if not clauses:
print(f"ERROR: 未解析到任何条款({RULES_DIR})。", file=sys.stderr)
return 3
if a.list:
print(f"# 全部条款({len(clauses)} 条)")
print()
cur = None
for c in clauses:
if c.file != cur:
cur = c.file
print(f"## {cur}")
print(f"- {c.rid} {c.title}")
return 0
if a.id is not None:
for c in clauses:
if c.rid == a.id:
print_clause(c)
return 0
print(f"无命中条款:{a.id}", file=sys.stderr)
return 1
hits = search(clauses, keywords, strict_and=a.strict_and)
if not hits:
print(f"无命中条款:{' '.join(keywords)}", file=sys.stderr)
return 1
shown = hits if a.limit <= 0 else hits[:a.limit]
mode = "AND 全命中" if a.strict_and else "相关度"
print(f"# 规则检索:{' '.join(keywords)}({mode},命中 {len(hits)} 条,示 {len(shown)} 条)")
print()
for c in shown:
print_clause(c)
return 0
except Exception:
print("ERROR: 检索内部错误。", file=sys.stderr)
traceback.print_exc(file=sys.stderr)
return 3
if __name__ == "__main__":
raise SystemExit(main())