Files
gaokao-volunteer-system/data/rules/loader.py

483 lines
18 KiB
Python
Raw Normal View History

from __future__ import annotations
from datetime import date, datetime, timedelta
from pathlib import Path
from typing import Any
import yaml # type: ignore[import-untyped]
from .models import LoadedRule, RulesStatus
PROVINCE_SLUGS = {
"安徽": "anhui",
"北京": "beijing",
"重庆": "chongqing",
"福建": "fujian",
"甘肃": "gansu",
"广东": "guangdong",
"广西": "guangxi",
"贵州": "guizhou",
"海南": "hainan",
"河北": "hebei",
"黑龙江": "heilongjiang",
"河南": "henan",
"湖北": "hubei",
"湖南": "hunan",
"吉林": "jilin",
"江苏": "jiangsu",
"江西": "jiangxi",
"辽宁": "liaoning",
"青海": "qinghai",
"山东": "shandong",
"山西": "shanxi",
"上海": "shanghai",
"四川": "sichuan",
"天津": "tianjin",
"西藏": "xizang",
"新疆": "xinjiang",
"云南": "yunnan",
"浙江": "zhejiang",
}
SLUG_TO_PROVINCE = {value: key for key, value in PROVINCE_SLUGS.items()}
EVIDENCE_TEMPLATE_STATUS = "draft_template"
EVIDENCE_REQUIRED_SECTIONS = (
"## 1. 官方原文摘录",
"## 2. 转写为机读规则",
"## 3. 关键边界与例外",
"## 4. 后续维护",
)
EVIDENCE_TEMPLATE_SENTINELS = (
f"证据状态: {EVIDENCE_TEMPLATE_STATUS}",
"TODO_OFFICIAL_EXCERPT",
)
class RuleLoader:
def __init__(self, truth_root: Path) -> None:
self._truth_root = Path(truth_root)
self._evidence_root = self._truth_root.parent / "_evidence"
self._national_doc = self._read_yaml(self._truth_root / "national.yaml")
self._province_docs = self._load_province_docs(self._truth_root / "province")
@classmethod
def from_truth_root(cls, truth_root: Path | str) -> "RuleLoader":
return cls(Path(truth_root))
def list_national_rules(self) -> list[LoadedRule]:
return self._convert_rules(self._national_doc, scope="national", province=None)
def list_province_rules(self, province: str) -> list[LoadedRule]:
doc = self._province_docs[province]
return self._convert_rules(doc, scope="province", province=province)
def active_provinces(self) -> list[str]:
return sorted(
province
for province, doc in self._province_docs.items()
if doc.get("status", "active") == "active"
)
def list_all_rules(self, *, include_inactive: bool = True) -> list[LoadedRule]:
rules = self.list_national_rules()
for province in self.active_provinces():
rules.extend(self.list_province_rules(province))
if include_inactive:
return rules
return [rule for rule in rules if rule.status == "active"]
def get_rule(self, rule_id: str) -> LoadedRule:
for rule in self.list_all_rules(include_inactive=True):
if rule.rule_id == rule_id:
return rule
raise KeyError(f"rule not found: {rule_id}")
def build_status(self, *, max_rule_age_days: int = 90) -> RulesStatus:
active_rules = self.list_all_rules(include_inactive=False)
stale_rules = self.find_stale_rules(active_rules, max_age_days=max_rule_age_days)
return RulesStatus(
province_count=len(self.active_provinces()),
national_rule_count=len(self._national_doc.get("rules", {})),
active_provinces=self.active_provinces(),
national_version=str(self._national_doc.get("version", "2026.1")),
province_versions={
province: str(self._province_docs[province].get("version", "2026.1"))
for province in self.active_provinces()
},
active_rule_count=len(active_rules),
stale_rule_max_age_days=max_rule_age_days,
stale_rule_count=len(stale_rules),
stale_rule_ids=[rule.rule_id for rule in stale_rules],
evidenced_rule_count=sum(1 for rule in active_rules if rule.evidence_exists),
missing_evidence_rule_count=sum(
1 for rule in active_rules if not rule.evidence_exists
),
)
def build_evidence_matrix(self) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
rows.append(self._build_scope_row(scope="national", province=None, doc=self._national_doc))
for province in self.active_provinces():
rows.append(
self._build_scope_row(
scope="province",
province=province,
doc=self._province_docs[province],
)
)
return rows
def find_stale_rules(
self,
rules: list[LoadedRule] | None = None,
*,
today: date | None = None,
max_age_days: int = 90,
) -> list[LoadedRule]:
baseline = today or date.today()
stale_before = baseline - timedelta(days=max_age_days)
candidates = rules if rules is not None else self.list_all_rules(include_inactive=False)
stale: list[LoadedRule] = []
for rule in candidates:
verified = _parse_rule_date(rule.last_verified_at)
if verified is None or verified < stale_before:
stale.append(rule)
return stale
def read_evidence_excerpt(self, rule: LoadedRule) -> str | None:
if not rule.evidence_path:
return None
path = Path(rule.evidence_path)
if not _evidence_file_is_complete(path):
return None
for line in path.read_text(encoding="utf-8").splitlines():
stripped = line.strip()
if stripped.startswith(">"):
return stripped.removeprefix(">").strip()
return None
def scaffold_missing_evidence(
self,
*,
province: str | None = None,
) -> dict[str, Any]:
rules = self._rules_for_scaffolding(province)
created_files: list[str] = []
existing_files: list[str] = []
touched_indexes: list[str] = []
for group in rules.values():
if not group:
continue
rule = group[0]
evidence_dir = self._evidence_dir_for_rule(rule)
evidence_dir.mkdir(parents=True, exist_ok=True)
index_path = evidence_dir / "INDEX.md"
index_path.write_text(
self._build_index_markdown(group),
encoding="utf-8",
)
touched_indexes.append(str(index_path))
for scoped_rule in group:
if scoped_rule.evidence_exists:
existing_files.append(scoped_rule.evidence_path or scoped_rule.rule_id)
continue
evidence_path = self._evidence_dir_for_rule(scoped_rule) / (
f"{scoped_rule.source_evidence_id}.md"
)
if evidence_path.exists():
existing_files.append(str(evidence_path))
continue
evidence_path.write_text(
self._build_evidence_template(scoped_rule),
encoding="utf-8",
)
created_files.append(str(evidence_path))
return {
"ok": True,
"province": province,
"created_rule_count": len(created_files),
"created_files": created_files,
"existing_rule_count": len(existing_files),
"existing_files": existing_files,
"touched_index_count": len(touched_indexes),
"touched_indexes": touched_indexes,
}
def verify(
self,
*,
strict_evidence: bool = False,
max_rule_age_days: int = 90,
) -> dict[str, Any]:
status = self.build_status(max_rule_age_days=max_rule_age_days)
missing_required_files: list[str] = []
if not (self._truth_root / "national.yaml").is_file():
missing_required_files.append("national.yaml")
if not (self._truth_root / "province").is_dir():
missing_required_files.append("province/")
active_rules = self.list_all_rules(include_inactive=False)
stale_rules = self.find_stale_rules(active_rules, max_age_days=max_rule_age_days)
missing_evidence_rules = [rule for rule in active_rules if not rule.evidence_exists]
ok = not missing_required_files and not stale_rules
if strict_evidence and missing_evidence_rules:
ok = False
return {
"ok": ok,
"province_count": status.province_count,
"national_rule_count": status.national_rule_count,
"national_version": status.national_version,
"active_rule_count": status.active_rule_count,
"missing_required_files": missing_required_files,
"strict_evidence": strict_evidence,
"max_rule_age_days": max_rule_age_days,
"stale_rule_count": len(stale_rules),
"stale_rule_ids": [rule.rule_id for rule in stale_rules],
"missing_evidence_rule_count": len(missing_evidence_rules),
"missing_evidence_rule_ids": [
rule.rule_id for rule in missing_evidence_rules
],
}
def _load_province_docs(self, province_dir: Path) -> dict[str, dict[str, Any]]:
docs: dict[str, dict[str, Any]] = {}
for path in sorted(province_dir.glob("*.yaml")):
doc = self._read_yaml(path)
province = doc.get("province") or SLUG_TO_PROVINCE.get(path.stem)
if not province:
raise ValueError(f"cannot resolve province for truth file: {path}")
docs[province] = doc
return docs
def _convert_rules(
self, doc: dict[str, Any], *, scope: str, province: str | None
) -> list[LoadedRule]:
rules: list[LoadedRule] = []
prefix = "NATIONAL" if scope == "national" else self._province_prefix(province)
for rule_key, payload in doc.get("rules", {}).items():
evidence_path = self._resolve_evidence_path(
scope=scope,
province=province,
source_evidence_id=payload["source_evidence_id"],
)
rules.append(
LoadedRule(
rule_id=f"{prefix}.{rule_key}",
title=payload["title"],
severity=payload["severity"],
value=payload["value"],
source_evidence_id=payload["source_evidence_id"],
effective_date=payload["effective_date"],
status=payload.get("status", "active"),
scope=scope,
province=province,
year=doc.get("year", 2026),
version=doc.get("version", "2026.1"),
last_verified_at=payload.get(
"last_verified_at",
doc.get("last_verified_at", "2026-06-17"),
),
evidence_path=str(evidence_path) if evidence_path else None,
evidence_exists=bool(
evidence_path and _evidence_file_is_complete(evidence_path)
),
)
)
return rules
def _rules_for_scaffolding(
self,
province: str | None,
) -> dict[str, list[LoadedRule]]:
groups: dict[str, list[LoadedRule]] = {}
base_rules: list[LoadedRule]
if province is None:
base_rules = self.list_national_rules()
provinces = self.active_provinces()
for active_province in provinces:
base_rules.extend(self.list_province_rules(active_province))
elif province == "national":
base_rules = self.list_national_rules()
else:
base_rules = self.list_province_rules(province)
for rule in base_rules:
if rule.status != "active":
continue
key = rule.province or "national"
groups.setdefault(key, []).append(rule)
return groups
def _evidence_dir_for_rule(self, rule: LoadedRule) -> Path:
if rule.scope == "national":
return self._evidence_root / "national"
assert rule.province is not None
slug = PROVINCE_SLUGS[rule.province]
return self._evidence_root / slug
def _build_index_markdown(self, rules: list[LoadedRule]) -> str:
sample = rules[0]
scope_label = "国家级" if sample.scope == "national" else "省级"
province_label = sample.province or "national"
lines = [
f"# {province_label} evidence index",
"",
f"- 范围: {scope_label}",
f"- 规则版本: {sample.version}",
f"- 最后刷新: {date.today().isoformat()}",
"",
"| rule_id | source_evidence_id | 状态 |",
"| --- | --- | --- |",
]
for rule in sorted(rules, key=lambda item: item.rule_id):
status = "已完成" if rule.evidence_exists else "待补摘录"
lines.append(
f"| `{rule.rule_id}` | `{rule.source_evidence_id}` | {status} |"
)
lines.append("")
lines.append(
"> 说明: `待补摘录` 表示模板已准备或证据仍缺失,不计入 CLI evidence 覆盖。"
)
lines.append("")
return "\n".join(lines)
def _build_evidence_template(self, rule: LoadedRule) -> str:
value_yaml = yaml.safe_dump(
{
rule.rule_id: {
"severity": rule.severity,
"value": rule.value,
"effective_date": rule.effective_date,
"source_evidence_id": rule.source_evidence_id,
"status": rule.status,
}
},
allow_unicode=True,
sort_keys=False,
).strip()
scope_label = "国家级" if rule.scope == "national" else "省级"
owner = rule.province or "national"
return "\n".join(
[
f"# {rule.source_evidence_id}",
"",
f"> 对应规则: `{rule.rule_id}`",
f"> 所属: {scope_label}",
f"> 规则版本: `{rule.version}`",
f"> 摘录时间: {date.today().isoformat()}",
"> 摘录人: 待补充",
f"> 证据状态: {EVIDENCE_TEMPLATE_STATUS}",
"",
"## 1. 官方原文摘录",
"",
'> "TODO_OFFICIAL_EXCERPT" —— 出处: TODO_SOURCE_TITLE, TODO_URL, TODO_PUBLISHED_DATE',
"",
"## 2. 转写为机读规则",
"",
"```yaml",
value_yaml,
"```",
"",
"## 3. 关键边界与例外",
"",
"- 例 1TODO",
"- 例 2TODO",
"",
"## 4. 后续维护",
"",
"- 下次复核时间: TODO",
f"- 复核来源: {owner}",
"- 复核负责人: 待指派",
"",
]
)
def _build_scope_row(
self,
*,
scope: str,
province: str | None,
doc: dict[str, Any],
) -> dict[str, Any]:
if scope == "national":
rules = self.list_national_rules()
else:
assert province is not None
rules = self.list_province_rules(province)
active_rules = [rule for rule in rules if rule.status == "active"]
missing = [rule.rule_id for rule in active_rules if not rule.evidence_exists]
return {
"scope": scope,
"province": province,
"version": str(doc.get("version", "2026.1")),
"last_verified_at": str(doc.get("last_verified_at", "2026-06-17")),
"active_rule_count": len(active_rules),
"evidenced_rule_count": sum(
1 for rule in active_rules if rule.evidence_exists
),
"missing_evidence_rule_ids": missing,
}
def _resolve_evidence_path(
self,
*,
scope: str,
province: str | None,
source_evidence_id: str,
) -> Path | None:
if scope == "national":
return self._evidence_root / "national" / f"{source_evidence_id}.md"
if province is None:
return None
slug = PROVINCE_SLUGS.get(province)
if not slug:
return None
return self._evidence_root / slug / f"{source_evidence_id}.md"
@staticmethod
def _read_yaml(path: Path) -> dict[str, Any]:
return yaml.safe_load(path.read_text(encoding="utf-8")) or {}
@staticmethod
def _province_prefix(province: str | None) -> str:
if not province:
return "PROVINCE"
slug = PROVINCE_SLUGS.get(province)
if slug:
return slug.upper()
return province.upper()
def _parse_rule_date(raw: str) -> date | None:
value = str(raw or "").strip()
if not value:
return None
for fmt in ("%Y-%m-%d", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M:%S%z"):
try:
parsed = datetime.strptime(value, fmt)
return parsed.date()
except ValueError:
continue
try:
return datetime.fromisoformat(value).date()
except ValueError:
return None
def _evidence_file_is_complete(path: Path) -> bool:
if not path.is_file():
return False
text = path.read_text(encoding="utf-8")
for section in EVIDENCE_REQUIRED_SECTIONS:
if section not in text:
return False
for sentinel in EVIDENCE_TEMPLATE_SENTINELS:
if sentinel in text:
return False
return True