validate_kb.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
  1. #!/usr/bin/env python3
  2. """知识库质量校验:JSON Schema 结构 + 跨库引用完整性。
  3. 用法:
  4. python3 validate_kb.py [知识库目录]
  5. 校验清单:
  6. 1. 各子库 JSON 可解析、顶层字段存在;
  7. 2. id 唯一、id 前缀模式正确(sup-/ind-/ex-/ix-/protocol-);
  8. 3. 枚举值合法(category/timing/form/type/intensity/severity 等);
  9. 4. 跨库引用完整性:
  10. - protocols: supplement_ids->supplements.json, exercise_ids->exercises.json,
  11. indicator_ids->clinical_indicators.json, cycle_id->cycles
  12. - interactions: supplement_id->supplements.json
  13. """
  14. import json
  15. import re
  16. import sys
  17. from pathlib import Path
  18. KB_DIR = Path(sys.argv[1] if len(sys.argv) > 1 else "docs/参考资料/知识库")
  19. SCHEMA_FILES = {
  20. "supplements.json": "schema/supplement.schema.json",
  21. "clinical_indicators.json": "schema/clinical_indicator.schema.json",
  22. "exercises.json": "schema/exercise.schema.json",
  23. "interactions.json": "schema/interaction.schema.json",
  24. }
  25. PROTOCOL_SCHEMA = "schema/protocol.schema.json"
  26. ID_PATTERNS = {
  27. "supplements.json": ("supplements", "id", re.compile(r"^sup-[a-z0-9-]+$")),
  28. "clinical_indicators.json": ("indicators", "id", re.compile(r"^ind-[a-z0-9-]+$")),
  29. "exercises.json": ("exercises", "id", re.compile(r"^ex-[a-z0-9-]+$")),
  30. "interactions.json": ("interactions", "id", re.compile(r"^ix-[a-z0-9-]+$")),
  31. "protocols": ("members/phases", "protocol", re.compile(r"^protocol-[a-z0-9-]+$")),
  32. }
  33. CATEGORY_ENUM = {"益生菌", "益生元", "氨基酸", "维生素", "矿物质", "脂肪酸", "植物提取物"}
  34. TIMING_ENUM = {"晨起空腹", "早餐后", "午餐后", "晚餐后", "睡前", "可选"}
  35. FORM_ENUM = {"粉剂", "胶囊", "片剂", "滴剂", "颗粒", "液体"}
  36. EX_TYPE_ENUM = {"有氧", "抗阻", "柔韧", "平衡", "其他"}
  37. EX_INTENSITY_ENUM = {"低", "中低", "中", "中高", "高"}
  38. IX_TYPE_ENUM = {"协同", "拮抗", "时序禁忌", "安全警示"}
  39. IX_SEVERITY_ENUM = {"提示", "警示", "禁忌"}
  40. def load(path: Path):
  41. try:
  42. return json.loads(path.read_text(encoding="utf-8"))
  43. except Exception as e: # noqa: BLE001
  44. raise SystemExit(f"JSON 解析失败: {path} -> {e}")
  45. def check_enum(field, value, allowed, errors, where):
  46. if value not in allowed:
  47. errors.append(f"{where}.{field}={value!r} 不在枚举 {sorted(allowed)} 内")
  48. def validate_list_ids(path: Path, items, item_key, id_pattern, errors):
  49. seen = set()
  50. for i, item in enumerate(items):
  51. where = f"{path.name}[{i}]"
  52. if not isinstance(item, dict):
  53. errors.append(f"{where} 不是对象")
  54. continue
  55. iid = item.get(item_key)
  56. if not iid:
  57. errors.append(f"{where} 缺 {item_key}")
  58. continue
  59. if iid in seen:
  60. errors.append(f"{where} 重复 {item_key}: {iid}")
  61. seen.add(iid)
  62. if not id_pattern.match(iid):
  63. errors.append(f"{where} {item_key}={iid!r} 不匹配 {id_pattern.pattern}")
  64. def validate_supplements(path, items, errors):
  65. for i, item in enumerate(items):
  66. where = f"{path.name}[{i}]"
  67. for req in ("name", "category", "key_ingredients", "form", "dose_reference", "timing"):
  68. if req not in item:
  69. errors.append(f"{where} 缺必需字段 {req}")
  70. check_enum("category", item.get("category"), CATEGORY_ENUM, errors, where)
  71. check_enum("timing", item.get("timing"), TIMING_ENUM, errors, where)
  72. check_enum("form", item.get("form"), FORM_ENUM, errors, where)
  73. for ing in item.get("key_ingredients", []):
  74. if not isinstance(ing, dict) or ing.get("name") is None or ing.get("amount") is None:
  75. errors.append(f"{where}.key_ingredients 缺 name/amount")
  76. def validate_clinical(path, items, errors):
  77. for i, item in enumerate(items):
  78. where = f"{path.name}[{i}]"
  79. for req in ("name", "unit", "normal_range"):
  80. if req not in item:
  81. errors.append(f"{where} 缺必需字段 {req}")
  82. nr = item.get("normal_range")
  83. if not isinstance(nr, dict) or "low" not in nr or "high" not in nr:
  84. errors.append(f"{where}.normal_range 需含 low/high")
  85. def validate_exercises(path, items, errors):
  86. for i, item in enumerate(items):
  87. where = f"{path.name}[{i}]"
  88. for req in ("name", "type", "intensity", "duration_min", "frequency"):
  89. if req not in item:
  90. errors.append(f"{where} 缺必需字段 {req}")
  91. check_enum("type", item.get("type"), EX_TYPE_ENUM, errors, where)
  92. check_enum("intensity", item.get("intensity"), EX_INTENSITY_ENUM, errors, where)
  93. if not isinstance(item.get("duration_min"), int) or item.get("duration_min") < 1:
  94. errors.append(f"{where}.duration_min 需为正整数")
  95. def validate_interactions(path, items, errors):
  96. for i, item in enumerate(items):
  97. where = f"{path.name}[{i}]"
  98. for req in ("type", "subjects_a", "subjects_b", "rule", "detail", "severity"):
  99. if req not in item:
  100. errors.append(f"{where} 缺必需字段 {req}")
  101. check_enum("type", item.get("type"), IX_TYPE_ENUM, errors, where)
  102. check_enum("severity", item.get("severity"), IX_SEVERITY_ENUM, errors, where)
  103. for part in ("subjects_a", "subjects_b"):
  104. s = item.get(part)
  105. if not isinstance(s, dict):
  106. errors.append(f"{where}.{part} 需为对象")
  107. continue
  108. if "supplement_id" not in s and "substance" not in s:
  109. errors.append(f"{where}.{part} 需含 supplement_id 或 substance")
  110. def validate_protocol(path, doc, errors, refs):
  111. root = doc
  112. cycles = dict((c["id"], c) for c in root.get("cycles", []) if isinstance(c, dict) and c.get("id"))
  113. for i, member in enumerate(root.get("members", [])):
  114. where = f"{path.name} 成员[{i}]"
  115. for req in ("family_id", "name", "user_id", "family_member_id", "phone", "phases",
  116. "daily_monitoring", "weekly_monitoring", "followups", "exercise_tasks"):
  117. if req not in member:
  118. errors.append(f"{where} 缺必需字段 {req}")
  119. for phase in member.get("phases", []):
  120. ph = f"{where}.phases"
  121. if not isinstance(phase, dict) or "cycle_id" not in phase or "time_slots" not in phase:
  122. errors.append(f"{ph} 缺 cycle_id/time_slots")
  123. continue
  124. if phase["cycle_id"] not in cycles:
  125. errors.append(f"{ph}.cycle_id={phase['cycle_id']!r} 未在 cycles 中定义")
  126. for slot in phase.get("time_slots", []):
  127. sl = f"{ph}.time_slots"
  128. if not isinstance(slot, dict) or "supplement_ids" not in slot:
  129. errors.append(f"{sl} 缺 supplement_ids")
  130. continue
  131. for sid in slot["supplement_ids"]:
  132. if sid not in refs["supplements"]:
  133. errors.append(f"{sl} 引用不存在的补充剂: {sid}")
  134. for wm in member.get("weekly_monitoring", []):
  135. for iid in wm.get("indicator_ids", []):
  136. if iid not in refs["indicators"]:
  137. errors.append(f"{where}.weekly_monitoring 引用不存在的指标: {iid}")
  138. for fu in member.get("followups", []):
  139. for iid in fu.get("indicator_ids", []):
  140. if iid not in refs["indicators"]:
  141. errors.append(f"{where}.followups 引用不存在的指标: {iid}")
  142. for eid in member.get("exercise_ids", []):
  143. if eid not in refs["exercises"]:
  144. errors.append(f"{where}.exercise_ids 引用不存在的运动: {eid}")
  145. for et in member.get("exercise_tasks", []):
  146. if not isinstance(et, dict) or "title" not in et or "exercise_ids" not in et:
  147. errors.append(f"{where}.exercise_tasks 缺 title/exercise_ids")
  148. continue
  149. for eid in et.get("exercise_ids", []):
  150. if eid not in refs["exercises"]:
  151. errors.append(f"{where}.exercise_tasks 引用不存在的运动: {eid}")
  152. def validate_interaction_refs(path, items, errors, refs):
  153. for i, item in enumerate(items):
  154. for part in ("subjects_a", "subjects_b"):
  155. s = item.get(part) or {}
  156. sid = s.get("supplement_id")
  157. if sid and sid not in refs["supplements"]:
  158. errors.append(f"{path.name}[{i}].{part} 引用不存在的补充剂: {sid}")
  159. def build_refs():
  160. refs = {"supplements": set(), "indicators": set(), "exercises": set()}
  161. for data_name, kind_id in (("supplements.json", 0), ("clinical_indicators.json", 0), ("exercises.json", 0)):
  162. p = KB_DIR / data_name
  163. if not p.exists():
  164. continue
  165. doc = load(p)
  166. kind = ID_PATTERNS[data_name][0]
  167. for item in doc.get(kind, []):
  168. iid = item.get("id")
  169. if iid:
  170. refs["supplements" if data_name == "supplements.json" else "indicators" if data_name == "clinical_indicators.json" else "exercises"].add(iid)
  171. return refs
  172. def main():
  173. failed = False
  174. refs = build_refs()
  175. for data_name, schema_rel in SCHEMA_FILES.items():
  176. data_path = KB_DIR / data_name
  177. if not data_path.exists():
  178. print(f"[SKIP] {data_path} 不存在")
  179. continue
  180. if not (KB_DIR / schema_rel).exists():
  181. print(f"[FAIL] schema 缺失: {schema_rel}")
  182. failed = True
  183. continue
  184. doc = load(data_path)
  185. kind = ID_PATTERNS[data_name][0]
  186. items = doc.get(kind)
  187. if items is None:
  188. print(f"[FAIL] {data_name} 缺少顶层数组字段 {kind}")
  189. failed = True
  190. continue
  191. errors = []
  192. validate_list_ids(data_path, items, "id", ID_PATTERNS[data_name][2], errors)
  193. if data_name == "supplements.json":
  194. validate_supplements(data_path, items, errors)
  195. elif data_name == "clinical_indicators.json":
  196. validate_clinical(data_path, items, errors)
  197. elif data_name == "exercises.json":
  198. validate_exercises(data_path, items, errors)
  199. elif data_name == "interactions.json":
  200. validate_interactions(data_path, items, errors)
  201. validate_interaction_refs(data_path, items, errors, refs)
  202. if errors:
  203. failed = True
  204. print(f"[FAIL] {data_name}: {len(errors)} 个问题")
  205. for e in errors[:20]:
  206. print(" -", e)
  207. else:
  208. print(f"[ OK ] {data_name}")
  209. for proto in sorted((KB_DIR / "protocols").glob("*.json")):
  210. if not (KB_DIR / PROTOCOL_SCHEMA).exists():
  211. print(f"[FAIL] schema 缺失: {PROTOCOL_SCHEMA}")
  212. failed = True
  213. break
  214. doc = load(proto)
  215. if doc.get("id") is None:
  216. print(f"[FAIL] {proto.name} 缺 id")
  217. failed = True
  218. continue
  219. pat = ID_PATTERNS["protocols"][2]
  220. if not pat.match(doc["id"]):
  221. print(f"[FAIL] {proto.name} id={doc['id']!r} 不匹配 {pat.pattern}")
  222. failed = True
  223. continue
  224. errors = []
  225. validate_protocol(proto, doc, errors, refs)
  226. if errors:
  227. failed = True
  228. print(f"[FAIL] {proto.name}: {len(errors)} 个问题")
  229. for e in errors[:25]:
  230. print(" -", e)
  231. else:
  232. print(f"[ OK ] {proto.name}")
  233. print("RESULT:", "FAIL" if failed else "PASS")
  234. sys.exit(1 if failed else 0)
  235. if __name__ == "__main__":
  236. main()