> ## Documentation Index
> Fetch the complete documentation index at: https://docs.profy.cn/llms.txt
> Use this file to discover all available pages before exploring further.

# Customs Declaration Extraction

> profy-customs — extract all 83 fields of a China import customs declaration from trade documents, as machine-validatable structured JSON

# Customs Declaration Extraction (profy-customs)

Turn trade paperwork — contracts, commercial invoices, bills of lading, packing lists, bonded-zone manifests — into a customs declaration JSON where **every field is traceable and machine-checkable**. Each field carries `value`, `reason`, `confidence`, and `page`, so you can see which sentence on which page produced it instead of receiving a pile of numbers you have no way to audit.

<Info>
  This is a vertical-domain plugin, not a general OCR wrapper. It ships China Customs field definitions, the five standard code tables, cross-field consistency rules, and a set of disambiguation decision trees. General OCR gives you text; this gives you **declarable fields**.
</Info>

## Enabling it

`profy-customs` is `user_selectable: true` — you **must tick it in the plugin panel** for a conversation. It never joins on its own.

Its `activation` block contains only `user_selectable`. There is no `requires` and no `conditions`, which means once selected it works in any sandbox mode. It does not need Desktop, and it does not need a browser.

```json theme={null}
"activation": { "user_selectable": true }
```

Compare this with the three MCP bridge plugins (Blender / Godot / Photoshop), which additionally gate on `desktop_connected: true`. Customs has no such gate — it is pure server-side computation plus one HTTP call to the OCR service.

## Quick start

Upload the documents, then say:

```
Extract the customs declaration from these documents
```

The skill routes on intent before doing anything else:

| What you want          | How to phrase it                                         | Path taken                              |
| ---------------------- | -------------------------------------------------------- | --------------------------------------- |
| Full extraction        | "Extract the customs declaration"                        | Step 1 → Step 7, the complete pipeline  |
| Ask about a field rule | "How should 成交方式 (trade terms) be filled?"               | Loads only that field group's reference |
| Validate existing JSON | "Validate this declaration JSON"                         | Calls `customs_validate` directly       |
| Look up a code         | "What is the supervision method code for general trade?" | Calls `customs_code_lookup` directly    |

Routing on intent matters because the full pipeline is expensive. A question about one field should not trigger a seven-step extraction.

## The three tools

### `customs_ocr` — layout-aware text recognition

```python theme={null}
customs_ocr(
    file_url="https://.../bill-of-lading.pdf",
    pages="all",        # or "1-3"
)
```

Calls the self-hosted RapidOCR service over HTTP. It **never runs OCR in-process** — that constraint keeps the agent runtime free of heavyweight vision dependencies and lets OCR scale independently.

Returns, per page:

| Field              | Meaning                                                           |
| ------------------ | ----------------------------------------------------------------- |
| `page`             | Page number, 1-based                                              |
| `width` / `height` | Page dimensions in pixels                                         |
| `texts[]`          | Each entry has `text`, `bbox([x1,y1,x2,y2])`, `score`             |
| `blocks[]`         | Layout blocks, `type` is one of `table` / `text` / `title`        |
| `markdown`         | The page rendered as markdown, with tables converted to md tables |

`bbox` is the reason this tool exists. Most of the information on a customs document lives inside tables, and a flat text stream loses "which column does this number belong to". Coordinates let the extraction step reconstruct the table structure.

Service endpoint is controlled by `CUSTOMS_OCR_URL`, defaulting to `http://profy-ocr:8001`. Requests go to `/ocr/parse` with a **120-second per-request timeout**.

### `customs_code_lookup` — standard code tables

```python theme={null}
customs_code_lookup(
    table="supervision-methods",
    query="一般贸易",
    direction="name_to_code",   # or code_to_name
    fuzzy=False,
)
```

Five standard tables ship with the plugin:

| `table` value         | Domain                         |
| --------------------- | ------------------------------ |
| `supervision-methods` | Supervision method (监管方式)      |
| `transport-modes`     | Mode of transport (运输方式)       |
| `trade-terms`         | Trade terms / Incoterms (成交方式) |
| `packaging-types`     | Packaging type (包装种类)          |
| `insurance-modes`     | Insurance mode (保险方式)          |

This is a deterministic lookup — the design intent is that **the model never guesses a code value**. Exact matching compares against both `name` and any `aliases` on the entry. Setting `fuzzy=True` switches to substring matching over the serialized entry and returns **at most 10 results**, a hard cap that keeps tool output from flooding the context window.

