from __future__ import annotations from pathlib import Path from docx import Document from docx.enum.section import WD_SECTION from docx.enum.table import WD_ALIGN_VERTICAL, WD_TABLE_ALIGNMENT from docx.enum.text import WD_ALIGN_PARAGRAPH from docx.oxml import OxmlElement from docx.oxml.ns import qn from docx.shared import Inches, Pt, RGBColor OUT = Path(__file__).with_name("医梦P0三智能体语音交互、补槽字段与调用契约_V1.0.docx") PURPLE = "2B1F99" PURPLE_DARK = "211477" TEAL = "138E91" BLUE = "2E74B5" INK = "1A1835" MUTED = "706D89" LIGHT = "F4F5FB" LIGHT_BLUE = "E8EEF5" SUCCESS = "168469" WARNING = "C46B13" RED = "C63B3B" WHITE = "FFFFFF" BORDER = "D9DEEA" FONT_CN = "STSong" FONT_LATIN = "Calibri" def set_run_font(run, size=11, bold=False, color=INK, italic=False, font=FONT_CN): run.font.name = font run._element.get_or_add_rPr().rFonts.set(qn("w:eastAsia"), font) run._element.get_or_add_rPr().rFonts.set(qn("w:ascii"), font) run._element.get_or_add_rPr().rFonts.set(qn("w:hAnsi"), font) run.font.size = Pt(size) run.font.bold = bold run.font.italic = italic run.font.color.rgb = RGBColor.from_string(color) def shade_cell(cell, color): tc_pr = cell._tc.get_or_add_tcPr() shd = tc_pr.find(qn("w:shd")) if shd is None: shd = OxmlElement("w:shd") tc_pr.append(shd) shd.set(qn("w:fill"), color) def set_cell_margins(cell, top=80, start=120, bottom=80, end=120): tc = cell._tc tc_pr = tc.get_or_add_tcPr() tc_mar = tc_pr.first_child_found_in("w:tcMar") if tc_mar is None: tc_mar = OxmlElement("w:tcMar") tc_pr.append(tc_mar) for m, value in (("top", top), ("start", start), ("bottom", bottom), ("end", end)): node = tc_mar.find(qn(f"w:{m}")) if node is None: node = OxmlElement(f"w:{m}") tc_mar.append(node) node.set(qn("w:w"), str(value)) node.set(qn("w:type"), "dxa") def set_table_geometry(table, widths): total = sum(widths) table.autofit = False table.alignment = WD_TABLE_ALIGNMENT.LEFT tbl_pr = table._tbl.tblPr layout = tbl_pr.find(qn("w:tblLayout")) if layout is None: layout = OxmlElement("w:tblLayout") tbl_pr.append(layout) layout.set(qn("w:type"), "fixed") tbl_w = tbl_pr.find(qn("w:tblW")) if tbl_w is None: tbl_w = OxmlElement("w:tblW") tbl_pr.append(tbl_w) tbl_w.set(qn("w:w"), str(total)) tbl_w.set(qn("w:type"), "dxa") tbl_ind = tbl_pr.find(qn("w:tblInd")) if tbl_ind is None: tbl_ind = OxmlElement("w:tblInd") tbl_pr.append(tbl_ind) tbl_ind.set(qn("w:w"), "120") tbl_ind.set(qn("w:type"), "dxa") grid = table._tbl.tblGrid for child in list(grid): grid.remove(child) for width in widths: col = OxmlElement("w:gridCol") col.set(qn("w:w"), str(width)) grid.append(col) for row in table.rows: for idx, cell in enumerate(row.cells): width = widths[min(idx, len(widths) - 1)] tc_pr = cell._tc.get_or_add_tcPr() tc_w = tc_pr.find(qn("w:tcW")) if tc_w is None: tc_w = OxmlElement("w:tcW") tc_pr.append(tc_w) tc_w.set(qn("w:w"), str(width)) tc_w.set(qn("w:type"), "dxa") cell.vertical_alignment = WD_ALIGN_VERTICAL.CENTER set_cell_margins(cell) def add_table(doc, headers, rows, widths, header_fill=PURPLE): table = doc.add_table(rows=1, cols=len(headers)) table.style = "Table Grid" table.rows[0]._tr.get_or_add_trPr().append(OxmlElement("w:tblHeader")) for idx, header in enumerate(headers): cell = table.rows[0].cells[idx] shade_cell(cell, header_fill) p = cell.paragraphs[0] p.alignment = WD_ALIGN_PARAGRAPH.CENTER p.paragraph_format.space_after = Pt(0) set_run_font(p.add_run(header), size=9.5, bold=True, color=WHITE) for row_data in rows: cells = table.add_row().cells for idx, value in enumerate(row_data): p = cells[idx].paragraphs[0] p.paragraph_format.space_after = Pt(0) p.paragraph_format.line_spacing = 1.12 set_run_font(p.add_run(str(value)), size=9.2, color=INK) set_table_geometry(table, widths) doc.add_paragraph().paragraph_format.space_after = Pt(0) return table def add_para(doc, text="", size=11, bold=False, color=INK, align=None, after=6, italic=False): p = doc.add_paragraph() p.paragraph_format.space_before = Pt(0) p.paragraph_format.space_after = Pt(after) p.paragraph_format.line_spacing = 1.25 if align is not None: p.alignment = align set_run_font(p.add_run(text), size=size, bold=bold, color=color, italic=italic) return p def add_bullets(doc, items): for item in items: p = doc.add_paragraph(style="List Bullet") p.paragraph_format.left_indent = Inches(0.375) p.paragraph_format.first_line_indent = Inches(-0.188) p.paragraph_format.space_after = Pt(4) p.paragraph_format.line_spacing = 1.25 set_run_font(p.add_run(item), size=10.5) def add_numbered(doc, items): for item in items: p = doc.add_paragraph(style="List Number") p.paragraph_format.left_indent = Inches(0.375) p.paragraph_format.first_line_indent = Inches(-0.188) p.paragraph_format.space_after = Pt(4) p.paragraph_format.line_spacing = 1.25 set_run_font(p.add_run(item), size=10.5) def add_callout(doc, label, text, color=PURPLE, fill=LIGHT): table = doc.add_table(rows=1, cols=2) table.style = "Table Grid" shade_cell(table.cell(0, 0), color) shade_cell(table.cell(0, 1), fill) p0 = table.cell(0, 0).paragraphs[0] p0.alignment = WD_ALIGN_PARAGRAPH.CENTER p0.paragraph_format.space_after = Pt(0) set_run_font(p0.add_run(label), size=10, bold=True, color=WHITE) p1 = table.cell(0, 1).paragraphs[0] p1.paragraph_format.space_after = Pt(0) p1.paragraph_format.line_spacing = 1.2 set_run_font(p1.add_run(text), size=10, color=INK) set_table_geometry(table, [1260, 8100]) doc.add_paragraph().paragraph_format.space_after = Pt(0) def add_code(doc, text): table = doc.add_table(rows=1, cols=1) table.style = "Table Grid" shade_cell(table.cell(0, 0), "F2F4F7") p = table.cell(0, 0).paragraphs[0] p.paragraph_format.space_after = Pt(0) p.paragraph_format.line_spacing = 1.0 run = p.add_run(text) set_run_font(run, size=8.2, color=INK, font="Consolas") set_table_geometry(table, [9360]) doc.add_paragraph().paragraph_format.space_after = Pt(0) def configure_styles(doc): styles = doc.styles normal = styles["Normal"] normal.font.name = FONT_CN normal._element.rPr.rFonts.set(qn("w:eastAsia"), FONT_CN) normal._element.rPr.rFonts.set(qn("w:ascii"), FONT_CN) normal._element.rPr.rFonts.set(qn("w:hAnsi"), FONT_CN) normal.font.size = Pt(11) normal.font.color.rgb = RGBColor.from_string(INK) normal.paragraph_format.space_after = Pt(6) normal.paragraph_format.line_spacing = 1.25 for name, size, color, before, after in ( ("Heading 1", 16, PURPLE, 18, 10), ("Heading 2", 13, PURPLE, 14, 7), ("Heading 3", 12, TEAL, 10, 5), ): st = styles[name] st.font.name = FONT_CN st._element.rPr.rFonts.set(qn("w:eastAsia"), FONT_CN) st._element.rPr.rFonts.set(qn("w:ascii"), FONT_CN) st._element.rPr.rFonts.set(qn("w:hAnsi"), FONT_CN) st.font.size = Pt(size) st.font.bold = True st.font.color.rgb = RGBColor.from_string(color) st.paragraph_format.space_before = Pt(before) st.paragraph_format.space_after = Pt(after) st.paragraph_format.keep_with_next = True for name in ("List Bullet", "List Number"): st = styles[name] st.font.name = FONT_CN st._element.rPr.rFonts.set(qn("w:eastAsia"), FONT_CN) st._element.rPr.rFonts.set(qn("w:ascii"), FONT_CN) st._element.rPr.rFonts.set(qn("w:hAnsi"), FONT_CN) st.font.size = Pt(10.5) def add_heading(doc, text, level=1): p = doc.add_heading(text, level=level) for run in p.runs: set_run_font(run, size={1: 16, 2: 13, 3: 12}[level], bold=True, color=PURPLE if level < 3 else TEAL) return p def add_page_number(paragraph): paragraph.alignment = WD_ALIGN_PARAGRAPH.RIGHT run = paragraph.add_run("第 ") set_run_font(run, size=9, color=MUTED) fld = OxmlElement("w:fldSimple") fld.set(qn("w:instr"), "PAGE") paragraph._p.append(fld) run2 = paragraph.add_run(" 页") set_run_font(run2, size=9, color=MUTED) doc = Document() configure_styles(doc) section = doc.sections[0] section.top_margin = Inches(0.82) section.bottom_margin = Inches(0.82) section.left_margin = Inches(1.0) section.right_margin = Inches(1.0) section.header_distance = Inches(0.42) section.footer_distance = Inches(0.42) header = section.header hp = header.paragraphs[0] hp.alignment = WD_ALIGN_PARAGRAPH.RIGHT set_run_font(hp.add_run("医梦 AI · P0 执行级附件"), size=8.5, color=MUTED) add_page_number(section.footer.paragraphs[0]) # Cover add_para(doc, "医梦 AI", size=11, bold=True, color=TEAL, after=20) add_para(doc, "P0 三智能体语音交互、\n补槽字段与调用契约", size=26, bold=True, color=PURPLE_DARK, after=8) add_para(doc, "医梦患者智能服务门户(Adjutant)执行级附件", size=14, color=MUTED, after=22) add_callout( doc, "核心原则", "患者主要通过语音表达诉求;智能体负责识别意图、提取已知信息和持续补槽;必要信息未完整、安全检查未通过或用户未确认时,不得进入业务执行。", color=TEAL, fill="E9F7F7", ) add_table( doc, ["项目", "内容"], [ ("文件版本", "V1.0"), ("编制日期", "2026 年 7 月 26 日"), ("适用范围", "单医院 P0:智能分诊挂号、检验检查报告解读、中医舌诊"), ("约束来源", "《医梦智能体分级开发与平台演进规范 V1.0》与《医梦 P0 智能体 Demo 开发与 FastGPT 编排实施方案 V1.0》"), ("使用对象", "产品、客户端、FastGPT、Mock MCP、测试与演示负责人"), ], [1900, 7460], ) add_para(doc, "本附件不替代两份上位规范,只负责把语音交互、补槽规则、系统调用和验收要求转换为可执行契约。", size=10, color=MUTED, italic=True, after=0) doc.add_page_break() # 1 add_heading(doc, "1. 文档目的与执行边界", 1) add_para(doc, "本附件用于统一客户端、FastGPT Workflow、Mock MCP 和测试对“患者如何开始、智能体如何追问、什么时候允许进入下一步”的理解。P0 数据可以是 Mock,但状态、字段和错误语义必须稳定。") add_heading(doc, "1.1 本附件解决的问题", 2) add_bullets(doc, [ "将语音明确为患者侧主要输入方式,文字、快捷选项、上传和卡片作为辅助交互。", "为三个智能体定义必填、条件必填、可选槽位,以及缺失、推断、确认、冲突和失效规则。", "定义客户端调用智能体、智能体调用 Mock MCP、智能体返回标准 UI 组件的统一协议。", "建立信息完整性门禁、医疗安全门禁和事务二次确认门禁。", "给出可重复演示脚本与金标验收用例。", ]) add_heading(doc, "1.2 P0 不包含", 2) add_bullets(doc, [ "真实医院 HIS、LIS、EMR、支付和生产号源。", "完整语音识别引擎训练;P0 可使用固定转写结果或浏览器语音能力。", "真实在线诊断、处方、正式急诊分级和自动临床决策。", "家庭成员代办界面;但请求模型必须区分 operatorId 与 subjectPatientId。", ]) # 2 add_heading(doc, "2. 统一语音交互契约", 1) add_heading(doc, "2.1 主交互链路", 2) add_callout(doc, "FLOW", "语音表达 → 转写回显 → 意图识别 → 槽位提取 → 安全检查 → 缺失字段追问 → 完整性摘要 → 用户确认 → Tool/MCP 调用 → 结果卡片 → 健康记录") add_heading(doc, "2.2 语音状态", 2) add_table( doc, ["状态", "患者侧表现", "系统动作"], [ ("VOICE_READY", "显示“按住说话”", "等待患者输入"), ("LISTENING", "显示波形与取消入口", "采集语音;P0 可模拟"), ("TRANSCRIBING", "显示“正在识别”", "生成文字转写"), ("TRANSCRIPT_CONFIRM", "回显识别内容", "低置信度或关键字段需确认"), ("SLOT_FILLING", "语音追问 + 快捷回答", "一次只追问一个高优先级缺失项"), ("READY_TO_PROCEED", "显示结构化摘要", "所有门禁通过,等待确认"), ("PROCESSING", "显示进度和不可重复提交", "调用 Tool/MCP"), ("COMPLETED / FAILED", "结果或恢复操作", "保存任务结果与 traceId"), ], [2050, 3050, 4260], ) add_heading(doc, "2.3 识别、确认与纠错规则", 2) add_bullets(doc, [ "语音转写必须回显,患者可修改后再继续。", "患者说“不是明天,是后天”时,新值覆盖旧值,并使依赖旧条件的候选结果失效。", "日期、时间、科室、医生、指标名称必须保留 rawValue 与 normalizedValue。", "低置信度关键字段不得静默采用;应追问“您说的是李明主任吗?”", "支持“不限医生”“都可以”等有效值,表示偏好已经解决。", "连续两次无法识别时,提供文字、选项或人工服务入口。", "重要医疗提示和事务确认必须视觉留痕,不能只通过语音播报。", ]) add_heading(doc, "2.4 通用槽位对象", 2) add_code(doc, '''{ "name": "doctorPreference", "rawValue": "李主任", "normalizedValue": "DOCTOR_LIMING", "source": "VOICE | PROFILE | TOOL | USER_SELECTION", "confidence": 0.92, "status": "MISSING | INFERRED | CONFIRMED | INVALID | CONFLICTED", "required": true, "updatedAt": "2026-07-26T10:20:00+08:00" }''') # 3 add_heading(doc, "3. 统一完整性与执行门禁", 1) add_heading(doc, "3.1 门禁顺序", 2) add_numbered(doc, [ "意图门禁:明确患者要使用挂号、报告解读或中医舌诊中的哪一项服务。", "身份门禁:明确当前 hospitalId、operatorId、subjectPatientId;P0 默认本人但字段不得省略。", "医疗安全门禁:先处理急诊红旗、危急值、图片/报告不适用等风险。", "槽位完整性门禁:所有必填和条件必填槽位均已 CONFIRMED 或具有允许的默认值。", "用户确认门禁:执行前展示摘要,患者明确确认。", "工具执行门禁:请求版本、结果版本、幂等键和 Mock 场景合法。", ]) add_callout(doc, "禁止", "不得使用“模型认为信息差不多完整”作为执行条件。信息完整性必须由确定性规则检查 missingSlots、conflictedSlots 和 riskDecision。", color=RED, fill="FFF1F1") add_heading(doc, "3.2 统一完整性结果", 2) add_table( doc, ["字段", "说明"], [ ("isComplete", "必要字段是否全部解决"), ("missingSlots", "仍需追问的槽位,按优先级排序"), ("conflictedSlots", "患者多次表达不一致的槽位"), ("needsConfirmation", "存在推断值或低置信度值时为 true"), ("riskDecision", "ALLOW / BLOCK / HUMAN_REVIEW"), ("nextAction", "ASK_SLOT / CONFIRM_SUMMARY / CALL_TOOL / TRANSFER"), ], [2300, 7060], ) # 4 Registration add_heading(doc, "4. 智能分诊挂号:语音与补槽契约", 1) add_heading(doc, "4.1 示例主对话", 2) add_table( doc, ["角色", "对话 / 系统行为"], [ ("患者", "“我头疼,帮我挂神经内科明天早上九点的专家号。”"), ("智能体", "提取症状、科室、日期、时间、号别;先执行急诊风险检查;发现医生偏好缺失。"), ("智能体", "“已记录神经内科、明天上午九点和专家号。请问您想挂哪位医生?”"), ("患者", "“李明主任。”"), ("智能体", "展示完整摘要;患者确认后才查询号源。若 09:00 无精确号源,展示最接近的可选时段,不得自动替患者选择。"), ], [1500, 7860], ) add_heading(doc, "4.2 槽位字典", 2) add_table( doc, ["槽位", "来源", "规则", "必填"], [ ("subjectPatientId", "患者上下文", "P0 固定本人;接口必须携带", "是"), ("chiefComplaint", "语音", "分诊场景必须;直接找科室时可选", "条件"), ("campusId", "语音/默认/追问", "单院区可默认,但摘要中展示", "是"), ("departmentCode", "语音/分诊 Tool/选择", "必须映射医院标准科室", "是"), ("visitDate", "语音/追问", "将明天、下周一标准化为日期", "是"), ("timePreference", "语音/追问", "支持精确时间或上午/下午范围", "是"), ("registrationType", "语音/追问", "普通/专家/特需;医院可配置", "是"), ("doctorPreference", "语音/追问", "具体医生或 NO_PREFERENCE", "是"), ("emergencyDecision", "规则 Tool", "ALLOW / BLOCK / HUMAN_REVIEW", "是"), ("userConfirmed", "确认卡片", "锁号/挂号前必须 true", "是"), ], [2100, 1700, 4200, 1360], ) add_heading(doc, "4.3 追问优先级", 2) add_callout(doc, "ORDER", "急诊风险信息 → 科室/症状 → 日期 → 时间 → 号别 → 医生偏好 → 院区 → 完整摘要确认", color=TEAL, fill="E9F7F7") add_heading(doc, "4.4 Tool 调用条件", 2) add_table( doc, ["Tool", "允许调用条件", "关键失败结果"], [ ("triage.emergencyScreen", "出现症状或分诊诉求后立即调用", "BLOCK / HUMAN_REVIEW"), ("triage.recommendDepartments", "需要分诊且风险允许继续", "LOW_CONFIDENCE / NO_MATCH"), ("his.searchSchedules", "所有查询槽位完整并经患者确认", "NO_SLOT / TIMEOUT"), ("his.lockRegistrationSlot", "患者选择有效 resultRef + resultVersion + candidateId", "RESULT_EXPIRED / SLOT_UNAVAILABLE"), ("his.createRegistration", "锁号有效、就诊人一致、二次确认完成", "DUPLICATE / LOCK_EXPIRED"), ("payment.mockPay", "挂号订单已创建", "FAILED / UNKNOWN"), ], [2200, 4800, 2360], ) add_heading(doc, "4.5 候选结果约束", 2) add_bullets(doc, [ "候选号源必须携带 resultRef、resultVersion、expiresAt 和 candidateId。", "患者修改日期、科室、医生或号别后,旧候选立即失效并重新查询。", "精确时间无号时只展示替代结果,不得静默改为其他时间。", "锁号成功后必须再次展示医生、时间、费用和就诊人并确认。", ]) # 5 Report add_heading(doc, "5. 报告解读:语音与补槽契约", 1) add_heading(doc, "5.1 入口与对话", 2) add_para(doc, "患者可以说“帮我解读刚出的血常规”,也可以从新报告待办进入。智能体先明确报告来源和归属,再判断报告是否为已审核、可解释版本。") add_table( doc, ["槽位", "规则", "必填"], [ ("reportSource", "HOSPITAL_REPORT / IMAGE / PDF / MOCK_SAMPLE", "是"), ("reportId 或 fileId", "院内报告使用 reportId;上传使用 fileId", "是"), ("reportOwnerConfirmed", "确认属于当前患者;P0 固定本人", "是"), ("sourceStatus", "FINAL / PRELIMINARY;P0 仅 FINAL 进入正式解读", "是"), ("verificationStatus", "VERIFIED / NEED_REVIEW", "是"), ("reportFamily", "LAB / EXAM", "是"), ("reportType", "BLOOD_ROUTINE / ULTRASOUND 等", "是"), ("consentGranted", "患者同意 AI 辅助解读及结果保存", "是"), ("symptoms / medication / history", "根据异常组合条件追问,不应无差别询问", "条件"), ], [2300, 5500, 1560], ) add_heading(doc, "5.2 报告可用性门禁", 2) add_callout(doc, "GATE", "报告存在 + 患者归属确认 + 已授权 + FINAL + VERIFIED + 当前版本有效 + 未命中禁止自动解读规则", color=TEAL, fill="E9F7F7") add_bullets(doc, [ "报告更新、撤回或 sourceHash 变化后,旧解读结果标记 INVALIDATED。", "OCR 低置信度的关键指标、单位或参考范围必须回显确认。", "危急风险检查先于普通知识检索和解释生成。", "优先使用报告原始参考范围,不用 Prompt 中的固定范围覆盖。", "检查报告只解释报告文字,不宣称完成原始医学影像诊断。", ]) add_heading(doc, "5.3 输出组件顺序", 2) add_numbered(doc, [ "风险优先提示:需立即联系医生、急诊或无紧急风险。", "一句话概览:报告类型、异常数量和主要方向。", "异常指标或检查所见卡片。", "联合解读、影响因素与不确定性。", "下一步行动、建议科室和可向医生提出的问题。", "知识与规则来源、报告版本、解读版本和已知限制。", ]) # 6 Tongue add_heading(doc, "6. 中医舌诊:语音与补槽契约", 1) add_heading(doc, "6.1 主流程", 2) add_callout(doc, "FLOW", "语音确认健康诉求 → 服务边界与授权 → 分组语音问答 → 问答完整性检查 → 拍摄指导 → 图片质量检查 → 现有舌诊 MCP → 问答与舌象融合 → 辅助评估结果") add_heading(doc, "6.2 槽位字典", 2) add_table( doc, ["槽位组", "核心字段", "规则"], [ ("服务与授权", "healthGoal、consentGranted", "未授权不得上传和分析图片"), ("基础信息", "年龄、性别、既往疾病、当前用药", "患者档案优先,患者可纠正"), ("寒热", "怕冷/怕热、发热、手脚温度、出汗", "支持“不确定”"), ("脾胃", "食欲、腹胀、口干口苦、饮食偏好", "分组追问"), ("二便", "大便性状、小便颜色与频率", "允许跳过敏感问题"), ("睡眠精神", "入睡、易醒、疲倦、情绪", "分组追问"), ("女性专项", "月经、孕期、哺乳期", "条件出现"), ("舌象图片", "imageId、qualityStatus、analysisVersion", "质量合格后才能调用 MCP"), ], [2000, 3500, 3860], ) add_heading(doc, "6.3 图片与结果规则", 2) add_bullets(doc, [ "质量失败必须返回具体原因:模糊、过曝、偏色、舌体不完整或遮挡。", "问答进度与图片任务分离保存,重新拍摄不丢失问答。", "MCP 必须返回版本、置信度、原始特征、错误码和 unsupported 状态。", "问答证据与舌象证据冲突时,应降低置信度并提示中医师进一步确认。", "输出使用“体质倾向、证候倾向、辅助评估”,不得表述为确诊或直接开方。", ]) # 7 Gateway add_heading(doc, "7. 门户调用智能体协议", 1) add_heading(doc, "7.1 请求结构", 2) add_code(doc, '''{ "hospitalId": "H001", "operatorId": "USER001", "subjectPatientId": "PATIENT001", "capabilityCode": "SMART_REGISTRATION", "taskId": "TASK001", "sessionId": "SESSION001", "input": { "type": "VOICE_TRANSCRIPT", "content": "我头疼,帮我挂神经内科明天早上九点的专家号" }, "context": { "channel": "H5", "currentPage": "HOME", "mock": true } }''') add_heading(doc, "7.2 响应结构", 2) add_code(doc, '''{ "success": true, "taskId": "TASK001", "taskStatus": "WAITING_USER", "currentStep": "ASK_DOCTOR_PREFERENCE", "collectedSlots": [], "missingSlots": ["doctorPreference"], "riskDecision": "ALLOW", "ui": [{ "type": "VOICE_FOLLOW_UP", "title": "请问您想挂哪位医生?", "quickReplies": ["李明 主任医师", "王佳 副主任医师", "不限医生"] }], "traceId": "TRACE001" }''') add_heading(doc, "7.3 能力编码", 2) add_table( doc, ["能力编码", "患者可见名称(医院可配置)", "P0 实现"], [ ("SMART_REGISTRATION", "智能分诊挂号 / 语音挂号", "FastGPT + Mock HIS MCP"), ("REPORT_INTERPRETATION", "报告智能解读", "FastGPT + Mock Report MCP + 知识库"), ("TCM_TONGUE_ASSESSMENT", "中医舌诊", "FastGPT + 现有舌诊 MCP"), ], [2700, 3300, 3360], ) # 8 UI add_heading(doc, "8. 标准 UI 组件协议", 1) add_table( doc, ["组件", "用途", "必要字段"], [ ("VOICE_INPUT", "主要语音输入", "prompt、status、allowTextFallback"), ("TRANSCRIPT", "转写回显与修改", "text、confidence、editable"), ("VOICE_FOLLOW_UP", "缺失字段追问", "slotName、prompt、quickReplies"), ("SLOT_SUMMARY", "显示信息完整性", "slots、status、confirmAction"), ("RISK_ALERT", "急诊/危急/不适用提示", "level、message、actions"), ("CANDIDATE_LIST", "科室、医生、号源或报告选择", "resultRef、version、expiresAt、items"), ("UPLOAD", "报告或舌象上传", "accept、qualityRequirements"), ("CONFIRM_ACTION", "事务或敏感操作确认", "summary、action、expiresAt"), ("PROCESSING", "长任务进度", "stage、canCancel、retryPolicy"), ("RESULT_SUMMARY", "完成结果与记录入口", "resultId、summary、nextActions"), ], [2200, 3300, 3860], ) add_callout(doc, "边界", "智能体只能返回标准组件 Schema,不允许返回任意 HTML 控制客户端。语音、文字和卡片入口最终调用同一个 capabilityCode。") # 9 MCP add_heading(doc, "9. Mock MCP 通用契约", 1) add_heading(doc, "9.1 返回格式", 2) add_code(doc, '''{ "success": false, "code": "SLOT_UNAVAILABLE", "message": "当前号源已不可用", "data": {}, "retryable": true, "traceId": "TRACE001", "mockScenario": "slot_taken" }''') add_heading(doc, "9.2 统一错误码", 2) add_table( doc, ["错误码", "含义", "客户端/Workflow 动作"], [ ("INVALID_ARGUMENT", "字段缺失或格式错误", "返回补槽或纠错"), ("LOW_CONFIDENCE", "识别或匹配置信度不足", "用户确认或人工处理"), ("NO_RESULT", "无科室、报告或号源结果", "提供替代条件"), ("RESULT_EXPIRED", "候选结果已过期", "重新查询"), ("VERSION_CONFLICT", "结果版本不一致", "废弃旧卡片并重新查询"), ("SLOT_UNAVAILABLE", "号源已被占用", "展示其他候选"), ("TIMEOUT", "调用超时", "按策略重试,不重复执行副作用"), ("DUPLICATE_REQUEST", "重复请求", "返回原业务结果"), ("UNAUTHORIZED", "未授权或患者不匹配", "停止流程并重新授权"), ("UNSUPPORTED", "当前报告/图片/业务不支持", "说明边界或转人工"), ("HUMAN_REVIEW_REQUIRED", "需要人工判断", "携带上下文转人工"), ], [2500, 3000, 3860], ) add_heading(doc, "9.3 幂等与版本", 2) add_bullets(doc, [ "事务请求必须携带 requestId;相同 requestId 返回同一结果。", "所有 Tool 响应必须返回 traceId。", "候选结果、报告结果和舌诊分析结果必须有版本。", "Mock 场景通过 mockScenario 显式注入,不在 Prompt 中随机决定失败。", ]) # 10 Safety add_heading(doc, "10. 医疗安全、隐私与人工转接", 1) add_table( doc, ["场景", "强制动作"], [ ("胸痛、明显呼吸困难、意识异常等红旗", "中止普通流程;急诊、120 或现场就医提示"), ("报告危急规则命中", "先展示风险行动,不用长篇解释稀释提示"), ("信息矛盾或连续识别失败", "转人工或提供传统页面入口"), ("患者身份/报告归属不一致", "停止处理,不展示或保存结果"), ("舌象图片质量不足或不适用", "说明原因并重新采集,不生成结论"), ("Tool 状态 UNKNOWN", "禁止宣称成功;进入查询状态或人工处理"), ], [3300, 6060], ) add_bullets(doc, [ "P0 所有患者数据均为 Mock,并在页面和演示脚本中明确标识。", "日志不记录不必要的完整身份证号、电话号码、报告原图和原始语音。", "人工转接应携带已收集槽位、风险结果、失败原因和 traceId,避免患者重复描述。", "患者可随时取消当前任务;取消后不得继续执行有副作用的 Tool。", ]) # 11 Acceptance add_heading(doc, "11. P0 金标用例与验收", 1) add_heading(doc, "11.1 挂号", 2) add_table( doc, ["用例", "输入", "预期"], [ ("完整但缺医生", "头疼;神经内科;明天 9 点;专家号", "只追问医生;完成后才能查询号源"), ("不限医生", "明天下午挂消化内科,医生都可以", "doctorPreference=NO_PREFERENCE,不重复追问"), ("纠正时间", "明天上午;随后改为后天下午", "旧值被替换,旧候选失效"), ("急诊红旗", "胸痛并呼吸困难,想挂心内科", "中止普通挂号并急诊引导"), ("号源失效", "选择已过期 candidateId", "提示重新查询,不进入挂号"), ], [1800, 3500, 4060], ) add_heading(doc, "11.2 报告与舌诊", 2) add_table( doc, ["智能体", "用例", "预期"], [ ("报告", "选择已审核血常规并授权", "完成风险检查、结构化解读与记录"), ("报告", "报告为 PRELIMINARY 或 NEED_REVIEW", "不生成正式解读;说明原因"), ("报告", "报告版本更新", "旧解读结果失效"), ("舌诊", "问答完成、图片模糊", "保留问答,只要求重拍"), ("舌诊", "问答与舌象证据冲突", "降低置信度并提示医生确认"), ("舌诊", "MCP 返回 UNSUPPORTED", "不生成证候结论,说明边界"), ], [1500, 3700, 4160], ) add_heading(doc, "11.3 通过标准", 2) add_bullets(doc, [ "所有金标用例可重复运行,结果稳定且 traceId 可查询。", "任何必填槽位缺失时,不得错误进入 Tool 执行。", "任何医疗安全 BLOCK 场景不得继续普通业务路径。", "FastGPT、Prompt、知识库或 MCP 修改后必须重新回归。", "演示环境可一键重置到固定 Mock 初始状态。", ]) # 12 Delivery add_heading(doc, "12. 实施交付清单", 1) add_table( doc, ["交付物", "负责人", "完成证据"], [ ("三智能体槽位 Schema", "FastGPT / 后端", "JSON Schema + 示例"), ("统一门户调用协议", "客户端 / Gateway", "请求响应样例"), ("标准 UI Schema", "客户端 / 产品", "组件清单 + 渲染 Demo"), ("Mock MCP 契约", "后端", "接口、错误码、Mock 场景"), ("三个 FastGPT Workflow", "智能体开发", "导出版本 + 节点说明"), ("金标测试集", "产品 / 测试", "输入、预期槽位、预期路径"), ("发布清单", "技术负责人", "版本、模型、Prompt、知识库、MCP"), ("演示脚本", "演示负责人", "正常、失败、安全路径"), ], [3000, 2000, 4360], ) add_heading(doc, "12.1 发布清单", 2) add_table( doc, ["版本项", "记录内容"], [ ("Portal", "客户端版本、能力配置版本"), ("Agent", "capabilityCode、FastGPT App、Workflow 版本"), ("Model / Prompt", "模型名称、参数、Prompt 版本"), ("Knowledge", "知识库名称、版本、审核人和更新时间"), ("Tool / MCP", "接口版本、Mock 数据集、错误场景版本"), ("Safety", "急诊/危急值/不适用规则版本"), ("Test", "回归测试版本、通过率、已知限制"), ], [2600, 6760], ) # Appendix add_heading(doc, "附录 A:FastGPT 画布落地建议", 1) add_callout(doc, "分区", "INPUT_* → NLP_* → SAFETY_* → SLOT_* → DECISION_* → TOOL_* → OUTPUT_*") add_table( doc, ["节点", "输入", "输出"], [ ("INPUT_VOICE_TRANSCRIPT", "语音转写、患者上下文", "rawUtterance"), ("NLP_ROUTE_CAPABILITY", "rawUtterance", "capabilityCode、confidence"), ("NLP_EXTRACT_SLOTS", "utterance + existingSlots", "slotUpdates"), ("SAFETY_GATE", "结构化症状/报告/图片状态", "riskDecision"), ("SLOT_VALIDATE", "slot schema + collectedSlots", "missing/conflicted/complete"), ("DECISION_NEXT_ACTION", "完整性与风险结果", "ASK / CONFIRM / TOOL / TRANSFER"), ("TOOL_CALL", "标准业务参数", "toolResult"), ("OUTPUT_UI_SCHEMA", "任务状态与结果", "标准 UI 组件"), ], [2900, 3200, 3260], ) add_heading(doc, "附录 B:统一任务上下文", 1) add_code(doc, '''{ "taskId": "TASK001", "sessionId": "SESSION001", "hospitalId": "H001", "operatorId": "USER001", "subjectPatientId": "PATIENT001", "capabilityCode": "SMART_REGISTRATION", "taskStatus": "WAITING_USER", "currentStep": "ASK_DOCTOR_PREFERENCE", "collectedSlots": {}, "missingSlots": [], "conflictedSlots": [], "riskDecision": "ALLOW", "activeResultRef": null, "toolResults": [], "evidence": [], "mock": true, "traceId": "TRACE001" }''') add_callout(doc, "最终原则", "语音负责表达,智能体负责补槽,规则负责门禁,卡片负责确认,MCP 负责确定性能力,任务记录负责恢复与追溯。", color=TEAL, fill="E9F7F7") doc.core_properties.title = "医梦 P0 三智能体语音交互、补槽字段与调用契约 V1.0" doc.core_properties.subject = "医梦患者智能服务门户(Adjutant)执行级附件" doc.core_properties.author = "医梦开发技术中心" doc.core_properties.keywords = "P0, FastGPT, 语音交互, 补槽, MCP, 挂号, 报告解读, 中医舌诊" doc.save(OUT) print(OUT)