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