### `customs_validate` — format and cross-field checks

```python theme={null}
customs_validate(fields_json="{...full extraction result...}")
```

Pure computation, no external calls. Response shape:

```json theme={null}
{
  "passed": true,
  "errors": [],
  "cross_issues": [],
  "summary": {
    "total_fields": 83,
    "failed": 0,
    "cross_issues": 0,
    "by_severity": { "P0": 0, "P1": 0, "P2": 0, "P3": 0 }
  }
}
```

`passed` is computed as **P0 count == 0 AND P1 count == 0**. P2 and P3 findings are reported but do not block. The severity ladder maps to real customs rejection risk: P0 means the declaration will be rejected, P1 means it likely will, P2 is a consistency smell worth a human look, P3 is informational.

## The full field set (83 fields)

56 header fields plus 27 body fields. Fields are grouped, and the skill loads only the reference for the group it is currently working on:

| Group           | Fields | Subject                                                          |
| --------------- | ------ | ---------------------------------------------------------------- |
| 1+2 Identity    | 14     | Filing number, corporate entities, inspection & quarantine codes |
| 5 Transport     | 9      | Mode of transport, B/L number, port of departure                 |
| 7 Trade terms   | 8      | Incoterms, freight and insurance, miscellaneous charges          |
| 8 Cargo spec    | 5      | Package count, packaging, gross / net weight                     |
| 9 Container     | 3      | Container number, size type, item-number relations               |
| 10 Destination  | 5      | Trading country, destination                                     |
| 11 Supervision  | 6      | Supervision method, duty exemption, accompanying documents       |
| 12 Confirmation | 6      | Price confirmation items, marks and numbers                      |
| Body — basic    | 14     | Commodity line items, per row                                    |
| Body — food     | 7      | Food import specific fields                                      |
| Body — hazard   | 6      | Dangerous goods specific fields                                  |

The food and hazard groups are **conditionally activated**. Food fields load when the HS code's regulatory conditions include A or B; hazard fields load when an MSDS is present and its section 14 carries a dangerous goods classification. Loading all groups unconditionally would waste context on the majority of shipments that are neither.

## Scenario detection

Before extracting anything, the skill classifies the shipment, because the same field means different things across scenarios:

| Signal in the documents                                           | Scenario                          | Consequence                                                                                                  |
| ----------------------------------------------------------------- | --------------------------------- | ------------------------------------------------------------------------------------------------------------ |
| A bonded-zone manifest exists, or filing number starts with B/C/Z | Bonded / special supervision zone | Most fields prefer the manifest as source; declaring customs office takes the last 4 digits of the QD number |
| B/L contains AWB / HAWB / MAWB                                    | Air freight                       | Transport mode = 5; B/L number is `master_awb_house_awb`; no container                                       |
| B/L contains B/L No. / Vessel / CY / CFS                          | Ocean freight                     | Transport mode = 2; FCL vs LCL decides whether container number is mandatory                                 |
| HS code is a food category (regulatory condition A/B)             | Food import                       | Activates the food field group                                                                               |
| MSDS present with a section 14 classification                     | Dangerous goods                   | Activates the hazard field group                                                                             |

## Validation rules in full

### Format validation

| Field            | Rule                               | Exact message                  | Severity |
| ---------------- | ---------------------------------- | ------------------------------ | -------- |
| 商品编号(HSCODE)     | `^\d{10}$`                         | HS编码必须为10位纯数字                  | P0       |
| 境内收发货人-18位社会信用代码 | `^[0-9A-Z]{18}$`                   | 统一社会信用代码必须为18位字母数字             | P0       |
| 境内收发货人-10位海关编码   | `^\d{10}$`                         | 海关编码必须为10位纯数字                  | P0       |
| 消费使用单位-10位海关编码   | `^(\d{10}\|NO)$`                   | 海关编码必须为10位纯数字或'NO'             | P1       |
| 运输方式             | `^[25]$`                           | 运输方式代码必须为2(海运)或5(空运)           | P0       |
| 净重 / 毛重          | `^\d+(\.\d+)?$`                    | 净重必须为纯数字                       | P0       |
| 件数               | `^\d+$`                            | 件数必须为正整数                       | P0       |
| 进口日期 / 启运时间      | `^\d{8}$` plus real calendar check | 日期格式必须为YYYYMMDD 8位数字           | P1       |
| 成交方式             | `^(CIF\|FOB\|C&F\|EXW\|C&I)$`      | 成交方式必须为CIF/FOB/C\&F/EXW/C\&I之一 | P1       |
| 运费方式 / 保费方式      | `^[123]$`                          | 运费方式必须为1(率)/2(单价)/3(总价)        | P1       |
| 征免性质             | `^\d{3}$`                          | 征免性质必须为3位数字代码                  | P1       |
| 备案号              | `^[A-Z]\d{11}$`                    | 备案号首位大写字母+11位数字(共12位)          | P1       |
| 包装种类             | `^\d{2}$`                          | 包装种类必须为2位数字代码                  | P1       |
| 集装箱规格            | Enum of 8 standard options         | 集装箱规格必须是8种标准选项之一               | P2       |

