Skill: axlemax/openclaw-skill-customs (version 1.0.10)

--- name: openclaw-skill-customs description: > 海关报关单据处理助手。上传报关单据（发票、装箱单、提单等），AI 自动分类识别文件类型， 提取报关结构化数据，生成标准报关 Excel。当用户提到报关、海关、customs declaration、 invoice、packing list、bill of lading、HS 编码等关键词时，使用此技能。 license: Proprietary compatibility: Requires Python 3.8+ and network access to platform.daofeiai.com metadata: {"openclaw":{"homepage":"https://platform.daofeiai.com","requires":{"env":["LEAP_API_KEY"]},"primaryEnv":"LEAP_API_KEY"}} --- # 海关报关智能助手 ## ⛔ 行为约束 严格按 Step 0 → Step 6 顺序执行，绝不跳步。在等待期间主动与用户互动分享进展。 ### 五条铁律 — 在任何情况下都不得违反 1. **异步等待不可跳过**：分类和报关都是异步任务，提交后必须通过 `submit_and_poll.py` 轮询，等到 `status=completed` 才能继续。**禁止**在 `pending/processing` 状态下进入下一步。 2. **用户确认不可跳过**：分类结果展示后，必须等用户明确回复"确认/OK/好的"后，才能提交报关任务。 3. **文件收集完毕由用户决定**：收到文件后只做保存和确认，**禁止**自行判断"文件够了"而进入 Step 2。只有用户明确发出处理意图（如"开始处理"/"开始分类"/"就这些"/"OK"）后，才能进入 Step 2 的上传和分类流程。 4. **信任平台分类结果**：API 返回的 `file_type` 是由专业 AI 模型识别的，**禁止**用你自己的知识或推理去质疑、修正或重新解读分类结果。展示时严格按 `references/FILE_TYPES.md` 中的 `file_type → 中文名称` 映射翻译，不要自行翻译枚举值，不要因为文件名与类型名"看起来不一致"而向用户发出警告。只有在置信度低于 0.70 时，才提醒用户人工确认。 5. **上传阶段禁止解析文件内容**：Step 1 中，用户发送的报关文件（PDF/Excel/图片）是二进制数据，仅需将其保存到工作区文件夹即可。**绝对不要**尝试打开、读取、解析或在对话中展示这些文件的内容。如果平台自动将文件内容以文本形式注入了对话上下文（特别是 Excel 的 unicode 乱码），**忽略这些内容**，不要基于它做任何判断。文件内容的分类和解析由 Leap 平台 AI 完成。（注意：此规则仅限上传阶段。Step 6 修改阶段需要从原始文件提取字段值时，允许读取已保存的文件。） --- ## Step 0：配置 API Key ### 方式1：OpenClaw 平台 UI（推荐） 在 OpenClaw 中打开此技能的设置页面，添加环境变量： ```json { "skills": { "entries": { "openclaw-skill-customs": { "enabled": true, "env": { "LEAP_API_KEY": "your_api_key_here" } } } } } ``` 其他等效方式： - **Shell（临时）**：`export LEAP_API_KEY="your_api_key_here"` - **Shell（永久）**：写入 `~/.bashrc` 或 `~/.zshrc` > ⚠️ **请勿将 API Key 直接粘贴到对话框中。** 请通过平台 env 设置安全配置。 ### 验证配置 ```bash python scripts/check_config.py ``` - 输出 `"auth_ok": true` → 通过，继续 - 输出错误 → 按提示重新配置 --- ## Step 1：文件收集与上传 ### 1a. 创建任务工作区 当用户提到报关意图或发送第一个文件时，立即创建本次任务的工作目录： ```bash mkdir -p tasks/customs_<YYYYMMDD_HHMMSS>/raw ``` > 以当前时间戳命名（如 `customs_20260408_231500`）。后续所有步骤的中间文件（classify_result.json、customs_payload.json 等）都存放在该任务目录下。 ### 1b. 收集文件到工作区（循环） 每当用户发送一个文件，将其保存到任务目录的 `raw/` 子目录中： ```bash cp <平台提供的文件路径> tasks/customs_<id>/raw/ ``` 保存后立即确认： > ✅ 已收到 `{文件名}`（工作区已有 {N} 个文件） > 📎 请继续发送下一个文件，或说「**开始处理**」进入分类。 ⛔ **在用户明确发出处理意图之前，禁止进入 Step 2。** 每次收到文件都只做保存和确认这两件事。 ### 1c. 用户确认后批量上传 用户说"开始处理"/"开始"/"就这些"后： 1. 列出 `raw/` 目录内所有文件（含文件大小），展示汇总表 2. ⛔ **等用户确认文件列表无误后才执行上传。** 如用户说"还有一个"，返回 Step 1b 继续收集。 3. 确认后，**一次命令**批量上传所有文件到 Leap 平台： ```bash python scripts/file_transfer.py --mode upload \ --file-path "tasks/customs_<id>/raw/file1.pdf" \ --file-path "tasks/customs_<id>/raw/file2.xlsx" ``` 3. 收集返回的所有 `file_id`，展示上传结果汇总，进入 Step 2 --- ## Step 2：提交分类 + ⛔ 等待完成 **执行分类脚本，该命令会阻塞等待直至任务处理完成（completed/failed）后才返回。** 如果是多文件，传递多个 `--file-id`： ```bash python scripts/submit_and_poll.py --mode classify \ --file-id "<id_1>" \ --file-id "<id_2>" \ --save-to tasks/customs_<id>/classify_result.json ``` - **`--save-to tasks/customs_<id>/classify_result.json`**：任务完成后自动将完整结果保存到任务目录，Step 3~4 均从此文件读取数据。 - 脚本运行期间会通过 stderr 定期输出进度 JSON；**参考 `references/INTERACTION.md` 的话术与用户互动，切记不要沉默空等。** - 如平台支持流式响应（如飞书 WebSocket 模式）：在等待期间实时输出话术。 - 如平台为批量回调模式：命令返回后，根据累计 stderr 记录补述等待过程。 - 脚本退出码 `0` = 成功，输出完整结果 JSON。 - 脚本退出码 `1` = 失败或超时，按提示处理。 --- ## Step 3：展示分类结果 + ⛔ 等待用户确认 从任务目录的 `classify_result.json` 中的 `result_data.files[].segments` 解析分类结果。 **为每个文件生成分片表格**，格式和置信度标注规则参见 `references/FILE_TYPES.md`。 **⛔ 展示后必须停下来，等用户明确回复"确认/OK/好的"后才能继续 Step 4。** - 用户要求修改分片类型 → 直接修改任务目录下的 `classify_result.json` 中对应 segment 的 `file_type` 字段，重新展示表格，再次等待确认 - 用户直接确认 → 进入 Step 4 > ⚠️ 修改任务目录下的 `classify_result.json` 时仅更改 `file_type` 字段，不得展开、删除或省略其他字段（`type`、`confidence`、`pages` 等必须完整保留）。 --- ## Step 4：生成 payload + 提交报关 + ⛔ 等待完成 **先一键生成 customs payload，再提交报关任务。** ```bash # 第一步：从 classify_result.json 自动组装完整 segments，生成 customs_payload.json python scripts/build_payload.py --input tasks/customs_<id>/classify_result.json \ --output tasks/customs_<id>/customs_payload.json # 第二步：提交报关任务，阻塞等待完成 python scripts/submit_and_poll.py --mode customs \ --json-file tasks/customs_<id>/customs_payload.json \ --save-to tasks/customs_<id>/customs_result.json ``` - 脚本运行期间会通过 stderr 定期输出进度 JSON；**参考 `references/INTERACTION.md` 的话术与用户互动，不要沉默。** - 如平台支持流式响应：在等待期间实时输出话术。 - 如平台为批量回调模式：命令返回后，根据累计 stderr 记录补述等待过程。 - 脚本退出码 `0` = 成功，继续 Step 5。 --- ## Step 5：展示结果并下载 从任务目录的 `customs_result.json` 中提取： - `result_data.structured_data.summary` → 展示报关表头（申报单位、贸易国别、总金额等） - `result_data.structured_data.items` → 展示商品明细表（商品编码、品名、数量、单价） - `result_data.output_files[].file_name` → 提供下载命令 下载 Excel 文件（保存到任务目录）： ```bash python scripts/file_transfer.py --mode download --result-id <result_id> --filename <filename> \ --output tasks/customs_<id>/customs_result.xlsx ``` 展示完结果后，**立即执行 Step 5.5 合规性检查**（不等用户指令）。 --- ## Step 5.5：合规性检查（自动执行） 展示结果后，**在询问用户是否修改之前**，自动执行合规检查。 ### 执行流程 1. **阅读通用规则** — 打开 [COMPLIANCE_RULES.md](references/COMPLIANCE_RULES.md)，逐项核对 Step 5 展示的 `structured_data` 2. **行业规则检查（可选）** — 查看结果中各商品的 HS 编码前 2 位，如果命中 [INDUSTRY_RULES.md](references/INDUSTRY_RULES.md) 中的章节（21/22/39/41/64/85），打开该文件阅读对应章节，检查是否遗漏行业特殊申报要素 3. **生成检查报告** — 按 `COMPLIANCE_RULES.md` 中规定的输出格式生成报告 ### ⛔ 约束 - 检查结果是 **建议性质**，不阻塞流程。用户可以选择忽略 - 仅输出实际发现的问题，通过项只汇总数量不逐项列出 - 🔴 必须级最多输出 5 条，🟡 建议级最多输出 3 条 - **不要过度检查**：如果某字段在结果中本身不存在（如后端未返回保费字段），不视为异常；只在字段存在但值异常时提示 - 全部通过时只输出「✅ 合规检查通过（共 N 项）」 ### 报告输出后 合规报告输出完毕后，**主动询问用户**： > 📋 如需修改任何内容（如品名、编码、数量等），请直接告诉我要改什么。或回复「确认」继续。 --- ## Step 6：结果修改（用户反馈修正） 当用户对 Step 5 展示的结果提出修改要求时，参照 [MODIFICATION.md](references/MODIFICATION.md) 执行。 核心约束：**禁止**重新上传文件或提交新任务，**必须**基于已有 `structured_data` 和已下载的 Excel 文件直接修改单元格。修改前必须展示变更对比并等待用户确认。 修改字段时，Agent 应打开 [FIELD_GUIDE.md](references/FIELD_GUIDE.md) 查阅对应字段的规范，在修改预览中附带合规提示（详见 MODIFICATION.md）。 --- ## 辅助命令 ```bash # 手动轮询指定任务 python scripts/submit_and_poll.py --mode poll --result-id <result_id> # 查找历史任务（如遗忘了 result_id） python scripts/submit_and_poll.py --mode list-tasks --limit 10 # 取消任务 python scripts/submit_and_poll.py --mode cancel --result-id <result_id> # 重试失败任务 python scripts/submit_and_poll.py --mode retry --result-id <result_id> ``` ## 常见错误 | 错误码 | 原因 | 处理 | |--------|------|------| | 400 | 文件类型不支持或过大 | 检查扩展名（PDF/xlsx/jpg/png/tiff）和大小 | | 401 | API Key 无效或过期 | 重新获取并设置 `LEAP_API_KEY` | | 404 | 文件或任务不存在 | 检查 ID 是否正确 | | task failed | 文件损坏或无法解析 | 查看 `error_message`，建议重新上传 | ## 参考资料 - 详细 API 接口规范：[API_REFERENCE.md](references/API_REFERENCE.md) - 文件类型枚举与展示格式：[FILE_TYPES.md](references/FILE_TYPES.md) - 等待期互动话术：[INTERACTION.md](references/INTERACTION.md) - 结果修改详细流程：[MODIFICATION.md](references/MODIFICATION.md) - 合规检查规则：[COMPLIANCE_RULES.md](references/COMPLIANCE_RULES.md) - 字段填写规范速查：[FIELD_GUIDE.md](references/FIELD_GUIDE.md) - 行业特殊申报要素：[INDUSTRY_RULES.md](references/INDUSTRY_RULES.md)

