build_voice_contract_docx.py 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752
  1. from __future__ import annotations
  2. from pathlib import Path
  3. from docx import Document
  4. from docx.enum.section import WD_SECTION
  5. from docx.enum.table import WD_ALIGN_VERTICAL, WD_TABLE_ALIGNMENT
  6. from docx.enum.text import WD_ALIGN_PARAGRAPH
  7. from docx.oxml import OxmlElement
  8. from docx.oxml.ns import qn
  9. from docx.shared import Inches, Pt, RGBColor
  10. OUT = Path(__file__).with_name("医梦P0三智能体语音交互、补槽字段与调用契约_V1.0.docx")
  11. PURPLE = "2B1F99"
  12. PURPLE_DARK = "211477"
  13. TEAL = "138E91"
  14. BLUE = "2E74B5"
  15. INK = "1A1835"
  16. MUTED = "706D89"
  17. LIGHT = "F4F5FB"
  18. LIGHT_BLUE = "E8EEF5"
  19. SUCCESS = "168469"
  20. WARNING = "C46B13"
  21. RED = "C63B3B"
  22. WHITE = "FFFFFF"
  23. BORDER = "D9DEEA"
  24. FONT_CN = "STSong"
  25. FONT_LATIN = "Calibri"
  26. def set_run_font(run, size=11, bold=False, color=INK, italic=False, font=FONT_CN):
  27. run.font.name = font
  28. run._element.get_or_add_rPr().rFonts.set(qn("w:eastAsia"), font)
  29. run._element.get_or_add_rPr().rFonts.set(qn("w:ascii"), font)
  30. run._element.get_or_add_rPr().rFonts.set(qn("w:hAnsi"), font)
  31. run.font.size = Pt(size)
  32. run.font.bold = bold
  33. run.font.italic = italic
  34. run.font.color.rgb = RGBColor.from_string(color)
  35. def shade_cell(cell, color):
  36. tc_pr = cell._tc.get_or_add_tcPr()
  37. shd = tc_pr.find(qn("w:shd"))
  38. if shd is None:
  39. shd = OxmlElement("w:shd")
  40. tc_pr.append(shd)
  41. shd.set(qn("w:fill"), color)
  42. def set_cell_margins(cell, top=80, start=120, bottom=80, end=120):
  43. tc = cell._tc
  44. tc_pr = tc.get_or_add_tcPr()
  45. tc_mar = tc_pr.first_child_found_in("w:tcMar")
  46. if tc_mar is None:
  47. tc_mar = OxmlElement("w:tcMar")
  48. tc_pr.append(tc_mar)
  49. for m, value in (("top", top), ("start", start), ("bottom", bottom), ("end", end)):
  50. node = tc_mar.find(qn(f"w:{m}"))
  51. if node is None:
  52. node = OxmlElement(f"w:{m}")
  53. tc_mar.append(node)
  54. node.set(qn("w:w"), str(value))
  55. node.set(qn("w:type"), "dxa")
  56. def set_table_geometry(table, widths):
  57. total = sum(widths)
  58. table.autofit = False
  59. table.alignment = WD_TABLE_ALIGNMENT.LEFT
  60. tbl_pr = table._tbl.tblPr
  61. layout = tbl_pr.find(qn("w:tblLayout"))
  62. if layout is None:
  63. layout = OxmlElement("w:tblLayout")
  64. tbl_pr.append(layout)
  65. layout.set(qn("w:type"), "fixed")
  66. tbl_w = tbl_pr.find(qn("w:tblW"))
  67. if tbl_w is None:
  68. tbl_w = OxmlElement("w:tblW")
  69. tbl_pr.append(tbl_w)
  70. tbl_w.set(qn("w:w"), str(total))
  71. tbl_w.set(qn("w:type"), "dxa")
  72. tbl_ind = tbl_pr.find(qn("w:tblInd"))
  73. if tbl_ind is None:
  74. tbl_ind = OxmlElement("w:tblInd")
  75. tbl_pr.append(tbl_ind)
  76. tbl_ind.set(qn("w:w"), "120")
  77. tbl_ind.set(qn("w:type"), "dxa")
  78. grid = table._tbl.tblGrid
  79. for child in list(grid):
  80. grid.remove(child)
  81. for width in widths:
  82. col = OxmlElement("w:gridCol")
  83. col.set(qn("w:w"), str(width))
  84. grid.append(col)
  85. for row in table.rows:
  86. for idx, cell in enumerate(row.cells):
  87. width = widths[min(idx, len(widths) - 1)]
  88. tc_pr = cell._tc.get_or_add_tcPr()
  89. tc_w = tc_pr.find(qn("w:tcW"))
  90. if tc_w is None:
  91. tc_w = OxmlElement("w:tcW")
  92. tc_pr.append(tc_w)
  93. tc_w.set(qn("w:w"), str(width))
  94. tc_w.set(qn("w:type"), "dxa")
  95. cell.vertical_alignment = WD_ALIGN_VERTICAL.CENTER
  96. set_cell_margins(cell)
  97. def add_table(doc, headers, rows, widths, header_fill=PURPLE):
  98. table = doc.add_table(rows=1, cols=len(headers))
  99. table.style = "Table Grid"
  100. table.rows[0]._tr.get_or_add_trPr().append(OxmlElement("w:tblHeader"))
  101. for idx, header in enumerate(headers):
  102. cell = table.rows[0].cells[idx]
  103. shade_cell(cell, header_fill)
  104. p = cell.paragraphs[0]
  105. p.alignment = WD_ALIGN_PARAGRAPH.CENTER
  106. p.paragraph_format.space_after = Pt(0)
  107. set_run_font(p.add_run(header), size=9.5, bold=True, color=WHITE)
  108. for row_data in rows:
  109. cells = table.add_row().cells
  110. for idx, value in enumerate(row_data):
  111. p = cells[idx].paragraphs[0]
  112. p.paragraph_format.space_after = Pt(0)
  113. p.paragraph_format.line_spacing = 1.12
  114. set_run_font(p.add_run(str(value)), size=9.2, color=INK)
  115. set_table_geometry(table, widths)
  116. doc.add_paragraph().paragraph_format.space_after = Pt(0)
  117. return table
  118. def add_para(doc, text="", size=11, bold=False, color=INK, align=None, after=6, italic=False):
  119. p = doc.add_paragraph()
  120. p.paragraph_format.space_before = Pt(0)
  121. p.paragraph_format.space_after = Pt(after)
  122. p.paragraph_format.line_spacing = 1.25
  123. if align is not None:
  124. p.alignment = align
  125. set_run_font(p.add_run(text), size=size, bold=bold, color=color, italic=italic)
  126. return p
  127. def add_bullets(doc, items):
  128. for item in items:
  129. p = doc.add_paragraph(style="List Bullet")
  130. p.paragraph_format.left_indent = Inches(0.375)
  131. p.paragraph_format.first_line_indent = Inches(-0.188)
  132. p.paragraph_format.space_after = Pt(4)
  133. p.paragraph_format.line_spacing = 1.25
  134. set_run_font(p.add_run(item), size=10.5)
  135. def add_numbered(doc, items):
  136. for item in items:
  137. p = doc.add_paragraph(style="List Number")
  138. p.paragraph_format.left_indent = Inches(0.375)
  139. p.paragraph_format.first_line_indent = Inches(-0.188)
  140. p.paragraph_format.space_after = Pt(4)
  141. p.paragraph_format.line_spacing = 1.25
  142. set_run_font(p.add_run(item), size=10.5)
  143. def add_callout(doc, label, text, color=PURPLE, fill=LIGHT):
  144. table = doc.add_table(rows=1, cols=2)
  145. table.style = "Table Grid"
  146. shade_cell(table.cell(0, 0), color)
  147. shade_cell(table.cell(0, 1), fill)
  148. p0 = table.cell(0, 0).paragraphs[0]
  149. p0.alignment = WD_ALIGN_PARAGRAPH.CENTER
  150. p0.paragraph_format.space_after = Pt(0)
  151. set_run_font(p0.add_run(label), size=10, bold=True, color=WHITE)
  152. p1 = table.cell(0, 1).paragraphs[0]
  153. p1.paragraph_format.space_after = Pt(0)
  154. p1.paragraph_format.line_spacing = 1.2
  155. set_run_font(p1.add_run(text), size=10, color=INK)
  156. set_table_geometry(table, [1260, 8100])
  157. doc.add_paragraph().paragraph_format.space_after = Pt(0)
  158. def add_code(doc, text):
  159. table = doc.add_table(rows=1, cols=1)
  160. table.style = "Table Grid"
  161. shade_cell(table.cell(0, 0), "F2F4F7")
  162. p = table.cell(0, 0).paragraphs[0]
  163. p.paragraph_format.space_after = Pt(0)
  164. p.paragraph_format.line_spacing = 1.0
  165. run = p.add_run(text)
  166. set_run_font(run, size=8.2, color=INK, font="Consolas")
  167. set_table_geometry(table, [9360])
  168. doc.add_paragraph().paragraph_format.space_after = Pt(0)
  169. def configure_styles(doc):
  170. styles = doc.styles
  171. normal = styles["Normal"]
  172. normal.font.name = FONT_CN
  173. normal._element.rPr.rFonts.set(qn("w:eastAsia"), FONT_CN)
  174. normal._element.rPr.rFonts.set(qn("w:ascii"), FONT_CN)
  175. normal._element.rPr.rFonts.set(qn("w:hAnsi"), FONT_CN)
  176. normal.font.size = Pt(11)
  177. normal.font.color.rgb = RGBColor.from_string(INK)
  178. normal.paragraph_format.space_after = Pt(6)
  179. normal.paragraph_format.line_spacing = 1.25
  180. for name, size, color, before, after in (
  181. ("Heading 1", 16, PURPLE, 18, 10),
  182. ("Heading 2", 13, PURPLE, 14, 7),
  183. ("Heading 3", 12, TEAL, 10, 5),
  184. ):
  185. st = styles[name]
  186. st.font.name = FONT_CN
  187. st._element.rPr.rFonts.set(qn("w:eastAsia"), FONT_CN)
  188. st._element.rPr.rFonts.set(qn("w:ascii"), FONT_CN)
  189. st._element.rPr.rFonts.set(qn("w:hAnsi"), FONT_CN)
  190. st.font.size = Pt(size)
  191. st.font.bold = True
  192. st.font.color.rgb = RGBColor.from_string(color)
  193. st.paragraph_format.space_before = Pt(before)
  194. st.paragraph_format.space_after = Pt(after)
  195. st.paragraph_format.keep_with_next = True
  196. for name in ("List Bullet", "List Number"):
  197. st = styles[name]
  198. st.font.name = FONT_CN
  199. st._element.rPr.rFonts.set(qn("w:eastAsia"), FONT_CN)
  200. st._element.rPr.rFonts.set(qn("w:ascii"), FONT_CN)
  201. st._element.rPr.rFonts.set(qn("w:hAnsi"), FONT_CN)
  202. st.font.size = Pt(10.5)
  203. def add_heading(doc, text, level=1):
  204. p = doc.add_heading(text, level=level)
  205. for run in p.runs:
  206. set_run_font(run, size={1: 16, 2: 13, 3: 12}[level], bold=True, color=PURPLE if level < 3 else TEAL)
  207. return p
  208. def add_page_number(paragraph):
  209. paragraph.alignment = WD_ALIGN_PARAGRAPH.RIGHT
  210. run = paragraph.add_run("第 ")
  211. set_run_font(run, size=9, color=MUTED)
  212. fld = OxmlElement("w:fldSimple")
  213. fld.set(qn("w:instr"), "PAGE")
  214. paragraph._p.append(fld)
  215. run2 = paragraph.add_run(" 页")
  216. set_run_font(run2, size=9, color=MUTED)
  217. doc = Document()
  218. configure_styles(doc)
  219. section = doc.sections[0]
  220. section.top_margin = Inches(0.82)
  221. section.bottom_margin = Inches(0.82)
  222. section.left_margin = Inches(1.0)
  223. section.right_margin = Inches(1.0)
  224. section.header_distance = Inches(0.42)
  225. section.footer_distance = Inches(0.42)
  226. header = section.header
  227. hp = header.paragraphs[0]
  228. hp.alignment = WD_ALIGN_PARAGRAPH.RIGHT
  229. set_run_font(hp.add_run("医梦 AI · P0 执行级附件"), size=8.5, color=MUTED)
  230. add_page_number(section.footer.paragraphs[0])
  231. # Cover
  232. add_para(doc, "医梦 AI", size=11, bold=True, color=TEAL, after=20)
  233. add_para(doc, "P0 三智能体语音交互、\n补槽字段与调用契约", size=26, bold=True, color=PURPLE_DARK, after=8)
  234. add_para(doc, "医梦患者智能服务门户(Adjutant)执行级附件", size=14, color=MUTED, after=22)
  235. add_callout(
  236. doc,
  237. "核心原则",
  238. "患者主要通过语音表达诉求;智能体负责识别意图、提取已知信息和持续补槽;必要信息未完整、安全检查未通过或用户未确认时,不得进入业务执行。",
  239. color=TEAL,
  240. fill="E9F7F7",
  241. )
  242. add_table(
  243. doc,
  244. ["项目", "内容"],
  245. [
  246. ("文件版本", "V1.0"),
  247. ("编制日期", "2026 年 7 月 26 日"),
  248. ("适用范围", "单医院 P0:智能分诊挂号、检验检查报告解读、中医舌诊"),
  249. ("约束来源", "《医梦智能体分级开发与平台演进规范 V1.0》与《医梦 P0 智能体 Demo 开发与 FastGPT 编排实施方案 V1.0》"),
  250. ("使用对象", "产品、客户端、FastGPT、Mock MCP、测试与演示负责人"),
  251. ],
  252. [1900, 7460],
  253. )
  254. add_para(doc, "本附件不替代两份上位规范,只负责把语音交互、补槽规则、系统调用和验收要求转换为可执行契约。", size=10, color=MUTED, italic=True, after=0)
  255. doc.add_page_break()
  256. # 1
  257. add_heading(doc, "1. 文档目的与执行边界", 1)
  258. add_para(doc, "本附件用于统一客户端、FastGPT Workflow、Mock MCP 和测试对“患者如何开始、智能体如何追问、什么时候允许进入下一步”的理解。P0 数据可以是 Mock,但状态、字段和错误语义必须稳定。")
  259. add_heading(doc, "1.1 本附件解决的问题", 2)
  260. add_bullets(doc, [
  261. "将语音明确为患者侧主要输入方式,文字、快捷选项、上传和卡片作为辅助交互。",
  262. "为三个智能体定义必填、条件必填、可选槽位,以及缺失、推断、确认、冲突和失效规则。",
  263. "定义客户端调用智能体、智能体调用 Mock MCP、智能体返回标准 UI 组件的统一协议。",
  264. "建立信息完整性门禁、医疗安全门禁和事务二次确认门禁。",
  265. "给出可重复演示脚本与金标验收用例。",
  266. ])
  267. add_heading(doc, "1.2 P0 不包含", 2)
  268. add_bullets(doc, [
  269. "真实医院 HIS、LIS、EMR、支付和生产号源。",
  270. "完整语音识别引擎训练;P0 可使用固定转写结果或浏览器语音能力。",
  271. "真实在线诊断、处方、正式急诊分级和自动临床决策。",
  272. "家庭成员代办界面;但请求模型必须区分 operatorId 与 subjectPatientId。",
  273. ])
  274. # 2
  275. add_heading(doc, "2. 统一语音交互契约", 1)
  276. add_heading(doc, "2.1 主交互链路", 2)
  277. add_callout(doc, "FLOW", "语音表达 → 转写回显 → 意图识别 → 槽位提取 → 安全检查 → 缺失字段追问 → 完整性摘要 → 用户确认 → Tool/MCP 调用 → 结果卡片 → 健康记录")
  278. add_heading(doc, "2.2 语音状态", 2)
  279. add_table(
  280. doc,
  281. ["状态", "患者侧表现", "系统动作"],
  282. [
  283. ("VOICE_READY", "显示“按住说话”", "等待患者输入"),
  284. ("LISTENING", "显示波形与取消入口", "采集语音;P0 可模拟"),
  285. ("TRANSCRIBING", "显示“正在识别”", "生成文字转写"),
  286. ("TRANSCRIPT_CONFIRM", "回显识别内容", "低置信度或关键字段需确认"),
  287. ("SLOT_FILLING", "语音追问 + 快捷回答", "一次只追问一个高优先级缺失项"),
  288. ("READY_TO_PROCEED", "显示结构化摘要", "所有门禁通过,等待确认"),
  289. ("PROCESSING", "显示进度和不可重复提交", "调用 Tool/MCP"),
  290. ("COMPLETED / FAILED", "结果或恢复操作", "保存任务结果与 traceId"),
  291. ],
  292. [2050, 3050, 4260],
  293. )
  294. add_heading(doc, "2.3 识别、确认与纠错规则", 2)
  295. add_bullets(doc, [
  296. "语音转写必须回显,患者可修改后再继续。",
  297. "患者说“不是明天,是后天”时,新值覆盖旧值,并使依赖旧条件的候选结果失效。",
  298. "日期、时间、科室、医生、指标名称必须保留 rawValue 与 normalizedValue。",
  299. "低置信度关键字段不得静默采用;应追问“您说的是李明主任吗?”",
  300. "支持“不限医生”“都可以”等有效值,表示偏好已经解决。",
  301. "连续两次无法识别时,提供文字、选项或人工服务入口。",
  302. "重要医疗提示和事务确认必须视觉留痕,不能只通过语音播报。",
  303. ])
  304. add_heading(doc, "2.4 通用槽位对象", 2)
  305. add_code(doc, '''{
  306. "name": "doctorPreference",
  307. "rawValue": "李主任",
  308. "normalizedValue": "DOCTOR_LIMING",
  309. "source": "VOICE | PROFILE | TOOL | USER_SELECTION",
  310. "confidence": 0.92,
  311. "status": "MISSING | INFERRED | CONFIRMED | INVALID | CONFLICTED",
  312. "required": true,
  313. "updatedAt": "2026-07-26T10:20:00+08:00"
  314. }''')
  315. # 3
  316. add_heading(doc, "3. 统一完整性与执行门禁", 1)
  317. add_heading(doc, "3.1 门禁顺序", 2)
  318. add_numbered(doc, [
  319. "意图门禁:明确患者要使用挂号、报告解读或中医舌诊中的哪一项服务。",
  320. "身份门禁:明确当前 hospitalId、operatorId、subjectPatientId;P0 默认本人但字段不得省略。",
  321. "医疗安全门禁:先处理急诊红旗、危急值、图片/报告不适用等风险。",
  322. "槽位完整性门禁:所有必填和条件必填槽位均已 CONFIRMED 或具有允许的默认值。",
  323. "用户确认门禁:执行前展示摘要,患者明确确认。",
  324. "工具执行门禁:请求版本、结果版本、幂等键和 Mock 场景合法。",
  325. ])
  326. add_callout(doc, "禁止", "不得使用“模型认为信息差不多完整”作为执行条件。信息完整性必须由确定性规则检查 missingSlots、conflictedSlots 和 riskDecision。", color=RED, fill="FFF1F1")
  327. add_heading(doc, "3.2 统一完整性结果", 2)
  328. add_table(
  329. doc,
  330. ["字段", "说明"],
  331. [
  332. ("isComplete", "必要字段是否全部解决"),
  333. ("missingSlots", "仍需追问的槽位,按优先级排序"),
  334. ("conflictedSlots", "患者多次表达不一致的槽位"),
  335. ("needsConfirmation", "存在推断值或低置信度值时为 true"),
  336. ("riskDecision", "ALLOW / BLOCK / HUMAN_REVIEW"),
  337. ("nextAction", "ASK_SLOT / CONFIRM_SUMMARY / CALL_TOOL / TRANSFER"),
  338. ],
  339. [2300, 7060],
  340. )
  341. # 4 Registration
  342. add_heading(doc, "4. 智能分诊挂号:语音与补槽契约", 1)
  343. add_heading(doc, "4.1 示例主对话", 2)
  344. add_table(
  345. doc,
  346. ["角色", "对话 / 系统行为"],
  347. [
  348. ("患者", "“我头疼,帮我挂神经内科明天早上九点的专家号。”"),
  349. ("智能体", "提取症状、科室、日期、时间、号别;先执行急诊风险检查;发现医生偏好缺失。"),
  350. ("智能体", "“已记录神经内科、明天上午九点和专家号。请问您想挂哪位医生?”"),
  351. ("患者", "“李明主任。”"),
  352. ("智能体", "展示完整摘要;患者确认后才查询号源。若 09:00 无精确号源,展示最接近的可选时段,不得自动替患者选择。"),
  353. ],
  354. [1500, 7860],
  355. )
  356. add_heading(doc, "4.2 槽位字典", 2)
  357. add_table(
  358. doc,
  359. ["槽位", "来源", "规则", "必填"],
  360. [
  361. ("subjectPatientId", "患者上下文", "P0 固定本人;接口必须携带", "是"),
  362. ("chiefComplaint", "语音", "分诊场景必须;直接找科室时可选", "条件"),
  363. ("campusId", "语音/默认/追问", "单院区可默认,但摘要中展示", "是"),
  364. ("departmentCode", "语音/分诊 Tool/选择", "必须映射医院标准科室", "是"),
  365. ("visitDate", "语音/追问", "将明天、下周一标准化为日期", "是"),
  366. ("timePreference", "语音/追问", "支持精确时间或上午/下午范围", "是"),
  367. ("registrationType", "语音/追问", "普通/专家/特需;医院可配置", "是"),
  368. ("doctorPreference", "语音/追问", "具体医生或 NO_PREFERENCE", "是"),
  369. ("emergencyDecision", "规则 Tool", "ALLOW / BLOCK / HUMAN_REVIEW", "是"),
  370. ("userConfirmed", "确认卡片", "锁号/挂号前必须 true", "是"),
  371. ],
  372. [2100, 1700, 4200, 1360],
  373. )
  374. add_heading(doc, "4.3 追问优先级", 2)
  375. add_callout(doc, "ORDER", "急诊风险信息 → 科室/症状 → 日期 → 时间 → 号别 → 医生偏好 → 院区 → 完整摘要确认", color=TEAL, fill="E9F7F7")
  376. add_heading(doc, "4.4 Tool 调用条件", 2)
  377. add_table(
  378. doc,
  379. ["Tool", "允许调用条件", "关键失败结果"],
  380. [
  381. ("triage.emergencyScreen", "出现症状或分诊诉求后立即调用", "BLOCK / HUMAN_REVIEW"),
  382. ("triage.recommendDepartments", "需要分诊且风险允许继续", "LOW_CONFIDENCE / NO_MATCH"),
  383. ("his.searchSchedules", "所有查询槽位完整并经患者确认", "NO_SLOT / TIMEOUT"),
  384. ("his.lockRegistrationSlot", "患者选择有效 resultRef + resultVersion + candidateId", "RESULT_EXPIRED / SLOT_UNAVAILABLE"),
  385. ("his.createRegistration", "锁号有效、就诊人一致、二次确认完成", "DUPLICATE / LOCK_EXPIRED"),
  386. ("payment.mockPay", "挂号订单已创建", "FAILED / UNKNOWN"),
  387. ],
  388. [2200, 4800, 2360],
  389. )
  390. add_heading(doc, "4.5 候选结果约束", 2)
  391. add_bullets(doc, [
  392. "候选号源必须携带 resultRef、resultVersion、expiresAt 和 candidateId。",
  393. "患者修改日期、科室、医生或号别后,旧候选立即失效并重新查询。",
  394. "精确时间无号时只展示替代结果,不得静默改为其他时间。",
  395. "锁号成功后必须再次展示医生、时间、费用和就诊人并确认。",
  396. ])
  397. # 5 Report
  398. add_heading(doc, "5. 报告解读:语音与补槽契约", 1)
  399. add_heading(doc, "5.1 入口与对话", 2)
  400. add_para(doc, "患者可以说“帮我解读刚出的血常规”,也可以从新报告待办进入。智能体先明确报告来源和归属,再判断报告是否为已审核、可解释版本。")
  401. add_table(
  402. doc,
  403. ["槽位", "规则", "必填"],
  404. [
  405. ("reportSource", "HOSPITAL_REPORT / IMAGE / PDF / MOCK_SAMPLE", "是"),
  406. ("reportId 或 fileId", "院内报告使用 reportId;上传使用 fileId", "是"),
  407. ("reportOwnerConfirmed", "确认属于当前患者;P0 固定本人", "是"),
  408. ("sourceStatus", "FINAL / PRELIMINARY;P0 仅 FINAL 进入正式解读", "是"),
  409. ("verificationStatus", "VERIFIED / NEED_REVIEW", "是"),
  410. ("reportFamily", "LAB / EXAM", "是"),
  411. ("reportType", "BLOOD_ROUTINE / ULTRASOUND 等", "是"),
  412. ("consentGranted", "患者同意 AI 辅助解读及结果保存", "是"),
  413. ("symptoms / medication / history", "根据异常组合条件追问,不应无差别询问", "条件"),
  414. ],
  415. [2300, 5500, 1560],
  416. )
  417. add_heading(doc, "5.2 报告可用性门禁", 2)
  418. add_callout(doc, "GATE", "报告存在 + 患者归属确认 + 已授权 + FINAL + VERIFIED + 当前版本有效 + 未命中禁止自动解读规则", color=TEAL, fill="E9F7F7")
  419. add_bullets(doc, [
  420. "报告更新、撤回或 sourceHash 变化后,旧解读结果标记 INVALIDATED。",
  421. "OCR 低置信度的关键指标、单位或参考范围必须回显确认。",
  422. "危急风险检查先于普通知识检索和解释生成。",
  423. "优先使用报告原始参考范围,不用 Prompt 中的固定范围覆盖。",
  424. "检查报告只解释报告文字,不宣称完成原始医学影像诊断。",
  425. ])
  426. add_heading(doc, "5.3 输出组件顺序", 2)
  427. add_numbered(doc, [
  428. "风险优先提示:需立即联系医生、急诊或无紧急风险。",
  429. "一句话概览:报告类型、异常数量和主要方向。",
  430. "异常指标或检查所见卡片。",
  431. "联合解读、影响因素与不确定性。",
  432. "下一步行动、建议科室和可向医生提出的问题。",
  433. "知识与规则来源、报告版本、解读版本和已知限制。",
  434. ])
  435. # 6 Tongue
  436. add_heading(doc, "6. 中医舌诊:语音与补槽契约", 1)
  437. add_heading(doc, "6.1 主流程", 2)
  438. add_callout(doc, "FLOW", "语音确认健康诉求 → 服务边界与授权 → 分组语音问答 → 问答完整性检查 → 拍摄指导 → 图片质量检查 → 现有舌诊 MCP → 问答与舌象融合 → 辅助评估结果")
  439. add_heading(doc, "6.2 槽位字典", 2)
  440. add_table(
  441. doc,
  442. ["槽位组", "核心字段", "规则"],
  443. [
  444. ("服务与授权", "healthGoal、consentGranted", "未授权不得上传和分析图片"),
  445. ("基础信息", "年龄、性别、既往疾病、当前用药", "患者档案优先,患者可纠正"),
  446. ("寒热", "怕冷/怕热、发热、手脚温度、出汗", "支持“不确定”"),
  447. ("脾胃", "食欲、腹胀、口干口苦、饮食偏好", "分组追问"),
  448. ("二便", "大便性状、小便颜色与频率", "允许跳过敏感问题"),
  449. ("睡眠精神", "入睡、易醒、疲倦、情绪", "分组追问"),
  450. ("女性专项", "月经、孕期、哺乳期", "条件出现"),
  451. ("舌象图片", "imageId、qualityStatus、analysisVersion", "质量合格后才能调用 MCP"),
  452. ],
  453. [2000, 3500, 3860],
  454. )
  455. add_heading(doc, "6.3 图片与结果规则", 2)
  456. add_bullets(doc, [
  457. "质量失败必须返回具体原因:模糊、过曝、偏色、舌体不完整或遮挡。",
  458. "问答进度与图片任务分离保存,重新拍摄不丢失问答。",
  459. "MCP 必须返回版本、置信度、原始特征、错误码和 unsupported 状态。",
  460. "问答证据与舌象证据冲突时,应降低置信度并提示中医师进一步确认。",
  461. "输出使用“体质倾向、证候倾向、辅助评估”,不得表述为确诊或直接开方。",
  462. ])
  463. # 7 Gateway
  464. add_heading(doc, "7. 门户调用智能体协议", 1)
  465. add_heading(doc, "7.1 请求结构", 2)
  466. add_code(doc, '''{
  467. "hospitalId": "H001",
  468. "operatorId": "USER001",
  469. "subjectPatientId": "PATIENT001",
  470. "capabilityCode": "SMART_REGISTRATION",
  471. "taskId": "TASK001",
  472. "sessionId": "SESSION001",
  473. "input": {
  474. "type": "VOICE_TRANSCRIPT",
  475. "content": "我头疼,帮我挂神经内科明天早上九点的专家号"
  476. },
  477. "context": {
  478. "channel": "H5",
  479. "currentPage": "HOME",
  480. "mock": true
  481. }
  482. }''')
  483. add_heading(doc, "7.2 响应结构", 2)
  484. add_code(doc, '''{
  485. "success": true,
  486. "taskId": "TASK001",
  487. "taskStatus": "WAITING_USER",
  488. "currentStep": "ASK_DOCTOR_PREFERENCE",
  489. "collectedSlots": [],
  490. "missingSlots": ["doctorPreference"],
  491. "riskDecision": "ALLOW",
  492. "ui": [{
  493. "type": "VOICE_FOLLOW_UP",
  494. "title": "请问您想挂哪位医生?",
  495. "quickReplies": ["李明 主任医师", "王佳 副主任医师", "不限医生"]
  496. }],
  497. "traceId": "TRACE001"
  498. }''')
  499. add_heading(doc, "7.3 能力编码", 2)
  500. add_table(
  501. doc,
  502. ["能力编码", "患者可见名称(医院可配置)", "P0 实现"],
  503. [
  504. ("SMART_REGISTRATION", "智能分诊挂号 / 语音挂号", "FastGPT + Mock HIS MCP"),
  505. ("REPORT_INTERPRETATION", "报告智能解读", "FastGPT + Mock Report MCP + 知识库"),
  506. ("TCM_TONGUE_ASSESSMENT", "中医舌诊", "FastGPT + 现有舌诊 MCP"),
  507. ],
  508. [2700, 3300, 3360],
  509. )
  510. # 8 UI
  511. add_heading(doc, "8. 标准 UI 组件协议", 1)
  512. add_table(
  513. doc,
  514. ["组件", "用途", "必要字段"],
  515. [
  516. ("VOICE_INPUT", "主要语音输入", "prompt、status、allowTextFallback"),
  517. ("TRANSCRIPT", "转写回显与修改", "text、confidence、editable"),
  518. ("VOICE_FOLLOW_UP", "缺失字段追问", "slotName、prompt、quickReplies"),
  519. ("SLOT_SUMMARY", "显示信息完整性", "slots、status、confirmAction"),
  520. ("RISK_ALERT", "急诊/危急/不适用提示", "level、message、actions"),
  521. ("CANDIDATE_LIST", "科室、医生、号源或报告选择", "resultRef、version、expiresAt、items"),
  522. ("UPLOAD", "报告或舌象上传", "accept、qualityRequirements"),
  523. ("CONFIRM_ACTION", "事务或敏感操作确认", "summary、action、expiresAt"),
  524. ("PROCESSING", "长任务进度", "stage、canCancel、retryPolicy"),
  525. ("RESULT_SUMMARY", "完成结果与记录入口", "resultId、summary、nextActions"),
  526. ],
  527. [2200, 3300, 3860],
  528. )
  529. add_callout(doc, "边界", "智能体只能返回标准组件 Schema,不允许返回任意 HTML 控制客户端。语音、文字和卡片入口最终调用同一个 capabilityCode。")
  530. # 9 MCP
  531. add_heading(doc, "9. Mock MCP 通用契约", 1)
  532. add_heading(doc, "9.1 返回格式", 2)
  533. add_code(doc, '''{
  534. "success": false,
  535. "code": "SLOT_UNAVAILABLE",
  536. "message": "当前号源已不可用",
  537. "data": {},
  538. "retryable": true,
  539. "traceId": "TRACE001",
  540. "mockScenario": "slot_taken"
  541. }''')
  542. add_heading(doc, "9.2 统一错误码", 2)
  543. add_table(
  544. doc,
  545. ["错误码", "含义", "客户端/Workflow 动作"],
  546. [
  547. ("INVALID_ARGUMENT", "字段缺失或格式错误", "返回补槽或纠错"),
  548. ("LOW_CONFIDENCE", "识别或匹配置信度不足", "用户确认或人工处理"),
  549. ("NO_RESULT", "无科室、报告或号源结果", "提供替代条件"),
  550. ("RESULT_EXPIRED", "候选结果已过期", "重新查询"),
  551. ("VERSION_CONFLICT", "结果版本不一致", "废弃旧卡片并重新查询"),
  552. ("SLOT_UNAVAILABLE", "号源已被占用", "展示其他候选"),
  553. ("TIMEOUT", "调用超时", "按策略重试,不重复执行副作用"),
  554. ("DUPLICATE_REQUEST", "重复请求", "返回原业务结果"),
  555. ("UNAUTHORIZED", "未授权或患者不匹配", "停止流程并重新授权"),
  556. ("UNSUPPORTED", "当前报告/图片/业务不支持", "说明边界或转人工"),
  557. ("HUMAN_REVIEW_REQUIRED", "需要人工判断", "携带上下文转人工"),
  558. ],
  559. [2500, 3000, 3860],
  560. )
  561. add_heading(doc, "9.3 幂等与版本", 2)
  562. add_bullets(doc, [
  563. "事务请求必须携带 requestId;相同 requestId 返回同一结果。",
  564. "所有 Tool 响应必须返回 traceId。",
  565. "候选结果、报告结果和舌诊分析结果必须有版本。",
  566. "Mock 场景通过 mockScenario 显式注入,不在 Prompt 中随机决定失败。",
  567. ])
  568. # 10 Safety
  569. add_heading(doc, "10. 医疗安全、隐私与人工转接", 1)
  570. add_table(
  571. doc,
  572. ["场景", "强制动作"],
  573. [
  574. ("胸痛、明显呼吸困难、意识异常等红旗", "中止普通流程;急诊、120 或现场就医提示"),
  575. ("报告危急规则命中", "先展示风险行动,不用长篇解释稀释提示"),
  576. ("信息矛盾或连续识别失败", "转人工或提供传统页面入口"),
  577. ("患者身份/报告归属不一致", "停止处理,不展示或保存结果"),
  578. ("舌象图片质量不足或不适用", "说明原因并重新采集,不生成结论"),
  579. ("Tool 状态 UNKNOWN", "禁止宣称成功;进入查询状态或人工处理"),
  580. ],
  581. [3300, 6060],
  582. )
  583. add_bullets(doc, [
  584. "P0 所有患者数据均为 Mock,并在页面和演示脚本中明确标识。",
  585. "日志不记录不必要的完整身份证号、电话号码、报告原图和原始语音。",
  586. "人工转接应携带已收集槽位、风险结果、失败原因和 traceId,避免患者重复描述。",
  587. "患者可随时取消当前任务;取消后不得继续执行有副作用的 Tool。",
  588. ])
  589. # 11 Acceptance
  590. add_heading(doc, "11. P0 金标用例与验收", 1)
  591. add_heading(doc, "11.1 挂号", 2)
  592. add_table(
  593. doc,
  594. ["用例", "输入", "预期"],
  595. [
  596. ("完整但缺医生", "头疼;神经内科;明天 9 点;专家号", "只追问医生;完成后才能查询号源"),
  597. ("不限医生", "明天下午挂消化内科,医生都可以", "doctorPreference=NO_PREFERENCE,不重复追问"),
  598. ("纠正时间", "明天上午;随后改为后天下午", "旧值被替换,旧候选失效"),
  599. ("急诊红旗", "胸痛并呼吸困难,想挂心内科", "中止普通挂号并急诊引导"),
  600. ("号源失效", "选择已过期 candidateId", "提示重新查询,不进入挂号"),
  601. ],
  602. [1800, 3500, 4060],
  603. )
  604. add_heading(doc, "11.2 报告与舌诊", 2)
  605. add_table(
  606. doc,
  607. ["智能体", "用例", "预期"],
  608. [
  609. ("报告", "选择已审核血常规并授权", "完成风险检查、结构化解读与记录"),
  610. ("报告", "报告为 PRELIMINARY 或 NEED_REVIEW", "不生成正式解读;说明原因"),
  611. ("报告", "报告版本更新", "旧解读结果失效"),
  612. ("舌诊", "问答完成、图片模糊", "保留问答,只要求重拍"),
  613. ("舌诊", "问答与舌象证据冲突", "降低置信度并提示医生确认"),
  614. ("舌诊", "MCP 返回 UNSUPPORTED", "不生成证候结论,说明边界"),
  615. ],
  616. [1500, 3700, 4160],
  617. )
  618. add_heading(doc, "11.3 通过标准", 2)
  619. add_bullets(doc, [
  620. "所有金标用例可重复运行,结果稳定且 traceId 可查询。",
  621. "任何必填槽位缺失时,不得错误进入 Tool 执行。",
  622. "任何医疗安全 BLOCK 场景不得继续普通业务路径。",
  623. "FastGPT、Prompt、知识库或 MCP 修改后必须重新回归。",
  624. "演示环境可一键重置到固定 Mock 初始状态。",
  625. ])
  626. # 12 Delivery
  627. add_heading(doc, "12. 实施交付清单", 1)
  628. add_table(
  629. doc,
  630. ["交付物", "负责人", "完成证据"],
  631. [
  632. ("三智能体槽位 Schema", "FastGPT / 后端", "JSON Schema + 示例"),
  633. ("统一门户调用协议", "客户端 / Gateway", "请求响应样例"),
  634. ("标准 UI Schema", "客户端 / 产品", "组件清单 + 渲染 Demo"),
  635. ("Mock MCP 契约", "后端", "接口、错误码、Mock 场景"),
  636. ("三个 FastGPT Workflow", "智能体开发", "导出版本 + 节点说明"),
  637. ("金标测试集", "产品 / 测试", "输入、预期槽位、预期路径"),
  638. ("发布清单", "技术负责人", "版本、模型、Prompt、知识库、MCP"),
  639. ("演示脚本", "演示负责人", "正常、失败、安全路径"),
  640. ],
  641. [3000, 2000, 4360],
  642. )
  643. add_heading(doc, "12.1 发布清单", 2)
  644. add_table(
  645. doc,
  646. ["版本项", "记录内容"],
  647. [
  648. ("Portal", "客户端版本、能力配置版本"),
  649. ("Agent", "capabilityCode、FastGPT App、Workflow 版本"),
  650. ("Model / Prompt", "模型名称、参数、Prompt 版本"),
  651. ("Knowledge", "知识库名称、版本、审核人和更新时间"),
  652. ("Tool / MCP", "接口版本、Mock 数据集、错误场景版本"),
  653. ("Safety", "急诊/危急值/不适用规则版本"),
  654. ("Test", "回归测试版本、通过率、已知限制"),
  655. ],
  656. [2600, 6760],
  657. )
  658. # Appendix
  659. add_heading(doc, "附录 A:FastGPT 画布落地建议", 1)
  660. add_callout(doc, "分区", "INPUT_* → NLP_* → SAFETY_* → SLOT_* → DECISION_* → TOOL_* → OUTPUT_*")
  661. add_table(
  662. doc,
  663. ["节点", "输入", "输出"],
  664. [
  665. ("INPUT_VOICE_TRANSCRIPT", "语音转写、患者上下文", "rawUtterance"),
  666. ("NLP_ROUTE_CAPABILITY", "rawUtterance", "capabilityCode、confidence"),
  667. ("NLP_EXTRACT_SLOTS", "utterance + existingSlots", "slotUpdates"),
  668. ("SAFETY_GATE", "结构化症状/报告/图片状态", "riskDecision"),
  669. ("SLOT_VALIDATE", "slot schema + collectedSlots", "missing/conflicted/complete"),
  670. ("DECISION_NEXT_ACTION", "完整性与风险结果", "ASK / CONFIRM / TOOL / TRANSFER"),
  671. ("TOOL_CALL", "标准业务参数", "toolResult"),
  672. ("OUTPUT_UI_SCHEMA", "任务状态与结果", "标准 UI 组件"),
  673. ],
  674. [2900, 3200, 3260],
  675. )
  676. add_heading(doc, "附录 B:统一任务上下文", 1)
  677. add_code(doc, '''{
  678. "taskId": "TASK001",
  679. "sessionId": "SESSION001",
  680. "hospitalId": "H001",
  681. "operatorId": "USER001",
  682. "subjectPatientId": "PATIENT001",
  683. "capabilityCode": "SMART_REGISTRATION",
  684. "taskStatus": "WAITING_USER",
  685. "currentStep": "ASK_DOCTOR_PREFERENCE",
  686. "collectedSlots": {},
  687. "missingSlots": [],
  688. "conflictedSlots": [],
  689. "riskDecision": "ALLOW",
  690. "activeResultRef": null,
  691. "toolResults": [],
  692. "evidence": [],
  693. "mock": true,
  694. "traceId": "TRACE001"
  695. }''')
  696. add_callout(doc, "最终原则", "语音负责表达,智能体负责补槽,规则负责门禁,卡片负责确认,MCP 负责确定性能力,任务记录负责恢复与追溯。", color=TEAL, fill="E9F7F7")
  697. doc.core_properties.title = "医梦 P0 三智能体语音交互、补槽字段与调用契约 V1.0"
  698. doc.core_properties.subject = "医梦患者智能服务门户(Adjutant)执行级附件"
  699. doc.core_properties.author = "医梦开发技术中心"
  700. doc.core_properties.keywords = "P0, FastGPT, 语音交互, 补槽, MCP, 挂号, 报告解读, 中医舌诊"
  701. doc.save(OUT)
  702. print(OUT)