| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426 |
- """Build the editable Excalidraw overview for the P0 patient portal."""
- from __future__ import annotations
- import copy
- import json
- import os
- from pathlib import Path
- ROOT = Path(__file__).resolve().parent
- TEMPLATE = Path(
- os.environ.get(
- "EXCALIDRAW_TEMPLATE",
- ROOT / "templates" / "relationship-template.excalidraw",
- )
- )
- OUTPUT = ROOT / "医梦患者智能服务门户_P0客户端总交互流程.excalidraw"
- if not TEMPLATE.exists():
- raise SystemExit(
- "未找到 Excalidraw 模板。请通过 EXCALIDRAW_TEMPLATE 指定 "
- "relationship-template.excalidraw。"
- )
- template = json.loads(TEMPLATE.read_text(encoding="utf-8"))
- rect_template = next(e for e in template["elements"] if e["type"] == "rectangle")
- text_template = next(e for e in template["elements"] if e["type"] == "text")
- arrow_template = next(e for e in template["elements"] if e["type"] == "arrow")
- counter = 2000
- def next_id(prefix: str) -> str:
- global counter
- counter += 1
- return f"{prefix}-{counter}"
- def bound_box(
- x: int,
- y: int,
- width: int,
- height: int,
- text: str,
- *,
- fill: str,
- stroke: str,
- color: str = "#16133a",
- font_size: int = 18,
- stroke_width: int = 2,
- roughness: int = 1,
- ) -> list[dict]:
- rect = copy.deepcopy(rect_template)
- label = copy.deepcopy(text_template)
- rect_id = next_id("box")
- text_id = next_id("text")
- group_id = next_id("group")
- seed = counter * 7919
- rect.update(
- {
- "id": rect_id,
- "x": x,
- "y": y,
- "width": width,
- "height": height,
- "strokeColor": stroke,
- "backgroundColor": fill,
- "strokeWidth": stroke_width,
- "roughness": roughness,
- "groupIds": [group_id],
- "boundElements": [{"type": "text", "id": text_id}],
- "text": None,
- "fontSize": None,
- "fontFamily": None,
- "textAlign": None,
- "verticalAlign": None,
- "seed": seed,
- "versionNonce": seed + 1,
- }
- )
- line_count = text.count("\n") + 1
- text_height = min(int(line_count * font_size * 1.35 + 8), height - 16)
- label.update(
- {
- "id": text_id,
- "x": x + 14,
- "y": y + (height - text_height) / 2,
- "width": width - 28,
- "height": text_height,
- "strokeColor": color,
- "text": text,
- "fontSize": font_size,
- "fontFamily": 5,
- "textAlign": "center",
- "verticalAlign": "middle",
- "containerId": rect_id,
- "groupIds": [group_id],
- "seed": seed + 2,
- "versionNonce": seed + 3,
- }
- )
- return [rect, label]
- def free_text(
- x: int,
- y: int,
- width: int,
- height: int,
- text: str,
- *,
- size: int,
- color: str = "#16133a",
- align: str = "left",
- ) -> dict:
- label = copy.deepcopy(text_template)
- seed = counter * 6151
- label.update(
- {
- "id": next_id("label"),
- "x": x,
- "y": y,
- "width": width,
- "height": height,
- "strokeColor": color,
- "text": text,
- "fontSize": size,
- "fontFamily": 5,
- "textAlign": align,
- "verticalAlign": "top",
- "containerId": None,
- "groupIds": [],
- "seed": seed,
- "versionNonce": seed + 1,
- }
- )
- return label
- def arrow(
- x: int,
- y: int,
- points: list[list[int]],
- *,
- color: str = "#69708a",
- stroke_width: int = 2,
- dashed: bool = False,
- ) -> dict:
- element = copy.deepcopy(arrow_template)
- xs = [point[0] for point in points]
- ys = [point[1] for point in points]
- seed = counter * 4271
- element.update(
- {
- "id": next_id("arrow"),
- "x": x,
- "y": y,
- "width": max(xs) - min(xs),
- "height": max(ys) - min(ys),
- "points": points,
- "strokeColor": color,
- "strokeWidth": stroke_width,
- "strokeStyle": "dashed" if dashed else "solid",
- "groupIds": [],
- "startBinding": None,
- "endBinding": None,
- "seed": seed,
- "versionNonce": seed + 1,
- }
- )
- return element
- elements: list[dict | list[dict]] = [
- free_text(
- 70,
- 35,
- 2100,
- 60,
- "医梦患者智能服务门户|P0 客户端总交互流程",
- size=34,
- color="#21147d",
- ),
- free_text(
- 70,
- 98,
- 2400,
- 42,
- "单医院 · 固定 Mock 患者 · 语音优先 · 对话式任务界面 · 三智能体 · 结果回到健康记录与首页",
- size=18,
- color="#69708a",
- ),
- free_text(70, 165, 340, 36, "① 发现与唤起", size=20, color="#21147d"),
- free_text(500, 165, 380, 36, "② 路由与任务启动", size=20, color="#21147d"),
- free_text(990, 165, 440, 36, "③ 对话式任务界面", size=20, color="#21147d"),
- free_text(1550, 110, 560, 36, "④ 三个 P0 智能体", size=20, color="#21147d"),
- free_text(2250, 350, 860, 36, "⑤ 结果与患者状态回流", size=20, color="#21147d"),
- bound_box(
- 70,
- 230,
- 340,
- 120,
- "首页统一语音入口\n不知道使用哪项服务",
- fill="#f1f0ff",
- stroke="#2b1f99",
- color="#21147d",
- font_size=19,
- ),
- bound_box(
- 70,
- 405,
- 340,
- 120,
- "首页 / 服务中心卡片\n明确选择挂号、报告或舌诊",
- fill="#ffffff",
- stroke="#9ea9cc",
- font_size=18,
- ),
- bound_box(
- 70,
- 580,
- 340,
- 120,
- "患者状态与待办\n预约、新报告、继续舌诊、失败重试",
- fill="#e9fbfc",
- stroke="#2fc9d2",
- color="#115e64",
- font_size=18,
- ),
- bound_box(
- 70,
- 755,
- 340,
- 120,
- "健康记录 / 业务内嵌\n查看结果、恢复任务、再次办理",
- fill="#ffffff",
- stroke="#9ea9cc",
- font_size=18,
- ),
- bound_box(
- 500,
- 300,
- 380,
- 530,
- "统一助手 / 任务启动器\n\n① 判断入口来源\n② 识别自然语言意图\n③ 加载医院与患者上下文\n④ 医疗安全前置判断\n⑤ 确认目标服务\n\n明确能力:直接创建任务\n自然语言:确认后创建任务\n已有 taskId:恢复原任务状态",
- fill="#2b1f99",
- stroke="#21147d",
- color="#ffffff",
- font_size=18,
- roughness=0,
- ),
- bound_box(
- 990,
- 245,
- 440,
- 640,
- "统一对话式任务界面\n\n最近对话\n患者原话 + 助手理解\n\n已收集信息\n完整度 · 缺失项 · 修改入口\n\n当前唯一任务\n一次只追问或处理一件事\n\n语音主输入\n文字备选 · 快捷选项 · 上传\n\n完整性与安全检查\n通过后才查询、分析或执行\n\n操作记录\n转写、规则与工具步骤可展开",
- fill="#f7f8ff",
- stroke="#2b1f99",
- color="#21147d",
- font_size=18,
- roughness=0,
- ),
- bound_box(
- 990,
- 970,
- 440,
- 185,
- "P0 固定运行上下文\n\n单家指定医院|固定本人 Mock 患者\n演示语音转写|FastGPT Workflow\n挂号与报告 Mock MCP|现有舌诊 MCP",
- fill="#f7fbff",
- stroke="#9ea9cc",
- font_size=16,
- ),
- bound_box(
- 1550,
- 165,
- 560,
- 250,
- "智能分诊挂号|SMART_REGISTRATION\n\n急诊安全门禁 → 挂号条件补槽\n→ 医生与号源选择 → 独立确认\n→ Mock 锁号 / 挂号 / 支付\n→ 挂号结果",
- fill="#f1f0ff",
- stroke="#2b1f99",
- color="#21147d",
- font_size=18,
- ),
- bound_box(
- 1550,
- 485,
- 560,
- 250,
- "报告智能解读|REPORT_INTERPRETATION\n\n选择院内报告或上传 → 归属确认\n→ 文件质量 / OCR / 风险检查\n→ 指标与术语标准化\n→ 结构化解读结果",
- fill="#eef5ff",
- stroke="#4f7fdb",
- color="#163d78",
- font_size=18,
- ),
- bound_box(
- 1550,
- 805,
- 560,
- 250,
- "中医舌诊|TCM_TONGUE_ASSESSMENT\n\n体质症候问答 → 阶段性保存\n→ 舌象拍摄与图片质控\n→ 调用现有舌诊 MCP\n→ 问答与舌象融合结果",
- fill="#fff2f1",
- stroke="#d45b61",
- color="#7c3036",
- font_size=18,
- ),
- bound_box(
- 1550,
- 1120,
- 560,
- 175,
- "共同异常与恢复\n信息不足:继续追问|图片问题:重新上传\n工具失败:保留上下文重试|医疗风险:中止普通流程\n必要时转急诊、人工或线下处理",
- fill="#fff4e8",
- stroke="#f0a020",
- color="#754500",
- font_size=16,
- ),
- bound_box(
- 2250,
- 430,
- 390,
- 330,
- "统一结果与 UI 组件\n\n挂号确认与结果卡\n报告风险与指标卡\n舌诊特征与建议卡\n失败 / 重试 / 转人工状态\n\n任务结果不只保留在对话中",
- fill="#e9fbfc",
- stroke="#2fc9d2",
- color="#115e64",
- font_size=18,
- ),
- bound_box(
- 2750,
- 430,
- 360,
- 220,
- "健康记录\n\n保存完成结果\n保存未完成任务\n按挂号 / 报告 / 中医分类\n支持查看与继续",
- fill="#ffffff",
- stroke="#2b1f99",
- color="#21147d",
- font_size=18,
- ),
- bound_box(
- 2750,
- 760,
- 360,
- 220,
- "首页状态与待办\n\n明日预约\n新报告或解读结果\n继续未完成舌诊\n失败任务重试",
- fill="#f1f0ff",
- stroke="#2b1f99",
- color="#21147d",
- font_size=18,
- ),
- free_text(
- 2225,
- 1080,
- 800,
- 60,
- "结果回流后,患者可从首页待办或健康记录继续原任务,也可以重新发起服务。",
- size=17,
- color="#69708a",
- align="center",
- ),
- arrow(410, 290, [[0, 0], [90, 180]], color="#2b1f99"),
- arrow(410, 465, [[0, 0], [90, 60]], color="#69708a"),
- arrow(410, 640, [[0, 0], [90, -60]], color="#2fc9d2"),
- arrow(410, 815, [[0, 0], [90, -180]], color="#69708a"),
- arrow(880, 565, [[0, 0], [110, 0]], color="#2b1f99", stroke_width=3),
- arrow(1430, 430, [[0, 0], [120, -140]], color="#2b1f99"),
- arrow(1430, 565, [[0, 0], [120, 45]], color="#4f7fdb"),
- arrow(1430, 700, [[0, 0], [120, 230]], color="#d45b61"),
- arrow(2110, 290, [[0, 0], [140, 240]], color="#2b1f99"),
- arrow(2110, 610, [[0, 0], [140, 0]], color="#4f7fdb"),
- arrow(2110, 930, [[0, 0], [140, -240]], color="#d45b61"),
- arrow(1825, 415, [[0, 0], [0, 705]], color="#f0a020", dashed=True),
- arrow(1825, 735, [[0, 0], [0, 385]], color="#f0a020", dashed=True),
- arrow(1825, 1055, [[0, 0], [0, 65]], color="#f0a020", dashed=True),
- arrow(1550, 1210, [[0, 0], [-120, 0], [-120, -325]], color="#f0a020", dashed=True),
- arrow(2640, 595, [[0, 0], [110, -55]], color="#2fc9d2", stroke_width=3),
- arrow(2930, 650, [[0, 0], [0, 110]], color="#2b1f99", stroke_width=3),
- arrow(
- 2930,
- 980,
- [[0, 0], [0, 390], [-2690, 390], [-2690, -105]],
- color="#2b1f99",
- dashed=True,
- ),
- ]
- flattened: list[dict] = []
- for element in elements:
- if isinstance(element, (tuple, list)):
- flattened.extend(element)
- else:
- flattened.append(element)
- result = {
- "type": "excalidraw",
- "version": 2,
- "source": "https://excalidraw.com",
- "elements": flattened,
- "appState": {
- "viewBackgroundColor": "#f7fbff",
- "gridSize": 20,
- },
- "files": {},
- }
- assert all(isinstance(element, dict) for element in flattened)
- assert len({element["id"] for element in flattened}) == len(flattened)
- assert all(
- element.get("fontFamily") == 5
- for element in flattened
- if element.get("type") == "text"
- )
- for element in flattened:
- if element.get("type") == "rectangle":
- assert element.get("boundElements"), f"Unbound rectangle: {element['id']}"
- text_id = element["boundElements"][0]["id"]
- child = next(item for item in flattened if item["id"] == text_id)
- assert child.get("containerId") == element["id"]
- assert child.get("groupIds") == element.get("groupIds")
- OUTPUT.write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
- json.loads(OUTPUT.read_text(encoding="utf-8"))
- print(OUTPUT)
|