path: PUBLISHING.md
content: # Publishing / provenance notes

- Publisher: DaoFei AI
- Service homepage: https://platform.daofeiai.com
- Skill purpose: 海关报关智能助手 — 上传单据、智能分类、提取报关数据、生成报关 Excel。

## Required credential

This skill requires exactly one credential:

- `LEAP_API_KEY` — required

Do not paste the API key into chat. Configure it through the platform's secure environment-variable setting for this skill.

API Key 获取方式：在 Leap 开放平台「API Key 管理」页面创建。

## Safety / usage notes

- 此技能仅处理用户主动上传的报关单据文件，不会访问用户系统中的其他文件。
- 所有文件数据均通过 HTTPS 加密传输至 Leap 平台处理。
- 处理结果中可能包含商业敏感信息（金额、商品编码等），请注意信息安全。
- 此技能不会请求与报关业务无关的任何凭证。


path: scripts/.gitignore
content: __pycache__/
*.pyc
*.pyo
# 任务工作目录（包含用户上传文件和处理结果，不入版本控制）
tasks/

path: scripts/build_payload.py
content: #!/usr/bin/env python3
"""
Leap customs payload 构建工具

读取分类任务结果（本地 JSON 文件 或 API 查询），
自动组装可直接用于 customs 提交的 payload 文件。

用法:
  从本地文件构建（推荐，classify 完成后自动保存）：
    python scripts/build_payload.py --input classify_result.json

  从 API 实时获取并构建：
    python scripts/build_payload.py --classify-result-id <result_id>

  指定输出路径（默认 customs_payload.json）：
    python scripts/build_payload.py --input classify_result.json --output my_payload.json

零外部依赖 — 仅使用 Python 标准库。
"""
import argparse
import json
import os
import sys
import urllib.request
import urllib.error

DEFAULT_BASE_URL = "https://platform.daofeiai.com"
DEFAULT_OUTPUT = "customs_payload.json"


def _fetch_from_api(result_id: str, api_key: str) -> dict:
    """从 API 获取任务结果。"""
    url = f"{DEFAULT_BASE_URL}/api/v1/process/tasks/{result_id}"
    req = urllib.request.Request(url, headers={
        "Authorization": f"Bearer {api_key}",
        "Accept": "application/json",
    })
    try:
        with urllib.request.urlopen(req, timeout=30) as resp:
            return json.loads(resp.read().decode("utf-8"))
    except urllib.error.HTTPError as e:
        body = e.read().decode("utf-8", "replace")
        raise RuntimeError(f"HTTP {e.code}: {body}")
    except urllib.error.URLError as e:
        raise RuntimeError(f"连接失败: {e}") from e


def _extract_files(classify_data: dict) -> list:
    """从分类结果中提取 files 列表，兼容 API 响应的不同层级结构。"""
    # 优先从 result_data 中提取（完整 API 响应格式）
    result_data = classify_data.get("result_data", {})
    if result_data and "files" in result_data:
        return result_data["files"]
    # 降级：直接从顶层取（已裁剪的简化格式）
    if "files" in classify_data:
        return classify_data["files"]
    return []


def build_payload(classify_data: dict) -> dict:
    """
    从分类结果构建 customs API payload。

    保留每个文件的 file_id、file_name（可选）、segments 完整数组和 metadata（可选）。
    segments 中所有字段（type/file_type/confidence/pages 等）原样保留，
    确保 API 收到完整、有效的数据结构。
    """
    files_in = _extract_files(classify_data)
    if not files_in:
        raise ValueError(
            "分类结果中未找到 files 数组。"
            "请确认 classify_result.json 是已完成（status=completed）任务的完整输出。"
        )

    files_out = []
    for file_info in files_in:
        file_entry: dict = {
            "file_id": file_info.get("file_id", ""),
            "segments": file_info.get("segments", []),
        }
        # 可选字段：有则带上，无则不插入（保持 payload 干净）
        if file_info.get("file_name"):
            file_entry["file_name"] = file_info["file_name"]
        if file_info.get("metadata"):
            file_entry["metadata"] = file_info["metadata"]
        files_out.append(file_entry)

    return {"files": files_out}


def main():
    parser = argparse.ArgumentParser(
        description="从分类结果构建 customs 提交 payload（无需手动组装 segments）"
    )
    source_group = parser.add_mutually_exclusive_group(required=True)
    source_group.add_argument(
        "--input",
        metavar="CLASSIFY_JSON",
        help="本地分类结果 JSON 文件路径（由 submit_and_poll.py --save-to 生成）",
    )
    source_group.add_argument(
        "--classify-result-id",
        metavar="RESULT_ID",
        help="分类任务 result_id，直接从 API 获取完整结果",
    )
    parser.add_argument(
        "--output",
        default=DEFAULT_OUTPUT,
        help=f"输出 payload 文件路径（默认: {DEFAULT_OUTPUT}）",
    )
    args = parser.parse_args()

    # ── 加载分类结果 ───────────────────────────────────────────────────────
    if args.input:
        if not os.path.isfile(args.input):
            print(json.dumps({
                "status": "error",
                "error_message": f"文件不存在: {args.input}。请先运行 submit_and_poll.py --mode classify --save-to <文件名>",
            }, ensure_ascii=False, indent=2))
            sys.exit(1)
        try:
            with open(args.input, "r", encoding="utf-8") as f:
                classify_data = json.load(f)
        except json.JSONDecodeError as e:
            print(json.dumps({
                "status": "error",
                "error_message": f"JSON 解析失败: {e}。请检查 {args.input} 文件是否完整。",
            }, ensure_ascii=False, indent=2))
            sys.exit(1)
    else:
        [REDACTED_SECRET]"LEAP_API_KEY", "")
        if not [REDACTED_SECRET]
                "status": "error",
                "error_message": "LEAP_API_KEY 未配置。请在 OpenClaw skill 设置中配置环境变量。",
            }, ensure_ascii=False, indent=2))
            sys.exit(1)
        try:
            classify_data = _fetch_from_api(args.classify_result_id, api_key)
        except Exception as e:
            print(json.dumps({
                "status": "error",
                "error_message": f"从 API 获取结果失败: {e}",
            }, ensure_ascii=False, indent=2))
            sys.exit(1)

    # ── 构建 payload ───────────────────────────────────────────────────────
    try:
        payload = build_payload(classify_data)
    except ValueError as e:
        print(json.dumps({"status": "error", "error_message": str(e)}, ensure_ascii=False, indent=2))
        sys.exit(1)

    # ── 写入输出文件 ───────────────────────────────────────────────────────
    try:
        with open(args.output, "w", encoding="utf-8") as f:
            json.dump(payload, f, ensure_ascii=False, indent=2)
    except OSError as e:
        print(json.dumps({
            "status": "error",
            "error_message": f"写入文件失败: {e}",
        }, ensure_ascii=False, indent=2))
        sys.exit(1)

    abs_output = os.path.abspath(args.output)
    total_segments = sum(len(fi.get("segments", [])) for fi in payload["files"])

    print(json.dumps({
        "status": "success",
        "output_file": abs_output,
        "files_count": len(payload["files"]),
        "total_segments": total_segments,
        "tip": (
            f"如需修改某个分片的 file_type，请直接编辑 {abs_output} "
            f"中对应 segment 的 file_type 字段，保存后再提交 customs 任务。"
        ),
    }, ensure_ascii=False, indent=2))


