validate_kb.py 12 KB

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