# -*- coding: utf-8 -*- """生成《智慧医院 AI 影像诊断与电子病历辅助决策系统》需求答辩 PPT。""" from __future__ import annotations from pathlib import Path from pptx import Presentation from pptx.dml.color import RGBColor from pptx.enum.shapes import MSO_CONNECTOR, MSO_SHAPE from pptx.enum.text import MSO_ANCHOR, PP_ALIGN from pptx.oxml.ns import qn from pptx.util import Emu, Inches, Pt # ---------- 主题 ---------- DARK = RGBColor(0x0B, 0x3A, 0x5C) TEAL = RGBColor(0x1A, 0xBC, 0x9C) TEAL_DARK = RGBColor(0x12, 0x8A, 0x72) LIGHT = RGBColor(0xF5, 0xF8, 0xFB) WHITE = RGBColor(0xFF, 0xFF, 0xFF) GRAY = RGBColor(0x5A, 0x6A, 0x7A) GRAY_LIGHT = RGBColor(0xE8, 0xEE, 0xF3) ORANGE = RGBColor(0xE6, 0x7E, 0x22) RED_SOFT = RGBColor(0xC0, 0x39, 0x2B) CARD_BG = RGBColor(0xFF, 0xFF, 0xFF) ROW_ALT = RGBColor(0xEE, 0xF6, 0xF4) FONT = "微软雅黑" SLIDE_W = Inches(13.333) SLIDE_H = Inches(7.5) OUT_PATH = Path(__file__).resolve().parent.parent / "智慧医院-需求答辩PPT.pptx" MEMBERS = [ ("成员 A", "项目负责人 / 需求分析", "需求梳理、文档、答辩统筹、验收", "全局 P0–P2", "需求规格、答辩 PPT、演示剧本"), ("成员 B", "前端负责人", "布局/路由守卫/仪表盘/通用组件", "F-01/06/08/14/16", "页面路由、ECharts、交互体验"), ("成员 C", "前端业务开发", "患者/预约/病历/影像结果展示", "F-02/03/05/07", "业务页、360°档案、报告弹窗"), ("成员 D", "业务后端负责人", "JWT、权限、统一响应、用户管理", "F-01/06/11/12", "REST、Security、种子账号"), ("成员 E", "后端业务 + 数据", "CRUD、异步诊断编排、降级", "F-02–05/07/13", "JPA、状态机、AI 客户端"), ("成员 F", "AI 微服务", "FastAPI、YOLO、RAG/LLM、知识库", "F-04/05/09–12", "检测报告、决策、权重管理"), ] def set_run_font(run, size=14, bold=False, color=DARK, font_name=FONT): run.font.size = Pt(size) run.font.bold = bold run.font.color.rgb = color run.font.name = font_name rPr = run._r.get_or_add_rPr() ea = rPr.get_or_add_ea() if hasattr(rPr, "get_or_add_ea") else None # east asian font from lxml import etree for child in list(rPr): if child.tag.endswith("}ea") or child.tag.endswith("}cs"): pass ea_elem = rPr.find(qn("a:ea")) if ea_elem is None: ea_elem = etree.SubElement(rPr, qn("a:ea")) ea_elem.set("typeface", font_name) def add_textbox(slide, left, top, width, height, text, size=14, bold=False, color=DARK, align=PP_ALIGN.LEFT, font_name=FONT): box = slide.shapes.add_textbox(left, top, width, height) tf = box.text_frame tf.word_wrap = True p = tf.paragraphs[0] p.alignment = align run = p.add_run() run.text = text set_run_font(run, size=size, bold=bold, color=color, font_name=font_name) return box def add_paragraph(tf, text, size=13, bold=False, color=DARK, align=PP_ALIGN.LEFT, space_before=0, space_after=4): p = tf.add_paragraph() if tf.paragraphs[0].text or len(tf.paragraphs) > 1 or (tf.paragraphs[0].runs) else tf.paragraphs[0] if p.text and p.runs: p = tf.add_paragraph() p.alignment = align p.space_before = Pt(space_before) p.space_after = Pt(space_after) run = p.add_run() run.text = text set_run_font(run, size=size, bold=bold, color=color) return p def fill_shape(shape, color: RGBColor): shape.fill.solid() shape.fill.fore_color.rgb = color shape.line.fill.background() def add_rect(slide, left, top, width, height, fill=TEAL, line=None): shape = slide.shapes.add_shape(MSO_SHAPE.ROUNDED_RECTANGLE, left, top, width, height) fill_shape(shape, fill) if line is not None: shape.line.color.rgb = line shape.line.width = Pt(1) else: shape.line.fill.background() # softer corners try: shape.adjustments[0] = 0.08 except Exception: pass return shape def add_rect_sharp(slide, left, top, width, height, fill=DARK): shape = slide.shapes.add_shape(MSO_SHAPE.RECTANGLE, left, top, width, height) fill_shape(shape, fill) return shape def set_shape_text(shape, text, size=12, bold=False, color=WHITE, align=PP_ALIGN.CENTER, valign=MSO_ANCHOR.MIDDLE): tf = shape.text_frame tf.word_wrap = True tf.auto_size = None tf.paragraphs[0].alignment = align shape.text_frame.paragraphs[0].clear() if False else None p = tf.paragraphs[0] p.alignment = align # clear existing for r in list(p.runs): r.text = "" if p.runs: run = p.runs[0] run.text = text else: run = p.add_run() run.text = text set_run_font(run, size=size, bold=bold, color=color) tf.paragraphs[0].alignment = align try: tf._txBody.bodyPr.set("anchor", "ctr" if valign == MSO_ANCHOR.MIDDLE else "t") except Exception: pass def clear_and_set_text(shape, lines, size=12, bold=False, color=WHITE, align=PP_ALIGN.CENTER): """lines: str or list of (text, size, bold, color).""" tf = shape.text_frame tf.word_wrap = True if isinstance(lines, str): lines = [(lines, size, bold, color)] # first paragraph p0 = tf.paragraphs[0] p0.alignment = align # remove extra paragraphs for i, item in enumerate(lines): if len(item) == 4: t, s, b, c = item else: t = item[0] s = item[1] if len(item) > 1 else size b = item[2] if len(item) > 2 else bold c = item[3] if len(item) > 3 else color if i == 0: p = p0 if p.runs: p.runs[0].text = t set_run_font(p.runs[0], size=s, bold=b, color=c) for extra in p.runs[1:]: extra.text = "" else: run = p.add_run() run.text = t set_run_font(run, size=s, bold=b, color=c) else: p = tf.add_paragraph() p.alignment = align run = p.add_run() run.text = t set_run_font(run, size=s, bold=b, color=c) try: tf._txBody.bodyPr.set("anchor", "ctr") except Exception: pass def add_footer(slide, page_no, total=18): add_rect_sharp(slide, 0, Inches(7.15), SLIDE_W, Inches(0.35), fill=DARK) add_textbox( slide, Inches(0.4), Inches(7.18), Inches(8), Inches(0.28), "智慧医院 · 需求答辩 | 仅供教学实训演示", size=10, color=WHITE, ) add_textbox( slide, Inches(11.2), Inches(7.18), Inches(1.7), Inches(0.28), f"{page_no} / {total}", size=10, color=WHITE, align=PP_ALIGN.RIGHT, ) def add_header_bar(slide, title, subtitle=None): add_rect_sharp(slide, 0, 0, SLIDE_W, Inches(0.95), fill=DARK) add_rect_sharp(slide, 0, Inches(0.95), SLIDE_W, Inches(0.08), fill=TEAL) add_textbox(slide, Inches(0.45), Inches(0.22), Inches(12), Inches(0.45), title, size=24, bold=True, color=WHITE) if subtitle: add_textbox(slide, Inches(0.45), Inches(0.58), Inches(12), Inches(0.3), subtitle, size=11, color=TEAL) def add_bg(slide): add_rect_sharp(slide, 0, 0, SLIDE_W, SLIDE_H, fill=LIGHT) def style_table(table, header=True, col_widths=None): for r_idx, row in enumerate(table.rows): for c_idx, cell in enumerate(row.cells): cell.vertical_anchor = MSO_ANCHOR.MIDDLE # background tc = cell._tc tcPr = tc.get_or_add_tcPr() # remove existing solid fill for child in list(tcPr): if child.tag.endswith("}solidFill") or child.tag.endswith("}//a:solidFill"): tcPr.remove(child) from lxml import etree solid = etree.SubElement(tcPr, qn("a:solidFill")) srgb = etree.SubElement(solid, qn("a:srgbClr")) if header and r_idx == 0: srgb.set("val", "0B3A5C") elif r_idx % 2 == 0: srgb.set("val", "EEF6F4") else: srgb.set("val", "FFFFFF") for p in cell.text_frame.paragraphs: p.alignment = PP_ALIGN.CENTER for run in p.runs: run.font.name = FONT run.font.size = Pt(11 if r_idx > 0 else 12) run.font.bold = r_idx == 0 if header and r_idx == 0: run.font.color.rgb = WHITE else: run.font.color.rgb = DARK rPr = run._r.get_or_add_rPr() from lxml import etree as ET ea = rPr.find(qn("a:ea")) if ea is None: ea = ET.SubElement(rPr, qn("a:ea")) ea.set("typeface", FONT) if col_widths: for i, w in enumerate(col_widths): for cell in table.columns[i].cells if False else []: pass table.columns[i].width = w def fill_table(table, data, header=True): for r, row_data in enumerate(data): for c, val in enumerate(row_data): cell = table.cell(r, c) cell.text = str(val) for p in cell.text_frame.paragraphs: for run in p.runs: run.font.name = FONT style_table(table, header=header) def add_table(slide, left, top, width, height, rows, cols, data, col_widths=None): table_shape = slide.shapes.add_table(rows, cols, left, top, width, height) table = table_shape.table if col_widths: for i, w in enumerate(col_widths): table.columns[i].width = w fill_table(table, data) return table def card(slide, left, top, width, height, title, body, accent=TEAL): shape = add_rect(slide, left, top, width, height, fill=WHITE, line=GRAY_LIGHT) bar = add_rect_sharp(slide, left, top, Inches(0.12), height, fill=accent) add_textbox(slide, left + Inches(0.25), top + Inches(0.12), width - Inches(0.35), Inches(0.35), title, size=14, bold=True, color=DARK) box = slide.shapes.add_textbox(left + Inches(0.25), top + Inches(0.45), width - Inches(0.4), height - Inches(0.55)) tf = box.text_frame tf.word_wrap = True p = tf.paragraphs[0] run = p.add_run() run.text = body set_run_font(run, size=11, color=GRAY) return shape # ==================== 各页 ==================== def slide_cover(prs): slide = prs.slides.add_slide(prs.slide_layouts[6]) add_rect_sharp(slide, 0, 0, SLIDE_W, SLIDE_H, fill=DARK) # decorative shapes add_rect(slide, Inches(9.5), Inches(-0.5), Inches(5), Inches(4), fill=TEAL) s = slide.shapes[-1] try: s.adjustments[0] = 0.2 except Exception: pass # make teal semi by using another block add_rect_sharp(slide, Inches(10.8), Inches(4.2), Inches(3), Inches(4), fill=TEAL_DARK) add_rect_sharp(slide, 0, Inches(6.6), SLIDE_W, Inches(0.9), fill=TEAL) add_textbox(slide, Inches(0.7), Inches(1.3), Inches(9), Inches(0.4), "高校实训 · 需求答辩", size=16, color=TEAL) add_textbox( slide, Inches(0.7), Inches(1.9), Inches(10), Inches(1.4), "智慧医院 AI 影像诊断与\n电子病历辅助决策系统", size=32, bold=True, color=WHITE, ) add_textbox( slide, Inches(0.7), Inches(3.6), Inches(9), Inches(0.5), "Smart Hospital | 业务闭环 × 可演示 AI × 前后端分离", size=14, color=RGBColor(0xB8, 0xD4, 0xE8), ) add_textbox( slide, Inches(0.7), Inches(4.5), Inches(9), Inches(0.8), "小组组员:成员 A · 成员 B · 成员 C · 成员 D · 成员 E · 成员 F", size=13, color=WHITE, ) add_textbox(slide, Inches(0.7), Inches(5.2), Inches(8), Inches(0.4), "文档版本 v1.0 | 2026-07", size=12, color=RGBColor(0xB8, 0xD4, 0xE8)) add_textbox( slide, Inches(0.7), Inches(6.75), Inches(12), Inches(0.4), "本系统输出仅供教学实训与辅助决策演示,不能替代执业医师正式诊断", size=11, color=WHITE, ) def slide_toc(prs): slide = prs.slides.add_slide(prs.slide_layouts[6]) add_bg(slide) add_header_bar(slide, "汇报目录", "需求答辩六大板块 · 约 15 分钟讲述节奏") items = [ ("01", "背景与目标", "痛点、主题定位、建设目标与应用场景"), ("02", "角色与需求", "三角色诉求、P0/P1/P2 优先级与验收要点"), ("03", "功能需求深挖", "影像 AI、病历决策、配套业务与功能全景"), ("04", "非功能与边界", "性能/安全/可用性、约束假设与已知边界"), ("05", "方案如何落地", "逻辑架构与技术选型如何服务需求"), ("06", "组织与验收", "六人分工、演示剧本、总结与展望"), ] for i, (num, title, desc) in enumerate(items): col = i % 3 row = i // 3 left = Inches(0.5 + col * 4.2) top = Inches(1.4 + row * 2.6) add_rect(slide, left, top, Inches(3.9), Inches(2.2), fill=WHITE, line=GRAY_LIGHT) circ = slide.shapes.add_shape(MSO_SHAPE.OVAL, left + Inches(0.25), top + Inches(0.35), Inches(0.7), Inches(0.7)) fill_shape(circ, TEAL if row == 0 else DARK) clear_and_set_text(circ, num, size=14, bold=True, color=WHITE) add_textbox(slide, left + Inches(1.1), top + Inches(0.4), Inches(2.5), Inches(0.5), title, size=16, bold=True, color=DARK) add_textbox(slide, left + Inches(0.3), top + Inches(1.2), Inches(3.3), Inches(0.8), desc, size=12, color=GRAY) add_footer(slide, 2) def slide_pain(prs): slide = prs.slides.add_slide(prs.slide_layouts[6]) add_bg(slide) add_header_bar(slide, "01 项目背景与痛点", "传统教学型医院管理系统难以支撑「真实 AI + 完整业务」答辩演示") add_textbox( slide, Inches(0.5), Inches(1.2), Inches(12.3), Inches(0.4), "问题本质:业务割裂、AI 不可信、架构耦合、权限薄弱、依赖脆弱 —— 导致实训演示断链、需求无法验收。", size=13, color=GRAY, ) data = [ ["痛点", "典型表现", "对需求/答辩的影响"], ["业务割裂", "患者、影像、病历、预约缺少统一档案视图", "无法讲清「以患者为中心」闭环"], ["AI 仅假数据", "随机文案/关键字匹配,无真实检测框与模型链路", "AI 能力不可演示、不可复核"], ["技术栈陈旧", "Thymeleaf 服务端渲染,前后端耦合", "扩展难、角色权限与 SPA 体验弱"], ["权限薄弱", "仅 Session 登录判断,缺角色级接口保护", "管理员/医生/影像师诉求无法区分"], ["不可降级", "外部 AI/LLM 失败则整条链路中断", "答辩现场风险高,验收不稳定"], ] add_table( slide, Inches(0.45), Inches(1.7), Inches(12.4), Inches(4.8), 6, 3, data, col_widths=[Inches(2.2), Inches(5.5), Inches(4.7)], ) add_footer(slide, 3) def slide_goals(prs): slide = prs.slides.add_slide(prs.slide_layouts[6]) add_bg(slide) add_header_bar(slide, "01 主题定位与建设目标", "双核心链路 + 五项建设目标,定义「做什么」与「做到什么程度」") # two chain cards add_rect(slide, Inches(0.45), Inches(1.25), Inches(6.1), Inches(1.7), fill=WHITE, line=GRAY_LIGHT) add_rect_sharp(slide, Inches(0.45), Inches(1.25), Inches(6.1), Inches(0.4), fill=TEAL) add_textbox(slide, Inches(0.6), Inches(1.3), Inches(5.8), Inches(0.35), "链路一 · 医学影像 AI", size=13, bold=True, color=WHITE) add_textbox( slide, Inches(0.65), Inches(1.8), Inches(5.7), Inches(1.0), "影像检查 → YOLO 辅助读片 → 结构化诊断报告\n(检测框 · 置信度 · 所见/印象/建议分段)", size=13, color=DARK, ) add_rect(slide, Inches(6.8), Inches(1.25), Inches(6.1), Inches(1.7), fill=WHITE, line=GRAY_LIGHT) add_rect_sharp(slide, Inches(6.8), Inches(1.25), Inches(6.1), Inches(0.4), fill=DARK) add_textbox(slide, Inches(6.95), Inches(1.3), Inches(5.8), Inches(0.35), "链路二 · 电子病历辅助决策", size=13, bold=True, color=WHITE) add_textbox( slide, Inches(7.0), Inches(1.8), Inches(5.7), Inches(1.0), "病历录入 → AI 决策(治疗/用药/护理/随访)\n→ 知识库引用(RAG)与冲突/风险提示", size=13, color=DARK, ) data = [ ["建设目标", "需求含义", "可验收表现"], ["业务闭环", "登录、患者、影像、病历、预约、用户管理全覆盖", "各模块可走通主路径"], ["AI 可演示", "YOLO 真实检测 + LLM/模板报告与决策", "有框、有置信度、有分段报告"], ["前后端分离", "Vue SPA + Spring REST + FastAPI AI", "职责清晰,便于分模块讲解"], ["可离线实训", "默认 H2;AI 不可用时可降级", "无 MySQL/无 Key 仍可演示"], ["安全可讲", "JWT + 三角色 + BCrypt + 双重路由守卫", "越权访问 403,密码加密"], ] add_table( slide, Inches(0.45), Inches(3.2), Inches(12.4), Inches(3.5), 6, 3, data, col_widths=[Inches(2.2), Inches(5.5), Inches(4.7)], ) add_footer(slide, 4) def slide_scenes(prs): slide = prs.slides.add_slide(prs.slide_layouts[6]) add_bg(slide) add_header_bar(slide, "01 应用场景与项目声明", "四类角色场景覆盖日常诊疗演示路径") scenes = [ ("影像科", "RADIOLOGIST", "上传胸片等影像 → 一键 AI 诊断 → 查看 YOLO 标注框、置信度与分段报告", TEAL), ("临床医生", "DOCTOR", "书写病历(高血压/肺炎等关键词)→ 获取治疗、护理、随访建议与知识库来源", DARK), ("系统管理员", "ADMIN", "配置 LLM、管理知识库、切换/上传 YOLO 权重、管理系统用户", ORANGE), ("挂号窗口", "业务协同", "预约登记与状态流转:预约 → 确认 → 完成 / 取消 / 未到诊", TEAL_DARK), ] for i, (title, role, body, color) in enumerate(scenes): left = Inches(0.45 + (i % 2) * 6.4) top = Inches(1.25 + (i // 2) * 2.2) add_rect(slide, left, top, Inches(6.1), Inches(2.0), fill=WHITE, line=GRAY_LIGHT) add_rect_sharp(slide, left, top, Inches(0.15), Inches(2.0), fill=color) add_textbox(slide, left + Inches(0.35), top + Inches(0.25), Inches(3.5), Inches(0.4), title, size=18, bold=True, color=DARK) badge = add_rect(slide, left + Inches(3.8), top + Inches(0.28), Inches(2.0), Inches(0.35), fill=color) clear_and_set_text(badge, role, size=10, bold=True, color=WHITE) add_textbox(slide, left + Inches(0.35), top + Inches(0.85), Inches(5.5), Inches(0.95), body, size=13, color=GRAY) # declaration banner add_rect(slide, Inches(0.45), Inches(5.7), Inches(12.4), Inches(1.1), fill=RGBColor(0xFD, 0xF2, 0xE9), line=ORANGE) add_textbox( slide, Inches(0.7), Inches(5.9), Inches(12), Inches(0.8), "【项目声明】系统输出仅供教学实训与辅助决策演示,不能替代执业医师的正式诊断与医疗文书。\n涉及真实患者数据与临床部署时,需另行满足医疗信息化、隐私与合规要求。", size=13, bold=False, color=RGBColor(0x8E, 0x44, 0x0A), ) add_footer(slide, 5) def slide_roles(prs): slide = prs.slides.add_slide(prs.slide_layouts[6]) add_bg(slide) add_header_bar(slide, "02 用户角色与诉求", "角色驱动需求:权限边界清晰,才谈得上功能优先级") data = [ ["角色", "代码枚举", "核心诉求", "关键能力边界"], ["系统管理员", "ADMIN", "用户管理、LLM 配置、YOLO 权重、全局运维", "可见 AI 配置/权重/用户管理菜单"], ["临床医生", "DOCTOR", "患者与病历、辅助决策、预约、查看影像报告", "主业务读写;无系统级配置权"], ["影像医师", "RADIOLOGIST", "影像登记、上传、触发 AI、审阅标注与报告", "聚焦影像链路与诊断结果"], ["访客/未登录", "—", "仅可访问登录页", "业务路由强制跳转登录"], ] add_table( slide, Inches(0.45), Inches(1.3), Inches(12.4), Inches(3.6), 5, 4, data, col_widths=[Inches(2.0), Inches(2.2), Inches(4.8), Inches(3.4)], ) add_textbox(slide, Inches(0.5), Inches(5.15), Inches(12.3), Inches(0.35), "需求推导:前后端双重守卫(路由 meta.roles + 后端 @PreAuthorize)是 F-06 的验收基础。", size=13, bold=True, color=DARK) # three bullets points = [ "管理员配置 AI,保证「可演示」不依赖开发者本机临时改配置", "医生与影像师分权,贴合医院真实岗位,避免「全能账号」掩盖权限需求", "未登录零业务数据暴露,是安全类非功能需求的最小闭环", ] for i, t in enumerate(points): y = Inches(5.55 + i * 0.4) dot = slide.shapes.add_shape(MSO_SHAPE.OVAL, Inches(0.6), y + Inches(0.08), Inches(0.16), Inches(0.16)) fill_shape(dot, TEAL) add_textbox(slide, Inches(0.95), y, Inches(11.5), Inches(0.35), t, size=12, color=GRAY) add_footer(slide, 6) def slide_priority(prs): slide = prs.slides.add_slide(prs.slide_layouts[6]) add_bg(slide) add_header_bar(slide, "02 功能需求优先级总览", "P0 必须可验收 · P1 重要增强 · P2 体验与健壮性") # three columns blocks = [ ( "P0 必须具备", TEAL, [ "F-01 登录/登出/改密(JWT+BCrypt)", "F-02 患者 CRUD + 搜索分页 + 360°", "F-03 影像 CRUD + 文件上传", "F-04 AI 影像诊断(状态机+结果)", "F-05 病历 CRUD + AI 辅助决策", "F-06 角色权限(前后端双重)", ], ), ( "P1 重要增强", DARK, [ "F-07 预约挂号全流程与统计", "F-08 仪表盘 KPI 与趋势图", "F-09 AI 多轮对话助手", "F-10 知识库管理(RAG 引用)", "F-11 AI/LLM 配置(管理员)", "F-12 YOLO 权重管理(管理员)", ], ), ( "P2 体验健壮", ORANGE, [ "F-13 AI 不可用时业务降级", "F-14 弹窗/抽屉不被布局裁切", "F-15 报告分段可读展示", "F-16 路由过渡与看板体验", "", "→ 保障答辩现场「不断链」", ], ), ] for i, (title, color, lines) in enumerate(blocks): left = Inches(0.4 + i * 4.25) add_rect(slide, left, Inches(1.25), Inches(4.05), Inches(5.5), fill=WHITE, line=GRAY_LIGHT) add_rect_sharp(slide, left, Inches(1.25), Inches(4.05), Inches(0.55), fill=color) add_textbox(slide, left + Inches(0.15), Inches(1.35), Inches(3.7), Inches(0.4), title, size=16, bold=True, color=WHITE, align=PP_ALIGN.CENTER) for j, line in enumerate(lines): if not line: continue add_textbox(slide, left + Inches(0.2), Inches(2.0 + j * 0.7), Inches(3.65), Inches(0.65), line, size=12, color=DARK) add_footer(slide, 7) def slide_panorama(prs): slide = prs.slides.add_slide(prs.slide_layouts[6]) add_bg(slide) add_header_bar(slide, "03 功能全景图", "四大能力域覆盖「身份 → 主数据 → 诊疗 → 智能」") domains = [ ("身份与权限", DARK, ["登录 / JWT", "修改密码", "角色菜单", "头像上传"]), ("业务主数据", TEAL, ["患者管理", "用户管理", "知识库文档", "—"]), ("诊疗业务", TEAL_DARK, ["影像检查", "电子病历", "预约挂号", "仪表盘统计"]), ("智能能力", ORANGE, ["YOLO 影像检测", "AI 诊断报告", "EMR 决策 + RAG", "对话 / LLM / 权重"]), ] for i, (title, color, items) in enumerate(domains): left = Inches(0.4 + i * 3.2) add_rect(slide, left, Inches(1.3), Inches(3.05), Inches(5.3), fill=WHITE, line=GRAY_LIGHT) add_rect_sharp(slide, left, Inches(1.3), Inches(3.05), Inches(0.7), fill=color) add_textbox(slide, left, Inches(1.45), Inches(3.05), Inches(0.45), title, size=16, bold=True, color=WHITE, align=PP_ALIGN.CENTER) for j, it in enumerate(items): y = Inches(2.3 + j * 1.0) cell = add_rect(slide, left + Inches(0.2), y, Inches(2.65), Inches(0.75), fill=LIGHT if j % 2 == 0 else GRAY_LIGHT) clear_and_set_text(cell, it, size=13, bold=False, color=DARK) add_footer(slide, 8) def slide_imaging(prs): slide = prs.slides.add_slide(prs.slide_layouts[6]) add_bg(slide) add_header_bar(slide, "03 核心需求① AI 影像诊断(F-03 / F-04)", "异步状态机 + 真实检测结果 + 分段报告 + 可降级") # flow boxes steps = [ ("新建影像\n上传图片", DARK), ("PENDING", GRAY), ("触发 AI\nANALYZING", TEAL), ("YOLO 检测\n+ 报告生成", TEAL_DARK), ("COMPLETED\n/ ERROR", ORANGE), ("前端轮询\n打开报告", DARK), ] y = Inches(1.35) for i, (text, color) in enumerate(steps): left = Inches(0.35 + i * 2.15) box = add_rect(slide, left, y, Inches(1.95), Inches(0.95), fill=color) clear_and_set_text(box, text, size=11, bold=True, color=WHITE) if i < len(steps) - 1: add_textbox(slide, left + Inches(1.85), y + Inches(0.25), Inches(0.35), Inches(0.4), "→", size=18, bold=True, color=TEAL) add_textbox(slide, Inches(0.45), Inches(2.5), Inches(12), Inches(0.35), "状态机:PENDING → ANALYZING → COMPLETED / ERROR(@Async 避免阻塞 HTTP)", size=12, bold=True, color=DARK) data = [ ["验收维度", "要点", "深度说明"], ["状态可见", "列表/KPI 可按状态过滤", "过程可观测,避免「点了没反应」"], ["检测结果", "标注图对比、类别、bbox、置信度", "证明不是假随机文案"], ["报告结构", "所见 / 印象 / 建议 / 声明分块", "满足 F-15 可读性,便于医师复核叙事"], ["降级策略", "AI 宕机时本地规则仍出结果", "满足 F-13,答辩现场不断链"], ["检查类型", "X_RAY / CT / MRI / ULTRASOUND", "覆盖常见影像检查登记场景"], ] add_table( slide, Inches(0.45), Inches(2.95), Inches(12.4), Inches(3.7), 6, 3, data, col_widths=[Inches(2.2), Inches(5.0), Inches(5.2)], ) add_footer(slide, 9) def slide_emr(prs): slide = prs.slides.add_slide(prs.slide_layouts[6]) add_bg(slide) add_header_bar(slide, "03 核心需求② 电子病历辅助决策(F-05 / F-10)", "结构化病历 → 决策服务 → 多维建议 + RAG 来源可追溯") flow = [ ("填写并保存病历", "主诉/现病史/诊断/用药等"), ("Decision 服务", "Spring 编排调用 AI"), ("RAG + 可选 LLM", "知识检索 / 大模型增强"), ("失败走模板", "关键词规则保底"), ("分节展示结果", "Dialog 多维建议"), ] for i, (t, s) in enumerate(flow): left = Inches(0.35 + i * 2.55) box = add_rect(slide, left, Inches(1.3), Inches(2.4), Inches(1.15), fill=WHITE, line=TEAL) clear_and_set_text( box, [(t, 13, True, DARK), (s, 10, False, GRAY)], align=PP_ALIGN.CENTER, ) if i < len(flow) - 1: add_textbox(slide, left + Inches(2.25), Inches(1.6), Inches(0.35), Inches(0.4), "→", size=16, bold=True, color=TEAL) # output dimensions outs = [ ("治疗建议", "方案级条目表"), ("用药建议", "药物与注意点"), ("护理建议", "护理要点列表"), ("随访计划", "复诊与监测"), ("风险评估", "潜在风险提示"), ("药物冲突", "相互作用警示"), ("知识来源", "RAG 引用可点查"), ] add_textbox(slide, Inches(0.45), Inches(2.7), Inches(12), Inches(0.35), "决策输出七维(验收时建议逐项点开):", size=13, bold=True, color=DARK) for i, (t, s) in enumerate(outs): left = Inches(0.4 + i * 1.82) c = add_rect(slide, left, Inches(3.15), Inches(1.72), Inches(1.2), fill=TEAL if i % 2 == 0 else DARK) clear_and_set_text(c, [(t, 12, True, WHITE), (s, 10, False, GRAY_LIGHT)]) add_rect(slide, Inches(0.45), Inches(4.6), Inches(12.4), Inches(2.1), fill=WHITE, line=GRAY_LIGHT) add_textbox( slide, Inches(0.65), Inches(4.75), Inches(12), Inches(1.8), "深度要点\n" "• 诊断含「高血压 / 糖尿病 / 肺炎 / 结节」等关键词时,模板与 RAG 效果更明显,便于课堂对比。\n" "• 配置 DeepSeek 等 OpenAI 兼容模型后,由 LLM 增强结构化建议;无 Key 时仍可演示。\n" "• 知识库文档(F-10)与内置医学 Markdown 片段共同支撑「建议有出处」,区别于纯关键字弹窗。\n" "• 业务价值:缩短教学场景下「从病历到处置思路」的演示路径,并强调人工复核责任。", size=12, color=DARK, ) add_footer(slide, 10) def slide_support_features(prs): slide = prs.slides.add_slide(prs.slide_layouts[6]) add_bg(slide) add_header_bar(slide, "03 配套业务需求", "支撑双 AI 主链路的业务底座与管理能力") data = [ ["模块", "需求编号", "关键能力", "验收关注点"], ["登录与个人中心", "F-01", "JWT 持久化、改密后重登、头像", "未登录拦截 / 已登录跳转仪表盘"], ["患者管理", "F-02", "分页搜索、CRUD、360° 档案抽屉", "影像/病历/预约关联计数正确"], ["预约挂号", "F-07", "状态机、按日筛选、统计卡片", "预约→确认→完成/取消/未到诊"], ["仪表盘", "F-08", "KPI、近 7 日趋势、类型/状态分布", "ECharts 与快捷入口可导览"], ["AI 助手", "F-09", "多轮对话、Markdown、历史持久化", "历史不随 H2 清空丢失"], ["知识库 / AI 配置 / YOLO", "F-10~12", "文档、LLM 同步、权重激活", "仅 ADMIN;同步后走大模型"], ["用户管理", "F-06 配套", "三角色 CRUD、禁停当前账号", "doctor 访问 /users → 403"], ] add_table( slide, Inches(0.35), Inches(1.25), Inches(12.6), Inches(5.5), 8, 4, data, col_widths=[Inches(2.6), Inches(1.6), Inches(4.2), Inches(4.2)], ) add_footer(slide, 11) def slide_nfr(prs): slide = prs.slides.add_slide(prs.slide_layouts[6]) add_bg(slide) add_header_bar(slide, "04 非功能需求", "非功能不是「附加项」,而是答辩可演示与可讲解的质量属性") data = [ ["类别", "要求", "项目落地", "对应价值"], ["性能", "诊断不阻塞请求线程", "@Async + 线程池;前端轮询有上限", "长任务可感知、可取消清理"], ["可用性", "无 MySQL 亦可演示", "H2 内存库 + DataInitializer 种子", "降低环境门槛"], ["安全", "鉴权、加密、CORS", "Spring Security + JWT + 白名单", "角色隔离可讲可验"], ["可维护", "统一响应与异常", "Result + GlobalExceptionHandler", "前后端契约稳定"], ["可扩展", "AI 与业务解耦", "FastAPI 独立进程 + base-url 配置", "视觉/LLM 依赖不污染 Java"], ["兼容", "大模型可切换", "OpenAI 兼容协议(DeepSeek/Qwen)", "无厂商锁定"], ] add_table( slide, Inches(0.35), Inches(1.25), Inches(12.6), Inches(5.5), 7, 4, data, col_widths=[Inches(1.6), Inches(3.2), Inches(4.4), Inches(3.4)], ) add_footer(slide, 12) def slide_constraints(prs): slide = prs.slides.add_slide(prs.slide_layouts[6]) add_bg(slide) add_header_bar(slide, "04 约束、假设与边界", "主动界定范围:避免把「演示系统」误当成生产 HIS/PACS") data = [ ["类型", "说明", "答辩话术建议"], ["定位约束", "面向实训/课程设计/答辩,非生产 HIS 替代", "强调教学闭环与可演示性"], ["影像假设", "以 JPG/PNG 为主,DICOM 全工作流非重点", "后续扩展方向可答 DICOM"], ["模型边界", "YOLO 权重与类别为演示级,需医师复核", "输出=辅助,不是确诊"], ["数据假设", "H2 内存退出丢库;AI 配置/对话存文件可保留", "持久化可切 MySQL profile"], ["产品边界", "无医保、收费、电子签名、CA 等", "范围控制是需求管理能力"], ["外部依赖", "LLM 受网络/额度/延迟影响,有超时上限", "模板降级是风险应对设计"], ] add_table( slide, Inches(0.4), Inches(1.25), Inches(12.5), Inches(4.6), 7, 3, data, col_widths=[Inches(2.0), Inches(6.0), Inches(4.5)], ) add_textbox( slide, Inches(0.5), Inches(6.05), Inches(12.3), Inches(0.7), "设计取舍摘要:默认 H2 降门槛 · AI 独立进程隔离依赖 · JWT 适配分离架构 · LLM 可关保证无 Key 可演 · 报告强制分段避免「墙文本」。", size=12, color=GRAY, ) add_footer(slide, 13) def slide_arch(prs): slide = prs.slides.add_slide(prs.slide_layouts[6]) add_bg(slide) add_header_bar(slide, "05 逻辑架构(方案如何满足需求)", "浏览器只访问业务后端;AI 能力集中;失败可降级") # layers layers = [ (Inches(3.5), Inches(1.25), Inches(6.3), Inches(0.85), "浏览器 Vue 3 SPA · :5173", TEAL, "身份/页面/图表/报告展示"), (Inches(3.5), Inches(2.5), Inches(6.3), Inches(0.95), "Spring Boot 业务端 · :8080", DARK, "JWT · JPA · 文件 · 异步编排 · 降级"), ] for left, top, w, h, title, color, sub in layers: box = add_rect(slide, left, top, w, h, fill=color) clear_and_set_text(box, [(title, 14, True, WHITE), (sub, 11, False, GRAY_LIGHT)]) # arrow labels add_textbox(slide, Inches(5.8), Inches(2.1), Inches(2), Inches(0.35), "↓ /api 代理", size=11, color=GRAY, align=PP_ALIGN.CENTER) # bottom two left_box = add_rect(slide, Inches(1.2), Inches(3.9), Inches(4.8), Inches(1.3), fill=WHITE, line=DARK) clear_and_set_text(left_box, [("H2 / MySQL 业务库", 14, True, DARK), ("患者 · 影像 · 病历 · 预约 · 用户", 11, False, GRAY)]) right_box = add_rect(slide, Inches(7.3), Inches(3.9), Inches(4.8), Inches(1.3), fill=WHITE, line=TEAL) clear_and_set_text(right_box, [("FastAPI AI · :8001", 14, True, TEAL_DARK), ("YOLO · RAG · LLM · 权重/配置", 11, False, GRAY)]) add_textbox(slide, Inches(3.0), Inches(3.5), Inches(2), Inches(0.35), "↓ 持久化", size=11, color=GRAY, align=PP_ALIGN.CENTER) add_textbox(slide, Inches(9.0), Inches(3.5), Inches(2.5), Inches(0.35), "↓ HTTP(可选/可降级)", size=11, color=GRAY, align=PP_ALIGN.CENTER) # principles principles = [ "1. 浏览器不直连 AI 端口(统一鉴权与编排)", "2. Spring 负责落库、任务状态与降级", "3. 管理端 LLM 配置单向同步至 FastAPI 运行时", "4. AI 超时/宕机 → 规则/模板结果保底演示", ] for i, p in enumerate(principles): left = Inches(0.5 + (i % 2) * 6.4) top = Inches(5.5 + (i // 2) * 0.55) add_textbox(slide, left, top, Inches(6.2), Inches(0.45), p, size=12, color=DARK) add_footer(slide, 14) def slide_tech(prs): slide = prs.slides.add_slide(prs.slide_layouts[6]) add_bg(slide) add_header_bar(slide, "05 技术选型对照", "选型服务于需求:可演示、可分离、可降级、可讲解") data = [ ["层级", "技术", "服务的需求", "为何选"], ["前端", "Vue3 + Vite + Element Plus + ECharts", "F-06/08/14/16 体验与权限路由", "SPA 分离、组件化、图表答辩直观"], ["业务后端", "Spring Boot 3.3 + Security + JPA", "F-01~07 业务与安全闭环", "成熟权限模型、异步、统一异常"], ["数据", "H2 默认 / MySQL 可选", "可用性 + 可迁移", "开箱演示;需要持久再切 profile"], ["AI 服务", "FastAPI + Ultralytics YOLO", "F-04 真实检测", "Python 视觉生态,与 Java 解耦"], ["决策/报告", "LangChain 生态 + OpenAI 兼容 LLM", "F-05/09/10/11", "RAG+可关 LLM,无 Key 可演"], ["认证", "JWT + BCrypt", "F-01/06 安全", "无状态,适配前后端分离"], ] add_table( slide, Inches(0.3), Inches(1.25), Inches(12.7), Inches(5.5), 7, 4, data, col_widths=[Inches(1.6), Inches(4.0), Inches(3.6), Inches(3.5)], ) add_footer(slide, 15) def slide_team(prs): slide = prs.slides.add_slide(prs.slide_layouts[6]) add_bg(slide) add_header_bar(slide, "06 小组分工(6 人)", "主责清晰 · 需求编号可追溯 · 交付物可检查(姓名可替换)") data = [["成员", "角色定位", "主责模块", "对应需求", "主要交付物"]] for row in MEMBERS: data.append(list(row)) add_table( slide, Inches(0.25), Inches(1.2), Inches(12.8), Inches(4.6), 7, 5, data, col_widths=[Inches(1.3), Inches(2.4), Inches(3.3), Inches(2.2), Inches(3.6)], ) add_textbox( slide, Inches(0.4), Inches(5.95), Inches(12.5), Inches(0.85), "协作关系:A 统筹需求与验收 → B/C 前端联调 → D/E 业务后端与数据 → F 提供 AI 能力;D/E 负责鉴权编排与降级兜底。\n" "建议联调节奏:先 F-01/06 打通登录权限 → 患者/影像主数据 → AI 诊断 → 病历决策 → 管理端配置与降级演练。", size=12, color=GRAY, ) add_footer(slide, 16) def slide_acceptance(prs): slide = prs.slides.add_slide(prs.slide_layouts[6]) add_bg(slide) add_header_bar(slide, "06 验收标准与演示剧本", "把需求变成可执行检查项与 7 步演示路径") left_title = add_rect(slide, Inches(0.4), Inches(1.2), Inches(6.1), Inches(0.45), fill=TEAL) clear_and_set_text(left_title, "验证清单(节选)", size=14, bold=True, color=WHITE) checks = [ "三端启动,5173 可登录", "admin 可见用户管理;doctor1 访问 /users 为 403", "患者增删改与 360° 档案抽屉正常", "影像 AI 可达 COMPLETED,报告分段清晰", "病历 AI 建议含多类内容与来源", "预约状态可流转", "改密后需重新登录", "关闭 AI 服务后诊断仍可降级返回", ] for i, c in enumerate(checks): y = Inches(1.8 + i * 0.55) box = add_rect(slide, Inches(0.4), y, Inches(0.35), Inches(0.35), fill=TEAL) clear_and_set_text(box, "✓", size=12, bold=True, color=WHITE) add_textbox(slide, Inches(0.9), y, Inches(5.5), Inches(0.4), c, size=12, color=DARK) right_title = add_rect(slide, Inches(6.8), Inches(1.2), Inches(6.1), Inches(0.45), fill=DARK) clear_and_set_text(right_title, "推荐演示剧本", size=14, bold=True, color=WHITE) script = [ "1. doctor1 / pass123 登录,看仪表盘 KPI", "2. 患者管理 → 打开 360° 档案", "3. 影像:上传样例 → AI 诊断 → 原图/标注对比", "4. 病历:诊断填「高血压/肺炎」→ 看 AI 建议", "5. 预约:新建并切换状态", "6. 切换 admin:AI 配置 / YOLO / 用户", "7. 关闭 AI 服务,对比降级结果", ] accounts = [ ("admin", "admin123", "ADMIN"), ("doctor1", "pass123", "DOCTOR"), ("radio1", "radio123", "RADIOLOGIST"), ] for i, s in enumerate(script): add_textbox(slide, Inches(7.0), Inches(1.8 + i * 0.45), Inches(5.8), Inches(0.4), s, size=12, color=DARK) add_textbox(slide, Inches(7.0), Inches(5.1), Inches(5.8), Inches(0.3), "内置演示账号", size=12, bold=True, color=DARK) for i, (u, p, r) in enumerate(accounts): add_textbox(slide, Inches(7.0), Inches(5.45 + i * 0.35), Inches(5.8), Inches(0.35), f"{u} / {p} · {r}", size=11, color=GRAY) add_footer(slide, 17) def slide_summary(prs): slide = prs.slides.add_slide(prs.slide_layouts[6]) add_bg(slide) add_header_bar(slide, "总结与展望", "需求闭环已定义清晰;实现路径可演示、可分工、可验收") # summary cards sums = [ ("需求主线", "双链路:影像 YOLO 诊断 + 病历 RAG/LLM 决策,外加完整业务底座"), ("质量主线", "P0 可验收、权限可讲、AI 可降级、环境可离线实训"), ("组织主线", "6 人按前端/后端/AI/统筹切开,需求编号追溯到交付物"), ] for i, (t, b) in enumerate(sums): left = Inches(0.4 + i * 4.25) add_rect(slide, left, Inches(1.25), Inches(4.05), Inches(1.8), fill=WHITE, line=GRAY_LIGHT) add_rect_sharp(slide, left, Inches(1.25), Inches(4.05), Inches(0.45), fill=TEAL if i != 2 else DARK) add_textbox(slide, left + Inches(0.15), Inches(1.32), Inches(3.7), Inches(0.35), t, size=14, bold=True, color=WHITE, align=PP_ALIGN.CENTER) add_textbox(slide, left + Inches(0.2), Inches(1.9), Inches(3.65), Inches(1.0), b, size=12, color=DARK) add_textbox(slide, Inches(0.5), Inches(3.3), Inches(12), Inches(0.4), "后续可扩展(超出当前范围,可作为提问储备)", size=14, bold=True, color=DARK) exts = [ ("DICOM 解析\n与序列阅片", TEAL), ("报告 PDF\n与电子签收", DARK), ("细粒度权限\n与操作审计", TEAL_DARK), ("向量库增强\nRAG", ORANGE), ("Docker Compose\n一键拉起", DARK), ("接口自动化\n与 CI", TEAL), ] for i, (t, c) in enumerate(exts): left = Inches(0.4 + i * 2.15) box = add_rect(slide, left, Inches(3.85), Inches(2.05), Inches(1.35), fill=c) clear_and_set_text(box, t, size=12, bold=True, color=WHITE) add_rect(slide, Inches(0.4), Inches(5.5), Inches(12.5), Inches(1.2), fill=DARK) add_textbox( slide, Inches(0.7), Inches(5.7), Inches(12), Inches(0.9), "谢谢各位老师! 欢迎提问\nSmart Hospital · 需求答辩 · 组员 A–F · 输出仅供教学演示", size=16, bold=True, color=WHITE, align=PP_ALIGN.CENTER, ) add_footer(slide, 18) def main(): prs = Presentation() prs.slide_width = SLIDE_W prs.slide_height = SLIDE_H slide_cover(prs) slide_toc(prs) slide_pain(prs) slide_goals(prs) slide_scenes(prs) slide_roles(prs) slide_priority(prs) slide_panorama(prs) slide_imaging(prs) slide_emr(prs) slide_support_features(prs) slide_nfr(prs) slide_constraints(prs) slide_arch(prs) slide_tech(prs) slide_team(prs) slide_acceptance(prs) slide_summary(prs) OUT_PATH.parent.mkdir(parents=True, exist_ok=True) prs.save(str(OUT_PATH)) print(f"OK: {OUT_PATH}") print(f"slides: {len(prs.slides)}") if __name__ == "__main__": main()