if __name__ == "__main__":
    main()


path: scripts/check_config.py
content: #!/usr/bin/env python3
"""
验证 Leap API 配置是否正确。
用法: python scripts/check_config.py

零外部依赖 — 仅使用 Python 标准库。
"""
import json
import os
import sys
import urllib.request
import urllib.error

DEFAULT_BASE_URL = "https://platform.daofeiai.com"


def check_config():
    """检查 API Key 和 Base URL 配置"""
    [REDACTED_SECRET]"LEAP_API_KEY", "")
    base_url = DEFAULT_BASE_URL

    results = {
        "api_key_set": bool(api_key),
        "base_url": base_url,
        "connection_ok": False,
        "auth_ok": False,
        "errors": [],
    }

    if not [REDACTED_SECRET]"errors"].append(
            "LEAP_API_KEY 未配置。\n"
            "  请在 OpenClaw skill 设置界面配置 LEAP_API_KEY 环境变量。"
        )
        print(json.dumps(results, ensure_ascii=False, indent=2))
        return False

    # 测试连通性和认证
    url = f"{base_url}/api/v1/process/tasks?limit=1"
    req = urllib.request.Request(
        url,
        headers={"Authorization": f"Bearer {api_key}", "Accept": "application/json"},
        method="GET",
    )

    try:
        with urllib.request.urlopen(req, timeout=10) as resp:
            results["connection_ok"] = True
            if resp.status == 200:
                results["auth_ok"] = True
    except urllib.error.HTTPError as e:
        results["connection_ok"] = True
        if e.code == 401:
            results["errors"].append("API Key 无效或已过期，请检查后重新配置。")
        elif e.code == 403:
            results["errors"].append("API Key 权限不足，请联系管理员。")
        else:
            results["errors"].append(f"API 返回异常状态码: {e.code}")
    except urllib.error.URLError as e:
        results["errors"].append(f"无法连接到 {base_url}，请检查网络或服务状态：{e}")
    except Exception as e:
        results["errors"].append(f"未知错误: {e}")

    print(json.dumps(results, ensure_ascii=False, indent=2))
    return results["auth_ok"]


if __name__ == "__main__":
    success = check_config()
    sys.exit(0 if success else 1)


path: scripts/file_transfer.py
content: #!/usr/bin/env python3
"""
Leap 文件传输脚本 — 上传 & 下载。
封装了 multipart/form-data 上传和二进制文件下载，
替代原生 curl 以避免 Windows 下通配符 / 转义兼容问题。

用法:
  上传文件:
    python scripts/file_transfer.py --mode upload --file-path "<文件路径>"
  下载结果文件:
    python scripts/file_transfer.py --mode download --result-id <id> --filename <name> [--output <保存路径>]

零外部依赖 — 仅使用 Python 标准库。
"""
import argparse
import json
import mimetypes
import os
import sys
import uuid
import urllib.request
import urllib.error

DEFAULT_BASE_URL = "https://platform.daofeiai.com"


def _build_multipart(file_path: str):
    """构造 multipart/form-data 请求体，仅依赖标准库。"""
    boundary = uuid.uuid4().hex
    filename = os.path.basename(file_path)
    mime_type, _ = mimetypes.guess_type(file_path)
    if mime_type is None:
        mime_type = "application/octet-stream"

    with open(file_path, "rb") as f:
        file_data = f.read()

    body = (
        f"--{boundary}\r\n"
        f'Content-Disposition: form-data; name="file"; filename="{filename}"\r\n'
        f"Content-Type: {mime_type}\r\n"
        f"\r\n"
    ).encode("utf-8") + file_data + f"\r\n--{boundary}--\r\n".encode("utf-8")

    content_type = f"multipart/form-data; boundary={boundary}"
    return body, content_type


def _upload_single(base_url: str, api_key: str, abs_path: str) -> dict:
    """上传单个文件到 Leap 平台，返回结果 dict。"""
    body, content_type = _build_multipart(abs_path)

    url = f"{base_url}/api/v1/files/upload"
    req = urllib.request.Request(url, data=body, method="POST", headers={
        "Authorization": f"Bearer {api_key}",
        "Content-Type": content_type,
        "Accept": "application/json",
    })

    try:
        with urllib.request.urlopen(req, timeout=120) as resp:
            return json.loads(resp.read().decode("utf-8"))
    except urllib.error.HTTPError as e:
        body_text = e.read().decode("utf-8", "replace")
        return {"status": "error", "http_code": e.code, "error_message": body_text,
                "file_path": abs_path}
    except urllib.error.URLError as e:
        return {"status": "error", "error_message": f"连接失败: {e}",
                "file_path": abs_path}


def upload_files(base_url: str, api_key: str, file_paths: list):
    """上传一个或多个文件到 Leap 平台。"""
    results = []
    errors = []

    for fp in file_paths:
        abs_path = os.path.abspath(fp)
        if not os.path.isfile(abs_path):
            errors.append({"status": "error", "error_message": f"文件不存在: {abs_path}",
                           "file_path": abs_path})
            continue
        result = _upload_single(base_url, api_key, abs_path)
        if "error_message" in result:
            errors.append(result)
        else:
            results.append(result)

    # 单文件：直接输出原始结果（保持向后兼容）
    if len(file_paths) == 1:
        output = results[0] if results else errors[0]
        print(json.dumps(output, ensure_ascii=False, indent=2))
        sys.exit(0 if results else 1)

    # 多文件：输出汇总
    output = {
        "status": "success" if not errors else ("partial" if results else "error"),
        "uploaded": results,
        "errors": errors,
        "total_uploaded": len(results),
        "total_errors": len(errors),
    }
    print(json.dumps(output, ensure_ascii=False, indent=2))
    sys.exit(0 if not errors else 1)


def download_file(base_url: str, api_key: str, result_id: str,
                  filename: str, output_path: str | None):
    """从 Leap 平台下载结果文件。"""
    url = f"{base_url}/api/v1/results/{result_id}/files/{filename}"
    save_path = output_path if output_path else filename

    req = urllib.request.Request(url, method="GET", headers={
        "Authorization": f"Bearer {api_key}",
    })

    try:
        with urllib.request.urlopen(req, timeout=120) as resp:
            with open(save_path, "wb") as f:
                while True:
                    chunk = resp.read(8192)
                    if not chunk:
                        break
                    f.write(chunk)

        abs_save = os.path.abspath(save_path)
        file_size = os.path.getsize(abs_save)
        print(json.dumps({
            "status": "success",
            "saved_to": abs_save,
            "file_size_bytes": file_size,
            "filename": filename,
        }, ensure_ascii=False, indent=2))
        sys.exit(0)
    except urllib.error.HTTPError as e:
        body_text = e.read().decode("utf-8", "replace")
        print(json.dumps({
            "status": "error",
            "http_code": e.code,
            "error_message": body_text,
        }, ensure_ascii=False, indent=2))
        sys.exit(1)
    except urllib.error.URLError as e:
        print(json.dumps({
            "status": "error",
            "error_message": f"连接失败: {e}",
        }, ensure_ascii=False, indent=2))
        sys.exit(1)


