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