build_client_interaction_flow.py 13 KB

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