def main():
    parser = argparse.ArgumentParser(description="Leap 文件上传 / 下载工具")
    parser.add_argument("--mode", required=True, choices=["upload", "download"],
                        help="操作模式: upload=上传文件, download=下载结果文件")
    parser.add_argument("--file-path", action="append", default=[],
                        help="待上传的文件路径 (upload 模式，可多次指定批量上传)")
    parser.add_argument("--result-id", help="任务 result_id (download 模式必填)")
    parser.add_argument("--filename", help="要下载的文件名 (download 模式必填)")
    parser.add_argument("--output", help="下载文件保存路径 (download 模式可选，默认当前目录)")
    args = parser.parse_args()

    [REDACTED_SECRET]"LEAP_API_KEY", "")
    base_url = DEFAULT_BASE_URL

    if not [REDACTED_SECRET]
            "status": "error",
            "error_message": (
                "LEAP_API_KEY 未配置。\n"
                "请在 OpenClaw skill 设置界面配置 LEAP_API_KEY 环境变量。"
            )
        }, ensure_ascii=False, indent=2))
        sys.exit(1)

    if args.mode == "upload":
        if not args.file_path:
            print("错误: upload 模式必须提供至少一个 --file-path", file=sys.stderr)
            sys.exit(1)
        upload_files(base_url, api_key, args.file_path)

    elif args.mode == "download":
        if not args.result_id or not args.filename:
            print("错误: download 模式必须提供 --result-id 和 --filename", file=sys.stderr)
            sys.exit(1)
        download_file(base_url, api_key, args.result_id, args.filename, args.output)


if __name__ == "__main__":
    main()


path: scripts/requirements.txt
content: # Step 0–5 仅使用 Python 标准库，无需安装任何第三方依赖。
# Python >= 3.8 即可直接运行 file_transfer.py 和 submit_and_poll.py。
#
# Step 6（结果修改）需要 openpyxl 用于读写 Excel 文件：
#   pip install openpyxl
# 如果用户未安装，SKILL.md Step 6 / MODIFICATION.md 中会提示执行上述命令。


path: scripts/submit_and_poll.py
content: #!/usr/bin/env python3
"""
Leap 报关技能统一任务脚本（优化版）
- 指数退避轮询 + 减少上下文输出
- 支持 --quiet 模式，适合 OpenClaw Agent 调用
- 结构化错误处理 + 连续错误保护
"""

import json
import os
import sys
import time
import argparse
import urllib.request
import urllib.error
import random
from typing import Optional, Dict

DEFAULT_BASE_URL = "https://platform.daofeiai.com"


class ApiError(Exception):
    """结构化 API 错误，保留 HTTP 状态码"""
    def __init__(self, status_code: int, body: str):
        self.status_code = status_code
        self.body = body
        super().__init__(f"HTTP {status_code}: {body}")


def _request(method: str, url: str, api_key: str, json_body=None, timeout: int = 30) -> dict:
    headers = {
        "Authorization": f"Bearer {api_key}",
        "Accept": "application/json",
    }
    data = None
    if json_body is not None:
        data = json.dumps(json_body).encode("utf-8")
        headers["Content-Type"] = "application/json"

    req = urllib.request.Request(url, data=data, headers=headers, method=method)
    try:
        with urllib.request.urlopen(req, timeout=timeout) as resp:
            return json.loads(resp.read().decode("utf-8"))
    except urllib.error.HTTPError as e:
        body = e.read().decode("utf-8", "replace")
        raise ApiError(e.code, body)
    except urllib.error.URLError as e:
        raise RuntimeError(f"连接失败: {e}") from e


def _emit_progress(attempt: int, elapsed: int, status: str, progress: int, quiet: bool = False):
    """减少输出频率，避免上下文膨胀"""
    if quiet:
        return
    # processing 状态每 3 次才输出一次，或状态发生变化时输出
    if status == "processing" and attempt % 3 != 0:
        return
    print(json.dumps({
        "poll_attempt": attempt,
        "elapsed_seconds": elapsed,
        "status": status,
        "progress": progress,
    }, ensure_ascii=False), file=sys.stderr)


def _poll_until_done(base_url: str, api_key: str, result_id: str,
                     interval: int = 8, max_wait: int = 300,
                     initial_delay: int = 3, quiet: bool = False,
                     save_to: Optional[str] = None):
    """优化版轮询：指数退避 + 结构化错误 + 减少输出 + 可选结果持久化"""
    poll_url = f"{base_url}/api/v1/process/tasks/{result_id}"
    start_time = time.time()

    time.sleep(initial_delay)  # 首次缓冲，可配置

    attempt = 0
    consecutive_errors = 0
    last_status: Optional[str] = None

    MAX_CONSECUTIVE_ERRORS = 4
    BASE_INTERVAL = max(2, interval // 2)   # 基础间隔
    MAX_INTERVAL = 30

    while True:
        attempt += 1
        elapsed = int(time.time() - start_time)

        if elapsed > max_wait:
            print(json.dumps({
                "status": "timeout",
                "result_id": result_id,
                "elapsed_seconds": elapsed,
                "message": f"等待超过 {max_wait} 秒，请稍后手动查询。",
                "manual_check": f'curl -H "Authorization: Bearer $LEAP_API_KEY" "{poll_url}"'
            }, ensure_ascii=False, indent=2))
            sys.exit(1)

        try:
            data = _request("GET", poll_url, api_key)
            consecutive_errors = 0

            status = data.get("status", "unknown")
            progress = data.get("progress", 0)

            # 状态变化或每3次输出一次进度
            if status != last_status or attempt % 3 == 0:
                _emit_progress(attempt, elapsed, status, progress, quiet)
                last_status = status

            if status == "completed":
                if save_to:
                    try:
                        with open(save_to, "w", encoding="utf-8") as f:
                            json.dump(data, f, ensure_ascii=False, indent=2)
                        print(f"[结果已保存至 {os.path.abspath(save_to)}]", file=sys.stderr)
                    except OSError as write_err:
                        print(f"[警告] 保存结果文件失败: {write_err}", file=sys.stderr)
                print(json.dumps(data, ensure_ascii=False, indent=2))
                sys.exit(0)

            if status == "failed":
                print(json.dumps({
                    "status": "failed",
                    "error_message": data.get("error_message", "未知错误"),
                    "result_id": result_id,
                    "elapsed_seconds": elapsed,
                }, ensure_ascii=False, indent=2))
                sys.exit(1)

        except ApiError as e:
            if e.status_code == 404:
                print(json.dumps({
                    "status": "error",
                    "error_message": f"任务不存在: {result_id}，请检查 result_id 是否正确"
                }, ensure_ascii=False, indent=2))
                sys.exit(1)

            consecutive_errors += 1
            if consecutive_errors >= MAX_CONSECUTIVE_ERRORS:
                print(json.dumps({
                    "status": "error",
                    "error_message": str(e)
                }, ensure_ascii=False, indent=2), file=sys.stderr)
                sys.exit(1)
            # 其他 HTTP 错误短暂继续轮询

        except Exception as e:
            consecutive_errors += 1
            if consecutive_errors >= MAX_CONSECUTIVE_ERRORS:
                print(json.dumps({
                    "status": "error",
                    "error_message": str(e)
                }, ensure_ascii=False, indent=2), file=sys.stderr)
                sys.exit(1)
            _emit_progress(attempt, elapsed, "error", 0, quiet)

        # 指数退避 + 小抖动
        delay = min(BASE_INTERVAL * (1.6 ** (attempt - 1)), MAX_INTERVAL)
        delay += random.uniform(0, delay * 0.08)
        time.sleep(delay)


def main():
    parser = argparse.ArgumentParser(description="Leap 异步任务提交与同步等待脚本（优化版）")
    parser.add_argument("--mode", required=True,
                        choices=["classify", "customs", "poll", "list-tasks", "cancel", "retry"],
                        help="任务模式")
    parser.add_argument("--file-id", action="append", default=[], help="文件ID (classify 模式可多次指定)")
    parser.add_argument("--json-data", help="报关模式所需的 JSON 参数（字符串形式）")
    parser.add_argument("--json-file", help="报关模式所需的 JSON 参数（文件路径，由 build_payload.py 生成）")
    parser.add_argument("--result-id", help="poll/cancel/retry 模式必填")
    parser.add_argument("--limit", type=int, default=10, help="list-tasks 返回数量")
    parser.add_argument("--interval", type=int, default=8, help="基础轮询间隔（秒）")
    parser.add_argument("--max-wait", type=int, default=300, help="最长等待秒数")
    parser.add_argument("--initial-delay", type=int, default=3, help="首次轮询前等待秒数")
    parser.add_argument("--quiet", action="store_true", help="安静模式，减少进度输出")
    parser.add_argument("--save-to", metavar="FILE",
                        help="任务完成后将完整结果 JSON 保存到指定文件（classify 推荐 classify_result.json，customs 推荐 customs_result.json）")

    args = parser.parse_args()

    [REDACTED_SECRET]"LEAP_API_KEY", "")
    base_url = DEFAULT_BASE_URL

    if not [REDACTED_SECRET]
            "status": "error",
            "error_message": "LEAP_API_KEY 未配置。请在 OpenClaw skill 设置中配置环境变量。"
        }, ensure_ascii=False, indent=2))
        sys.exit(1)

    # poll 模式
    if args.mode == "poll":
        if not args.result_id:
            print("错误: poll 模式必须提供 --result-id", file=sys.stderr)
            sys.exit(1)
        _poll_until_done(base_url, api_key, args.result_id,
                         args.interval, args.max_wait, args.initial_delay, args.quiet,
                         save_to=args.save_to)
        return

    # list-tasks 模式
    if args.mode == "list-tasks":
        url = f"{base_url}/api/v1/process/tasks?limit={args.limit}"
        try:
            data = _request("GET", url, api_key)
            print(json.dumps(data, ensure_ascii=False, indent=2))
            sys.exit(0)
        except Exception as e:
            print(json.dumps({"status": "error", "error_message": str(e)}, ensure_ascii=False, indent=2))
            sys.exit(1)

    # cancel 模式
    if args.mode == "cancel":
        if not args.result_id:
            print("错误: cancel 模式必须提供 --result-id", file=sys.stderr)
            sys.exit(1)
        url = f"{base_url}/api/v1/process/tasks/{args.result_id}"
        try:
            data = _request("DELETE", url, api_key)
            print(json.dumps(data, ensure_ascii=False, indent=2))
            sys.exit(0)
        except Exception as e:
            print(json.dumps({"status": "error", "error_message": str(e)}, ensure_ascii=False, indent=2))
            sys.exit(1)

    # retry 模式
    if args.mode == "retry":
        if not args.result_id:
            print("错误: retry 模式必须提供 --result-id", file=sys.stderr)
            sys.exit(1)
        url = f"{base_url}/api/v1/process/tasks/{args.result_id}/retry"
        try:
            data = _request("POST", url, api_key)
            print(json.dumps(data, ensure_ascii=False, indent=2))
            sys.exit(0)
        except Exception as e:
            print(json.dumps({"status": "error", "error_message": str(e)}, ensure_ascii=False, indent=2))
            sys.exit(1)

    # classify / customs 模式：提交任务
    payload: Dict = {}
    if args.mode == "classify":
        if not args.file_id:
            print("错误: classify 模式必须提供至少一个 --file-id", file=sys.stderr)
            sys.exit(1)
        payload["output"] = "classify_fast"
        payload["params"] = {"files": [{"file_id": fid} for fid in args.file_id]}
        payload["force_reprocess"] = True

    elif args.mode == "customs":
        if not args.json_data and not args.json_file:
            print("错误: customs 模式必须提供 --json-data 或 --json-file", file=sys.stderr)
            sys.exit(1)
        if args.json_file:
            if not os.path.isfile(args.json_file):
                print(json.dumps({
                    "status": "error",
                    "error_message": f"--json-file 指定的文件不存在: {args.json_file}。请先运行 build_payload.py 生成。"
                }, ensure_ascii=False, indent=2))
                sys.exit(1)
            try:
                with open(args.json_file, "r", encoding="utf-8") as f:
                    params_data = json.load(f)
            except (json.JSONDecodeError, OSError) as e:
                print(json.dumps({
                    "status": "error",
                    "error_message": f"读取 --json-file 失败: {e}"
                }, ensure_ascii=False, indent=2))
                sys.exit(1)
        else:
            try:
                params_data = json.loads(args.json_data)
            except json.JSONDecodeError as e:
                print(f"错误: --json-data 格式不合法 - {e}", file=sys.stderr)
                sys.exit(1)

        payload["output"] = "customs"
        payload["params"] = params_data if "files" in params_data else {"files": params_data}
        payload["force_reprocess"] = True

    # 提交任务
    submit_url = f"{base_url}/api/v1/process"
    try:
        submit_resp = _request("POST", submit_url, api_key, json_body=payload)
    except Exception as e:
        print(json.dumps({
            "status": "error",
            "stage": "submit",
            "error_message": str(e)
        }, ensure_ascii=False, indent=2))
        sys.exit(1)

    result_id = submit_resp.get("result_id")
    if not result_id:
        print(json.dumps({
            "status": "error",
            "stage": "submit",
            "error_message": "提交接口未返回 result_id"
        }, ensure_ascii=False, indent=2))
        sys.exit(1)

    print(json.dumps({"stage": "submitted", "result_id": result_id}, ensure_ascii=False), file=sys.stderr)

    # 开始轮询
    _poll_until_done(base_url, api_key, result_id,
                     args.interval, args.max_wait, args.initial_delay, args.quiet,
                     save_to=args.save_to)


