feat: improve skill import and runtime delivery
This commit is contained in:
@@ -0,0 +1,431 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user