build_client_interaction_flow.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417
  1. """Build the editable Excalidraw overview for the P0 patient portal."""
  2. from __future__ import annotations
  3. import copy
  4. import json
  5. from pathlib import Path
  6. ROOT = Path(__file__).resolve().parent
  7. TEMPLATE = Path(
  8. "/Users/destiny/.agents/skills/excalidraw-diagram-generator/templates/"
  9. "relationship-template.excalidraw"
  10. )
  11. OUTPUT = ROOT / "医梦患者智能服务门户_P0客户端总交互流程.excalidraw"
  12. template = json.loads(TEMPLATE.read_text(encoding="utf-8"))
  13. rect_template = next(e for e in template["elements"] if e["type"] == "rectangle")
  14. text_template = next(e for e in template["elements"] if e["type"] == "text")
  15. arrow_template = next(e for e in template["elements"] if e["type"] == "arrow")
  16. counter = 2000
  17. def next_id(prefix: str) -> str:
  18. global counter
  19. counter += 1
  20. return f"{prefix}-{counter}"
  21. def bound_box(
  22. x: int,
  23. y: int,
  24. width: int,
  25. height: int,
  26. text: str,
  27. *,
  28. fill: str,
  29. stroke: str,
  30. color: str = "#16133a",
  31. font_size: int = 18,
  32. stroke_width: int = 2,
  33. roughness: int = 1,
  34. ) -> list[dict]:
  35. rect = copy.deepcopy(rect_template)
  36. label = copy.deepcopy(text_template)
  37. rect_id = next_id("box")
  38. text_id = next_id("text")
  39. group_id = next_id("group")
  40. seed = counter * 7919
  41. rect.update(
  42. {
  43. "id": rect_id,
  44. "x": x,
  45. "y": y,
  46. "width": width,
  47. "height": height,
  48. "strokeColor": stroke,
  49. "backgroundColor": fill,
  50. "strokeWidth": stroke_width,
  51. "roughness": roughness,
  52. "groupIds": [group_id],
  53. "boundElements": [{"type": "text", "id": text_id}],
  54. "text": None,
  55. "fontSize": None,
  56. "fontFamily": None,
  57. "textAlign": None,
  58. "verticalAlign": None,
  59. "seed": seed,
  60. "versionNonce": seed + 1,
  61. }
  62. )
  63. line_count = text.count("\n") + 1
  64. text_height = min(int(line_count * font_size * 1.35 + 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. "strokeColor": color,
  73. "text": text,
  74. "fontSize": font_size,
  75. "fontFamily": 5,
  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(
  86. x: int,
  87. y: int,
  88. width: int,
  89. height: int,
  90. text: str,
  91. *,
  92. size: int,
  93. color: str = "#16133a",
  94. align: str = "left",
  95. ) -> dict:
  96. label = copy.deepcopy(text_template)
  97. seed = counter * 6151
  98. label.update(
  99. {
  100. "id": next_id("label"),
  101. "x": x,
  102. "y": y,
  103. "width": width,
  104. "height": height,
  105. "strokeColor": color,
  106. "text": text,
  107. "fontSize": size,
  108. "fontFamily": 5,
  109. "textAlign": align,
  110. "verticalAlign": "top",
  111. "containerId": None,
  112. "groupIds": [],
  113. "seed": seed,
  114. "versionNonce": seed + 1,
  115. }
  116. )
  117. return label
  118. def arrow(
  119. x: int,
  120. y: int,
  121. points: list[list[int]],
  122. *,
  123. color: str = "#69708a",
  124. stroke_width: int = 2,
  125. dashed: bool = False,
  126. ) -> dict:
  127. element = copy.deepcopy(arrow_template)
  128. xs = [point[0] for point in points]
  129. ys = [point[1] for point in points]
  130. seed = counter * 4271
  131. element.update(
  132. {
  133. "id": next_id("arrow"),
  134. "x": x,
  135. "y": y,
  136. "width": max(xs) - min(xs),
  137. "height": max(ys) - min(ys),
  138. "points": points,
  139. "strokeColor": color,
  140. "strokeWidth": stroke_width,
  141. "strokeStyle": "dashed" if dashed else "solid",
  142. "groupIds": [],
  143. "startBinding": None,
  144. "endBinding": None,
  145. "seed": seed,
  146. "versionNonce": seed + 1,
  147. }
  148. )
  149. return element
  150. elements: list[dict | list[dict]] = [
  151. free_text(
  152. 70,
  153. 35,
  154. 2100,
  155. 60,
  156. "医梦患者智能服务门户|P0 客户端总交互流程",
  157. size=34,
  158. color="#21147d",
  159. ),
  160. free_text(
  161. 70,
  162. 98,
  163. 2400,
  164. 42,
  165. "单医院 · 固定 Mock 患者 · 语音优先 · 对话式任务界面 · 三智能体 · 结果回到健康记录与首页",
  166. size=18,
  167. color="#69708a",
  168. ),
  169. free_text(70, 165, 340, 36, "① 发现与唤起", size=20, color="#21147d"),
  170. free_text(500, 165, 380, 36, "② 路由与任务启动", size=20, color="#21147d"),
  171. free_text(990, 165, 440, 36, "③ 对话式任务界面", size=20, color="#21147d"),
  172. free_text(1550, 110, 560, 36, "④ 三个 P0 智能体", size=20, color="#21147d"),
  173. free_text(2250, 350, 860, 36, "⑤ 结果与患者状态回流", size=20, color="#21147d"),
  174. bound_box(
  175. 70,
  176. 230,
  177. 340,
  178. 120,
  179. "首页统一语音入口\n不知道使用哪项服务",
  180. fill="#f1f0ff",
  181. stroke="#2b1f99",
  182. color="#21147d",
  183. font_size=19,
  184. ),
  185. bound_box(
  186. 70,
  187. 405,
  188. 340,
  189. 120,
  190. "首页 / 服务中心卡片\n明确选择挂号、报告或舌诊",
  191. fill="#ffffff",
  192. stroke="#9ea9cc",
  193. font_size=18,
  194. ),
  195. bound_box(
  196. 70,
  197. 580,
  198. 340,
  199. 120,
  200. "患者状态与待办\n预约、新报告、继续舌诊、失败重试",
  201. fill="#e9fbfc",
  202. stroke="#2fc9d2",
  203. color="#115e64",
  204. font_size=18,
  205. ),
  206. bound_box(
  207. 70,
  208. 755,
  209. 340,
  210. 120,
  211. "健康记录 / 业务内嵌\n查看结果、恢复任务、再次办理",
  212. fill="#ffffff",
  213. stroke="#9ea9cc",
  214. font_size=18,
  215. ),
  216. bound_box(
  217. 500,
  218. 300,
  219. 380,
  220. 530,
  221. "统一助手 / 任务启动器\n\n① 判断入口来源\n② 识别自然语言意图\n③ 加载医院与患者上下文\n④ 医疗安全前置判断\n⑤ 确认目标服务\n\n明确能力:直接创建任务\n自然语言:确认后创建任务\n已有 taskId:恢复原任务状态",
  222. fill="#2b1f99",
  223. stroke="#21147d",
  224. color="#ffffff",
  225. font_size=18,
  226. roughness=0,
  227. ),
  228. bound_box(
  229. 990,
  230. 245,
  231. 440,
  232. 640,
  233. "统一对话式任务界面\n\n最近对话\n患者原话 + 助手理解\n\n已收集信息\n完整度 · 缺失项 · 修改入口\n\n当前唯一任务\n一次只追问或处理一件事\n\n语音主输入\n文字备选 · 快捷选项 · 上传\n\n完整性与安全检查\n通过后才查询、分析或执行\n\n操作记录\n转写、规则与工具步骤可展开",
  234. fill="#f7f8ff",
  235. stroke="#2b1f99",
  236. color="#21147d",
  237. font_size=18,
  238. roughness=0,
  239. ),
  240. bound_box(
  241. 990,
  242. 970,
  243. 440,
  244. 185,
  245. "P0 固定运行上下文\n\n单家指定医院|固定本人 Mock 患者\n演示语音转写|FastGPT Workflow\n挂号与报告 Mock MCP|现有舌诊 MCP",
  246. fill="#f7fbff",
  247. stroke="#9ea9cc",
  248. font_size=16,
  249. ),
  250. bound_box(
  251. 1550,
  252. 165,
  253. 560,
  254. 250,
  255. "智能分诊挂号|SMART_REGISTRATION\n\n急诊安全门禁 → 挂号条件补槽\n→ 医生与号源选择 → 独立确认\n→ Mock 锁号 / 挂号 / 支付\n→ 挂号结果",
  256. fill="#f1f0ff",
  257. stroke="#2b1f99",
  258. color="#21147d",
  259. font_size=18,
  260. ),
  261. bound_box(
  262. 1550,
  263. 485,
  264. 560,
  265. 250,
  266. "报告智能解读|REPORT_INTERPRETATION\n\n选择院内报告或上传 → 归属确认\n→ 文件质量 / OCR / 风险检查\n→ 指标与术语标准化\n→ 结构化解读结果",
  267. fill="#eef5ff",
  268. stroke="#4f7fdb",
  269. color="#163d78",
  270. font_size=18,
  271. ),
  272. bound_box(
  273. 1550,
  274. 805,
  275. 560,
  276. 250,
  277. "中医舌诊|TCM_TONGUE_ASSESSMENT\n\n体质症候问答 → 阶段性保存\n→ 舌象拍摄与图片质控\n→ 调用现有舌诊 MCP\n→ 问答与舌象融合结果",
  278. fill="#fff2f1",
  279. stroke="#d45b61",
  280. color="#7c3036",
  281. font_size=18,
  282. ),
  283. bound_box(
  284. 1550,
  285. 1120,
  286. 560,
  287. 175,
  288. "共同异常与恢复\n信息不足:继续追问|图片问题:重新上传\n工具失败:保留上下文重试|医疗风险:中止普通流程\n必要时转急诊、人工或线下处理",
  289. fill="#fff4e8",
  290. stroke="#f0a020",
  291. color="#754500",
  292. font_size=16,
  293. ),
  294. bound_box(
  295. 2250,
  296. 430,
  297. 390,
  298. 330,
  299. "统一结果与 UI 组件\n\n挂号确认与结果卡\n报告风险与指标卡\n舌诊特征与建议卡\n失败 / 重试 / 转人工状态\n\n任务结果不只保留在对话中",
  300. fill="#e9fbfc",
  301. stroke="#2fc9d2",
  302. color="#115e64",
  303. font_size=18,
  304. ),
  305. bound_box(
  306. 2750,
  307. 430,
  308. 360,
  309. 220,
  310. "健康记录\n\n保存完成结果\n保存未完成任务\n按挂号 / 报告 / 中医分类\n支持查看与继续",
  311. fill="#ffffff",
  312. stroke="#2b1f99",
  313. color="#21147d",
  314. font_size=18,
  315. ),
  316. bound_box(
  317. 2750,
  318. 760,
  319. 360,
  320. 220,
  321. "首页状态与待办\n\n明日预约\n新报告或解读结果\n继续未完成舌诊\n失败任务重试",
  322. fill="#f1f0ff",
  323. stroke="#2b1f99",
  324. color="#21147d",
  325. font_size=18,
  326. ),
  327. free_text(
  328. 2225,
  329. 1080,
  330. 800,
  331. 60,
  332. "结果回流后,患者可从首页待办或健康记录继续原任务,也可以重新发起服务。",
  333. size=17,
  334. color="#69708a",
  335. align="center",
  336. ),
  337. arrow(410, 290, [[0, 0], [90, 180]], color="#2b1f99"),
  338. arrow(410, 465, [[0, 0], [90, 60]], color="#69708a"),
  339. arrow(410, 640, [[0, 0], [90, -60]], color="#2fc9d2"),
  340. arrow(410, 815, [[0, 0], [90, -180]], color="#69708a"),
  341. arrow(880, 565, [[0, 0], [110, 0]], color="#2b1f99", stroke_width=3),
  342. arrow(1430, 430, [[0, 0], [120, -140]], color="#2b1f99"),
  343. arrow(1430, 565, [[0, 0], [120, 45]], color="#4f7fdb"),
  344. arrow(1430, 700, [[0, 0], [120, 230]], color="#d45b61"),
  345. arrow(2110, 290, [[0, 0], [140, 240]], color="#2b1f99"),
  346. arrow(2110, 610, [[0, 0], [140, 0]], color="#4f7fdb"),
  347. arrow(2110, 930, [[0, 0], [140, -240]], color="#d45b61"),
  348. arrow(1825, 415, [[0, 0], [0, 705]], color="#f0a020", dashed=True),
  349. arrow(1825, 735, [[0, 0], [0, 385]], color="#f0a020", dashed=True),
  350. arrow(1825, 1055, [[0, 0], [0, 65]], color="#f0a020", dashed=True),
  351. arrow(1550, 1210, [[0, 0], [-120, 0], [-120, -325]], color="#f0a020", dashed=True),
  352. arrow(2640, 595, [[0, 0], [110, -55]], color="#2fc9d2", stroke_width=3),
  353. arrow(2930, 650, [[0, 0], [0, 110]], color="#2b1f99", stroke_width=3),
  354. arrow(
  355. 2930,
  356. 980,
  357. [[0, 0], [0, 390], [-2690, 390], [-2690, -105]],
  358. color="#2b1f99",
  359. dashed=True,
  360. ),
  361. ]
  362. flattened: list[dict] = []
  363. for element in elements:
  364. if isinstance(element, (tuple, list)):
  365. flattened.extend(element)
  366. else:
  367. flattened.append(element)
  368. result = {
  369. "type": "excalidraw",
  370. "version": 2,
  371. "source": "https://excalidraw.com",
  372. "elements": flattened,
  373. "appState": {
  374. "viewBackgroundColor": "#f7fbff",
  375. "gridSize": 20,
  376. },
  377. "files": {},
  378. }
  379. assert all(isinstance(element, dict) for element in flattened)
  380. assert len({element["id"] for element in flattened}) == len(flattened)
  381. assert all(
  382. element.get("fontFamily") == 5
  383. for element in flattened
  384. if element.get("type") == "text"
  385. )
  386. for element in flattened:
  387. if element.get("type") == "rectangle":
  388. assert element.get("boundElements"), f"Unbound rectangle: {element['id']}"
  389. text_id = element["boundElements"][0]["id"]
  390. child = next(item for item in flattened if item["id"] == text_id)
  391. assert child.get("containerId") == element["id"]
  392. assert child.get("groupIds") == element.get("groupIds")
  393. OUTPUT.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
  394. json.loads(OUTPUT.read_text(encoding="utf-8"))
  395. print(OUTPUT)