# Yzf Invoice MCP Server Skill

> AI 开票员技能——通过 MCP 工具调用后端开票服务，支持异步轮询实时获取进度。当前仅支持蓝票（正常开票/改票），暂不支持红票（红冲/作废）与批量开票。触发词：开票、开发票、专票、普票、改发票。

- Skill: `ahang1598/yzf-invoice-mcp-server-skill` (Agent Skill, multi-file: 4 files)
- Install (CLI): `npx skillmds@latest add ahang1598/yzf-invoice-mcp-server-skill`
- Raw SKILL.md: https://api.skillmd.com/api/skills/ahang1598/yzf-invoice-mcp-server-skill/raw
- Safety review: pending (external: skill-scanner PASS, skillspector PASS)
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- Author: ahang1598 (https://skillmd.com/u/ahang1598)
- Updated: 2026-09-09
- Page: https://skillmd.com/skills/ahang1598/yzf-invoice-mcp-server-skill

---


# AI 开票员 (yzf-invoice-mcp-server-skill)

本 Skill 提供智能开票的完整能力，通过 MCP 工具与后端税局系统交互，支持异步轮询实时获取开票进度。

> ⚠️ **当前支持范围**：仅支持**蓝票**（正常开票 / 修改发票信息）。
> **红票**（红冲、作废、负数发票）与**批量开票**（一次开多张、批量导入开票）**暂不支持**，命中即拦截并向客户说明，不调用后端工具。详见 [暂不支持场景](#暂不支持场景)。

---

## 环境配置

使用本技能前需完成以下配置：

1. **激活 MCP Connector**：在 WorkBuddy 的「Connector 管理」页面找到 `yzf-invoice-mcp-server`，点击「信任」并激活。激活后所有 MCP 工具（`company_management` / `invoice_intent_process` / `poll_invoice` / `apply_storage_pre_signature_url`）才能正常调用。
2. **云帐房账号登录**：首次使用时，后端会引导完成云帐房账号授权登录（如尚未登录）；登录状态由平台维护，后续无需重复操作。
3. **CODEBUDDY_SESSION_ID**：由平台在每次对话中自动注入为环境变量，技能内默认优先读取该环境变量；若环境中不存在，技能会通过标准库 UUID v4 自动生成兜底值，**无需手动配置**。

> ⚠️ 如果 MCP Connector 未激活，所有开票工具调用都会失败，技能无法正常使用。请务必先完成第 1 步。

---

## 可用工具

### company_management — 企业开票信息校验（**每次激活必调**）

> ⚠️ **铁律**：本技能被激活后，**必须最先调用此工具**校验当前用户的开票信息是否已维护完成，**未通过校验不得进入开票主流程**。

**参数说明**：

| 参数 | 类型 | 必填 | 说明 |
|------|------:|------|------|
| `codebuddySessionId` | string | ✅ | 当前会话 ID。按下方规则自动获取，调用时必须传入 |
| `returnUrl` | boolean | 否 | 是否返回企业信息维护页面 URL。当用户表达**修改企业信息**意图时传 `true`；正常开票校验场景不传 |

> `codebuddySessionId` 获取规则与 `invoice_intent_process` / `poll_invoice` 一致：优先读取 `CODEBUDDY_SESSION_ID`，不存在则生成 UUID v4 并持久化到系统临时目录下的 `.wb_invoice_session_id`（Python `tempfile.gettempdir()` / Node `os.tmpdir()`）。

调用后返回当前用户默认企业的开票信息维护状态。

**返回值**：

```json
// 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`。

**参数说明**：

| 参数 | 类型 | 必填 | 说明 |
|------|------:|------|------|
| `userInput` | string | ✅ | 用户原始**文字**输入。**只放客户说的话**（如「按这个文件开票」），**绝对不要把文件 URL、文件路径、Base64 等任何文件相关信息拼进去** |
| `files` | array | 否 | 客户携带的文件列表。每个元素为 `{"file_url": "", "name": ""}`。`file_url` 来自 `apply_storage_pre_signature_url` 返回的 `publicUrl`。无文件时不传此参数 |
| `createTime` | integer | 否 | 请求创建时间，毫秒时间戳。自动生成，无需手动传 |
| `invoiceType` | string | 否 | 发票类型：`"1"`=蓝票（默认，**当前唯一支持类型**）；`"2"`=红票（**暂不支持，命中即拦截，不要传入**） |
| `codebuddySessionId` | string | 否 | 当前会话 ID。默认按下方规则自动获取，无需手动传 |

**codebuddySessionId 获取规则**：

1. 优先读取 `CODEBUDDY_SESSION_ID`
2. 如果不存在，则使用 Python/Node 标准库生成 **UUID v4**（随机 UUID），并持久化到系统临时目录（Python `tempfile.gettempdir()` / Node `os.tmpdir()`）下的 `.wb_invoice_session_id` 作为兜底，保证同一调用链内会话 ID 一致。UUID v4 基于随机数生成，每个会话独立生成，不同用户、不同会话之间**绝不重复**

**返回值**：

```json
// 异步场景（需要轮询）
{"phase": "submitted", "taskId": "1521216305259118849", "message": "您的开票请求已提交...", "finished": false}

// 同步场景（直接完成）
{"phase": "completed", "finished": true, "ok": true, "data": {...}}
```

**使用示例**：

- 客户说「帮云账房开一张专票」：调用 `invoice_intent_process`，传入 `userInput="帮云账房开一张专票"`
- 客户说「金额改成2元」：调用 `invoice_intent_process`，传入 `userInput="金额改成2元"`
- 客户说「不开了」：调用 `invoice_intent_process`，传入 `userInput="不开了"`
- 客户发了一张采购清单图片并说「按这个开票」：先调 `apply_storage_pre_signature_url`（传 fileName）拿到 uploadUrl + publicUrl，再跑 `python3 scripts/upload_file.py "文件路径" "uploadUrl" "publicUrl"` 上传，最后调 `invoice_intent_process`，传入 `userInput="按这个开票"`, `files=[{"file_url": publicUrl, "name": "采购清单.png"}]`

### poll_invoice — 轮询开票进度

对已提交的任务进行单次轮询查询。每次调用只做一次 HTTP 请求并立即返回本轮状态。

**参数说明**：

| 参数 | 类型 | 必填 | 说明 |
|------|------:|------|------|
| `taskId` | string | ✅ | `开票意图主流程`（invoice_intent_process）返回的 `taskId` |
| `codebuddySessionId` | string | 否 | 当前会话 ID。与提交阶段保持一致；默认按上方规则自动获取 |
| `createTime` | integer | 否 | 本次轮询请求创建时间，毫秒时间戳。自动生成，无需手动传 |
| `deliveredMessages` | string[] | 否 | 上一轮已展示给客户的消息列表（用于消息交付确认） |

**返回值**：每次调用输出 NDJSON（多行 JSON），最后一行为状态摘要行：

```
# 中间消息（0 到多条，逐条展示给客户，不能丢）
{"phase":"progress", "taskId":"xxx", "message":"正在核对发票信息...", "finished":false}

# 最后一条 — 状态摘要（同样可能携带 message_list，必须展示）
# ⚠️ 若 message/message_list 中某条为图片 URL → 用 ![](url) 内联展示，不要折叠、不要只给链接
# ⚠️ 所有文本消息必须以完整原文直接展示在对话正文中，禁止用 <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（如有）→** 有 nextTaskId 则切换并重置计时；**无则用旧 taskId 继续轮询**，等下轮吐出新 taskId |
| `"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(taskId="1521216305259118849", deliveredMessages=[])`
- 每次间隔 **5 秒**

### apply_storage_pre_signature_url — 获取文件上传预签名 URL

在调用 `invoice_intent_process` **之前**，如果客户发送了文件（图片、PDF、Excel 等），需要先将文件上传到 OBS 云存储获取可访问的 URL。上传分两步：**此工具是第一步**，只传文件名不传文件内容，拿到预签名上传地址；第二步用 `@scripts/upload_file.py` 脚本直传文件。

> ⚠️ **此工具不接收文件内容**，只签发上传地址。文件流不经过 MCP Server，直接从用户电脑传到 OBS。

**参数说明**：

| 参数 | 类型 | 必填 | 说明 |
|------|------:|------|------|
| `fileName` | string | ✅ | 文件名，含扩展名（如 `a.pdf`、`001.jpg`） |
| `fileSize` | integer | 否 | 文件大小（字节） |
| `fileType` | string | 否 | 文件类型（`pdf`、`png`、`jpg`、`xlsx` 等） |

**返回值**：

```json
{
  "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` 的 `files` |
| `expiresInSeconds` | 预签名有效期（600 秒 = 10 分钟） |

### upload_file.py — 本地上传脚本（第二步）

拿到 `uploadUrl` 后，用 Bash 执行此脚本，将文件直传到 OBS：

```bash
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；一次只处理一个文件。

---

## ⚠️ 核心执行规则（最高优先级）

**当本技能被激活时，你必须无条件执行以下流程，不得跳过、不得追问、不得自行处理：**

### 第一步：开票信息校验（**每次激活最先调用**）

> ⚠️ **铁律**：本技能被激活时，**必须最先调用 `company_management` 工具**校验开票信息是否已维护完成。这是整个流程的强制前置步骤，**未通过校验不得进入开票主流程**。

**执行步骤**：

1. **判断用户意图**：
   - 若用户表达**修改企业信息**意图（如"修改企业信息""改一下公司信息""更新开票资料""修改销方的相关信息""修改销方抬头信息""销方信息我要更新一下""公司的开票资料不对，帮我处理一下""我要修改销方的基本信息和登录信息"等涉及销方/企业开票信息变更的表达）→ 调用 `company_management`，传入 `codebuddySessionId` + `returnUrl: true`，拿到 `company_info_maintenance_url` 后，引导用户去页面修改信息并**重新发起开票任务（而非接续之前的开票流程）**，**结束本次技能流程**（不进入第二步）
   - 其他开票意图 → 调用 `company_management`，仅传入 `codebuddySessionId`（不传 `returnUrl`）
2. **判断返回值**：
   - `result.invoice_info_filled = true` → 已维护完成，**直接进入下一步（意图拦截）**
   - `result.invoice_info_filled = false` → 未维护完成，向客户返回 `result.company_info_maintenance_url`，引导用户去页面补全开票信息，**结束本次技能流程**（不进入第二步、不调 `invoice_intent_process`、不轮询）
3. 若调用 `company_management` 失败（`code != "0"`），向客户说明「开票信息校验失败，请稍后重试」，**结束本次技能流程**

**向客户展示维护页面的示例话术**：

> 「您当前的开票信息还未完善，请先点击链接补全开票信息：[维护地址]。补全后再次发起开票即可。」

**修改企业信息意图的示例话术**：

> 「请点击链接修改您的企业开票信息：[维护地址]。修改完成后请重新发起开票任务，不要继续之前的开票流程。」

### 第二步：意图拦截（提交前必做）

在调用任何 MCP 工具之前，先对用户输入做意图预判，命中以下两类意图即**直接拦截、不调用工具、不进入第三步**：

1. **红票意图**（红冲 / 作废原发票 / 负数发票 / 把这张票冲一下 / 对上一张开红字 / 退货冲红 …）→ 直接回复客户：
   > 「抱歉，目前暂不支持红票（红冲/作废）相关操作，后续版本会支持，敬请谅解。」
2. **批量开票意图**（一次开多张 / 批量开票 / 把这个名单/Excel 都开了 / 帮这几家公司分别开票 / 一次开 N 张 …）→ 直接回复客户：
   > 「抱歉，目前暂不支持批量开票，请逐张提供开票信息，后续版本会支持批量开票，敬请谅解。」

> 拦截后**结束本次技能流程**，不要进入第三步，也不要轮询。意图判定细节（红票关键词、批量开票特征）查阅 `@references/intent_prompt.md`。

### 第三步：提交

**立刻调用 `开票意图主流程`（invoice_intent_process）工具**，将用户原始输入传入。

**客户携带文件时的处理流程**：

当客户发送了文件（图片、PDF、Excel、文档等），**不要解析或提取文件内容，不要转 Base64**，按以下步骤处理：

1. **获取文件路径**：从对话上下文中获取客户发送的文件本地路径（WorkBuddy 平台自动提供，无需手动拼接）
2. **调 `apply_storage_pre_signature_url` 拿预签名地址**：传入 `fileName`（从文件路径提取文件名），获取 `uploadUrl`（预签名 PUT 地址）和 `publicUrl`（公网访问地址）
3. **跑 `upload_file.py` 直传文件**：用 Bash 执行脚本，将文件 PUT 到 `uploadUrl`，文件从用户电脑直达 OBS，不经过 MCP Server：

   ```bash
   python3 scripts/upload_file.py "文件路径" "uploadUrl" "publicUrl"
   ```

4. **解析脚本输出**：成功时输出 `{"ok": true, "publicUrl": "...", ...}`，取 `publicUrl`
5. **调用 `invoice_intent_process`**：把 `publicUrl` 传入 `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`**
- `phase:"completed"` → 同步结果，直接展示，**结束**
- 无 taskId → 同步结果，**结束**

### 第四步：轮询获取消息（有 taskId 时）

**⚠️ 铁律：只有以下 4 种情况可以停止轮询，除此之外绝对不能停止！**

1. 收到 `phase:"completed"`
2. 收到 `current_round_finish_state` 为真值（`true` 或 `"1"`）且 **无 confirm_invoice_flag**（`confirm_invoice_flag` 为 `"0"` 或空）
3. 总轮询时间超过 **1 小时**
4. `taskRec` 返回终止状态值（E200/E400/E500/E800/CANCELED）—— **注意：此时 `message_list` 仍需正常发送给客户**

> **⚠️ 关键澄清（防误判）**：
> - `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](#decision-flow) 和 [taskRec 处理规则](#taskrec-处理规则)。

### ⛔ 绝对禁止的行为（触犯即 Bug）

在轮询循环中（从拿到 taskId 到满足上述 3 个停止条件之前），**绝对禁止**以下行为：

- ❌ **禁止说「请告诉我」「等我通知」「验证完成后告诉我」之类的话** —— 轮询是自动的，不需要客户触发！
- ❌ **禁止等待客户回复后再继续轮询** —— 展示完本轮消息后，立刻等待 5s 再调下一次 `poll_invoice`！
- ❌ **禁止因为返回了链接/验证码/登录页面就认为需要暂停** —— 链接是给客户点的，你的轮询不能停！
- ❌ **禁止输出任何暗示「我停下来等」的文字** —— 比如「我会继续轮询」「稍后查询」，这些话会让客户以为你停了！
- ❌ **禁止向客户展示 taskId** —— taskId 是内部技术标识，客户不需要知道。展示消息时只输出 `message` / `message_list` 的内容，绝不输出 taskId、task_id 等技术字段！
- ❌ **禁止折叠任何需要展示给客户的信息** —— `message` / `message_list` 中的每一条消息，必须以完整明文直接展示在对话中。禁止使用 `<details>` 标签、代码块包裹、折叠面板、"展开查看"等任何折叠/收起手段。图片 URL 用 `![](url)` 内联展示，文本消息直接输出原文，让客户一眼就能看到全部内容，不需要任何额外点击或展开操作。

**正确做法：展示消息 → 等 5s → 自动调用下一轮 `poll_invoice` → 展示消息 → 等 5s → ... 循环直到终态**

### 展示铁律（消息不丢 + 不折叠）

> **最高优先级规则，覆盖所有轮询阶段。**

1. **消息不丢**：每一轮 `poll_invoice` 返回的所有 `message` / `message_list`，无论在哪条 NDJSON 行、无论 `phase` 是什么，**必须逐条展示给客户，一条都不能丢、不能省略、不能合并**。即使需要继续轮询，也**必须先把本轮全部消息展示完**再进入 5s 等待。`deliveredMessages` 也要同步追加，确保下一轮不重复发送。

2. **信息不折叠**：所有需要展示给客户的信息，**必须以完整内容直接展示在对话正文中，禁止任何形式的折叠**：
   - 文本消息：直接将原文输出到对话中，不截断、不省略
   - 图片 URL（以 `http` 开头，结尾为 `.png` / `.jpg` / `.jpeg` / `.gif` / `.webp` / `.bmp`）：必须用 Markdown 图片语法 `![](url)` 内联展示
   - 链接类消息：直接输出链接文本
   - 禁止使用 `<details>` / `<summary>` 标签、代码块包裹消息、"点击展开"等任何需要客户额外操作的手段
   - 每条消息独立展示，不合并多条为一条

**伪代码流程**：

```
# 第一步：开票信息校验（必须最先做）
# 判断是否为修改企业信息意图
if 用户表达修改企业信息意图:
    companyCheck = company_management(codebuddySessionId=会话ID, returnUrl=true)
    if companyCheck.code != "0":
        告知客户"开票信息校验失败，请稍后重试"，结束
    # 返回 company_info_maintenance_url，引导用户修改后重新发起开票任务（非接续之前流程）
    告知客户"请点击链接修改企业开票信息：[维护地址]。修改完成后请重新发起开票任务，不要继续之前的开票流程"，结束
else:
    companyCheck = company_management(codebuddySessionId=会话ID)
    if companyCheck.code != "0":
        告知客户"开票信息校验失败，请稍后重试"，结束
    if companyCheck.result.invoice_info_filled == false:
        告知客户"开票信息未完善，请先点击 [维护地址] 补全后再次发起开票"，结束

# 第二步：意图拦截（已由 references/intent_prompt.md 完成判断）
# 如果命中红票/批量开票 → 直接拦截，不进入主流程

# 如果客户发了文件，预签名URL直传OBS（不走MCP传文件内容，不转Base64）
if 客户发送了文件:
    fileList = []
    for file_path in 客户发送的文件列表:
        # 第一步：调 MCP 工具拿预签名地址（只传文件名，不传文件内容）
        presign = apply_storage_pre_signature_url(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

result = invoice_intent_process(userInput="用户原话", files=fileList)  # 开票意图主流程

if result.phase == "submitted":
    taskId = result.taskId
    startTime = 当前时间
    deliveredMessages = []

    while True:
        if 当前时间 - startTime > 1小时:
            告知客户"处理超时，请稍后重试"，结束

        result = poll_invoice(
            taskId=taskId,
            codebuddySessionId=提交阶段相同会话ID,
            createTime=当前毫秒时间戳,
            deliveredMessages=deliveredMessages
        )

        # 逐行处理 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 匹配图片URL正则 (以 http 开头，结尾为 .png/.jpg/.jpeg/.gif/.webp/.bmp):
                    用 Markdown 图片语法展示：![](msg)
                else:
                    # 文本消息直接输出原文到对话正文，不截断、不省略、不用 <details> 或代码块折叠
                    展示 msg 文本给客户（完整原文，禁止折叠）
                deliveredMessages.append(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")
            hasNextTask = bool(line.get("nextTaskId"))

            # 按 [状态判断表](#第五步状态判断表唯一退出继续依据) 优先级 ①→②→③ 判断：
            #   ① taskRec 终止 → 按编码告知结果后停止
            #   ② confirm_invoice_flag + nextTaskId → 切换 taskId/重置计时/继续
            #   ③ roundFinished 真且无 confirm_invoice_flag → 停止（即使 taskRec=E100）；否则继续

        sleep(5秒)  # 等待 5 秒再轮下一次
```

### 第五步：状态判断表（唯一退出/继续依据）

> **⚠️ 按优先级①→②→③从上往下判断，命中即执行。不要自己加判断！**

| 优先级 | 条件 | 动作 |
|:---:|------|------|
| ① | `task_rec` = E200/E400/E500/E800/CANCELED（终止） | 展示 message_list → 按 [taskRec 处理规则](#taskrec-处理规则) 的客户提示语告知结果 → **停止** ✅ |
| ② | `confirm_invoice_flag` = async_login/submit_invoice，**有** `nextTaskId` | 展示 message_list → 切换 taskId + 重置计时 → **继续轮询** |
| ② | `confirm_invoice_flag` = async_login/submit_invoice，**无** `nextTaskId` | 展示 message_list → 用旧 taskId → **继续轮询** |
| ③ | `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 商品，普票/专票"
- "改成专票""金额改一下""增加一个发票项目"（修改发票信息，属蓝票流程）
- "不开了""先不开"（取消尚未开具的发票，属蓝票流程，需记录拒绝原因）
- "修改企业信息""改一下公司信息""更新开票资料""修改销方的相关信息""修改销方抬头信息""销方信息我要更新一下""公司的开票资料不对，帮我处理一下""我要修改销方的基本信息和登录信息"（修改企业/销方开票信息，调 `company_management` 传 `returnUrl: true`）

### 暂不支持场景（激活后拦截，不调工具）

> 以下场景**会激活本技能**，但在第二步被拦截，**不调用 `invoice_intent_process`**，直接向客户说明暂不支持：

- **红票 / 红冲 / 作废**：红票、红字发票、红冲、冲红、发票作废、把这个票冲一下、退货冲红、负数金额开票、对上一张发票开红字 等
- **批量开票**：一次开多张、批量开票、把这个名单/Excel 都开了、帮这几家公司分别开票、一次开 N 张 等

### 不触发

- "开账户""开户头"（开户 ≠ 开票）
- "开发""开门""开车"
- 单纯提供开户行信息无开票意图

> ⚠️ **意图判断职责**：本技能为 MCP 工具型，意图判断由后端 `invoice_intent_process` 接口完成。`intent_prompt.md` 作为 LLM 侧的意图识别参考，用于判断是否激活本技能、是否命中暂不支持场景，以及在不激活时如何回复客户。激活后 LLM 不再做字段提取，直接把用户原话传入 `userInput` 参数。

---

## 异步轮询协议详解

### 后端响应结构

`poll_invoice` 对应的后端接口返回：

```json
{
  "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，用 `![](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 已经为真值！
```

### 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 给客户，**停止轮询** |

### 消息交付确认（deliveredMessages）

每轮 `poll_invoice` 调用需携带上一轮已发送的消息列表：

- 第一轮：不传或传空数组
- 后续每轮：携带**上一轮**所有 `phase:"progress"` 的消息
- 后端据此判断消息是否已成功交付

---

## 注意事项

- **轮询是自动的**：拿到 `taskId` 后 AI 必须自动循环调 `poll_invoice`，不需要客户任何操作
- **链接不影响轮询**：如果返回了验证链接，链接是给客户点的，AI 的轮询不能因此暂停
- **超时兜底**：总轮询超过 1 小时仍未结束时，告知客户「处理超时，请稍后重试」
- **多阶段跳转**：遇到 `async_login` 或 `submit_invoice` 标志时，自动切换新 `taskId` 继续轮询（最多跳转 5 次）
- **消息展示**：严格遵守 [展示铁律](#展示铁律消息不丢--不折叠)，消息不丢、不折叠、图片用 `![](url)` 内联
- **taskId 不展示**：taskId 是内部技术标识，**绝不向客户展示**。展示消息时只输出 `message` / `message_list` 的内容，不输出 taskId 等技术字段
- **跨平台兼容**：macOS 和 Windows 均可正常工作

---

## 子资源

- `@references/intent_prompt.md` — 意图识别 Prompt（LLM 用，意图分类 + 红蓝票判定与拦截 + 批量开票拦截 + 反例拦截 + few-shot 示例，不含字段提取）