Messages are emitted in Chinese because they mirror what a Chinese customs broker expects to read. Date validation goes beyond digit count: the year must fall in 1900–2099, the month in 1–12, and the day is bounded by `calendar.monthrange` for that specific year and month. So `20250230` fails with `日期30超出2025年2月最大天数28` rather than passing a naive regex.

### Cross-field consistency

| Check                             | Trigger                                                     | Exact message                     | Severity |
| --------------------------------- | ----------------------------------------------------------- | --------------------------------- | -------- |
| Net ≤ gross weight                | Both present and net > gross                                | 净重(N)大于毛重(M)                      | P0       |
| Ocean FCL requires container      | Transport = 2, B/L contains CY, no container number         | 海运整箱(CY)场景下集装箱号为必填                | P0       |
| CIF/C\&F freight must be blank    | Trade term is CIF or C\&F but freight mode has a value      | 成交方式为CIF时运费方式应留空，当前值=X            | P1       |
| CIF insurance must be blank       | Trade term is CIF but insurance mode has a value            | 成交方式为CIF时保费方式应留空，当前值=X            | P1       |
| Filing prefix matches supervision | B/C should pair with processing trade, Z with general trade | 备案号首字母B通常对应\['来料加工',...]，当前监管方式=X | P2       |
| Air freight has no container      | Transport = 5 but container number has a value              | 运输方式为空运(5)时集装箱号应留空                | P2       |

These encode the arithmetic a customs broker does by hand. CIF already includes freight and insurance in the price, so declaring them separately double-counts — hence P1 rather than a warning.

## How extraction is structured

### Phase split

| Phase | Scope                                                            | Context budget                     |
| ----- | ---------------------------------------------------------------- | ---------------------------------- |
| A     | Steps 1–5, the 56 header fields plus code mapping                | One session                        |
| B     | Steps 3–4, N body rows × 14 basic fields plus conditional groups | New session recommended when N ≥ 8 |

### Three-tier context loading

Reference material is loaded and released deliberately rather than dumped up front:

| Tier | File                       | Loaded                                | Released               |
| ---- | -------------------------- | ------------------------------------- | ---------------------- |
| L0   | `CHEATSHEET.md`            | Always, first                         | Kept until the end     |
| L1   | Group reference (11 files) | When handling that group              | After writing the lock |
| L1   | `decision-trees.md`        | On ambiguity                          | Immediately after use  |
| L2   | `rag-label-mapping.md`     | Step 5, code mapping                  | After mapping          |
| L2   | `anti-patterns.md`         | Step 6, validation                    | After validation       |
| Lock | `extraction_lock.json`     | Read before each group, written after | Held through Step 7    |

The lock file is what makes long extractions survivable. Committed field values are written to disk, so a session that runs long does not have to keep every intermediate result in the context window, and a resumed session picks up where the previous one stopped.

### Ten disambiguation decision trees

| Situation                              | Tree  | Typical ambiguity                                          |
| -------------------------------------- | ----- | ---------------------------------------------------------- |
| No explicit transport marking          | DT-1  | Default to ocean, lower the confidence                     |
| Multiple B/L numbers                   | DT-2  | Master + house, same carrier multi-lot, different carriers |
| Trade term outside the standard 5      | DT-3  | CIP→CIF, CFR→C\&F, D-group→CIF                             |
| Contract number conflicts with invoice | DT-4  | Contract wins                                              |
| English-only company name              | DT-5  | Do not translate; trigger a registry lookup                |
| Transshipment                          | DT-6  | Country of departure ≠ trading country                     |
| Consumer / end-user backfill           | DT-7  | No agency agreement → copy the domestic consignee          |
| Bonded-zone filing number              | DT-8  | Manifest present → extract, absent → leave blank           |
| Container size type                    | DT-9  | 20GP→S, 40HC→L                                             |
| Duty exemption                         | DT-10 | Derived from the supervision method                        |

