build_conversation_first_flow.py 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277
  1. """Build the concise P0 conversation-first interaction overview."""
  2. from __future__ import annotations
  3. import copy
  4. import json
  5. import os
  6. from pathlib import Path
  7. ROOT = Path(__file__).resolve().parent
  8. TEMPLATE = Path(
  9. os.environ.get(
  10. "EXCALIDRAW_TEMPLATE",
  11. ROOT / "templates" / "relationship-template.excalidraw",
  12. )
  13. )
  14. OUTPUT = ROOT / "医梦患者智能服务门户_P0对话式三智能体总交互流程.excalidraw"
  15. if not TEMPLATE.exists():
  16. raise SystemExit(
  17. "未找到 Excalidraw 模板。请通过 EXCALIDRAW_TEMPLATE 指定 "
  18. "relationship-template.excalidraw。"
  19. )
  20. source = json.loads(TEMPLATE.read_text(encoding="utf-8"))
  21. rect_template = next(element for element in source["elements"] if element["type"] == "rectangle")
  22. bound_text_template = next(element for element in source["elements"] if element["type"] == "text")
  23. free_text_template = next(element for element in source["elements"] if element["type"] == "text")
  24. arrow_template = next(element for element in source["elements"] if element["type"] == "arrow")
  25. sequence = 5000
  26. def next_value() -> int:
  27. global sequence
  28. sequence += 1
  29. return sequence
  30. def box(
  31. x: int,
  32. y: int,
  33. width: int,
  34. height: int,
  35. text: str,
  36. *,
  37. fill: str,
  38. stroke: str,
  39. color: str = "#16133a",
  40. size: int = 18,
  41. ) -> list[dict]:
  42. rect = copy.deepcopy(rect_template)
  43. label = copy.deepcopy(bound_text_template)
  44. rect_id = f"box-{next_value()}"
  45. text_id = f"text-{next_value()}"
  46. group_id = f"group-{next_value()}"
  47. seed = next_value()
  48. rect.update(
  49. {
  50. "id": rect_id,
  51. "x": x,
  52. "y": y,
  53. "width": width,
  54. "height": height,
  55. "strokeColor": stroke,
  56. "backgroundColor": fill,
  57. "groupIds": [group_id],
  58. "boundElements": [{"type": "text", "id": text_id}],
  59. "seed": seed,
  60. "versionNonce": seed + 1,
  61. }
  62. )
  63. line_count = text.count("\n") + 1
  64. text_height = min(int(line_count * size * 1.4 + 8), height - 16)
  65. label.update(
  66. {
  67. "id": text_id,
  68. "x": x + 14,
  69. "y": y + (height - text_height) / 2,
  70. "width": width - 28,
  71. "height": text_height,
  72. "text": text,
  73. "fontSize": size,
  74. "fontFamily": 5,
  75. "strokeColor": color,
  76. "textAlign": "center",
  77. "verticalAlign": "middle",
  78. "containerId": rect_id,
  79. "groupIds": [group_id],
  80. "seed": seed + 2,
  81. "versionNonce": seed + 3,
  82. }
  83. )
  84. return [rect, label]
  85. def free_text(x: int, y: int, width: int, height: int, text: str, *, size: int, color: str) -> dict:
  86. label = copy.deepcopy(free_text_template)
  87. seed = next_value()
  88. label.update(
  89. {
  90. "id": f"label-{next_value()}",
  91. "x": x,
  92. "y": y,
  93. "width": width,
  94. "height": height,
  95. "text": text,
  96. "fontSize": size,
  97. "fontFamily": 5,
  98. "strokeColor": color,
  99. "textAlign": "left",
  100. "containerId": None,
  101. "groupIds": [],
  102. "seed": seed,
  103. "versionNonce": seed + 1,
  104. }
  105. )
  106. return label
  107. def arrow(x: int, y: int, points: list[list[int]], *, color: str, dashed: bool = False) -> dict:
  108. element = copy.deepcopy(arrow_template)
  109. xs = [point[0] for point in points]
  110. ys = [point[1] for point in points]
  111. seed = next_value()
  112. element.update(
  113. {
  114. "id": f"arrow-{next_value()}",
  115. "x": x,
  116. "y": y,
  117. "width": max(xs) - min(xs),
  118. "height": max(ys) - min(ys),
  119. "points": points,
  120. "strokeColor": color,
  121. "strokeWidth": 3,
  122. "strokeStyle": "dashed" if dashed else "solid",
  123. "startBinding": None,
  124. "endBinding": None,
  125. "groupIds": [],
  126. "seed": seed,
  127. "versionNonce": seed + 1,
  128. }
  129. )
  130. return element
  131. elements: list[dict | list[dict]] = [
  132. free_text(
  133. 80,
  134. 40,
  135. 2200,
  136. 60,
  137. "医梦患者智能服务门户|P0 对话式三智能体总交互流程",
  138. size=34,
  139. color="#21147d",
  140. ),
  141. free_text(
  142. 80,
  143. 100,
  144. 2200,
  145. 40,
  146. "单医院 · 固定 Mock 患者 · 语音优先 · 三个智能体共用一个对话任务壳",
  147. size=18,
  148. color="#69708a",
  149. ),
  150. box(
  151. 80,
  152. 230,
  153. 360,
  154. 560,
  155. "门户入口\n\n统一语音入口\n不知道使用哪个服务\n\n三项服务卡片\n明确选择挂号、报告或舌诊\n\n患者状态与待办\n继续报告或舌诊任务",
  156. fill="#f1f0ff",
  157. stroke="#2b1f99",
  158. color="#21147d",
  159. size=19,
  160. ),
  161. box(
  162. 570,
  163. 230,
  164. 480,
  165. 560,
  166. "统一对话式任务壳\n\n保留患者原话与助手回复\n\n语音主输入\n文字、快捷选项、上传为辅\n\n智能体连续追问补齐信息\n\n只在必要时出现\n号源选择 / 风险提醒 / 操作确认 / 结果\n\n三个智能体独立保存任务状态",
  167. fill="#ffffff",
  168. stroke="#2b1f99",
  169. color="#21147d",
  170. size=19,
  171. ),
  172. box(
  173. 1210,
  174. 170,
  175. 620,
  176. 240,
  177. "挂号智能体\n\n语音描述完整诉求\n→ 只追问缺失医生\n→ 查询并选择 Mock 号源\n→ 确认 Mock 挂号\n\n安全支线:胸痛 + 呼吸困难 → 中止普通挂号",
  178. fill="#f1f0ff",
  179. stroke="#2b1f99",
  180. color="#21147d",
  181. size=18,
  182. ),
  183. box(
  184. 1210,
  185. 480,
  186. 620,
  187. 240,
  188. "报告解读智能体\n\n对话内上传报告\n→ OCR 与指标分析进度\n→ 结构化 Markdown 解读\n→ 风险提醒 + 异常指标表 + 行动建议",
  189. fill="#eef7ff",
  190. stroke="#4f7fdb",
  191. color="#254b8d",
  192. size=18,
  193. ),
  194. box(
  195. 1210,
  196. 790,
  197. 620,
  198. 240,
  199. "中医舌诊智能体\n\n对话内完成体质与症候问答\n→ 上传舌象\n→ 调用现有舌诊 MCP\n→ 返回问答 + 舌象综合结果",
  200. fill="#fff4ea",
  201. stroke="#d8874c",
  202. color="#8a4d25",
  203. size=18,
  204. ),
  205. box(
  206. 2010,
  207. 390,
  208. 430,
  209. 430,
  210. "统一结果回流\n\n挂号:Mock 预约结果\n\n报告:Markdown 解读与风险提醒\n\n舌诊:体质 / 证候倾向与健康建议\n\n完成结果进入健康记录\n未完成任务回到首页待办",
  211. fill="#e9fbfc",
  212. stroke="#2fc9d2",
  213. color="#115e64",
  214. size=19,
  215. ),
  216. arrow(440, 510, [[0, 0], [130, 0]], color="#2b1f99"),
  217. arrow(1050, 510, [[0, 0], [80, 0], [80, -220], [160, -220]], color="#2b1f99"),
  218. arrow(1050, 510, [[0, 0], [160, 90]], color="#4f7fdb"),
  219. arrow(1050, 510, [[0, 0], [80, 0], [80, 400], [160, 400]], color="#d8874c"),
  220. arrow(1830, 290, [[0, 0], [90, 0], [90, 315], [180, 315]], color="#2b1f99"),
  221. arrow(1830, 600, [[0, 0], [180, 5]], color="#4f7fdb"),
  222. arrow(1830, 910, [[0, 0], [90, 0], [90, -305], [180, -305]], color="#d8874c"),
  223. arrow(2225, 820, [[0, 0], [0, 290], [-1965, 290], [-1965, -320]], color="#2fc9d2", dashed=True),
  224. ]
  225. flattened: list[dict] = []
  226. for element in elements:
  227. if isinstance(element, (tuple, list)):
  228. flattened.extend(element)
  229. else:
  230. flattened.append(element)
  231. result = {
  232. "type": "excalidraw",
  233. "version": 2,
  234. "source": "https://excalidraw.com",
  235. "elements": flattened,
  236. "appState": {"viewBackgroundColor": "#f7fbff", "gridSize": 20},
  237. "files": {},
  238. }
  239. assert all(isinstance(element, dict) for element in flattened)
  240. assert len({element["id"] for element in flattened}) == len(flattened)
  241. assert all(
  242. element.get("fontFamily") == 5
  243. for element in flattened
  244. if element.get("type") == "text"
  245. )
  246. for element in flattened:
  247. if element.get("type") == "rectangle":
  248. assert element.get("boundElements")
  249. text_id = element["boundElements"][0]["id"]
  250. child = next(item for item in flattened if item["id"] == text_id)
  251. assert child.get("containerId") == element["id"]
  252. assert child.get("groupIds") == element.get("groupIds")
  253. OUTPUT.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
  254. json.loads(OUTPUT.read_text(encoding="utf-8"))
  255. print(OUTPUT)