if __name__ == "__main__":
    main()

path: references/API_REFERENCE.md
content: # Leap Customs API Reference

## Base URL

```
https://platform.daofeiai.com
```

## Authentication

All requests require the `Authorization` header:

```
Authorization: Bearer $LEAP_API_KEY
```

---

## Endpoints

### 1. File Upload

**POST** `/api/v1/files/upload`

| Parameter | Type | Required | Description |
|---|---|---|---|
| `file` | binary (multipart) | ✅ | 支持 PDF, Excel (.xlsx/.xls), 图片 (.jpg/.png/.tiff) |

**Response:**
```json
{
  "file_id": "string (UUID)",
  "original_name": "string",
  "file_size": "integer (bytes)",
  "mime_type": "string",
  "content_hash": "string",
  "is_duplicate": "boolean",
  "created_at": "string (ISO 8601)",
  "download_url": "string"
}
```

---

### 2. Submit Process Task

**POST** `/api/v1/process`

#### 2a. Fast Classification

| Parameter | Type | Required | Description |
|---|---|---|---|
| `output` | string | ✅ | 固定值 `"classify_fast"` |
| `params.files` | array | ✅ | 文件列表 |
| `params.files[].file_id` | string | ✅ | 文件ID |
| `force_reprocess` | boolean | ❌ | 是否强制重新处理（忽略缓存），默认 `false` |

**Request Example:**
```json
{
  "params": {
    "files": [
      { "file_id": "<id_1>" },
      { "file_id": "<id_2>" }
    ]
  },
  "output": "classify_fast",
  "force_reprocess": true
}
```

#### 2b. Customs Declaration

| Parameter | Type | Required | Description |
|---|---|---|---|
| `output` | string | ✅ | 固定值 `"customs"` |
| `params.files` | array | ✅ | 文件列表，包含 file_id 和 segments |
| `params.files[].file_id` | string | ✅ | 文件ID |
| `params.files[].file_name` | string | ❌ | 原始文件名 |
| `params.files[].segments` | array | ✅ | 分类结果中的 segments |
| `params.files[].metadata` | object | ❌ | 文件元信息（total_segments, file_format 等） |
| `force_reprocess` | boolean | ❌ | 是否强制重新处理（忽略缓存），默认 `false` |

**Request Example:**
```json
{
  "params": {
    "files": [
      {
        "file_id": "<id>",
        "file_name": "invoice.pdf",
        "segments": [
          {
            "type": "page",
            "file_type": "invoice",
            "confidence": 0.95,
            "pages": [1, 2, 3]
          }
        ],
        "metadata": {
          "total_segments": 1,
          "file_format": "pdf"
        }
      }
    ]
  },
  "output": "customs",
  "force_reprocess": true
}
```

#### 2c. Shared Response (classify_fast / customs)

**Response:**
```json
{
  "result_id": "string (UUID)",
  "file_id": "string",
  "file_ids": "string[] | null",
  "document_type": "string (e.g. \"universal\")",
  "status": "pending | processing | completed | failed",
  "message": "string",
  "cache_hit": "boolean",
  "task_id": "string (UUID)",
  "processing_time": "integer (ms) | null",
  "download_url": "string | null",
  "created_at": "string (ISO 8601)"
}
```

---

### 3. Query Task Status

**GET** `/api/v1/process/tasks/{result_id}`

**Response (completed):**
```json
{
  "result_id": "string",
  "status": "completed",
  "progress": 100,
  "processing_time": "integer (ms)",
  "result_data": { ... }
}
```

---

### 4. List Tasks

**GET** `/api/v1/process/tasks`

| Parameter | Type | Required | Description |
|---|---|---|---|
| `status` | string | ❌ | 过滤状态: pending/processing/completed/failed |
| `limit` | integer | ❌ | 返回数量 (默认 20) |
| `offset` | integer | ❌ | 偏移量 (默认 0) |

---

### 5. Download Result File

**GET** `/api/v1/results/{result_id}/files/{filename}`

Returns the binary file content (e.g., Excel).

---

### 6. Cancel Task

**DELETE** `/api/v1/process/tasks/{result_id}`

Only works for `pending` or `processing` tasks.

---

### 7. Retry Failed Task

**POST** `/api/v1/process/tasks/{result_id}/retry`

Only works for `failed` tasks.

---

## File Type Enum & Segment Type Enum

→ 完整的文件类型枚举、分片类型枚举、合并漏斗规则和置信度解读，请参见 [FILE_TYPES.md](FILE_TYPES.md)。


path: references/COMPLIANCE_RULES.md
content: <!-- 版本: v1.0 | 基于: 海关总署2024年第81号公告 + 2024年第30号公告 + 2026年规范申报目录释义 | 更新: 2026-04-15 -->

# 报关单合规检查规则

