437 lines
15 KiB
Python
437 lines
15 KiB
Python
#!/usr/bin/env python3
|
|
"""Validate 2026.7 hierarchical fast/slow routing records."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import re
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
|
|
POLICY_VERSION = "FAST-SLOW-2026.7"
|
|
SKILL_ROOT = Path(__file__).resolve().parent.parent
|
|
CONFLICT_FILE = SKILL_ROOT / "references" / "policy-conflicts.md"
|
|
|
|
DEFAULT_ROUTE_BY_LABEL = {
|
|
"快": "快系统",
|
|
"慢": "慢系统",
|
|
"模糊": "慢系统",
|
|
}
|
|
CONFIDENCE_VALUES = {"high", "medium", "low"}
|
|
DECISION_LEVELS = {"domain", "structure", "core"}
|
|
DOMAIN_VALUES = {
|
|
"导航/生服",
|
|
"车控",
|
|
"产品问答",
|
|
"内容",
|
|
"系统/App控制",
|
|
"通用工具",
|
|
"IoT",
|
|
"自动化",
|
|
}
|
|
RULE_ID_PATTERN = re.compile(r"^[A-Z][A-Z0-9_]+$")
|
|
CONFLICT_ID_PATTERN = re.compile(r"^CONFLICT_[A-Z0-9_]+$")
|
|
|
|
REQUIRED_FIELDS = {
|
|
"query",
|
|
"label",
|
|
"default_route",
|
|
"domain",
|
|
"candidate_domains",
|
|
"decision_level",
|
|
"decision_rule_id",
|
|
"decision_source",
|
|
"decision_path",
|
|
"reason",
|
|
"evidence",
|
|
"structural_signals",
|
|
"context_used",
|
|
"fast_system_pending",
|
|
"route_override",
|
|
"policy_conflict",
|
|
"conflict_refs",
|
|
"confidence",
|
|
"review_required",
|
|
"uncertainties",
|
|
"policy_version",
|
|
}
|
|
OPTIONAL_FIELDS = {
|
|
"id",
|
|
"counterevidence",
|
|
"context_relation",
|
|
}
|
|
CONTEXT_RELATION_VALUES = {
|
|
"independent",
|
|
"resolved_direct",
|
|
"operation_continuation",
|
|
"constraint_extension",
|
|
"planning_continuation",
|
|
"clarification_completion",
|
|
"unresolved_reference",
|
|
}
|
|
|
|
|
|
def _is_nonempty_string(value: Any) -> bool:
|
|
return isinstance(value, str) and bool(value.strip())
|
|
|
|
|
|
def _check_string_list(
|
|
value: Any,
|
|
*,
|
|
field: str,
|
|
errors: list[str],
|
|
require_nonempty: bool = False,
|
|
unique: bool = False,
|
|
) -> None:
|
|
if not isinstance(value, list):
|
|
errors.append(f"{field} 必须是数组")
|
|
return
|
|
if require_nonempty and not value:
|
|
errors.append(f"{field} 至少包含一项")
|
|
if any(not _is_nonempty_string(item) for item in value):
|
|
errors.append(f"{field} 的每一项都必须是非空字符串")
|
|
if unique and all(isinstance(item, str) for item in value):
|
|
if len(value) != len(set(value)):
|
|
errors.append(f"{field} 不允许重复项")
|
|
|
|
|
|
def _validate_decision_source(
|
|
record: dict[str, Any],
|
|
*,
|
|
errors: list[str],
|
|
) -> None:
|
|
source = record.get("decision_source")
|
|
rule_id = record.get("decision_rule_id")
|
|
level = record.get("decision_level")
|
|
if not _is_nonempty_string(source):
|
|
errors.append("decision_source 必须是非空字符串")
|
|
return
|
|
relative_path = source.split("#", 1)[0]
|
|
if not relative_path.startswith("references/") or not relative_path.endswith(".md"):
|
|
errors.append("decision_source 必须指向 references/*.md")
|
|
return
|
|
resolved = (SKILL_ROOT / relative_path).resolve()
|
|
references_root = (SKILL_ROOT / "references").resolve()
|
|
if references_root not in resolved.parents:
|
|
errors.append("decision_source 不允许跳出 references 目录")
|
|
return
|
|
if not resolved.is_file():
|
|
errors.append(f"decision_source 文件不存在: {relative_path}")
|
|
return
|
|
try:
|
|
source_text = resolved.read_text(encoding="utf-8")
|
|
except OSError as exc:
|
|
errors.append(f"无法读取 decision_source: {exc}")
|
|
return
|
|
if _is_nonempty_string(rule_id) and rule_id not in source_text:
|
|
errors.append(f"decision_rule_id 未出现在来源文件中: {rule_id}")
|
|
|
|
filename = resolved.name
|
|
if level == "domain" and not filename.startswith("domain-"):
|
|
errors.append("decision_level=domain 时来源文件必须是 domain-*.md")
|
|
if level == "structure" and not filename.startswith("structure-"):
|
|
errors.append("decision_level=structure 时来源文件必须是 structure-*.md")
|
|
if level == "core" and filename != "core-policy.md":
|
|
errors.append("decision_level=core 时来源文件必须是 core-policy.md")
|
|
|
|
|
|
def _validate_conflicts(
|
|
record: dict[str, Any],
|
|
*,
|
|
errors: list[str],
|
|
) -> None:
|
|
policy_conflict = record.get("policy_conflict")
|
|
conflict_refs = record.get("conflict_refs")
|
|
if not isinstance(policy_conflict, bool):
|
|
errors.append("policy_conflict 必须是布尔值")
|
|
_check_string_list(
|
|
conflict_refs,
|
|
field="conflict_refs",
|
|
errors=errors,
|
|
unique=True,
|
|
)
|
|
if not isinstance(conflict_refs, list):
|
|
return
|
|
if policy_conflict is True and not conflict_refs:
|
|
errors.append("policy_conflict=true 时 conflict_refs 至少包含一项")
|
|
if policy_conflict is False and conflict_refs:
|
|
errors.append("policy_conflict=false 时 conflict_refs 必须为空")
|
|
|
|
try:
|
|
known_conflicts = CONFLICT_FILE.read_text(encoding="utf-8")
|
|
except OSError as exc:
|
|
errors.append(f"无法读取冲突表: {exc}")
|
|
return
|
|
for conflict_id in conflict_refs:
|
|
if not isinstance(conflict_id, str):
|
|
continue
|
|
if not CONFLICT_ID_PATTERN.fullmatch(conflict_id):
|
|
errors.append(f"冲突 ID 格式非法: {conflict_id!r}")
|
|
elif conflict_id not in known_conflicts:
|
|
errors.append(f"冲突 ID 未登记: {conflict_id}")
|
|
|
|
|
|
def validate_record(record: Any, index: int) -> dict[str, Any]:
|
|
errors: list[str] = []
|
|
warnings: list[str] = []
|
|
|
|
if not isinstance(record, dict):
|
|
return {
|
|
"index": index,
|
|
"valid": False,
|
|
"errors": ["记录必须是 JSON 对象"],
|
|
"warnings": [],
|
|
}
|
|
if "__parse_error__" in record:
|
|
return {
|
|
"index": index,
|
|
"valid": False,
|
|
"errors": [str(record["__parse_error__"])],
|
|
"warnings": [],
|
|
}
|
|
|
|
missing = sorted(REQUIRED_FIELDS - set(record))
|
|
if missing:
|
|
errors.append(f"缺少必填字段: {', '.join(missing)}")
|
|
unknown = sorted(set(record) - REQUIRED_FIELDS - OPTIONAL_FIELDS)
|
|
if unknown:
|
|
errors.append(f"存在未知字段: {', '.join(unknown)}")
|
|
|
|
if not _is_nonempty_string(record.get("query")):
|
|
errors.append("query 必须是非空字符串")
|
|
|
|
label = record.get("label")
|
|
if not isinstance(label, str) or label not in DEFAULT_ROUTE_BY_LABEL:
|
|
errors.append("label 必须是 快、慢、模糊 之一")
|
|
expected_route = DEFAULT_ROUTE_BY_LABEL.get(label) if isinstance(label, str) else None
|
|
if expected_route is not None and record.get("default_route") != expected_route:
|
|
errors.append(f"label={label} 时 default_route 必须是 {expected_route}")
|
|
|
|
domain = record.get("domain")
|
|
if domain is not None and (
|
|
not isinstance(domain, str) or domain not in DOMAIN_VALUES
|
|
):
|
|
errors.append("domain 不是受支持的粗粒度垂域")
|
|
_check_string_list(
|
|
record.get("candidate_domains"),
|
|
field="candidate_domains",
|
|
errors=errors,
|
|
unique=True,
|
|
)
|
|
candidate_domains = record.get("candidate_domains")
|
|
if isinstance(candidate_domains, list):
|
|
unknown_domains = [
|
|
value
|
|
for value in candidate_domains
|
|
if not isinstance(value, str) or value not in DOMAIN_VALUES
|
|
]
|
|
if unknown_domains:
|
|
errors.append(f"candidate_domains 包含未知垂域: {unknown_domains}")
|
|
|
|
level = record.get("decision_level")
|
|
if not isinstance(level, str) or level not in DECISION_LEVELS:
|
|
errors.append("decision_level 必须是 domain、structure、core 之一")
|
|
if level == "domain" and domain is None:
|
|
errors.append("decision_level=domain 时 domain 不能为空")
|
|
|
|
rule_id = record.get("decision_rule_id")
|
|
if not _is_nonempty_string(rule_id) or not RULE_ID_PATTERN.fullmatch(rule_id):
|
|
errors.append("decision_rule_id 必须是大写下划线规则 ID")
|
|
|
|
_check_string_list(
|
|
record.get("decision_path"),
|
|
field="decision_path",
|
|
errors=errors,
|
|
require_nonempty=True,
|
|
)
|
|
decision_path = record.get("decision_path")
|
|
if (
|
|
isinstance(decision_path, list)
|
|
and _is_nonempty_string(rule_id)
|
|
and not any(rule_id in str(item) for item in decision_path)
|
|
):
|
|
errors.append("decision_path 必须包含 decision_rule_id")
|
|
|
|
_validate_decision_source(record, errors=errors)
|
|
|
|
if not _is_nonempty_string(record.get("reason")):
|
|
errors.append("reason 必须是非空字符串")
|
|
_check_string_list(
|
|
record.get("evidence"),
|
|
field="evidence",
|
|
errors=errors,
|
|
require_nonempty=True,
|
|
)
|
|
if "counterevidence" in record:
|
|
_check_string_list(
|
|
record.get("counterevidence"),
|
|
field="counterevidence",
|
|
errors=errors,
|
|
)
|
|
|
|
structural_signals = record.get("structural_signals")
|
|
expected_signal_keys = {
|
|
"complex",
|
|
"multi_instruction",
|
|
"auto_task",
|
|
"context_dependent",
|
|
}
|
|
if not isinstance(structural_signals, dict):
|
|
errors.append("structural_signals 必须是对象")
|
|
else:
|
|
actual_keys = set(structural_signals)
|
|
if actual_keys != expected_signal_keys:
|
|
errors.append(
|
|
"structural_signals 必须且只能包含 "
|
|
"complex、multi_instruction、auto_task、context_dependent"
|
|
)
|
|
for key in expected_signal_keys:
|
|
value = structural_signals.get(key)
|
|
if value is not None and not isinstance(value, bool):
|
|
errors.append(f"structural_signals.{key} 必须是布尔值或 null")
|
|
|
|
if not isinstance(record.get("context_used"), bool):
|
|
errors.append("context_used 必须是布尔值")
|
|
context_relation = record.get("context_relation")
|
|
if context_relation is not None and (
|
|
not isinstance(context_relation, str)
|
|
or context_relation not in CONTEXT_RELATION_VALUES
|
|
):
|
|
errors.append("context_relation 不是受支持的上下文关系")
|
|
if context_relation == "independent" and record.get("context_used") is True:
|
|
errors.append("context_relation=independent 时 context_used 必须为 false")
|
|
if context_relation not in {None, "independent"}:
|
|
if record.get("context_used") is not True:
|
|
errors.append("依赖上下文的 context_relation 要求 context_used=true")
|
|
if isinstance(structural_signals, dict) and (
|
|
structural_signals.get("context_dependent") is not True
|
|
):
|
|
errors.append(
|
|
"依赖上下文的 context_relation 要求 "
|
|
"structural_signals.context_dependent=true"
|
|
)
|
|
|
|
fast_system_pending = record.get("fast_system_pending")
|
|
if fast_system_pending is not None and not isinstance(fast_system_pending, bool):
|
|
errors.append("fast_system_pending 必须是布尔值或 null")
|
|
if fast_system_pending is True and label != "快":
|
|
errors.append("只有 label=快 时 fast_system_pending 才能为 true")
|
|
|
|
route_override = record.get("route_override")
|
|
if route_override is not None and not _is_nonempty_string(route_override):
|
|
errors.append("route_override 必须是非空字符串或 null")
|
|
|
|
_validate_conflicts(record, errors=errors)
|
|
|
|
confidence = record.get("confidence")
|
|
if not isinstance(confidence, str) or confidence not in CONFIDENCE_VALUES:
|
|
errors.append("confidence 必须是 high、medium、low 之一")
|
|
review_required = record.get("review_required")
|
|
if not isinstance(review_required, bool):
|
|
errors.append("review_required 必须是布尔值")
|
|
if confidence == "low" and review_required is not True:
|
|
errors.append("confidence=low 时 review_required 必须为 true")
|
|
|
|
_check_string_list(
|
|
record.get("uncertainties"),
|
|
field="uncertainties",
|
|
errors=errors,
|
|
)
|
|
uncertainties = record.get("uncertainties")
|
|
if isinstance(uncertainties, list) and uncertainties and review_required is False:
|
|
warnings.append("uncertainties 非空但 review_required=false,请确认不确定点不影响标签")
|
|
|
|
if record.get("policy_version") != POLICY_VERSION:
|
|
errors.append(f"policy_version 必须是 {POLICY_VERSION}")
|
|
|
|
return {
|
|
"index": index,
|
|
"id": record.get("id"),
|
|
"valid": not errors,
|
|
"errors": errors,
|
|
"warnings": warnings,
|
|
}
|
|
|
|
|
|
def _load_records_from_file(path: Path) -> list[Any]:
|
|
text = path.read_text(encoding="utf-8-sig")
|
|
if path.suffix.lower() == ".jsonl":
|
|
records: list[Any] = []
|
|
for line_number, line in enumerate(text.splitlines(), start=1):
|
|
if not line.strip():
|
|
continue
|
|
try:
|
|
records.append(json.loads(line))
|
|
except json.JSONDecodeError as exc:
|
|
records.append(
|
|
{
|
|
"__parse_error__": (
|
|
f"JSONL 第 {line_number} 行解析失败: {exc.msg}"
|
|
)
|
|
}
|
|
)
|
|
return records
|
|
|
|
payload = json.loads(text)
|
|
if isinstance(payload, list):
|
|
return payload
|
|
if isinstance(payload, dict) and isinstance(payload.get("records"), list):
|
|
return payload["records"]
|
|
return [payload]
|
|
|
|
|
|
def _parse_args() -> argparse.Namespace:
|
|
parser = argparse.ArgumentParser(
|
|
description="校验 FAST-SLOW-2026.7 分层打标 JSON/JSONL"
|
|
)
|
|
source_group = parser.add_mutually_exclusive_group(required=True)
|
|
source_group.add_argument("--record", help="单条 JSON 对象字符串")
|
|
source_group.add_argument("--file", type=Path, help="JSON 或 JSONL 文件路径")
|
|
parser.add_argument(
|
|
"--strict",
|
|
action="store_true",
|
|
help="把 warning 也视为校验失败",
|
|
)
|
|
return parser.parse_args()
|
|
|
|
|
|
def main() -> int:
|
|
args = _parse_args()
|
|
try:
|
|
if args.record is not None:
|
|
records = [json.loads(args.record)]
|
|
else:
|
|
records = _load_records_from_file(args.file)
|
|
except (OSError, json.JSONDecodeError) as exc:
|
|
result = {
|
|
"valid": False,
|
|
"total": 0,
|
|
"invalid": 1,
|
|
"warning_count": 0,
|
|
"errors": [str(exc)],
|
|
"results": [],
|
|
}
|
|
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
return 1
|
|
|
|
results = [validate_record(record, index) for index, record in enumerate(records)]
|
|
invalid = sum(not result["valid"] for result in results)
|
|
warning_count = sum(len(result["warnings"]) for result in results)
|
|
valid = invalid == 0 and (not args.strict or warning_count == 0)
|
|
summary = {
|
|
"valid": valid,
|
|
"total": len(records),
|
|
"invalid": invalid,
|
|
"warning_count": warning_count,
|
|
"results": results,
|
|
}
|
|
print(json.dumps(summary, ensure_ascii=False, indent=2))
|
|
return 0 if valid else 1
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|