render_ai_platform_architecture.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273
  1. from PIL import Image, ImageDraw, ImageFont
  2. from pathlib import Path
  3. import textwrap
  4. ROOT = Path(__file__).resolve().parents[1]
  5. OUT = ROOT / "docs/assets/ai-platform-module-architecture-map.png"
  6. W, H = 5250, 3300
  7. BG = "#F6FAFF"
  8. INK = "#102A5C"
  9. MUTED = "#536B90"
  10. BLUE = "#0D63CE"
  11. CYAN = "#00A7C7"
  12. PURPLE = "#6B3FD6"
  13. GREEN = "#10A37F"
  14. ORANGE = "#F28C28"
  15. RED = "#D94B4B"
  16. LINE = "#B9D4F4"
  17. CARD = "#FFFFFF"
  18. def font(size, bold=False):
  19. candidates = [
  20. "/System/Library/Fonts/Hiragino Sans GB.ttc",
  21. "/System/Library/Fonts/STHeiti Medium.ttc" if bold else "/System/Library/Fonts/STHeiti Light.ttc",
  22. "/System/Library/Fonts/Supplemental/Arial Unicode.ttf",
  23. ]
  24. for p in candidates:
  25. if Path(p).exists():
  26. return ImageFont.truetype(p, size=size, index=0)
  27. return ImageFont.load_default()
  28. F = {
  29. "title": font(74, True),
  30. "sub": font(34),
  31. "h1": font(42, True),
  32. "h2": font(34, True),
  33. "body": font(27),
  34. "small": font(23),
  35. "tiny": font(20),
  36. "mono": font(22),
  37. }
  38. img = Image.new("RGB", (W, H), BG)
  39. draw = ImageDraw.Draw(img)
  40. def text_size(s, ft):
  41. box = draw.textbbox((0, 0), s, font=ft)
  42. return box[2] - box[0], box[3] - box[1]
  43. def wrap_text(text, ft, max_w):
  44. lines = []
  45. for para in text.split("\n"):
  46. current = ""
  47. for ch in para:
  48. if text_size(current + ch, ft)[0] <= max_w:
  49. current += ch
  50. else:
  51. if current:
  52. lines.append(current)
  53. current = ch
  54. if current:
  55. lines.append(current)
  56. return lines
  57. def rounded(x, y, w, h, fill=CARD, outline=LINE, radius=28, width=3):
  58. draw.rounded_rectangle([x, y, x + w, y + h], radius=radius, fill=fill, outline=outline, width=width)
  59. def shadow_card(x, y, w, h, fill=CARD, outline=LINE, radius=28):
  60. draw.rounded_rectangle([x + 10, y + 12, x + w + 10, y + h + 12], radius=radius, fill="#D8E8FA")
  61. rounded(x, y, w, h, fill, outline, radius)
  62. def label(text, x, y, fill=INK, ft=None, anchor=None):
  63. draw.text((x, y), text, font=ft or F["body"], fill=fill, anchor=anchor)
  64. def pill(text, x, y, fill, fg="#FFFFFF", pad_x=22, pad_y=10, ft=None):
  65. ft = ft or F["small"]
  66. tw, th = text_size(text, ft)
  67. draw.rounded_rectangle([x, y, x + tw + pad_x * 2, y + th + pad_y * 2], radius=22, fill=fill)
  68. label(text, x + pad_x, y + pad_y - 2, fg, ft)
  69. return tw + pad_x * 2
  70. def card(title, lines, x, y, w, h, accent=BLUE, tag=None, subtitle=None):
  71. shadow_card(x, y, w, h, CARD, "#A9C8EE", 22)
  72. draw.rounded_rectangle([x, y, x + w, y + 66], radius=22, fill=accent)
  73. draw.rectangle([x, y + 42, x + w, y + 66], fill=accent)
  74. label(title, x + 24, y + 14, "#FFFFFF", F["h2"])
  75. if tag:
  76. pill(tag, x + w - text_size(tag, F["tiny"])[0] - 84, y + 14, "#FFFFFF", accent, 16, 8, F["tiny"])
  77. ty = y + 88
  78. if subtitle:
  79. label(subtitle, x + 24, ty, accent, F["small"])
  80. ty += 38
  81. for ln in lines:
  82. for wrapped in wrap_text("• " + ln, F["small"], w - 56):
  83. label(wrapped, x + 28, ty, INK, F["small"])
  84. ty += 34
  85. ty += 2
  86. def layer_band(idx, name, desc, y, h, color):
  87. draw.rounded_rectangle([80, y, W - 80, y + h], radius=30, fill="#EEF6FF", outline="#C8DDF5", width=2)
  88. draw.rounded_rectangle([96, y + 18, 310, y + h - 18], radius=24, fill=color)
  89. label(idx, 203, y + 48, "#FFFFFF", F["h1"], "ma")
  90. label(name, 340, y + 30, INK, F["h2"])
  91. label(desc, 340, y + 76, MUTED, F["small"])
  92. def arrow(x1, y1, x2, y2, color=BLUE, width=8):
  93. draw.line([x1, y1, x2, y2], fill=color, width=width)
  94. import math
  95. ang = math.atan2(y2 - y1, x2 - x1)
  96. size = 24
  97. p1 = (x2, y2)
  98. p2 = (x2 - size * math.cos(ang - 0.45), y2 - size * math.sin(ang - 0.45))
  99. p3 = (x2 - size * math.cos(ang + 0.45), y2 - size * math.sin(ang + 0.45))
  100. draw.polygon([p1, p2, p3], fill=color)
  101. # Title
  102. label("医梦 AI 中台 Maven 模块演进与依赖关系图", W // 2, 70, INK, F["title"], "ma")
  103. label("现状 emoon-mcp-api 先不动代码,仅按目标拆分为 ai-agent-api / ai-card-api / ai-mcp-api 进行架构设计", W // 2, 154, MUTED, F["sub"], "ma")
  104. pill("大厂技术培训风", 350, 230, BLUE)
  105. pill("高密度强层级", 660, 230, CYAN)
  106. pill("现状 + 规划同屏", 965, 230, PURPLE)
  107. pill("初级工程师可读", 1325, 230, GREEN)
  108. shadow_card(1960, 215, 3160, 92, "#FFFFFF", "#8DB7E8", 22)
  109. label("依赖红线", 2000, 238, RED, F["small"])
  110. compact_rules = [
  111. "入口层只依赖 API 契约",
  112. "Card 不直连 MCP",
  113. "计量先落库再入账",
  114. "敏感数据不进外部日志/prompt 明文",
  115. "三开发优先模块化单体",
  116. ]
  117. rx = 2200
  118. for r in compact_rules:
  119. rx += pill(r, rx, 232, "#EAF4FF", INK, 14, 7, F["tiny"]) + 18
  120. # L0 external touchpoints
  121. layer_band("L0", "业务触点层", "用户和业务系统进入 AI 中台的入口,不直接依赖底层实现模块。", 330, 310, BLUE)
  122. l0 = [
  123. ("患者端 / 小程序", ["问诊、报告解读、随访、建档"]),
  124. ("医生工作站", ["舌诊、面诊、病历生成、医嘱建议"]),
  125. ("院内运营后台", ["能力配置、合同、套餐、统计报表"]),
  126. ("医院系统集成", ["HIS / EMR / LIS / PACS 调用"]),
  127. ("IoMT / 语音设备", ["语音陪诊、床旁设备、可穿戴数据"]),
  128. ]
  129. x = 520
  130. for title, lines in l0:
  131. card(title, lines, x, 420, 850, 150, BLUE, "外部调用")
  132. x += 900
  133. # L1 application entry
  134. layer_band("L1", "应用入口层", "保持当前业务入口,向下只调用领域 API,不跨层访问具体实现。", 690, 330, CYAN)
  135. l1 = [
  136. ("emoon-openplatform", ["开放接口、SSE 会话、院内集成入口", "当前已依赖 emoon-mcp-api"]),
  137. ("emoon-admin", ["平台运营后台、租户配置、能力包管理", "三期补齐计费、统计、审计运营页"]),
  138. ("emoon-app / 小程序端", ["患者问诊、报告、随访、AI 卡片交互", "只认业务 API 和 SSE 事件"]),
  139. ("emoon-extend / worker", ["文件处理、异步任务、外部通道适配", "后续承接 OCR/ASR/回调类任务"]),
  140. ]
  141. x = 540
  142. for title, lines in l1:
  143. card(title, lines, x, 780, 1040, 170, CYAN, "入口")
  144. x += 1140
  145. # L2 current maven baseline and split
  146. layer_band("L2", "当前 Maven 基座与目标 API 拆分", "不做微服务先行;先把 API 契约从 emoon-mcp-api 中按职责拆清楚。", 1070, 610, PURPLE)
  147. card("当前 emoon-infra / emoon-modules-api", [
  148. "emoon-chat-api:聊天/消息领域契约",
  149. "emoon-system-api:租户、用户、字典、权限基础能力",
  150. "emoon-knowledge-api:知识库与文档能力契约",
  151. "emoon-mcp-api:当前混放 Agent、Card、MCP、三方 SDK 依赖",
  152. ], 520, 1180, 1250, 360, PURPLE, "现状")
  153. card("emoon-mcp-api(现状待拆)", [
  154. "包含 AgentEngine / AgentRequest / AgentResponse",
  155. "包含 AiConversation / AiCardInstance 等领域对象",
  156. "依赖偏重:web、sse、websocket、langchain4j、pdfbox、tika 等",
  157. "问题:API 契约和实现依赖混在一起,调用边界不清晰",
  158. ], 1910, 1150, 1320, 420, RED, "不要先动代码")
  159. target_cards = [
  160. ("ai-agent-api", ["统一 AI 调用契约", "会话、SSE、编排、Dify/DirectLLM 引擎抽象", "只放 DTO / SPI / 轻量接口"]),
  161. ("ai-card-api", ["AI 卡片契约", "卡片定义、实例、事件、状态、渲染数据", "面向前端和业务场景复用"]),
  162. ("ai-mcp-api", ["MCP 工具契约", "Tool Registry、Tool Call、权限、审计元数据", "仅表达工具协议,不承载 Agent 编排"]),
  163. ]
  164. for i, (t, lines) in enumerate(target_cards):
  165. card(t, lines, 3420, 1125 + i * 165, 1500, 140, [BLUE, GREEN, ORANGE][i], "目标 API")
  166. arrow(1775, 1360, 1900, 1360, RED, 10)
  167. arrow(3235, 1255, 3405, 1195, BLUE, 8)
  168. arrow(3235, 1360, 3405, 1360, GREEN, 8)
  169. arrow(3235, 1465, 3405, 1525, ORANGE, 8)
  170. label("拆分原则:API 包只定义契约;实现、持久化、三方 SDK 依赖下沉到对应业务模块", 3460, 1600, RED, F["small"])
  171. # L3 target implementation modules
  172. layer_band("L3", "目标领域实现模块", "仍保持 Maven 模块化单体优先,模块内高内聚,模块间通过 API 契约调用。", 1740, 620, GREEN)
  173. impl = [
  174. ("ai-agent", BLUE, ["实现 ai-agent-api", "AgentEngineFactory、DifyEngine、DirectLLMEngine", "SSE 事件、幂等、降级、Outbox"]),
  175. ("ai-card", GREEN, ["实现 ai-card-api", "卡片定义/实例/版本/事件流", "卡片只经 Agent 调 MCP,不直连工具"]),
  176. ("ai-mcp", ORANGE, ["实现 ai-mcp-api", "工具注册、权限、调用路由、审计", "院内工具适配 HIS/EMR/LIS/PACS"]),
  177. ("ai-meter", PURPLE, ["计量事件中心", "Token/次数/分钟/项目/年费统一归集", "8 状态机、重放、幂等去重"]),
  178. ("ai-billing", RED, ["合同、能力值包、预扣款、结算", "pricing snapshot、special adjustment", "合理不限量与跨账户划拨"]),
  179. ("ai-knowledge", CYAN, ["知识库、模板、病种规则、提示词资产", "对接现有 emoon-knowledge-api", "支撑报告、病历、质控场景"]),
  180. ]
  181. x0, y0 = 520, 1850
  182. for i, (t, c, lines) in enumerate(impl):
  183. x = x0 + (i % 3) * 1540
  184. y = y0 + (i // 3) * 235
  185. card(t, lines, x, y, 1420, 190, c, "实现模块")
  186. for sx, sy, tx, ty, c in [
  187. (4170, 1265, 1230, 1825, BLUE),
  188. (4170, 1425, 2770, 1825, GREEN),
  189. (4170, 1590, 4310, 1825, ORANGE),
  190. (1250, 2090, 2770, 2090, PURPLE),
  191. (2770, 2090, 4310, 2090, RED),
  192. ]:
  193. arrow(sx, sy, tx, ty, c, 7)
  194. # L4 future independent boundaries
  195. layer_band("L4", "未来可独立部署边界", "三开发阶段不建议先拆微服务;当吞吐、隔离或交付责任成熟后,再按这些边界拆。", 2420, 360, ORANGE)
  196. future = [
  197. ("MCP Tool Server", ["医院内网工具调用隔离", "工具权限、审计、限流独立"]),
  198. ("AudioStreamGateway", ["电话/语音陪诊流式接入", "ASR/TTS、按分钟计量"]),
  199. ("IoMTEventIngestion", ["设备事件接入与清洗", "告警、趋势、随访触发"]),
  200. ("OutboundFollowup", ["随访任务、回访、消息触达", "失败重试与运营统计"]),
  201. ("File/OCR/ASR Worker", ["报告、影像、病历附件异步处理", "长任务与主链路解耦"]),
  202. ]
  203. x = 520
  204. for title, lines in future:
  205. card(title, lines, x, 2510, 850, 165, ORANGE, "可拆服务")
  206. x += 900
  207. # L5 external systems + data base
  208. layer_band("L5", "外部系统与数据可靠性底座", "所有调用、计量、账务、审计必须形成闭环,避免医疗场景不可追溯。", 2840, 360, BLUE)
  209. base = [
  210. ("AI 引擎", ["Dify Workflow / DirectLLM / Vision / Mock"]),
  211. ("院内系统", ["HIS / EMR / LIS / PACS / 预约挂号"]),
  212. ("数据存储", ["MySQL:业务状态", "Redis:锁/限流/短期缓存"]),
  213. ("可靠性", ["Outbox:异步一致性", "幂等键:API/Meter/Tool 分层"]),
  214. ("账务审计", ["Credit Ledger:能力值流水", "Audit/Trace:全链路追踪"]),
  215. ]
  216. x = 520
  217. for title, lines in base:
  218. card(title, lines, x, 2930, 850, 165, BLUE, "底座")
  219. x += 900
  220. shadow_card(80, 3180, W - 160, 82, "#EAF4FF", "#A9C8EE", 22)
  221. label("读图顺序:先看 L2 的 emoon-mcp-api 现状待拆,再看右侧三类目标 API,最后沿箭头进入 L3 实现模块和 L5 计量/账务/审计闭环。", W // 2, 3210, INK, F["small"], "ma")
  222. OUT.parent.mkdir(parents=True, exist_ok=True)
  223. img.save(OUT, "PNG", optimize=True)
  224. print(OUT)