> Agent 在 Step 5 展示结果后，必须逐项核对以下规则。仅输出实际发现的问题，通过项只汇总数量。

## 检查输出约束

- 🔴 必须级：最多输出 **5** 条
- 🟡 建议级：最多输出 **3** 条
- 全部通过时只输出：「✅ 合规检查通过（共 N 项）」
- 措辞使用「建议」「供参考」，不使用「错误」「必须修改」
- **不过度推断**：字段在结果中不存在 ≠ 错误。只在字段存在但值异常时提示

---

## A. 表头检查

| # | 检查项 | 规则 | 严重度 |
|---|--------|------|--------|
| A1 | 件数 ≥ 1 | 件数不得为 0 或空。裸装货物应填 "1" | 🔴 必须 |
| A2 | 毛重 ≥ 净重 | 总毛重必须大于等于总净重 | 🔴 必须 |
| A3 | 重量精度 | 毛重/净重不足1千克时，须精确到小数点后**2位** | 🟡 建议 |
| A4 | 保费提示 | 成交方式为 FOB(3)、EXW(7)、C&F(2) 时，即便未支付保费，也须填写保费率（通常 0.3‰） | 🟡 建议 |
| A5 | 杂费正负值 | 应计入完税价格的杂费填正值，应从完税价格中扣除的杂费填负值 | 🟡 建议 |
| A6 | 包装种类非空 | 包装种类字段不得为空 | 🔴 必须 |
| A7 | 运输方式非空 | 运输方式字段不得为空，且代码须为合法值（0-9, H, T, W, X, Y, Z, L, G） | 🔴 必须 |

## B. 商品明细检查

| # | 检查项 | 规则 | 严重度 |
|---|--------|------|--------|
| B1 | 商品编号格式 | 10位纯数字，不含字母。不足10位时提醒补齐 | 🔴 必须 |
| B2 | 价格一致性 | 总价 ≈ 单价 × 数量（允许 ±1% 误差） | 🟡 建议 |
| B3 | 品牌类型范围 | 值必须在 0-4 范围内（0=无品牌, 1=境内自主, 2=境内收购, 3=境外贴牌, 4=境外其他） | 🔴 必须 |
| B4 | 出口享惠范围 | 值必须在 0-3 范围内（0=不享受, 1=享受, 2=不确定, 3=不适用于进口报关单） | 🟡 建议 |
| B5 | 币制一致性 | 同一报关单内所有商品的币制应保持一致 | 🟡 建议 |
| B6 | 商品名称非空 | 每项商品必须有名称，不得为空 | 🔴 必须 |

## C. 交叉校验

| # | 检查项 | 规则 | 严重度 |
|---|--------|------|--------|
| C1 | 经停港/指运港/贸易国别 | 三者含义不同：经停港=最后中转港，指运港=最终目的港，贸易国别=购自/售予国。如三者填了完全相同的值，提醒用户确认是否正确 | 🟡 建议 |
| C2 | 备案号唯一 | 一份报关单只允许填报一个备案号/许可证号/提单号 | 🔴 必须 |
| C3 | 总金额校验 | 总金额应等于各商品总价之和（允许 ±1% 误差） | 🟡 建议 |

---

## 检查报告输出格式

展示结果后，按以下格式输出检查报告（不要用代码块包裹）：

```
🔍 合规性检查结果：

✅ 通过 N 项

⚠️ 发现以下可改进项：

| # | 检查项 | 问题描述 | 建议 |
|---|--------|----------|------|
| 1 | A4 保费 | 成交方式为 FOB(3)，保费字段为空 | 建议填写 0.3‰ |
| 2 | B1 商品编号 | 第3项编号仅8位 | 应补齐为10位 |

📋 以上仅供参考，以贵司报关实务为准。如需修改请告诉我，或回复「确认」继续。
```


path: references/FIELD_GUIDE.md
content: <!-- 版本: v1.0 | 基于: 海关总署2019年第18号公告 + 2024年第81号公告 | 更新: 2026-04-15 -->

# 报关单字段填写规范速查

> Agent 在 Step 6 修改字段时，打开本文件查阅对应字段的规范，在修改预览中附带合规提示。

---

## 成交方式（贸易术语 → 代码）

| 贸易术语 | 代码 | 保费规则 |
|----------|------|----------|
| CIF | 1 | 已含运费和保费 |
| C&F (CFR) | 2 | 已含运费，**保费未含**，须填保费率（默认 0.3‰） |
| FOB | 3 | 运费和保费均未含，须填保费率（默认 0.3‰） |
| C&I | 4 | 已含保费，运费未含 |
| 市场价 | 5 | — |
| 垫仓 | 6 | — |
| EXW | 7 | 运费和保费均未含，须填保费率（默认 0.3‰） |

> 💡 即便实际未支付保费，FOB/EXW/C&F 成交方式下也须填写保费率（通常为 0.3‰）。

---

## 运输方式（→ 代码）

| 运输方式 | 代码 | | 运输方式 | 代码 |
|----------|------|-|----------|------|
| 非保税区 | 0 | | 边境特殊海关作业区 | H |
| 监管仓库 | 1 | | 综合实验区 | T |
| 水路运输/海运 | 2 | | 物流中心 | W |
| 铁路运输 | 3 | | 物流园区 | X |
| 公路运输 | 4 | | 保税港区 | Y |
| 航空运输 | 5 | | 出口加工区 | Z |
| 邮件运输 | 6 | | 旅客携带 | L |
| 保税区 | 7 | | 固定设施运输 | G |
| 保税仓库 | 8 | | | |
| 其他方式运输 | 9 | | | |

---

## 包装种类（→ 代码）

| 包装种类 | 代码 | | 包装种类 | 代码 |
|----------|------|-|----------|------|
| 散装 | 0 | | 纸制桶/纤维板制桶 | 32 |
| 裸装 | 1 | | 木制桶/竹藤桶 | 33 |
| 球状罐类 | 4 | | 其他材料制桶 | 39 |
| 包/袋 | 6 | | 中型散装容器 | 41 |
| 纸制盒/箱 | 22 | | 便携式罐体 | 42 |
| 木制盒/箱 | 23 | | 可移动罐柜 | 43 |
| 其他材料制盒/箱 | 29 | | 再生木托 | 92 |
| | | | 天然木托 | 93 |
| | | | 植物性铺垫材料 | 98 |
| | | | 其他包装 | 99 |

---

## 品牌类型（→ 代码）

| 描述 | 代码 |
|------|------|
| 无品牌 / 无商标 | 0 |
| 境内自主品牌 | 1 |
| 境内收购品牌 | 2 |
| 境外品牌（贴牌生产） | 3 |
| 境外品牌（其他） | 4 |

---

## 出口享惠情况（→ 代码）

| 描述 | 代码 |
|------|------|
| 出口货物在最终目的国不享受优惠关税 | 0 |
| 出口货物在最终目的国享受优惠关税 | 1 |
| 不能确定是否享受优惠关税 | 2 |
| 不适用（进口报关单） | 3 |

---

## 征免方式（→ 代码）

| 描述 | 代码 | | 描述 | 代码 |
|------|------|-|------|------|
| 照章征税 | 1 | | 随征免性质 | 5 |
| 折半征税 | 2 | | 保证金 | 6 |
| 全免 | 3 | | 保函 | 7 |
| 特案 | 4 | | 折半补税 | 8 |
| | | | 全额退税 | 9 |

---

## 监管方式（→ 代码）

| 描述 | 代码 |
|------|------|
| 一般贸易 | 0110 |
| 跨境电商出口海外仓 | 9810 |

> 其他监管方式代码较多，按实际业务填写。

---

## 易混淆字段辨析

### 经停港 vs 指运港 vs 贸易国别

| 字段 | 定义 | 出口举例 | 进口举例 |
|------|------|----------|----------|
| **经停港** | 货物运输途中最后一个中转港 | 新加坡（出口中转） | 釜山（进口中转） |
| **指运港** | 出口货物的最终目的港 | 洛杉矶 | — |
| **贸易国别** | 售予国（出口）/ 购自国（进口） | 美国 | 日本 |

> ⚠️ 三者含义不同。经停港是物流概念，贸易国别是贸易概念。即使货物从深圳直发洛杉矶给美国客户，经停港也可能是"新加坡"而非"美国"。

### 境内货源地 vs 原产国

| 字段 | 定义 | 说明 |
|------|------|------|
| **境内货源地** | 出口货物在国内的产地或集货地 | 填到**县级行政区**代码 |
| **原产国** | 货物的生产/制造国 | 国家名称 |

> ⚠️ 出口时两者通常都是"中国"语境，但境内货源地需细化到地级市/县级。

---

## 重量与件数规则

| 规则 | 说明 |
|------|------|
| 毛重 ≥ 净重 | 毛重 = 净重 + 包装材料重量 |
| 重量精度 | 不足1千克时精确到小数点后**2位**（依据2024年第30号公告） |
| 件数 ≥ 1 | 裸装货物件数填 "1"，不得填 0 |
| 商品编号 | 10位纯数字（前8位税则编码 + 后2位监管附加编号），不含字母 |