Each tree resolves to a deterministic choice plus a confidence adjustment, which is why the output carries `confidence` per field rather than one number for the document.

## Boundaries and failure modes

<Warning>
  **`customs_validate` does not check for required fields.** An empty value short-circuits validation and returns as passing at P3. The tool answers "is what you filled in correct", not "did you fill in everything you had to". Missing fields must be caught during the extraction inventory step — do not treat validation as a safety net for completeness.
</Warning>

Other boundaries worth knowing before you rely on this in production:

* **Cross-field checks only run over header fields.** Body rows get per-field format validation only; relationships between rows are not checked.
* **Fuzzy lookup truncates silently at 10 results.** The returned `count` is the post-truncation count, so it cannot be used to detect that truncation happened.
* **An unknown table name returns a structured error**, `{"error": "未知代码表 'X'", "available_tables": [...]}`, rather than raising. All three tools return errors as JSON — they never throw into the agent loop.
* **Large consignments should be split across sessions.** Beyond roughly 8 body rows, the raw document text alone will dominate the context window.

### Troubleshooting

| Symptom                              | Cause                                                       | Fix                                                      |
| ------------------------------------ | ----------------------------------------------------------- | -------------------------------------------------------- |
| `OCR 服务超时（120s），请尝试减少页数或检查服务状态`      | Multi-page PDF exceeded the 120s CPU budget                 | Split with `pages="1-3"`                                 |
| `无法连接 OCR 服务 http://profy-ocr:8001`  | OCR service not ready, or `CUSTOMS_OCR_URL` misconfigured   | Ask your administrator to check the service              |
| `OCR 服务返回 HTTP 4xx/5xx: ...`         | File URL unreachable, or unsupported format                 | Confirm the upload succeeded and the file is PDF/PNG/JPG |
| `代码表 'supervision-methods' 为空或文件不存在` | Code table files are not at the path the tool reads         | See the known defect below                               |
| `输入 JSON 解析失败: ...`                  | The argument passed to `customs_validate` is not valid JSON | Check for stray markdown code fences                     |

<Warning>
  **Known defect (verified 2026-08-11)**: `customs_code_lookup` resolves code tables from `data/code-tables/` under the plugin directory, but the files actually ship at `skills/customs-extraction/assets/code-tables/`. While the paths disagree, all five tables return `代码表 'X' 为空或文件不存在`. Work around it by asking the model directly — the skill references carry the common codes — rather than relying on this tool.
</Warning>

## Where the result goes

The final JSON is written by Core into `message.metadata.declaration` when the conversation reaches `complete`, mapped to the preview contract `{basic, fields, goods, ...}`. This is the **persistence contract** between the plugin and the product: the declaration preview panel in the UI reads exactly this. Do not invent parallel field names such as `draftResult` or `declarationForm` — nothing downstream reads them.

## Verify your setup

1. With the plugin ticked, run a lookup with no file attached: "What is the supervision method code for general trade?" A response of `代码表 ... 为空或文件不存在` means you have hit the known defect above.
2. Upload a single-page invoice and say "extract only the identity field group". You should see the OCR card expand, and each field should carry `page` and `confidence`.
3. Feed the extraction result back with "validate this". The validation card should render a `by_severity` breakdown — **`passed: true` here is the signal that the whole chain works**.

## Related

<CardGroup cols={2}>
  <Card title="Plugin overview" href="/en/documentation/plugins/overview">
    All 28 built-in plugins and how they activate
  </Card>

  <Card title="Skills" href="/en/documentation/concepts/skills">
    How skills load on demand instead of filling the context up front
  </Card>
</CardGroup>

<Note>
  Verified 2026-08-11. Sources: `services/agent-runtime/src/plugins/builtin/customs/plugin.json`, `tools/customs_ocr.py`, `tools/customs_code_lookup.py`, `tools/customs_validate.py`, `skills/customs-extraction/SKILL.md`.
</Note>
