Files
gaokao-volunteer-system/data/rules/loader.py
Hermes Agent 246f21c7f2
Some checks failed
CI / pytest (Python 3.10) (push) Has been cancelled
CI / pytest (Python 3.11) (push) Has been cancelled
CI / pytest (Python 3.12) (push) Has been cancelled
feat(review): land unified remediation and rules evidence closure
2026-06-18 10:21:38 +08:00

483 lines
18 KiB
Python
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.
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