render_kb_md.py 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185
  1. #!/usr/bin/env python3
  2. """派生 MD 视图:以知识库 JSON 为唯一事实源,生成 Markdown 表格视图。
  3. 用法:
  4. python3 render_kb_md.py [知识库目录] [输出目录]
  5. python3 render_kb_md.py --check [知识库目录] [输出目录]
  6. 输出:<输出目录>/views/{supplements,clinical_indicators,exercises,interactions,protocols}.md
  7. 约定:JSON 为单一事实源,MD 为派生视图;改动数据只改 JSON,随后重跑本脚本。
  8. --check 模式:重生成到临时目录并与现视图对比,不一致退出码 1(供 CI/提交流程)。
  9. """
  10. import json
  11. import sys
  12. import tempfile
  13. from pathlib import Path
  14. DEFAULT_KB = "docs/参考资料/知识库"
  15. def load(path: Path):
  16. return json.loads(path.read_text(encoding="utf-8"))
  17. def md_table(headers, rows):
  18. out = ["| " + " | ".join(headers) + " |", "| " + " | ".join(["---"] * len(headers)) + " |"]
  19. for r in rows:
  20. cells = [str(c).replace("|", "\\|").replace("\n", " ") for c in r]
  21. out.append("| " + " | ".join(cells) + " |")
  22. return "\n".join(out)
  23. def render_supplements(doc):
  24. rows = []
  25. for s in doc["supplements"]:
  26. ings = ", ".join(f"{i['name']} {i['amount']}" for i in s["key_ingredients"])
  27. rows.append([s["id"], s["name"], s.get("brand") or "—", s["category"], s["form"],
  28. ings, s["dose_reference"], s["timing"], s.get("interaction_notes") or "—",
  29. s.get("contraindications") or "—", s.get("evidence_level", "—"),
  30. "✔" if s.get("enabled", True) else "✘"])
  31. return ("# 补充剂库(视图)\n\n> 由 supplements.json 派生,勿手工编辑。\n\n"
  32. + md_table(["id", "名称", "品牌", "类别", "剂型", "关键成分", "参考剂量", "时段",
  33. "相互作用注意", "禁忌", "证据", "启用"], rows))
  34. def render_indicators(doc):
  35. rows = []
  36. for ind in doc["indicators"]:
  37. nr = ind.get("normal_range", {})
  38. lo, hi = nr.get("low"), nr.get("high")
  39. rng = f"{lo}~{hi}" if lo is not None and hi is not None else (str(lo or hi))
  40. rt = ind.get("risk_thresholds", {})
  41. warn = "; ".join(x for x in [f"低<{rt['low_warn']}" if rt.get("low_warn") else "",
  42. f"高>{rt['high_warn']}" if rt.get("high_warn") else "",
  43. rt.get("note") or ""] if x)
  44. rows.append([ind["id"], ind["name"], ind.get("english_name", ""), ind["unit"], rng,
  45. warn, ind.get("monitoring_frequency_hint") or "—",
  46. ind.get("related_condition") or "—", ind.get("population_note") or "—"])
  47. return ("# 临床监测指标参考库(视图)\n\n> 由 clinical_indicators.json 派生,勿手工编辑。\n\n"
  48. + md_table(["id", "指标", "英文", "单位", "参考范围", "干预阈值", "复查频率",
  49. "相关疾病", "人群差异"], rows))
  50. def render_exercises(doc):
  51. rows = []
  52. for e in doc["exercises"]:
  53. rows.append([e["id"], e["name"], "/".join(e.get("aliases", [])), e["type"], e["intensity"],
  54. e["duration_min"], e["frequency"], e.get("timing_pref") or "—",
  55. e.get("suitable_population") or "—", e.get("safety_notes") or "—"])
  56. return ("# 运动处方库(视图)\n\n> 由 exercises.json 派生,勿手工编辑。\n\n"
  57. + md_table(["id", "名称", "别名", "类型", "强度", "时长(分)", "频率", "时段偏好",
  58. "适宜人群", "安全注意"], rows))
  59. def render_interactions(doc):
  60. def subj(s):
  61. return s.get("supplement_id") or s.get("substance", "")
  62. rows = []
  63. for ix in doc["interactions"]:
  64. rows.append([ix["id"], ix["type"], subj(ix["subjects_a"]), subj(ix["subjects_b"]),
  65. ix["rule"], ix["detail"], ix["severity"], ix.get("source") or "—"])
  66. return ("# 补剂相互作用库(视图)\n\n> 由 interactions.json 派生,勿手工编辑。\n\n"
  67. + md_table(["id", "类型", "主体A", "主体B", "规则", "说明", "等级", "来源"], rows))
  68. def render_protocols(docs, supplement_names):
  69. out = ["# 干预方案库(视图)\n", "> 由 protocols/*.json 派生,勿手工编辑。\n"]
  70. for doc in docs:
  71. out.append(f"\n## {doc['name']}(`{doc['id']}`)\n")
  72. out.append(f"> 来源文档:{doc.get('source_doc', '—')}\n")
  73. cycles = {c["id"]: c for c in doc.get("cycles", [])}
  74. out.append(md_table(["周期", "阶段", "起", "止"], [[cid, c.get("name"), c.get("start"), c.get("end")]
  75. for cid, c in cycles.items()]))
  76. for member in doc.get("members", []):
  77. out.append(f"\n### 成员:{member['name']}(family {member['family_id']},user {member['user_id']},手机 {member['phone']})\n")
  78. for phase in member.get("phases", []):
  79. cyc = cycles.get(phase["cycle_id"], {})
  80. out.append(f"\n#### {cyc.get('name', phase['cycle_id'])} 补剂\n")
  81. rows = []
  82. for slot in phase.get("time_slots", []):
  83. names = "、".join(supplement_names.get(sid, sid) for sid in slot.get("supplement_ids", []))
  84. rows.append([slot["time"], slot["label"], names, slot.get("dose", ""), slot.get("notes", "")])
  85. out.append(md_table(["时段", "标签", "补剂", "剂量", "备注"], rows))
  86. dm = member.get("daily_monitoring", [])
  87. if dm:
  88. out.append("\n#### 每日监测\n")
  89. out.append(md_table(["时间", "内容", "描述", "类型"],
  90. [[d["time"], d["name"], d.get("description", ""), d.get("kind", "")] for d in dm]))
  91. wm = member.get("weekly_monitoring", [])
  92. if wm:
  93. out.append("\n#### 每周监测\n")
  94. out.append(md_table(["周期", "时间", "内容", "描述", "关联指标"],
  95. [[w["weekday"], w.get("time", ""), w["name"], w.get("description", ""),
  96. "、".join(w.get("indicator_ids", []))] for w in wm]))
  97. fu = member.get("followups", [])
  98. if fu:
  99. out.append("\n#### 复查节点\n")
  100. out.append(md_table(["周期", "日期", "项目", "关联指标", "说明"],
  101. [[f.get("week", ""), f.get("due", ""), f["name"],
  102. "、".join(f.get("indicator_ids", [])), f.get("note", "")] for f in fu]))
  103. ex = member.get("exercise_tasks", [])
  104. if ex:
  105. out.append("\n#### 运动任务\n")
  106. out.append(md_table(["时间", "任务", "描述", "引用运动"],
  107. [[e["time"], e["title"], e.get("description", ""),
  108. "、".join(e.get("exercise_ids", []))] for e in ex]))
  109. return "\n".join(out)
  110. def main():
  111. argv = sys.argv[1:]
  112. check = False
  113. if argv and argv[0] == "--check":
  114. check = True
  115. argv = argv[1:]
  116. kb_dir = Path(argv[0]) if argv else Path(DEFAULT_KB)
  117. out_dir = Path(argv[1]) if len(argv) > 1 else kb_dir
  118. supplements = load(kb_dir / "supplements.json")
  119. indicators = load(kb_dir / "clinical_indicators.json")
  120. exercises = load(kb_dir / "exercises.json")
  121. interactions = load(kb_dir / "interactions.json")
  122. protocols = [load(p) for p in sorted((kb_dir / "protocols").glob("*.json"))]
  123. names = {s["id"]: s["name"] for s in supplements["supplements"]}
  124. rendered = {
  125. "supplements.md": render_supplements(supplements),
  126. "clinical_indicators.md": render_indicators(indicators),
  127. "exercises.md": render_exercises(exercises),
  128. "interactions.md": render_interactions(interactions),
  129. "protocols.md": render_protocols(protocols, names),
  130. }
  131. target = out_dir / "views"
  132. if check:
  133. with tempfile.TemporaryDirectory() as tmp:
  134. tmp_dir = Path(tmp) / "views"
  135. tmp_dir.mkdir(parents=True, exist_ok=True)
  136. for name, text in rendered.items():
  137. (tmp_dir / name).write_text(text + "\n", encoding="utf-8")
  138. diff_count = 0
  139. for name in rendered:
  140. existing = target / name
  141. if not existing.exists():
  142. print(f"[DIFF] {name} 缺失")
  143. diff_count += 1
  144. continue
  145. a = (tmp_dir / name).read_text(encoding="utf-8").splitlines()
  146. b = existing.read_text(encoding="utf-8").splitlines()
  147. if a != b:
  148. diffs = list(difflib.unified_diff(b, a, fromfile=name, tofile=name, lineterm=""))
  149. print(f"[DIFF] {name}: {len(diffs)} 行差异(JSON 与视图不同步)")
  150. print("\n".join(diffs[:20]))
  151. diff_count += 1
  152. print("RESULT:", "FAIL" if diff_count else "PASS")
  153. sys.exit(1 if diff_count else 0)
  154. else:
  155. target.mkdir(parents=True, exist_ok=True)
  156. for name, text in rendered.items():
  157. (target / name).write_text(text + "\n", encoding="utf-8")
  158. print(f"wrote {target / name}")
  159. print("RESULT: PASS")
  160. if __name__ == "__main__":
  161. main()