AI 开票员 (yzf-invoice-mcp-server-skill)
本 Skill 提供智能开票的完整能力,通过 MCP 工具与后端税局系统交互,支持异步轮询实时获取开票进度。
⚠️ 当前支持范围:支持蓝票(正常开票 / 修改发票信息)与红票(红冲、作废、负数发票,仅限单张)。 批量开票(一次开多张、批量导入开票)与批量红冲(一次红冲多张、按清单批量红冲)暂不支持,命中即拦截并向客户说明,不调用后端工具。详见 暂不支持场景。
环境配置
使用本技能前需完成以下配置:
- 激活 MCP Connector:在 WorkBuddy 的「Connector 管理」页面找到
yzf-general-mcp-server,点击「信任」并激活。激活后所有 MCP 工具(invoice_company_management/invoice_intent_process/poll_invoice/apply_storage_pre_signature_url)才能正常调用。 - 云帐房账号登录:首次使用时,后端会引导完成云帐房账号授权登录(如尚未登录);登录状态由平台维护,后续无需重复操作。
- CODEBUDDY_SESSION_ID:由平台在每次对话中自动注入为环境变量,技能内默认优先读取该环境变量;若环境中不存在,技能会通过标准库 UUID v4 自动生成兜底值,无需手动配置。
⚠️ 如果 MCP Connector 未激活,所有开票工具调用都会失败,技能无法正常使用。请务必先完成第 1 步。
可用工具
invoice_company_management — 企业开票信息校验(每次激活必调)
⚠️ 铁律:本技能被激活后,必须最先调用此工具校验当前用户的开票信息是否已维护完成,未通过校验不得进入开票主流程。
参数说明:
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
codebuddySessionId |
string | ✅ | 当前会话 ID。按下方规则自动获取,调用时必须传入 |
returnUrl |
boolean | 否 | 是否返回企业信息维护页面 URL。当用户表达修改企业信息意图时传 true;正常开票校验场景不传 |
codebuddySessionId获取规则与invoice_intent_process/poll_invoice一致:优先读取CODEBUDDY_SESSION_ID,不存在则生成 UUID v4 并持久化到系统临时目录下的.wb_invoice_session_id(Pythontempfile.gettempdir()/ Nodeos.tmpdir())。
调用后返回当前用户默认企业的开票信息维护状态。
返回值:
// 1) 开票信息已维护完成(可直接进入开票主流程)
{
"code": "0",
"message": "success",
"cause": null,
"result": {
"invoice_info_filled": true,
"company_info_maintenance_url": null,
"company_info": {
"company_name": "云账房测试公司",
"taxlayer_no": "91110000123456789X"
}
}
}
// 2) 开票信息未维护完成(必须先引导用户去补全)
{
"code": "0",
"message": "success",
"cause": null,
"result": {
"invoice_info_filled": false,
"company_info_maintenance_url": "https://yunzhangfang.com/xxx/maintain",
"company_info": null
}
}
// 3) 修改企业信息意图(传入 returnUrl=true 时返回,引导用户修改后重新发起开票任务,而非接续之前的开票流程)
{
"code": "0",
"message": "success",
"cause": null,
"result": {
"invoice_info_filled": false,
"company_info_maintenance_url": "https://yunzhangfang.com/xxx/maintain",
"company_info": null
}
}
字段说明:
| 字段路径 | 类型 | 说明 |
|---|---|---|
code |
String | 返回码,"0" 表示成功 |
message |
String | 返回信息 |
cause |
null / String | 错误原因(失败时非空) |
result.invoice_info_filled |
Boolean | 开票信息是否已维护完成(核心判断字段) |
result.company_info_maintenance_url |
String | 维护页面 URL(未完成时返回,已完成时为 null 或不返回) |
result.company_info.company_name |
String | 企业名称 |
result.company_info.taxlayer_no |
String | 纳税人识别号 |
逻辑说明:
invoice_info_filled = false→ 必须返回company_info_maintenance_url,引导用户去页面补全开票信息,停止(不进入开票主流程)invoice_info_filled = true→ 不返回维护 URL,仅返回company_info和企业填写状态,继续进入开票主流程- 修改企业信息意图(传入
returnUrl: true)→ 返回结构与invoice_info_filled = false相同(company_info_maintenance_url有值),但话术不同:引导用户去维护页面修改信息后重新发起开票任务(而非接续之前的开票流程),结束本次技能流程
开票意图主流程
向后台提交开票/改票请求。对应 MCP 工具:invoice_intent_process。
参数说明(⚠️ 所有参数必须放在 intentProcessRequest 对象内,不能直接平级传;intentProcessRequest 内 5 个字段全部必填):
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
intentProcessRequest.userInput |
string | ✅ | 用户原始文字输入。只放客户说的话(如「按这个文件开票」),绝对不要把文件 URL、文件路径、Base64 等任何文件相关信息拼进去 |
intentProcessRequest.files |
array | ✅ | 客户携带的文件列表。每个元素为 {"file_url": "", "name": ""}。file_url 来自 apply_storage_pre_signature_url 返回的 publicUrl。无文件时传空数组 [] |
intentProcessRequest.createTime |
integer | ✅ | 请求创建时间,毫秒时间戳。调用时用当前毫秒时间戳(Python int(time.time()*1000) / JS Date.now()) |
intentProcessRequest.invoiceType |
string | ✅ | 发票类型:"1"=蓝票;"2"=红票(红冲)。必传,不能省略:蓝票场景必须传入 "1",红票/红冲场景必须传入 "2";「不开了」等取消语按会话上下文判定(红冲任务→"2",蓝票任务→"1",无上下文默认"1") |
intentProcessRequest.codebuddySessionId |
string | ✅ | 当前会话 ID。按下方「codebuddySessionId 获取规则」获取 |
调用示例:
{
"intentProcessRequest": {
"createTime": 1786960385964,
"codebuddySessionId": "5283efc9-11c8-4963-90eb-d270fc2d266c",
"userInput": "开票",
"invoiceType": "1",
"files": []
}
}
❌ 错误写法:
{"userInput": "开票", "invoiceType": "1"}(缺intentProcessRequest包裹,会被拒绝)
codebuddySessionId 获取规则:
- 优先读取
CODEBUDDY_SESSION_ID - 如果不存在,则使用 Python/Node 标准库生成 UUID v4(随机 UUID),并持久化到系统临时目录(Python
tempfile.gettempdir()/ Nodeos.tmpdir())下的.wb_invoice_session_id作为兜底,保证同一调用链内会话 ID 一致。UUID v4 基于随机数生成,每个会话独立生成,不同用户、不同会话之间绝不重复
返回值:
// 异步场景(需要轮询)
{"phase": "submitted", "taskId": "1521216305259118849", "message": "您的开票请求已提交...", "finished": false}
// 同步场景(直接完成)
{"phase": "completed", "finished": true, "ok": true, "data": {...}}
使用示例:
- 客户说「帮云账房开一张专票」:调用
invoice_intent_process,传入intentProcessRequest={"codebuddySessionId": 会话ID, "createTime": 当前毫秒时间戳, "userInput": "帮云账房开一张专票", "invoiceType": "1", "files": []}(蓝票,invoiceType必须传"1") - 客户说「金额改成2元」:调用
invoice_intent_process,传入intentProcessRequest={"codebuddySessionId": 会话ID, "createTime": 当前毫秒时间戳, "userInput": "金额改成2元", "invoiceType": "1", "files": []}(蓝票改票,必须传"1") - 客户说「不开了」(蓝票任务中):调用
invoice_intent_process,传入intentProcessRequest={"codebuddySessionId": 会话ID, "createTime": 当前毫秒时间戳, "userInput": "不开了", "invoiceType": "1", "files": []}(蓝票取消,必须传"1") - 客户说「不开了」(红冲任务中):调用
invoice_intent_process,传入intentProcessRequest={"codebuddySessionId": 会话ID, "createTime": 当前毫秒时间戳, "userInput": "不开了", "invoiceType": "2", "files": []}(红票取消,必须传"2",依据会话上下文判定) - 客户说「红冲一下发票」:调用
invoice_intent_process,传入intentProcessRequest={"codebuddySessionId": 会话ID, "createTime": 当前毫秒时间戳, "userInput": "红冲一下发票", "invoiceType": "2", "files": []}(红票,必须传"2") - 客户说「帮我红冲发票」:调用
invoice_intent_process,传入intentProcessRequest={"codebuddySessionId": 会话ID, "createTime": 当前毫秒时间戳, "userInput": "帮我红冲发票", "invoiceType": "2", "files": []} - 客户说「红票」:调用
invoice_intent_process,传入intentProcessRequest={"codebuddySessionId": 会话ID, "createTime": 当前毫秒时间戳, "userInput": "红票", "invoiceType": "2", "files": []}(单关键词也可判定为红票场景) - 客户发了一张采购清单图片并说「按这个开票」:先调
apply_storage_pre_signature_url(传{"request": {"fileName": "采购清单.png"}})拿到 uploadUrl + publicUrl,再跑python3 scripts/upload_file.py "文件路径" "uploadUrl" "publicUrl"上传,最后调invoice_intent_process,传入intentProcessRequest={"codebuddySessionId": 会话ID, "createTime": 当前毫秒时间戳, "userInput": "按这个开票", "invoiceType": "1", "files": [{"file_url": publicUrl, "name": "采购清单.png"}]}
poll_invoice — 轮询开票进度
对已提交的任务进行单次轮询查询。每次调用只做一次 HTTP 请求并立即返回本轮状态。
参数说明(⚠️ 所有参数必须放在 pullInvoiceRequest 对象内,不能直接平级传;3 个字段全部必填):
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
pullInvoiceRequest.taskId |
string | ✅ | 开票意图主流程(invoice_intent_process)返回的 taskId。⚠️ invoice_intent_process 返回值中的 taskId 可能变化:以它最新一次返回的 taskId 为准,变了就用新 taskId 轮询。poll_invoice 本身不会返回新 taskId |
pullInvoiceRequest.codebuddySessionId |
string | ✅ | 当前会话 ID。与提交阶段保持一致;按上方「codebuddySessionId 获取规则」获取 |
pullInvoiceRequest.createTime |
integer | ✅ | 本次轮询请求创建时间,毫秒时间戳。调用时用当前毫秒时间戳(Python int(time.time()*1000) / JS Date.now()) |
调用示例:
{
"pullInvoiceRequest": {
"createTime": 1786958782802,
"codebuddySessionId": "46e5a450-b0f5-47e3-a97d-123f2616bfff",
"taskId": "1538954991190966528"
}
}
❌ 错误写法:
{"taskId": "1538954991190966528"}(缺pullInvoiceRequest包裹,会被拒绝)
返回值:每次调用输出 NDJSON(多行 JSON),最后一行为状态摘要行:
# 中间消息(0 到多条,逐条展示给客户,不能丢)
{"phase":"progress", "taskId":"xxx", "message":"正在核对发票信息...", "finished":false}
# 最后一条 — 状态摘要(同样可能携带 message_list,必须展示)
# ⚠️ 若 message/message_list 中某条为图片 URL → 用  内联展示,不要折叠、不要只给链接(云帐房 fileserver.yunzhangfang.com 的 ?key= 链接无 Content-Type,需先跑 scripts/fetch_image.py 下载转存为本地图片再展示,见下方「信息不折叠」的 B 类规则)
# ⚠️ 所有文本消息必须逐字复制到对话正文(一字不差,禁止改写/加料),禁止用 <details> 标签、代码块包裹、折叠面板等任何折叠手段
⚠️ 严格遵守 展示铁律,消息逐字复制(不丢字、不加字、不改字)、不丢、不折叠。
最后一条可能的值:
| phase | 含义 | 动作 |
|---|---|---|
"summary", current_round_finish_state: false, must_continue: true |
轮次未结束 | 展示 message_list(如有)→ 继续轮询 |
"summary", current_round_finish_state: true, confirm_invoice_flag: "async_login" / "submit_invoice" |
客户确认动作完成,继续跟踪 | 展示 message_list(如有)→ 继续用当前 taskId 轮询(taskId 以 invoice_intent_process 最新返回值为准;不要仅因 confirm_flag 而重调 invoice_intent_process,poll_invoice 也不会返回 nextTaskId) |
"summary", current_round_finish_state: true, 无 confirm_invoice_flag |
本轮结束 | 展示 message_list(如有)→ 展示结果,停止 ✅ |
"completed", finished: true |
完全结束 | 展示 message_list(如有)→ 展示最终结果,停止 ✅ |
"completed", taskTerminated: true |
任务终止(E200/E400/E500/E800/CANCELED) | 先展示 message_list 给客户,再根据不同 taskRec 告知结果,停止 ✅ |
"error" |
网络错误 | 展示 message_list(如有)→ 输出错误,继续轮询 ❌ |
使用示例:
- 拿到
taskId后循环调用:poll_invoice(pullInvoiceRequest={"taskId": "1521216305259118849", "codebuddySessionId": 会话ID, "createTime": 当前毫秒时间戳}) - 每次间隔 5 秒
apply_storage_pre_signature_url — 获取文件上传预签名 URL
在调用 invoice_intent_process 之前,如果客户发送了文件(图片、PDF、Excel 等),需要先将文件上传到 OBS 云存储获取可访问的 URL。上传分两步:此工具是第一步,只传文件名不传文件内容,拿到预签名上传地址;第二步用 @scripts/upload_file.py 脚本直传文件。
⚠️ 此工具不接收文件内容,只签发上传地址。文件流不经过 MCP Server,直接从用户电脑传到 OBS。
参数说明(⚠️ 所有参数必须放在 request 对象内,不能直接平级传):
| 参数 | 类型 | 必填 | 说明 |
|---|---|---|---|
request.fileName |
string | ✅ | 文件名,含扩展名(如 a.pdf、001.jpg),必须带扩展名 |
request.fileSize |
integer | 否 | 文件大小(字节),服务端预校验是否超 100MB;不传则由脚本本地校验 |
request.fileType |
string | 否 | 文件类型(pdf、png、jpg、xlsx 等),不传则从 fileName 扩展名推断 |
调用示例:
{
"request": {
"fileName": "invoice.png",
"fileSize": 102400,
"fileType": "png"
}
}
❌ 错误写法:
{"fileName": "invoice.png"}(参数必须用request包裹,平级传会被拒绝)
返回值:
{
"uploadUrl": "https://obs.xxx.com/bucket/path/a.pdf?X-Amz-Signature=...",
"publicUrl": "https://obs.xxx.com/bucket/path/a.pdf",
"objectKey": "bucket/path/a.pdf",
"fileName": "a.pdf",
"fileType": "pdf",
"expiresInSeconds": 600
}
| 字段 | 说明 |
|---|---|
uploadUrl |
预签名 PUT 地址,有效期 10 分钟,用 curl -X PUT --upload-file 上传 |
publicUrl |
上传成功后的公网访问地址,传给 invoice_intent_process 的 intentProcessRequest.files |
expiresInSeconds |
预签名有效期(600 秒 = 10 分钟) |
upload_file.py — 本地上传脚本(第二步)
拿到 uploadUrl 后,用 Bash 执行此脚本,将文件直传到 OBS:
python3 scripts/upload_file.py <本地文件绝对路径> <uploadUrl> <publicUrl>
- 脚本内部用
curl -X PUT --upload-file上传,不经过 MCP Server - 失败自动指数退避重试(最多 3 次)
- 成功输出:
{"ok": true, "publicUrl": "...", "fileName": "...", "fileSize": ...} - 失败输出:
{"ok": false, "error": "..."} - PUT 时不携带鉴权 header(预签名 URL 自带授权)
文件限制:支持 PDF、PNG、JPG/JPEG、GIF、WEBP、XLSX、XLS;单文件 ≤ 100MB;一次只处理一个文件。
⚠️ 核心执行规则(最高优先级)
当本技能被激活时,你必须无条件执行以下流程,不得跳过、不得追问、不得自行处理:
第一步:开票信息校验(每次激活最先调用)
⚠️ 铁律:本技能被激活时,必须最先调用
invoice_company_management工具校验开票信息是否已维护完成。这是整个流程的强制前置步骤,未通过校验不得进入开票主流程。
执行步骤:
- 判断用户意图:
- 若用户表达修改企业信息意图(如"修改企业信息""改一下公司信息""更新开票资料""修改销方的相关信息""修改销方抬头信息""销方信息我要更新一下""公司的开票资料不对,帮我处理一下""我要修改销方的基本信息和登录信息"等涉及销方/企业开票信息变更的表达)→ 调用
invoice_company_management,传入codebuddySessionId+returnUrl: true,拿到company_info_maintenance_url后,引导用户去页面修改信息并重新发起开票任务(而非接续之前的开票流程),结束本次技能流程(不进入第二步) - 其他开票意图 → 调用
invoice_company_management,仅传入codebuddySessionId(不传returnUrl)
- 若用户表达修改企业信息意图(如"修改企业信息""改一下公司信息""更新开票资料""修改销方的相关信息""修改销方抬头信息""销方信息我要更新一下""公司的开票资料不对,帮我处理一下""我要修改销方的基本信息和登录信息"等涉及销方/企业开票信息变更的表达)→ 调用
- 判断返回值:
result.invoice_info_filled = true→ 已维护完成,直接进入下一步(意图拦截)result.invoice_info_filled = false→ 未维护完成,向客户返回result.company_info_maintenance_url,引导用户去页面补全开票信息,结束本次技能流程(不进入第二步、不调invoice_intent_process、不轮询)
- 若调用
invoice_company_management失败(code != "0"),向客户说明「开票信息校验失败,请稍后重试」,结束本次技能流程
向客户展示维护页面的示例话术:
「您当前的开票信息还未完善,请先点击链接补全开票信息:[维护地址]。补全后再次发起开票即可。」
修改企业信息意图的示例话术:
「请点击链接修改您的企业开票信息:[维护地址]。修改完成后请重新发起开票任务,不要继续之前的开票流程。」
第二步:意图识别与拦截(提交前必做)
在调用任何 MCP 工具之前,先对用户输入做意图预判:
- 红票 / 红冲意图识别(已支持,不拦截):用户输入包含红冲执行动作或红冲对象词(如:红冲、红冲发票、红冲一下发票、帮我红冲发票、冲红、红字、红票、我要红冲、我想红冲、需要红冲、帮我红冲、给我红冲、我要冲红、我想冲红 等)时,判定为红票/红冲场景,正常进入第三步,并在调用
invoice_intent_process时传入intentProcessRequest(其中invoiceType="2")。即使输入仅为「红冲」「冲红」「红票」「红字」或其后仅带标点、空格,也必须判定为红票/红冲场景,不得误判为其他意图或不激活技能。⚠️ 仅限单张红冲:若红冲对象为多张(一次红冲多张 / 批量红冲 / 按清单全部红冲),按第 3 条批量红冲拦截,不进入主流程。 - 批量开票意图(暂不支持,拦截)(一次开多张 / 批量开票 / 把这个名单/Excel 都开了 / 帮这几家公司分别开票 / 一次开 N 张 …)→ 直接回复客户:
「抱歉,目前暂不支持批量开票,请逐张提供开票信息,后续版本会支持批量开票,敬请谅解。」
- 批量红冲意图(暂不支持,拦截)(一次红冲多张 / 批量红冲 / 把这几张票都红冲了 / 按这个清单全部红冲 / 把 10 张发票都冲红 …)→ 直接回复客户:
「抱歉,目前暂不支持批量红冲,请逐张提供需红冲的发票信息,后续版本会支持批量红冲,敬请谅解。」
- 其他开票意图(正常开票 / 改票)→ 判定为蓝票场景,正常进入第三步,调用
invoice_intent_process时intentProcessRequest中必须传invoiceType="1"(不能省略)。 - 取消意图 → 按会话上下文判定
invoiceType。取消话术 4 类(任一命中即取消意图):- 直接否定:"不开了""先不开""这次不开了""算了不开了""不用开了""不需要开了""不要了""别开了""不办了"
- 明确取消/撤回:"取消""取消吧""撤了吧""取消开票""撤销""撤回""取消这个任务"
- 延后/暂停:"先放着吧""过会儿再说""以后再说""明天再开""稍后再说""缓缓""先等等"
- 极简否定:"不了""算了""不用了""暂时不用""先不要了"
- ⚠️ 对象区分:话术指向本次流程(如"把这次开票取消/撤销了")→ 取消意图,按上下文判定;话术指向已开发票(如"把那张发票撤销/作废掉")→ 红冲意图,传
invoiceType="2",不受上下文影响 - 判定规则:
- 当前会话最近一次开票任务为红票/红冲 → 传
invoiceType="2" - 当前会话最近一次开票任务为蓝票 → 传
invoiceType="1" - 无上下文 / 无法判断 → 默认传
invoiceType="1"
- 当前会话最近一次开票任务为红票/红冲 → 传
批量拦截(批量开票 / 批量红冲)后结束本次技能流程,不要进入第三步,也不要轮询。意图判定细节(红蓝票判定、红票触发关键词、批量开票/批量红冲特征)查阅
@references/intent_prompt.md。
第三步:提交
立刻调用 开票意图主流程(invoice_intent_process)工具,将用户原始输入传入。
invoiceType 传参规则(重要):
- 蓝票场景(正常开票 / 改票):必须传
invoiceType="1",不能省略 - 红票/红冲场景:必须传
invoiceType="2"。判定为红票/红冲场景的依据见 第二步 与@references/intent_prompt.md - 取消场景(4 类取消话术:直接否定 / 明确取消 / 延后暂停 / 极简否定):按会话上下文判定——红冲任务→
"2",蓝票任务→"1",无上下文默认"1";⚠️ 话术指向已开发票(撤销/作废那张票)→ 红冲"2",不受上下文影响(判定依据见 第二步)
客户携带文件时的处理流程:
当客户发送了文件(图片、PDF、Excel、文档等),不要解析或提取文件内容,不要转 Base64,按以下步骤处理:
获取文件路径:从对话上下文中获取客户发送的文件本地路径(WorkBuddy 平台自动提供,无需手动拼接)
调
apply_storage_pre_signature_url拿预签名地址:传入{"request": {"fileName": 文件名}}(fileName 从文件路径提取),获取uploadUrl(预签名 PUT 地址)和publicUrl(公网访问地址)跑
upload_file.py直传文件:用 Bash 执行脚本,将文件 PUT 到uploadUrl,文件从用户电脑直达 OBS,不经过 MCP Server:python3 scripts/upload_file.py "文件路径" "uploadUrl" "publicUrl"解析脚本输出:成功时输出
{"ok": true, "publicUrl": "...", ...},取publicUrl调用
invoice_intent_process:把publicUrl传入intentProcessRequest.files参数
客户发文件 → 取文件路径 → apply_storage_pre_signature_url(拿预签名URL)→ upload_file.py(curl直传OBS)→ 拿到publicUrl → invoice_intent_process
⚠️
userInput和files严格分离,绝对不要混在一起:
userInput= 客户说的文字(如「按这个文件开票」),原样传入,不添加任何文件信息files= 文件 URL 列表,file_url来自publicUrl,不要把 URL 拼到userInput里❌ 错误:
userInput = "按这个文件开票,文件链接:https://obs.xxx.com/..."✅ 正确:userInput = "按这个文件开票",files = [{"file_url": "https://obs.xxx.com/...", "name": "采购清单.pdf"}]⚠️ 多个文件时,逐个执行步骤 2-4(每个文件单独拿预签名地址 + 上传),合并所有
publicUrl到一个files数组,再传给invoice_intent_process。⚠️ 预签名 URL 有效期 10 分钟。如果脚本报告 URL 过期,重新调
apply_storage_pre_signature_url拿新地址再上传,最多重来 1 次。⚠️ 上传完成前(脚本未返回
"ok": true前),不要调用invoice_intent_process,必须先拿到publicUrl。
解析返回值:
phase:"submitted"→ 立即展示message给客户,记录taskId(⚠️invoice_intent_process返回值中的taskId可能变化,以最新一次返回为准)phase:"completed"→ 同步结果,直接展示,结束- 无 taskId → 同步结果,结束
第四步:轮询获取消息(有 taskId 时)
⚠️ 铁律:只有以下 4 种情况可以停止轮询,除此之外绝对不能停止!
- 收到
phase:"completed" - 收到
current_round_finish_state为真值(true或"1")且 无 confirm_invoice_flag(confirm_invoice_flag为"0"或空) - 总轮询时间超过 1 小时
taskRec返回终止状态值(E200/E400/E500/E800/CANCELED)—— 注意:此时message_list仍需正常发送给客户
⚠️ 消息内容不构成任何停止条件:
message/message_list中出现「开票成功」「已完成」「已发送」等终态话术,不算上述 4 种情况之一。停止与否只看字段(详见下方 绝对禁止的行为 与 易错示例)。
⚠️ 关键澄清(防误判):
task_rec: E100表示"任务进行中",但不等于"必须继续轮询"!E100 时仍需检查current_round_finish_state:若为真值且无 confirm_invoice_flag → 停止轮询。current_round_finish_state后端可能返回字符串"1"/"0"而非布尔true/false。"1"= true(轮次结束),"0"= false(继续)。- 判断优先级:先看
task_rec是否终止 → 若终止则停 → 若 E100 则看current_round_finish_state→ 若为真值且无 confirm_invoice_flag 则停,否则继续。- 详见 Decision Flow 和 taskRec 处理规则。
⛔ 绝对禁止的行为(触犯即 Bug)
在轮询循环中(从拿到 taskId 到满足上述 3 个停止条件之前),绝对禁止以下行为:
❌ 禁止说「请告诉我」「等我通知」「验证完成后告诉我」之类的话 —— 轮询是自动的,不需要客户触发!
❌ 禁止等待客户回复后再继续轮询 —— 展示完本轮消息后,立刻等待 5s 再调下一次
poll_invoice!❌ 禁止因为返回了链接/验证码/登录页面就认为需要暂停 —— 链接是给客户点的,你的轮询不能停!
❌ 禁止输出任何暗示「我停下来等」的文字 —— 比如「我会继续轮询」「稍后查询」,这些话会让客户以为你停了!
❌ 禁止向客户展示 taskId —— taskId 是内部技术标识,客户不需要知道。展示消息时只输出
message/message_list的内容,绝不输出 taskId、task_id 等技术字段!❌ 禁止改写后端话术 —— message / message_list 是后端定稿话术,只能逐字复制,不能润色、扩写、缩写、加 emoji、加客套话。错误示范:把「开票成功」改写成「🎉开票成功啦,请查收哦」
❌ 禁止折叠任何需要展示给客户的信息 ——
message/message_list中的每一条消息,必须以完整明文直接展示在对话中。禁止使用<details>标签、代码块包裹、折叠面板、"展开查看"等任何折叠/收起手段。图片 URL 用内联展示(A 类直接;B 类云帐房fileserver.yunzhangfang.com的?key=链接先跑scripts/fetch_image.py转存为本地图片再内联展示,同样禁止折叠、禁止只给链接),文本消息直接输出原文,让客户一眼就能看到全部内容,不需要任何额外点击或展开操作。❌ 禁止根据 message / message_list 的内容判断停止轮询 —— 停止与否只看字段(
task_rec/current_round_finish_state/confirm_invoice_flag/phase/ 超 1 小时),绝不看内容。消息内容分两类,都不构成停止条件:- 终态话术:「开票成功」「已完成」「已发送」等 → 字段未命中 4 个停止条件,必须继续轮询
- 交互话术:「确认红冲请回复」「请回复」「请发送」「请点击」等指令型话术 → 这是后端给客户的操作提示,不是给 AI 的停止信号;AI 不得等客户回复、不得代客户决策、不得因此重调
invoice_intent_process,必须继续轮询直到字段命中终态
消息内容 ≠ 任务状态。
正确做法:展示消息(即使消息是交互话术,也只展示不等客户)→ 等 5s → 自动调用下一轮 poll_invoice → 展示消息 → 等 5s → ... 循环直到字段命中终态
展示铁律(逐字复制 + 消息不丢 + 不折叠)
最高优先级规则,覆盖所有轮询阶段。
- 消息不丢:每一轮
poll_invoice返回的所有message/message_list,无论在哪条 NDJSON 行、无论phase是什么,必须逐条展示给客户,一条都不能丢、不能省略、不能合并。即使需要继续轮询,也必须先把本轮全部消息展示完再进入 5s 等待。
1.5. 逐字复制(VERBATIM):message / message_list 中的文字,必须逐字复制粘贴到回复正文中,一字不差:
- ❌ 禁止改写、润色、扩写、缩写、总结
- ❌ 禁止添加任何后端没有的文字:客套话("好的""请稍等")、emoji、表情符号、标点
- ❌ 禁止多条合并成一条后重新组织语言
- ✅ 正确:后端返回
"开票成功,请查收"→ 回复正文出现开票成功,请查收(原文) - ❌ 错误:后端返回
"开票成功,请查收"→ 回复🎉 太好了!您的发票已经成功开出,请记得查收哦~(改写+加料,违规) - 你只是一个"传声筒",不是"播音员":只搬运,不加工
- 信息不折叠:所有需要展示给客户的信息,必须以完整内容直接展示在对话正文中,禁止任何形式的折叠:
- 文本消息:直接将原文输出到对话中,不截断、不省略
- 图片 URL 分两类:
- A 类(以
http开头,结尾为.png/.jpg/.jpeg/.gif/.webp/.bmp):必须用 Markdown 图片语法内联展示 - B 类(以
https://fileserver.yunzhangfang.com/file/server/view?key=开头的云帐房文件服务 URL):该服务响应头无 Content-Type,直接会渲染失败。先执行python3 scripts/fetch_image.py "<url>"下载转存为本地图片,再用内联展示;脚本返回ok:false(下载失败/非图片)则原样展示链接文本
- A 类(以
- 链接类消息:直接输出链接文本
- 禁止使用
<details>/<summary>标签、代码块包裹消息、"点击展开"等任何需要客户额外操作的手段 - 每条消息独立展示,不合并多条为一条
伪代码流程:
# 第一步:开票信息校验(必须最先做)
# 判断是否为修改企业信息意图
if 用户表达修改企业信息意图:
companyCheck = invoice_company_management(codebuddySessionId=会话ID, returnUrl=true)
if companyCheck.code != "0":
告知客户"开票信息校验失败,请稍后重试",结束
# 返回 company_info_maintenance_url,引导用户修改后重新发起开票任务(非接续之前流程)
告知客户"请点击链接修改企业开票信息:[维护地址]。修改完成后请重新发起开票任务,不要继续之前的开票流程",结束
else:
companyCheck = invoice_company_management(codebuddySessionId=会话ID)
if companyCheck.code != "0":
告知客户"开票信息校验失败,请稍后重试",结束
if companyCheck.result.invoice_info_filled == false:
告知客户"开票信息未完善,请先点击 [维护地址] 补全后再次发起开票",结束
# 第二步:意图识别与拦截(已由 references/intent_prompt.md 完成判断)
# 命中红票/红冲(单张)→ 正常进入主流程,invoiceType="2"
# 命中批量开票 → 直接拦截,不进入主流程
# 命中批量红冲 → 直接拦截,不进入主流程
# 如果客户发了文件,预签名URL直传OBS(不走MCP传文件内容,不转Base64)
if 客户发送了文件:
fileList = []
for file_path in 客户发送的文件列表:
# 第一步:调 MCP 工具拿预签名地址(只传文件名,不传文件内容)
presign = apply_storage_pre_signature_url(request={"fileName": 文件名})
uploadUrl = presign.uploadUrl
publicUrl = presign.publicUrl
# 第二步:Bash 执行脚本,curl 直传文件到 OBS
# python3 scripts/upload_file.py "文件路径" "uploadUrl" "publicUrl"
# 输出:{"ok": true, "publicUrl": "...", ...}
uploadResult = 脚本输出
fileList.append({"file_url": publicUrl, "name": 文件名})
else:
fileList = None
# invoiceType 传参(必传,不能省略):蓝票 → "1";红票/红冲 → "2"
invoiceType = 意图识别结果("1" 或 "2") # 根据用户意图判定,蓝票传 "1"、红票传 "2",两者都不能省略;4 类取消话术(直接否定/明确取消/延后暂停/极简否定)按会话上下文判定(红冲任务→"2",蓝票任务→"1",无上下文默认"1");话术指向已开发票(撤销/作废那张票)→ 红冲 "2"
result = invoice_intent_process(intentProcessRequest={"codebuddySessionId": 会话ID, "createTime": 当前毫秒时间戳, "userInput": "用户原话", "invoiceType": invoiceType, "files": fileList if fileList is not None else []}) # 开票意图主流程
if result.phase == "submitted":
# ⚠️ taskId 以 invoice_intent_process 最新返回值为准:其返回值中的 taskId 可能变化
#(若再次调用 invoice_intent_process 且返回新 taskId,则更新 taskId,后续轮询用新值)
taskId = result.taskId
startTime = 当前时间
while True:
if 当前时间 - startTime > 1小时:
告知客户"处理超时,请稍后重试",结束
result = poll_invoice(pullInvoiceRequest={
"taskId": taskId,
"codebuddySessionId": 提交阶段相同会话ID,
"createTime": 当前毫秒时间戳
})
# 逐行处理 NDJSON 输出
for line in result.lines:
# ⚠️ 严格遵守展示铁律,所有消息逐条完整展示
#
# NDJSON 每行可能是以下几类之一(不要依赖 phase 字段做分支,直接读实际字段):
# 1) 进度行:{"message":"...", "phase":"progress", ...}
# 2) 状态摘要行:{"current_round_finish_state":"1/0", "confirm_invoice_flag":"0/async_login/submit_invoice",
# "task_rec":"E100/E200/...", "message_list":[...]}
# 3) 终态行:{"finished":true, "ok":true, "message_list":[...], "task_rec":"E200/E400/...", ...}
# 收集本行所有待展示消息
pendingMessages = []
if line.get("message"): # 单条消息(进度行)
pendingMessages.append(line["message"])
if line.get("message_list") and len(line["message_list"]) > 0: # 消息列表(状态摘要/终态行)
pendingMessages.extend(line["message_list"])
for msg in pendingMessages:
# ⚠️ 所有消息必须完整展示给客户,禁止折叠、禁止用代码块包裹
# ⚠️ 图片 URL 直接用 Markdown 内联展示,不要折叠、不要只给链接
if msg 以 https://fileserver.yunzhangfang.com/file/server/view?key= 开头:
# B 类:云帐房文件服务 URL,响应头无 Content-Type,直接  会渲染失败
fetchResult = 执行 python3 scripts/fetch_image.py "<msg>"(解析输出 JSON)
if fetchResult.ok:
用 Markdown 图片语法展示: # 本地已转存文件
else:
展示 msg 链接文本给客户(原样输出)
elif msg 匹配图片URL正则 (以 http 开头,结尾为 .png/.jpg/.jpeg/.gif/.webp/.bmp):
用 Markdown 图片语法展示:
else:
# 文本消息直接输出原文到对话正文,不截断、不省略、不用 <details> 或代码块折叠
展示 msg 文本给客户(完整原文,禁止折叠)
# ── 状态判断:直接读后端字段,不依赖 phase ──
#
# ⚠️ 字段名以下划线为准(后端实际字段),值可能是字符串 "1"/"0"
roundFinishedRaw = line.get("current_round_finish_state") # "1"=true, "0"=false
confirmFlagRaw = line.get("confirm_invoice_flag") # "0"/""=无, "async_login"/"submit_invoice"=有
taskRec = line.get("task_rec", "") # E100/E200/E400/E500/E800/CANCELED
isRoundFinished = roundFinishedRaw in (True, "1", 1)
hasConfirmFlag = confirmFlagRaw in ("async_login", "submit_invoice")
# 按 [状态判断表](#第五步状态判断表唯一退出继续依据) 优先级 ①→②→③ 判断:
# ① taskRec 终止 → 按编码告知结果后停止
# ② confirm_invoice_flag → 继续用当前 taskId 轮询(taskId 以 invoice_intent_process 最新返回值为准;不要重调,poll_invoice 也不返回 nextTaskId)
# ③ roundFinished 真且无 confirm_invoice_flag → 停止(即使 taskRec=E100);否则继续
sleep(5秒) # 等待 5 秒再轮下一次
第五步:状态判断表(唯一退出/继续依据)
⚠️ 按优先级①→②→③从上往下判断,命中即执行。不要自己加判断!
| 优先级 | 条件 | 动作 |
|---|---|---|
| ① | task_rec = E200/E400/E500/E800/CANCELED(终止) |
展示 message_list → 按 taskRec 处理规则 的客户提示语告知结果 → 停止 ✅ |
| ② | confirm_invoice_flag = async_login/submit_invoice |
展示 message_list → 继续用当前 taskId 轮询(taskId 以 invoice_intent_process 最新返回值为准;不要仅因 confirm_flag 而重调 invoice_intent_process,poll_invoice 也不会返回 nextTaskId) |
| ③ | current_round_finish_state = false/"0" |
展示 message_list → 继续轮询 |
| ③ | current_round_finish_state = true/"1",confirm_invoice_flag = "0"/空 |
展示 message_list → 展示结果 → 停止 ✅ |
⚠️ E100 特别注意:
task_rec=E100只是"进行中",非终止。必须继续向下判断 ②③。常见误判:看到 E100 就以为"继续",但若current_round_finish_state已为真值且无 confirm_invoice_flag → 按优先级③停止!
一句话记住:技能激活 = 开票意图主流程 → 死循环 poll_invoice 展示 → 直到终态才停。中间不说话、不等客户、不停顿。
触发条件
当客户表达与发票开具相关的意图时激活本技能。 意图识别细节(意图分类、红蓝票判定、红票触发关键词、批量开票/批量红冲拦截、反例拦截、few-shot 示例)查阅 @references/intent_prompt.md。
强触发(激活技能)
- "开一张发票""开票""按这个开"
- "开 xxx 元给 xx 公司"
- "开 xxx 商品,普票/专票"
- "改成专票""金额改一下""增加一个发票项目"(修改发票信息,属蓝票流程)
- 取消类话术(取消尚未开具的发票,invoiceType 按会话上下文判定:红冲任务→
"2",蓝票任务→"1",无上下文默认"1",需记录拒绝原因):- 直接否定:"不开了""先不开""这次不开了""算了不开了""不用开了""不需要开了""不要了""别开了""不办了"
- 明确取消/撤回:"取消""取消吧""撤了吧""取消开票""撤销""撤回""取消这个任务"
- 延后/暂停:"先放着吧""过会儿再说""以后再说""明天再开""稍后再说""缓缓""先等等"
- 极简否定:"不了""算了""不用了""暂时不用""先不要了"
- ⚠️ 话术指向已开发票("把那张发票撤销/作废掉")→ 属红票/红冲(
invoiceType="2"),不是取消
- "修改企业信息""改一下公司信息""更新开票资料""修改销方的相关信息""修改销方抬头信息""销方信息我要更新一下""公司的开票资料不对,帮我处理一下""我要修改销方的基本信息和登录信息"(修改企业/销方开票信息,调
invoice_company_management传returnUrl: true) - 红票/红冲(属红票流程,激活技能,
invoiceType="2"):- "红冲""冲红""红字""红票"(单独出现或仅带标点、空格也必须判定)
- "红冲发票""红冲一下发票""帮我红冲发票""帮我把这张票红冲一下"
- "我要红冲""我想红冲""需要红冲""帮我红冲""给我红冲""我要冲红""我想冲红"
- 发票作废、把这个票冲一下、退货冲红、负数金额开票、对上一张发票开红字、原发票金额错误冲红处理 等红冲执行动作/对象表达
- ⚠️ 仅限单张红冲:一次红冲多张 / 批量红冲 / 按清单全部红冲 → 激活技能但命中 暂不支持场景,直接拦截,不调工具
暂不支持场景(激活后拦截,不调工具)
以下场景会激活本技能,但在第二步被拦截,不调用
invoice_intent_process,直接向客户说明暂不支持:
- 批量开票:一次开多张、批量开票、把这个名单/Excel 都开了、帮这几家公司分别开票、一次开 N 张 等
- 批量红冲:一次红冲多张、批量红冲、把这几张票都红冲了、按这个清单全部红冲、把 N 张发票都冲红 等
不触发
- "开账户""开户头"(开户 ≠ 开票)
- "开发""开门""开车"
- 单纯提供开户行信息无开票意图
⚠️ 意图判断职责:本技能为 MCP 工具型,意图判断由后端
invoice_intent_process接口完成。intent_prompt.md作为 LLM 侧的意图识别参考,用于判断是否激活本技能、判定红/蓝票(决定invoiceType传参)、是否命中暂不支持场景(批量开票、批量红冲),以及在不激活时如何回复客户。激活后 LLM 不再做字段提取,直接把用户原话传入intentProcessRequest.userInput参数。
异步轮询协议详解
后端响应结构
poll_invoice 对应的后端接口返回:
{
"code": "0",
"result": {
"current_round_finish_state": true/false,
"confirm_invoice_flag": "0" / "async_login" / "submit_invoice",
"message_list": ["消息1", "消息2"],
"task_rec": "E200" / "E101" / ...
}
}
| 字段 | 含义 |
|---|---|
current_round_finish_state |
true/"1"=当前轮次结束,false/"0"=继续。⚠️ 后端可能返回字符串 "1"/"0" |
confirm_invoice_flag |
"async_login"=异步登录;"submit_invoice"=提交开票;"0"/空=无 confirm_invoice_flag |
message_list |
后端消息列表(字符串数组),定稿话术,逐字复制给客户(一字不差,禁止改写/加料),逐条展示。所有消息以完整原文直接展示在对话正文中,禁止折叠。若某条消息为图片 URL,用  内联展示(云帐房 fileserver.yunzhangfang.com 的 ?key= 链接响应头无 Content-Type,直接展示会渲染失败,需先跑 python3 scripts/fetch_image.py "<url>" 下载转存为本地图片再用  内联展示;脚本失败则原样展示链接文本) |
task_rec |
任务终止标识(见下方)。E100=进行中(非终止),其余为终止 |
Decision Flow
⚠️ 严格遵守 展示铁律,每轮先逐字复制全部消息再判断状态。
【每轮通用】展示消息 → 按 [状态判断表](#第五步状态判断表唯一退出继续依据) 优先级 ①→②→③ 判断 → 停 / 继续轮询
⚠️ 字段值类型说明:后端返回的
current_round_finish_state可能是字符串"1"/"0"而非布尔true/false。"1"等价于 true(轮次结束),"0"等价于 false(继续)。confirm_invoice_flag为"0"表示无 confirm_invoice_flag。
易错示例:
返回:{"current_round_finish_state": "1", "confirm_invoice_flag": "0", "task_rec": "E100", "message_list": ["当前轮次已处理完成"]}
分析:task_rec=E100 → 非终止,继续判断 → current_round_finish_state="1"(真值) + confirm_invoice_flag="0"(无) → ✅ 终止轮询
⚠️ 常见误判:看到 E100 就认为"进行中→继续轮询",忽略了 current_round_finish_state 已经为真值!
返回:{"current_round_finish_state": "0", "confirm_invoice_flag": "0", "task_rec": "E100",
"message_list": ["发票已开具成功。更多文件可查看税局原始发票链接:…", "📄 [点击下载-xxx.pdf](https://…)", "开票成功!电子发票稍后发送给您。本次开票消耗 1 积分"]}
分析:task_rec=E100 → 非终止,继续判断 → confirm_invoice_flag="0" 无 → current_round_finish_state="0"(假值) → ✅ 必须继续轮询
⚠️ 常见误判:看到 message_list 内容是「开票成功」就停止轮询!消息内容 ≠ 任务状态,停止与否只看字段!后端可能先推送成功消息、终态字段(E200/finished/round_finish_state=1)在后续轮次才返回。
返回:{"current_round_finish_state": "0", "confirm_invoice_flag": "0", "task_rec": "E100",
"message_list": ["我将帮您红冲发票【26322000006686693026】,确认红冲请回复:确认红冲。若红冲其他发票,请发送发票PDF文件。", "https://fileserver.yunzhangfang.com/file/server/view?key=..."]}
分析:task_rec=E100 → 非终止,继续判断 → confirm_invoice_flag="0" 无 → current_round_finish_state="0"(假值) → ✅ 必须继续轮询
⚠️ 常见误判:看到 message_list 内容是「确认红冲请回复」就以为要等客户回复而停止轮询!交互话术 ≠ 任务状态,这是后端给客户的操作提示,不是给 AI 的停止信号,必须继续轮询!
taskRec 处理规则
taskRec 是 后端开票任务的终止标识。E100 为唯一非终止状态;其余值(E200/E400/E500/E800/CANCELED)均为终止状态,触发轮询停止。但即使为终止状态,message_list 仍需正常发送给客户。
⚠️ E100 ≠ 继续轮询:E100 只表示"任务未终止",是否继续轮询还需看
current_round_finish_state。若current_round_finish_state为真值且无 confirm_invoice_flag → 停止轮询。
| taskRec 值 | 含义 | 是否终止 | 客户提示语 | 轮询行为 |
|---|---|---|---|---|
"E100" |
进行中 | ❌ 非终止 | — | 需结合 current_round_finish_state 判断:真值+无 confirm_invoice_flag→停;假值→继续 |
"E200" |
成功 | ✅ 终止 | 「开票成功,请查收发票」 | 正常发送 message_list 给客户,停止轮询 |
"E400" |
失败 | ✅ 终止 | 「开票失败,请检查信息后重试」 | 正常发送 message_list 给客户,停止轮询 |
"E500" |
超时 | ✅ 终止 | 「处理超时,请稍后重试」 | 正常发送 message_list 给客户,停止轮询 |
"E800" |
取消 | ✅ 终止 | 「开票已取消」 | 正常发送 message_list 给客户,停止轮询 |
"CANCELED" |
超时 | ✅ 终止 | 「处理超时,请稍后重试」 | 正常发送 message_list 给客户,停止轮询 |
注意事项
- 轮询是自动的:拿到
taskId后 AI 必须自动循环调poll_invoice,不需要客户任何操作 - 链接不影响轮询:如果返回了验证链接,链接是给客户点的,AI 的轮询不能因此暂停
- 超时兜底:总轮询超过 1 小时仍未结束时,告知客户「处理超时,请稍后重试」
- confirm 标志处理:遇到
async_login或submit_invoice标志时,继续用当前taskId轮询(taskId以invoice_intent_process最新返回值为准),不要仅因 confirm_flag 而重调invoice_intent_process,也不要等新taskId。poll_invoice不会返回nextTaskId - 消息展示:严格遵守 展示铁律,消息逐字复制(不丢字、不加字、不改字)、不折叠、图片用
内联 - taskId 不展示:taskId 是内部技术标识,绝不向客户展示。展示消息时只输出
message/message_list的内容,不输出 taskId 等技术字段 - 跨平台兼容:macOS 和 Windows 均可正常工作
子资源
@references/intent_prompt.md— 意图识别 Prompt(LLM 用,意图分类 + 红蓝票判定(红票关键词触发) + 批量开票拦截 + 反例拦截 + few-shot 示例,不含字段提取)