432 lines
15 KiB
Python
432 lines
15 KiB
Python
#!/usr/bin/env python3
|
||
"""Validate the shared product evidence manifest used by marketing skills."""
|
||
|
||
import argparse
|
||
import datetime as dt
|
||
import json
|
||
import re
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
|
||
COLLECTIONS = (
|
||
"features",
|
||
"parameters",
|
||
"claims",
|
||
"use_cases",
|
||
"differentiators",
|
||
"limitations",
|
||
"evidence",
|
||
)
|
||
REQUIRED_FIELDS = {
|
||
"features": ("id", "name", "summary", "status"),
|
||
"parameters": ("id", "name", "value", "conditions", "disclosure"),
|
||
"claims": ("id", "text", "type", "status"),
|
||
"use_cases": ("id", "name", "audience", "problem", "workflow", "outcome"),
|
||
"differentiators": ("id", "statement", "comparison_scope"),
|
||
"limitations": ("id", "text", "applies_to"),
|
||
"evidence": (
|
||
"id",
|
||
"type",
|
||
"title",
|
||
"source",
|
||
"locator",
|
||
"verified_on",
|
||
"owner",
|
||
"disclosure",
|
||
),
|
||
}
|
||
ALLOWED = {
|
||
"product.maturity": {"released", "beta", "planned", "deprecated"},
|
||
"features.status": {"released", "beta", "planned", "deprecated"},
|
||
"claims.type": {"fact", "goal", "comparison"},
|
||
"claims.status": {"approved", "draft", "rejected"},
|
||
"disclosure": {"public", "restricted", "internal"},
|
||
}
|
||
OUTPUT_TYPES = {
|
||
"feature-catalog",
|
||
"technical-spec",
|
||
"presentation",
|
||
"technical-proposal",
|
||
"one-pager",
|
||
"whitepaper",
|
||
"tender-response",
|
||
"sales-demo",
|
||
"case-study",
|
||
"competitive-positioning",
|
||
}
|
||
DISCLOSURE_RANK = {"internal": 0, "restricted": 1, "public": 2}
|
||
PLACEHOLDERS = re.compile(
|
||
r"(?i)(?:\bTBD\b|\bTODO\b|待补充|待确认|placeholder|changeme)"
|
||
)
|
||
SECRET_QUERY = re.compile(r"(?i)(?:token|api[_-]?key|secret|password)=")
|
||
|
||
|
||
def issue(path, message):
|
||
return {"path": path, "message": message}
|
||
|
||
|
||
def is_nonempty_string(value):
|
||
return isinstance(value, str) and bool(value.strip())
|
||
|
||
|
||
def validate_record_shape(collection, index, record, errors):
|
||
path = f"{collection}[{index}]"
|
||
if not isinstance(record, dict):
|
||
errors.append(issue(path, "必须是对象"))
|
||
return False
|
||
for field in REQUIRED_FIELDS[collection]:
|
||
value = record.get(field)
|
||
if field in ("applies_to",):
|
||
if not isinstance(value, list) or not value:
|
||
errors.append(issue(f"{path}.{field}", "必须是非空数组"))
|
||
elif not is_nonempty_string(value):
|
||
errors.append(issue(f"{path}.{field}", "必须是非空字符串"))
|
||
return True
|
||
|
||
|
||
def evidence_disclosure_map(records):
|
||
return {
|
||
record.get("id"): record.get("disclosure")
|
||
for record in records["evidence"]
|
||
if isinstance(record, dict)
|
||
}
|
||
|
||
|
||
def validate_disclosure_chain(records, errors):
|
||
"""A record must not be more public than the evidence backing it."""
|
||
evidence_disclosure = evidence_disclosure_map(records)
|
||
for collection in ("parameters", "claims"):
|
||
for index, record in enumerate(records[collection]):
|
||
if not isinstance(record, dict):
|
||
continue
|
||
own = record.get("disclosure")
|
||
if own not in DISCLOSURE_RANK:
|
||
continue
|
||
for ref in record.get("evidence_ids", []) or []:
|
||
backing = evidence_disclosure.get(ref)
|
||
if backing not in DISCLOSURE_RANK:
|
||
continue
|
||
if DISCLOSURE_RANK[backing] < DISCLOSURE_RANK[own]:
|
||
errors.append(
|
||
issue(
|
||
f"{collection}[{index}].disclosure",
|
||
f"标记为 {own},但证据 {ref} 仅为 {backing}",
|
||
)
|
||
)
|
||
|
||
|
||
def validate_channel(records, channel, errors):
|
||
"""Reject claims cleared for delivery whose evidence is too restricted."""
|
||
target = DISCLOSURE_RANK[channel]
|
||
evidence_disclosure = evidence_disclosure_map(records)
|
||
for index, claim in enumerate(records["claims"]):
|
||
if not isinstance(claim, dict) or claim.get("status") != "approved":
|
||
continue
|
||
if not claim.get("allowed_outputs"):
|
||
continue
|
||
for ref in claim.get("evidence_ids", []) or []:
|
||
disclosure = evidence_disclosure.get(ref)
|
||
if disclosure not in DISCLOSURE_RANK:
|
||
continue
|
||
if DISCLOSURE_RANK[disclosure] < target:
|
||
errors.append(
|
||
issue(
|
||
f"claims[{index}].evidence_ids",
|
||
f"证据 {ref} 为 {disclosure},不足以支撑 {channel} 渠道",
|
||
)
|
||
)
|
||
|
||
|
||
def validate_manifest(data, channel=None):
|
||
errors = []
|
||
warnings = []
|
||
if not isinstance(data, dict):
|
||
return [issue("$", "根节点必须是对象")], warnings
|
||
if data.get("schema_version") != 1:
|
||
errors.append(issue("schema_version", "当前仅支持整数 1"))
|
||
|
||
product = data.get("product")
|
||
if not isinstance(product, dict):
|
||
errors.append(issue("product", "必须是对象"))
|
||
product = {}
|
||
for field in ("name", "version", "category", "summary", "maturity"):
|
||
if not is_nonempty_string(product.get(field)):
|
||
errors.append(issue(f"product.{field}", "必须是非空字符串"))
|
||
if product.get("maturity") not in ALLOWED["product.maturity"]:
|
||
errors.append(
|
||
issue(
|
||
"product.maturity",
|
||
f"必须是 {sorted(ALLOWED['product.maturity'])} 之一",
|
||
)
|
||
)
|
||
|
||
records = {}
|
||
all_ids = {}
|
||
for collection in COLLECTIONS:
|
||
values = data.get(collection, [])
|
||
if not isinstance(values, list):
|
||
errors.append(issue(collection, "必须是数组"))
|
||
values = []
|
||
records[collection] = values
|
||
for index, record in enumerate(values):
|
||
if not validate_record_shape(collection, index, record, errors):
|
||
continue
|
||
record_id = record.get("id")
|
||
if is_nonempty_string(record_id):
|
||
if record_id in all_ids:
|
||
errors.append(
|
||
issue(
|
||
f"{collection}[{index}].id",
|
||
f"ID 与 {all_ids[record_id]} 重复:{record_id}",
|
||
)
|
||
)
|
||
else:
|
||
all_ids[record_id] = f"{collection}[{index}]"
|
||
|
||
evidence_ids = {
|
||
record.get("id")
|
||
for record in records["evidence"]
|
||
if isinstance(record, dict) and is_nonempty_string(record.get("id"))
|
||
}
|
||
feature_ids = {
|
||
record.get("id")
|
||
for record in records["features"]
|
||
if isinstance(record, dict) and is_nonempty_string(record.get("id"))
|
||
}
|
||
use_case_ids = {
|
||
record.get("id")
|
||
for record in records["use_cases"]
|
||
if isinstance(record, dict) and is_nonempty_string(record.get("id"))
|
||
}
|
||
|
||
for collection in COLLECTIONS:
|
||
for index, record in enumerate(records[collection]):
|
||
if not isinstance(record, dict):
|
||
continue
|
||
path = f"{collection}[{index}]"
|
||
refs = record.get("evidence_ids", [])
|
||
if refs is not None and not isinstance(refs, list):
|
||
errors.append(issue(f"{path}.evidence_ids", "必须是数组"))
|
||
refs = []
|
||
for ref in refs or []:
|
||
if ref not in evidence_ids:
|
||
errors.append(
|
||
issue(f"{path}.evidence_ids", f"引用不存在:{ref}")
|
||
)
|
||
for ref in record.get("use_case_ids", []) or []:
|
||
if ref not in use_case_ids:
|
||
errors.append(
|
||
issue(f"{path}.use_case_ids", f"引用不存在:{ref}")
|
||
)
|
||
|
||
for index, limitation in enumerate(records["limitations"]):
|
||
if not isinstance(limitation, dict):
|
||
continue
|
||
for ref in limitation.get("applies_to", []) or []:
|
||
if ref not in feature_ids and ref not in all_ids:
|
||
errors.append(
|
||
issue(
|
||
f"limitations[{index}].applies_to",
|
||
f"引用不存在:{ref}",
|
||
)
|
||
)
|
||
|
||
for index, feature in enumerate(records["features"]):
|
||
if not isinstance(feature, dict):
|
||
continue
|
||
status = feature.get("status")
|
||
if status not in ALLOWED["features.status"]:
|
||
errors.append(
|
||
issue(
|
||
f"features[{index}].status",
|
||
f"必须是 {sorted(ALLOWED['features.status'])} 之一",
|
||
)
|
||
)
|
||
if status in {"released", "beta"} and not feature.get("evidence_ids"):
|
||
warnings.append(
|
||
issue(
|
||
f"features[{index}].evidence_ids",
|
||
"已发布或 beta 功能缺少证据",
|
||
)
|
||
)
|
||
|
||
for index, parameter in enumerate(records["parameters"]):
|
||
if not isinstance(parameter, dict):
|
||
continue
|
||
disclosure = parameter.get("disclosure")
|
||
if disclosure not in ALLOWED["disclosure"]:
|
||
errors.append(
|
||
issue(
|
||
f"parameters[{index}].disclosure",
|
||
f"必须是 {sorted(ALLOWED['disclosure'])} 之一",
|
||
)
|
||
)
|
||
if not parameter.get("evidence_ids"):
|
||
warnings.append(
|
||
issue(
|
||
f"parameters[{index}].evidence_ids",
|
||
"技术参数缺少证据",
|
||
)
|
||
)
|
||
|
||
for index, claim in enumerate(records["claims"]):
|
||
if not isinstance(claim, dict):
|
||
continue
|
||
if claim.get("type") not in ALLOWED["claims.type"]:
|
||
errors.append(
|
||
issue(
|
||
f"claims[{index}].type",
|
||
f"必须是 {sorted(ALLOWED['claims.type'])} 之一",
|
||
)
|
||
)
|
||
if claim.get("status") not in ALLOWED["claims.status"]:
|
||
errors.append(
|
||
issue(
|
||
f"claims[{index}].status",
|
||
f"必须是 {sorted(ALLOWED['claims.status'])} 之一",
|
||
)
|
||
)
|
||
if (
|
||
claim.get("type") in {"fact", "comparison"}
|
||
and claim.get("status") == "approved"
|
||
and not claim.get("evidence_ids")
|
||
):
|
||
errors.append(
|
||
issue(
|
||
f"claims[{index}].evidence_ids",
|
||
"已批准的事实或比较主张必须有证据",
|
||
)
|
||
)
|
||
outputs = claim.get("allowed_outputs", [])
|
||
if outputs is not None and not isinstance(outputs, list):
|
||
errors.append(
|
||
issue(f"claims[{index}].allowed_outputs", "必须是数组")
|
||
)
|
||
for output in outputs or []:
|
||
if output not in OUTPUT_TYPES:
|
||
errors.append(
|
||
issue(
|
||
f"claims[{index}].allowed_outputs",
|
||
f"未知产物类型:{output}",
|
||
)
|
||
)
|
||
|
||
for index, evidence in enumerate(records["evidence"]):
|
||
if not isinstance(evidence, dict):
|
||
continue
|
||
disclosure = evidence.get("disclosure")
|
||
if disclosure not in ALLOWED["disclosure"]:
|
||
errors.append(
|
||
issue(
|
||
f"evidence[{index}].disclosure",
|
||
f"必须是 {sorted(ALLOWED['disclosure'])} 之一",
|
||
)
|
||
)
|
||
verified_on = evidence.get("verified_on")
|
||
if is_nonempty_string(verified_on):
|
||
try:
|
||
dt.date.fromisoformat(verified_on)
|
||
except ValueError:
|
||
errors.append(
|
||
issue(
|
||
f"evidence[{index}].verified_on",
|
||
"必须使用 YYYY-MM-DD",
|
||
)
|
||
)
|
||
source = evidence.get("source", "")
|
||
if is_nonempty_string(source) and SECRET_QUERY.search(source):
|
||
errors.append(
|
||
issue(
|
||
f"evidence[{index}].source",
|
||
"来源中疑似包含密钥或令牌参数",
|
||
)
|
||
)
|
||
|
||
for index, differentiator in enumerate(records["differentiators"]):
|
||
if isinstance(differentiator, dict) and not differentiator.get(
|
||
"evidence_ids"
|
||
):
|
||
warnings.append(
|
||
issue(
|
||
f"differentiators[{index}].evidence_ids",
|
||
"差异点缺少证据",
|
||
)
|
||
)
|
||
|
||
validate_disclosure_chain(records, errors)
|
||
if channel in DISCLOSURE_RANK:
|
||
validate_channel(records, channel, errors)
|
||
|
||
for path, value in walk_strings(data):
|
||
if PLACEHOLDERS.search(value):
|
||
warnings.append(issue(path, f"包含占位内容:{value[:60]}"))
|
||
|
||
if not evidence_ids:
|
||
warnings.append(issue("evidence", "没有任何证据记录"))
|
||
return errors, warnings
|
||
|
||
|
||
def walk_strings(value, path="$"):
|
||
if isinstance(value, dict):
|
||
for key, item in value.items():
|
||
yield from walk_strings(item, f"{path}.{key}")
|
||
elif isinstance(value, list):
|
||
for index, item in enumerate(value):
|
||
yield from walk_strings(item, f"{path}[{index}]")
|
||
elif isinstance(value, str):
|
||
yield path, value
|
||
|
||
|
||
def parse_args():
|
||
parser = argparse.ArgumentParser(description="验证产品事实与证据清单")
|
||
parser.add_argument("manifest", help="product-evidence.json")
|
||
parser.add_argument("--json", action="store_true", help="输出 JSON")
|
||
parser.add_argument(
|
||
"--strict",
|
||
action="store_true",
|
||
help="存在占位符、缺证据等警告时也失败",
|
||
)
|
||
parser.add_argument(
|
||
"--channel",
|
||
choices=sorted(DISCLOSURE_RANK),
|
||
help="目标发布渠道,校验主张引用证据的公开级别是否足够",
|
||
)
|
||
return parser.parse_args()
|
||
|
||
|
||
def main():
|
||
args = parse_args()
|
||
path = Path(args.manifest).expanduser().resolve()
|
||
with path.open(encoding="utf-8") as handle:
|
||
data = json.load(handle)
|
||
errors, warnings = validate_manifest(data, channel=args.channel)
|
||
report = {
|
||
"file": str(path),
|
||
"channel": args.channel,
|
||
"valid": not errors and (not args.strict or not warnings),
|
||
"errors": errors,
|
||
"warnings": warnings,
|
||
"stats": {
|
||
collection: len(data.get(collection, []))
|
||
if isinstance(data, dict) and isinstance(data.get(collection, []), list)
|
||
else 0
|
||
for collection in COLLECTIONS
|
||
},
|
||
}
|
||
if args.json:
|
||
print(json.dumps(report, ensure_ascii=False, indent=2))
|
||
else:
|
||
for level, items in (("错误", errors), ("警告", warnings)):
|
||
for item in items:
|
||
print(f"[{level}] {item['path']}:{item['message']}")
|
||
print(
|
||
f"核验完成:错误 {len(errors)},警告 {len(warnings)},"
|
||
f"状态 {'通过' if report['valid'] else '失败'}"
|
||
)
|
||
return 0 if report["valid"] else 1
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|