"""Build the concise P0 conversation-first interaction overview.""" 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。" ) source = json.loads(TEMPLATE.read_text(encoding="utf-8")) rect_template = next(element for element in source["elements"] if element["type"] == "rectangle") bound_text_template = next(element for element in source["elements"] if element["type"] == "text") free_text_template = next(element for element in source["elements"] if element["type"] == "text") arrow_template = next(element for element in source["elements"] if element["type"] == "arrow") sequence = 5000 def next_value() -> int: global sequence sequence += 1 return sequence def box( x: int, y: int, width: int, height: int, text: str, *, fill: str, stroke: str, color: str = "#16133a", size: int = 18, ) -> list[dict]: rect = copy.deepcopy(rect_template) label = copy.deepcopy(bound_text_template) rect_id = f"box-{next_value()}" text_id = f"text-{next_value()}" group_id = f"group-{next_value()}" seed = next_value() rect.update( { "id": rect_id, "x": x, "y": y, "width": width, "height": height, "strokeColor": stroke, "backgroundColor": fill, "groupIds": [group_id], "boundElements": [{"type": "text", "id": text_id}], "seed": seed, "versionNonce": seed + 1, } ) line_count = text.count("\n") + 1 text_height = min(int(line_count * size * 1.4 + 8), height - 16) label.update( { "id": text_id, "x": x + 14, "y": y + (height - text_height) / 2, "width": width - 28, "height": text_height, "text": text, "fontSize": size, "fontFamily": 5, "strokeColor": color, "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) -> dict: label = copy.deepcopy(free_text_template) seed = next_value() label.update( { "id": f"label-{next_value()}", "x": x, "y": y, "width": width, "height": height, "text": text, "fontSize": size, "fontFamily": 5, "strokeColor": color, "textAlign": "left", "containerId": None, "groupIds": [], "seed": seed, "versionNonce": seed + 1, } ) return label def arrow(x: int, y: int, points: list[list[int]], *, color: str, dashed: bool = False) -> dict: element = copy.deepcopy(arrow_template) xs = [point[0] for point in points] ys = [point[1] for point in points] seed = next_value() element.update( { "id": f"arrow-{next_value()}", "x": x, "y": y, "width": max(xs) - min(xs), "height": max(ys) - min(ys), "points": points, "strokeColor": color, "strokeWidth": 3, "strokeStyle": "dashed" if dashed else "solid", "startBinding": None, "endBinding": None, "groupIds": [], "seed": seed, "versionNonce": seed + 1, } ) return element elements: list[dict | list[dict]] = [ free_text( 80, 40, 2200, 60, "医梦患者智能服务门户|P0 对话式三智能体总交互流程", size=34, color="#21147d", ), free_text( 80, 100, 2200, 40, "单医院 · 固定 Mock 患者 · 语音优先 · 三个智能体共用一个对话任务壳", size=18, color="#69708a", ), box( 80, 230, 360, 560, "门户入口\n\n统一语音入口\n不知道使用哪个服务\n\n三项服务卡片\n明确选择挂号、报告或舌诊\n\n患者状态与待办\n继续报告或舌诊任务", fill="#f1f0ff", stroke="#2b1f99", color="#21147d", size=19, ), box( 570, 230, 480, 560, "统一对话式任务壳\n\n保留患者原话与助手回复\n\n语音主输入\n文字、快捷选项、上传为辅\n\n智能体连续追问补齐信息\n\n只在必要时出现\n号源选择 / 风险提醒 / 操作确认 / 结果\n\n三个智能体独立保存任务状态", fill="#ffffff", stroke="#2b1f99", color="#21147d", size=19, ), box( 1210, 170, 620, 240, "挂号智能体\n\n语音描述完整诉求\n→ 只追问缺失医生\n→ 查询并选择 Mock 号源\n→ 确认 Mock 挂号\n\n安全支线:胸痛 + 呼吸困难 → 中止普通挂号", fill="#f1f0ff", stroke="#2b1f99", color="#21147d", size=18, ), box( 1210, 480, 620, 240, "报告解读智能体\n\n对话内上传报告\n→ OCR 与指标分析进度\n→ 结构化 Markdown 解读\n→ 风险提醒 + 异常指标表 + 行动建议", fill="#eef7ff", stroke="#4f7fdb", color="#254b8d", size=18, ), box( 1210, 790, 620, 240, "中医舌诊智能体\n\n对话内完成体质与症候问答\n→ 上传舌象\n→ 调用现有舌诊 MCP\n→ 返回问答 + 舌象综合结果", fill="#fff4ea", stroke="#d8874c", color="#8a4d25", size=18, ), box( 2010, 390, 430, 430, "统一结果回流\n\n挂号:Mock 预约结果\n\n报告:Markdown 解读与风险提醒\n\n舌诊:体质 / 证候倾向与健康建议\n\n完成结果进入健康记录\n未完成任务回到首页待办", fill="#e9fbfc", stroke="#2fc9d2", color="#115e64", size=19, ), arrow(440, 510, [[0, 0], [130, 0]], color="#2b1f99"), arrow(1050, 510, [[0, 0], [80, 0], [80, -220], [160, -220]], color="#2b1f99"), arrow(1050, 510, [[0, 0], [160, 90]], color="#4f7fdb"), arrow(1050, 510, [[0, 0], [80, 0], [80, 400], [160, 400]], color="#d8874c"), arrow(1830, 290, [[0, 0], [90, 0], [90, 315], [180, 315]], color="#2b1f99"), arrow(1830, 600, [[0, 0], [180, 5]], color="#4f7fdb"), arrow(1830, 910, [[0, 0], [90, 0], [90, -305], [180, -305]], color="#d8874c"), arrow(2225, 820, [[0, 0], [0, 290], [-1965, 290], [-1965, -320]], color="#2fc9d2", 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") 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)