"""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)