path: references/FILE_TYPES.md
content: # 文件类型与分片类型参考

## 文件类型枚举 (file_type)

### 核心单据（参与数据提取与合并）

| file_type | 中文名称 | 英文名称 |
|---|---|---|
| `invoice` | 商业发票 | Commercial Invoice / Proforma Invoice |
| `contract` | 合同 | Sales Contract / Sales Confirmation |
| `packing_list` | 装箱单 | Packing List |
| `draft` | 报关单草单 | Customs Declaration Draft |
| `detail` | 明细表 | Item Detail List |
| `declaration` | 申报要素 | HS Declaration Elements |

### 辅助单据（仅分类标记，不参与数据合并）

| file_type | 中文名称 | 英文名称 |
|---|---|---|
| `bill_of_lading` | 提单 | Bill of Lading (B/L) / Air Waybill (AWB) |
| `certificate` | 证书 | Certificate (C/O, 质检证书, 卫生证书等) |
| `customs_release` | 放行通知 | Customs Release Notice |
| `other` | 其他单据 | Other (代理报关委托书、保险单等) |
| `unknown` | 未知文档 | Unrecognized |

### 类型覆盖规则（合并漏斗）

当同一票中出现多种核心单据时，系统按以下规则精简：

| 规则 | 条件 | 保留 | 丢弃 | 原因 |
|---|---|---|---|---|
| R1 | invoice + contract | `contract` | `invoice` | 合同包含发票核心字段且具法律效力 |
| R2 | draft + packing_list | `draft` | `packing_list` | 草单含件数重量且已有报关格式 |
| R3 | draft + detail | `draft` | `detail` | 草单信息覆盖明细表 |
| R4 | detail + packing_list | `packing_list` | `detail` | 箱单比明细表更权威 |
| R5 | declaration | 始终保留 | — | 申报要素独立，不与其他类型重叠 |

> 合并后最多保留 **3 种**核心类型。

## 分片类型枚举 (segment.type)

| type | 适用场景 | 说明 |
|---|---|---|
| `page` | PDF 文档 | 按页码划分，使用 `pages` 数组 |
| `image` | 图片文件 | 整张图片为一个分片，无 `pages` 字段 |
| `sheet` | Excel 文件 | 按 Sheet 划分 |
| `text` | 纯文本 | 文本段落划分 |

## 置信度 (confidence) 解读

| 范围 | 建议标注 | 操作建议 |
|---|---|---|
| ≥ 0.90 | ✅ 可信 | 直接确认 |
| 0.70 ~ 0.89 | ⚠️ 建议核实 | 提醒用户目测确认 |
| < 0.70 | ❗ 请人工确认 | 强制要求用户确认类型 |

## 展示分类结果的标准格式

为每个文件生成如下表格：

```
📋 文件 N：<file_name>（共 M 页，识别 K 个分片）

| 分片 | 页码/范围        | 识别类型                | 置信度 | 建议       |
|------|-----------------|------------------------|--------|------------|
| 1    | 第1页           | 提单 (bill_of_lading)   | 95%   | ✅ 可信    |
| 2    | 第2-6页         | 发票 (invoice)          | 88%   | ⚠️ 核实   |
| 3    | 第7-18页        | 装箱单 (packing_list)   | 65%   | ❗ 请确认  |

请确认分类结果是否准确？
- 修改类型：例如"分片2应该是装箱单"
- 拆分/合并：请说明页码范围
- 确认：回复"确认"或"OK"
```


path: references/INDUSTRY_RULES.md
content: <!-- 版本: v1.0 | 基于: 2026年规范申报目录释义 | 更新: 2026-04-15 -->

# 行业特殊申报要素参考

> 📖 **使用说明**：根据结果中商品 HS 编码的**前 2 位**，在下方查找对应章节。
> 仅当结果中存在该章节商品时才需阅读对应部分，未命中则跳过本文件。

---

## 第21章 — 食品类（HS 21xx）

### 申报要素注意事项

| 要素 | 规范要求 | 常见遗漏 |
|------|----------|----------|
| 成分含量 | 须注明各成分及百分比 | 仅写"食品"不够，需写具体成分 |
| 制作方法 | 加工方式（烘焙/冷冻/腌制等） | 常遗漏 |
| 包装规格 | 每包净含量 | 需注明单位（克/毫升） |
| 食品添加剂 | 须注明具体名称及用途 | 不可笼统写"含添加剂" |
| 保质期形式 | 标注保质期表达方式 | — |

### 常见提示

- 预包装食品须标注净含量（如"每袋 500g"）
- 含动植物成分的需关注检验检疫要求

---

## 第22章 — 酒类饮料（HS 22xx）

### 申报要素注意事项

| 商品 | 必须申报的特殊要素 |
|------|-------------------|
| 🍷 葡萄酒 | 产区中英文名称、酒庄中英文名称、葡萄品种、酒精度(%) |
| 🍺 啤酒 | 每升麦芽汁浓度(°P)、容器材质（罐/瓶）、酒精度(%) |
| 🥃 蒸馏酒 | 酒精度(%)、原料（谷物/水果等） |
| 🧃 非酒精饮料 | 成分、是否含糖、包装容量 |

### 常见提示

- 零售包装酒类：重量须**包含瓶/罐重量**（不只是液体重量）
- 散装葡萄酒（桶装）与瓶装葡萄酒的 HS 编码不同
- 酒精度超过 0.5% 即归入酒类税号

---

## 第39章 — 塑料及制品（HS 39xx）

### 申报要素注意事项

| 产品形态 | 必须申报的特殊要素 |
|----------|-------------------|
| 初级形状 | 外观（颗粒/粉末/液态）、比重、成分、用途 |
| 板/片/膜 | 是否泡沫制、是否增强/层压、厚度 |
| 管/棒/型材 | 截面形状、是否硬质 |
| 再生塑料 | ⚠️ **底料来源**（进口废塑料/国产废塑料/回收利用）|
| 塑料制品 | 用途、材质（塑料品种）、品牌、型号 |

### 常见提示

- ⚠️ 再生塑料须注明底料来源，这是海关重点审核项
- 聚乙烯(PE)、聚丙烯(PP)、聚氯乙烯(PVC) 等须注明具体塑料品种
- "其他塑料制品"(3926) 须明确用途，不可仅写"塑料制品"

---

## 第41章 — 皮革（HS 41xx）

### 申报要素注意事项

| 要素 | 规范要求 | 说明 |
|------|----------|------|
| 鞣制方法 | 铬鞣/植鞣/半铬鞣/醛鞣 | 必填，不可留空 |
| 动物种类 | 牛/羊/猪/马/爬行动物/其他 | 须具体到物种 |
| 加工程度 | 湿蓝皮/坯皮/成品革/再鞣革 | 直接影响税号归类 |
| 加工方法 | 整张/半张/裁切 | — |
| 厚度 | 须注明（若适用） | — |

### 常见提示

- 濒危动物皮革（鳄鱼、蟒蛇等）需 CITES 许可证
- 湿蓝皮(wet-blue)和成品革的 HS 编码不同

---

## 第64章 — 鞋靴（HS 64xx）

### 申报要素注意事项

| 要素 | 规范要求 | 代码/分类 |
|------|----------|----------|
| 鞋面材料 | 须具体注明材质 | 皮革/纺织/塑料/橡胶 |
| 外底材料 | 须具体注明材质 | 橡胶/塑料/皮革/其他 |
| 类型 | 须注明用途 | 运动鞋/皮鞋/拖鞋/靴子等 |
| 尺码 | 内底长度 | 厘米或国际码 |
| 性别/年龄 | 男式/女式/儿童 | 影响税率 |

### 常见提示

- 鞋面材料面积占比 ≥ 50% 的材质为主要材质，决定 HS 归类
- 防水鞋靴有专门税号
- "运动鞋"需区分：有钉（田径/足球）vs 无钉（跑鞋/篮球鞋）

---

## 第85章 — 电子电气产品（HS 85xx）

### 申报要素注意事项

| 商品 | 必须申报的特殊要素 |
|------|-------------------|
| 芯片/集成电路 | ⚠️ **流片地**（境内/境外及具体国家）、工艺制程（纳米） |
| 手机/通讯设备 | 适用网络种类（蜂窝/无线/有线）、是否加密 |
| 电池 | 类型（锂离子/镍氢等）、容量(mAh/Wh)、电压 |
| 电机/马达 | 功率(W/kW)、电压、交流/直流 |
| 变压器/适配器 | 功率、输入输出电压、用途 |
| 电线电缆 | 额定电压、是否有接头、结构类型 |

### 常见提示

- ⚠️ 芯片流片地是近年海关重点关注项，须准确申报
- 通讯设备的"是否加密"字段须明确回答"未加密"或具体加密方式，不可留空
- 锂电池运输有特殊安全要求（UN3480/UN3481）


path: references/INTERACTION.md
content: # 轮询等待期互动话术参考

