125 lines
4.0 KiB
Python
125 lines
4.0 KiB
Python
#!/usr/bin/env python3
|
||
"""Validate candidate knowledge artifacts committed for review."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import datetime as dt
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
try:
|
||
import jsonschema
|
||
except ImportError as exc:
|
||
raise SystemExit(
|
||
f"缺少依赖 jsonschema,请使用 uvx --with jsonschema --with pyyaml python <script>\n{exc}"
|
||
) from exc
|
||
|
||
try:
|
||
import yaml
|
||
except ImportError as exc:
|
||
raise SystemExit(
|
||
f"缺少依赖 PyYAML,请使用 uvx --with pyyaml --with jsonschema python <script>\n{exc}"
|
||
) from exc
|
||
|
||
|
||
class ValidationError(Exception):
|
||
"""Raised when a candidate artifact is invalid."""
|
||
|
||
|
||
def normalize_yaml_value(value: object) -> object:
|
||
"""Normalize YAML-decoded scalars to contract-friendly JSON values."""
|
||
|
||
if isinstance(value, dt.datetime):
|
||
return value.isoformat()
|
||
if isinstance(value, dt.date):
|
||
return value.isoformat()
|
||
if isinstance(value, list):
|
||
return [normalize_yaml_value(item) for item in value]
|
||
if isinstance(value, dict):
|
||
return {str(key): normalize_yaml_value(item) for key, item in value.items()}
|
||
return value
|
||
|
||
|
||
def load_yaml_mapping(path: Path) -> dict[str, object]:
|
||
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||
if raw is None:
|
||
return {}
|
||
if not isinstance(raw, dict):
|
||
raise ValidationError(f"{path} 根节点必须是 mapping")
|
||
normalized = normalize_yaml_value(raw)
|
||
if not isinstance(normalized, dict):
|
||
raise ValidationError(f"{path} 根节点必须是 mapping")
|
||
return {str(key): value for key, value in normalized.items()}
|
||
|
||
|
||
def load_validator(path: Path) -> jsonschema.Draft202012Validator:
|
||
schema = load_yaml_mapping(path)
|
||
jsonschema.Draft202012Validator.check_schema(schema)
|
||
return jsonschema.Draft202012Validator(schema)
|
||
|
||
|
||
def parse_args() -> argparse.Namespace:
|
||
parser = argparse.ArgumentParser(
|
||
description="Validate candidate artifacts under knowledge/candidates"
|
||
)
|
||
parser.add_argument("--root", type=Path, default=Path("."), help="Repository root")
|
||
return parser.parse_args()
|
||
|
||
|
||
def main() -> int:
|
||
args = parse_args()
|
||
repo_root = args.root.resolve()
|
||
candidate_root = repo_root / "knowledge/candidates"
|
||
candidate_schema_path = repo_root / "platform/artifacts/artifact.schema.yaml"
|
||
insight_schema_path = (
|
||
repo_root / "platform/extraction/conversation-insight.schema.yaml"
|
||
)
|
||
|
||
if not candidate_root.exists():
|
||
print("candidate artifact 校验通过(未发现 knowledge/candidates 目录)")
|
||
return 0
|
||
|
||
candidate_validator = load_validator(candidate_schema_path)
|
||
insight_validator = load_validator(insight_schema_path)
|
||
yaml_files = sorted(candidate_root.rglob("*.yaml"))
|
||
candidate_files = [
|
||
path for path in yaml_files if not path.name.endswith(".insight.yaml")
|
||
]
|
||
insight_files = [path for path in yaml_files if path.name.endswith(".insight.yaml")]
|
||
|
||
errors: list[str] = []
|
||
for artifact_file in candidate_files:
|
||
payload = load_yaml_mapping(artifact_file)
|
||
try:
|
||
candidate_validator.validate(payload)
|
||
except jsonschema.ValidationError as exc:
|
||
errors.append(
|
||
f"{artifact_file} 不符合 candidate artifact schema: {exc.message}"
|
||
)
|
||
|
||
for insight_file in insight_files:
|
||
payload = load_yaml_mapping(insight_file)
|
||
try:
|
||
insight_validator.validate(payload)
|
||
except jsonschema.ValidationError as exc:
|
||
errors.append(
|
||
f"{insight_file} 不符合 conversation insight schema: {exc.message}"
|
||
)
|
||
|
||
if errors:
|
||
print("candidate artifact 校验失败:")
|
||
for message in errors:
|
||
print(f"- {message}")
|
||
return 1
|
||
|
||
print(
|
||
"candidate artifact 校验通过"
|
||
f"(artifact={len(candidate_files)} 个, insight={len(insight_files)} 个)"
|
||
)
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|