在异步任务轮询等待期间，根据当前状态和累计等待时长，选用以下话术与用户互动。
**不要沉默等待，每次轮询后都应输出一条互动消息。**

> **说明**：话术中的 `{progress}` 来自 `submit_and_poll.py` 通过 stderr 输出的 JSON 中的 `progress` 字段（0–100 整数）。`{elapsed}` 为累计等待秒数，`{error_message}` 为 `error_message` 字段。如平台不支持流式响应，上述话术在脚本返回后根据 stderr 记录输出即可。

---

## 分类任务等待话术（classify_fast）

| 累计等待时长 | 话术示例 |
|------------|---------|
| 0–5 秒（首次轮询前）| 🔍 分类任务已提交！AI 正在逐页扫描文档，识别发票、装箱单、提单等单证类型，请稍候... |
| 5–15 秒 | ⏳ 系统正在分析文档结构，将相邻同类型页面合并为「分片」(segment)... |
| 15–25 秒 | 📊 AI 模型正在对每一页计算分类置信度，确保识别结果准确... |
| 25–40 秒 | 💡 **报关知识**：标准出口报关需要三大核心单证——商业发票 (Invoice)、装箱单 (Packing List) 和提运单 (Bill of Lading)。系统正在帮您自动识别这些类型 ✨ |
| 40–60 秒 | 🔄 还在处理中...多页混合 PDF 识别通常需要 30–60 秒，文件越大越久。进度：{progress}% |
| > 60 秒 | ☕ 稍微久了些，但 AI 正在仔细校对每页的置信度，好结果值得等待！进度：{progress}% |

---

## 报关任务等待话术（customs）

| 累计等待时长 | 话术示例 |
|------------|---------|
| 0–5 秒（首次轮询前）| 🏗️ 报关任务已提交！系统正在根据分类结果提取报关数据（商品编码、金额、重量等），请稍候... |
| 5–15 秒 | 📝 正在识别发票中的商品明细——品名、规格型号、数量、单价、总价... |
| 15–25 秒 | 🔢 正在比对商品 HS 编码和法定计量单位，这一步需要对照海关编码体系... |
| 25–40 秒 | 📋 正在汇总报关单表头：申报单位、贸易国别、成交方式、运输方式、总金额... |
| 40–55 秒 | 📑 正在将提取的数据写入标准化报关 Excel 文件，格式符合海关系统要求... |
| > 55 秒 | ☕ 品项较多，AI 在仔细核对每一项数据，确保报关准确性。进度：{progress}% |

---

## 通用状态消息

| 场景 | 消息 |
|------|------|
| 任务进入 `processing` | ⚙️ 任务已开始处理（进度：{progress}%）... |
| 任务仍为 `pending` | 📬 任务排队中，等待处理器分配资源... |
| 任务 `completed` | ✅ 处理完成！共耗时约 {elapsed}秒 |
| 任务 `failed` | ❌ 任务失败：{error_message}。建议检查源文件是否完整，或尝试重试。 |
| 轮询超时（等待超过 300 秒后） | 🕐 已等待超过 5 分钟，任务可能仍在执行。可稍后用以下命令手动查询：<br>`python scripts/submit_and_poll.py --mode poll --result-id <result_id>` |


path: references/MODIFICATION.md
content: # 结果修改工作流（Step 6 详细指南）

当用户对 Step 5 展示的报关结果提出修改要求时，参考本文档执行。

---

## ⛔ 约束

- **禁止**重新上传文件或提交新的分类/报关任务
- **禁止**自行创建新的 Excel 模板或格式
- **必须**基于当前任务已有的 `structured_data` 进行修改，再用已下载的 Excel 文件直接修改对应单元格

---

## 工作流程

### 1. 判断修改类型

用户的修改意图分为两类：

#### 类型 A：用户直接给出修改值

用户明确说出了要改成什么值，直接映射到 `structured_data` 字段：

| 用户说法示例 | 对应操作 |
|---|---|
| "第3项品名改成塑料薄膜" | `items[2].品名 = "塑料薄膜"` |
| "HS编码第一个改成3920109090" | `items[0].商品编码 = "3920109090"` |
| "总金额应该是5000美元" | `summary.总金额 = "5000"` |
| "删掉最后一行" | 删除 `items` 末尾元素 |
| "加一个商品：螺丝，编码7318159001，100千克" | 在 `items` 末尾追加 |

→ 直接进入步骤 2（展示修改预览）。

#### 类型 B：用户只给方向，不给具体值

用户指向一个**原始文件**，要求从中提取某些字段来补充或修正结果。用户不会把每个值都列出来——他们用工具就是为了不手动抄写。

| 用户说法示例 | 用户真实意图 |
|---|---|
| "根据上传的报关单草单，把收货人、运输方式补充进去" | Agent 去读草单文件，找到收货人和运输方式的值 |
| "装箱单上的件数和毛重跟结果不一样，以装箱单为准" | Agent 去读装箱单，提取件数和毛重来覆盖结果 |
| "发票上的单价好像提取错了，你再看看" | Agent 去读发票，重新确认单价 |
| "把报关单里的贸易国别、成交方式这些表头信息补全" | Agent 去读报关单，提取多个表头字段 |

**处理步骤**：

1. **确定目标文件** — 根据用户提及的文件类型（报关单/装箱单/发票等），在当前任务目录的 `raw/` 子目录中找到对应的原始上传文件（如 `tasks/customs_<id>/raw/draft.xlsx`）
2. **自行阅读文件** — 使用你自身的能力（视觉理解/文本阅读）直接读取该文件内容
3. **提取用户要求的字段值** — 从文件中找到用户提及的字段（收货人、运输方式、件数等）
4. **进入步骤 2** — 将提取到的值作为修改预览展示给用户确认

> ⛔ **类型 B 仍然是修改操作，不是重新执行报关任务。** 你只是用自己的能力从原始文件中读取特定字段值，然后修改 `structured_data` 和 Excel。**禁止**重新调用 `submit_and_poll.py` 或提交新的处理任务。

---

### 2. 展示修改预览

修改前必须展示变更对比，等用户确认：

```
📝 将按您的要求修改以下内容：

| # | 字段 | 原值 | → | 新值 |
|---|------|------|---|------|
| 1 | 第3项 品名 | 塑料板 | → | 塑料薄膜 |
| 2 | 第3项 商品编码 | 3920991000 | → | 3920109090 |

确认修改吗？（确认/OK/取消）
```

### 2.5 修改时的合规建议

当用户要求修改以下字段时，Agent 应打开 [FIELD_GUIDE.md](FIELD_GUIDE.md) 查阅对应字段的规范，在修改预览表格**下方**附带合规提示：

| 用户修改的字段 | 查阅 FIELD_GUIDE 章节 | 提示内容 |
|---|---|---|
| 成交方式 | 「成交方式」表 | 对应保费规则（FOB/EXW/C&F 须填保费率 0.3‰） |
| 品牌类型 | 「品牌类型」表 | 正确的代码映射（0-4） |
| 运输方式 | 「运输方式」表 | 代码对照 |
| 包装种类 | 「包装种类」表 | 代码对照 |
| 经停港/指运港/贸易国别 | 「易混淆字段辨析」 | 三者的区别说明 |
| 征免方式 | 「征免方式」表 | 代码对照 |
| 监管方式 | 「监管方式」表 | 代码对照 |

**合规提示格式（附在修改预览表格下方）：**

```
💡 合规提示：FOB 成交方式下，即便未实际支付保费，也须填写保费率（通常为 0.3‰）。
当前保费字段为空，是否需要一并补充？
```

⛔ 约束：
- 合规提示仅在修改上述字段时出现，修改品名、数量等常规字段时**不附加提示**
- 提示不超过 2 行，简洁明了
- 用户可忽略提示直接确认修改

---

### 3. 用户确认后，直接修改 Excel 文件

使用 Python + openpyxl 打开任务目录下已下载的 Excel 文件，定位到对应单元格修改值并保存：

> ⚠️ **此步骤需要 `openpyxl` 库（非标准库）。** 如果用户环境未安装，先执行 `pip install openpyxl`。执行修改前请先确认该依赖可用。

```python
from openpyxl import load_workbook

# Excel 文件位于任务目录下
wb = load_workbook("tasks/customs_<id>/customs_result.xlsx")
ws = wb.active

# 根据实际行列位置修改对应单元格
# 例如: 第3项品名在第 N 行第 M 列
ws.cell(row=<行号>, column=<列号>).value = "塑料薄膜"

wb.save("tasks/customs_<id>/customs_result_v2.xlsx")
wb.close()
```

> ⚠️ 修改时保留原文件样式和格式，只改值，不改结构。
> 保存为新文件名（如 `_v2`、`_v3`），避免覆盖原始结果。

---

### 4. 告知用户修改完成

展示修改后的数据摘要，并告知新文件位置。

---

## 多轮修改

用户可以反复修改。每次修改都基于**最新版本**的数据和文件继续调整，不回退到原始版本。


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)