合并远程报价模板 AI 解析与超时设置:保留私聊图片和附件询价。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-18 17:47:48 +08:00
co-authored by Cursor
41 changed files with 4972 additions and 438 deletions
@@ -0,0 +1,128 @@
"""
报价模板 AI 映射入队路由。
本文件职责:校验 callback token,接收主账单元格清单,写入 recognition Stream 后快回。
禁止:在本请求线程调千问 / 等完整 mapping;禁止直连 MySQL。
Worker:recognition 槽认领 kind=quote_template_parse 后跑 mode_sheet_map。
"""
from __future__ import annotations
import logging
from typing import Any, Optional
from fastapi import APIRouter, Header, HTTPException, Response
from pydantic import BaseModel, Field
from agent.config import get_settings
from agent.jobs.recognition import enqueue_recognition
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/internal/quote-template", tags=["quote-template"])
class QuoteTemplateParseBody(BaseModel):
"""主账提交的模板映射解析任务。"""
version_id: str = Field(default="", alias="versionId")
template_id: str = Field(default="", alias="templateId")
template_name: str = Field(default="", alias="templateName")
file_name: str = Field(default="", alias="fileName")
biz_type: str = Field(default="", alias="bizType")
transport_mode: str = Field(default="sea", alias="transportMode")
sheet: str = ""
cell_inventory: list[Any] = Field(default_factory=list, alias="cellInventory")
cell_count: int = Field(default=0, alias="cellCount")
yellow_count: int = Field(default=0, alias="yellowCount")
truncated: bool = False
request_id: str = Field(default="", alias="requestId")
model_config = {"populate_by_name": True}
def _expected_token() -> str:
s = get_settings()
return (s.agent_callback_token or s.inquiry_agent_callback_token or "").strip()
def _check_token(authorization: Optional[str], x_agent_token: Optional[str]) -> None:
expected = _expected_token()
if not expected:
if (get_settings().ytd_env or "").lower() == "prod":
raise HTTPException(status_code=503, detail="callback token 未配置")
logger.warning("quote-template parse:未配置 callback token,test 允许空校验")
return
provided = (x_agent_token or "").strip()
if not provided and authorization:
auth = authorization.strip()
if auth.lower().startswith("bearer "):
provided = auth[7:].strip()
else:
provided = auth
if provided != expected:
raise HTTPException(status_code=401, detail="unauthorized")
@router.get("/ping")
def parse_ping() -> dict[str, str]:
return {"status": "ok", "channel": "quote-template-parse"}
@router.post("/parse")
def enqueue_parse(
body: QuoteTemplateParseBody,
response: Response,
authorization: Optional[str] = Header(default=None),
x_agent_callback_token: Optional[str] = Header(
default=None, alias="X-Agent-Callback-Token"
),
) -> dict[str, Any]:
"""
接收主账解析任务:入 recognition 队列后立即 202。
不在此调用千问。
"""
_check_token(authorization, x_agent_callback_token)
version_id = (body.version_id or "").strip()
if not version_id:
raise HTTPException(status_code=400, detail="versionId 必填")
inventory = body.cell_inventory or []
if not inventory:
raise HTTPException(status_code=400, detail="cellInventory 不能为空")
payload = {
"versionId": version_id,
"templateId": body.template_id,
"templateName": body.template_name,
"fileName": body.file_name,
"bizType": body.biz_type,
"transportMode": (body.transport_mode or "sea").strip().lower() or "sea",
"sheet": body.sheet or "",
"cellInventory": inventory,
"cellCount": body.cell_count or len(inventory),
"yellowCount": body.yellow_count,
"truncated": bool(body.truncated),
}
result = enqueue_recognition(
kind="quote_template_parse",
payload=payload,
idempotency_key=(body.request_id or "").strip()
or f"quote-tpl-parse:{version_id}:{len(inventory)}",
)
if not result.accepted:
# 幂等重复:视为已接受,避免主账把「重复」当成失败
if result.reason == "duplicate_idempotency_key":
response.status_code = 202
return {"accepted": True, "entry_id": "", "reason": "duplicate_idempotency_key"}
response.status_code = 503
return {"accepted": False, "reason": result.reason or "enqueue_failed"}
response.status_code = 202
logger.info(
"quote_template.parse.enqueued versionId=%s cells=%s entry=%s",
version_id,
len(inventory),
result.entry_id,
)
return {"accepted": True, "entry_id": result.entry_id}
+2
View File
@@ -16,6 +16,7 @@ from fastapi import FastAPI
from agent.channel.aibot import router as aibot_router
from agent.channel.bridge import router as bridge_router
from agent.channel.h5 import router as h5_router
from agent.channel.quote_template_parse import router as quote_template_parse_router
from agent.channel.runtime import start_channel_runtime
from agent.channel.wecom.callback import router as wecom_router
from agent.config import get_settings
@@ -73,6 +74,7 @@ def create_app() -> FastAPI:
app.include_router(h5_router)
app.include_router(bridge_router)
app.include_router(aibot_router)
app.include_router(quote_template_parse_router)
return app
+4 -1
View File
@@ -211,7 +211,10 @@ class WorkerRuntime:
return
entry_id, data = claimed[0]
try:
process_recognition(data)
process_recognition(
data,
allow_network=bool(self.settings.llm_allow_network),
)
finally:
stream.ack(entry_id)
+108 -18
View File
@@ -1,7 +1,9 @@
"""
识别任务壳:入队 Redis Stream,Worker HTTP 槽认领后跑占位处理。
识别任务:入队 Redis Stream,Worker HTTP 槽认领后处理。
本文件职责:识别入队;Worker 槽消费。kind=vision 跑私聊图片波次,kind=attachment 跑附件材料,其它 kind 仍占位。
本文件职责:enqueue;process_recognition 按 kind 分支。
kind=vision 跑私聊图片波次,kind=attachment 跑附件材料,
kind=quote_template_parse 跑报价模板 AI 映射;其它 kind 仍占位。
禁止:回调线程同步等识别完成;禁止本模块写主账六态。
"""
@@ -41,7 +43,7 @@ def enqueue_recognition(
"""
识别任务入队(快回)。
kind 例:attachment_text / vision / sheet_map。
kind 例:attachment / vision / quote_template_parse。
"""
rt = redis or get_redis_runtime()
if idempotency_key:
@@ -62,14 +64,29 @@ def enqueue_recognition(
return RecognitionEnqueueResult(accepted=True, entry_id=entry_id)
def process_recognition(data: dict[str, Any]) -> dict[str, Any]:
def process_recognition_stub(data: dict[str, Any]) -> dict[str, Any]:
"""
Worker 槽:vision 跑图片波次,其它 kind 仍走占位壳。
识别占位:回显 kind,不读附件、不调模型。
兼容旧调用名;正式路径请用 process_recognition。
禁止再调本函数以免与 process_recognition 互相递归。
"""
return process_recognition(data, allow_network=False)
def process_recognition(data: dict[str, Any], *, allow_network: bool = True) -> dict[str, Any]:
"""
Worker 槽:按 kind 处理识别/映射任务。
vision:私聊图片波次收口。
attachment:H5 附件材料收口。
quote_template_parse:调千问 sheet_map,再 PUT 主账 parseResult。
禁止在本函数写六态。
"""
kind = str(data.get("kind") or "")
kind = str(data.get("kind") or "unknown")
payload = data.get("payload") if isinstance(data.get("payload"), dict) else {}
if not isinstance(payload, dict):
payload = {}
if kind == "vision":
from agent.handlers.image_inquiry import finish_queued_image
@@ -78,21 +95,94 @@ def process_recognition(data: dict[str, Any]) -> dict[str, Any]:
from agent.handlers.attachment_inquiry import finish_queued_attachment
return finish_queued_attachment(payload)
return process_recognition_stub(data)
def process_recognition_stub(data: dict[str, Any]) -> dict[str, Any]:
"""
识别占位:回显 kind,不读附件、不调模型。
Worker 槽调用;正式实现替换为本函数旁的真实处理器。
"""
kind = str(data.get("kind") or "unknown")
payload = data.get("payload") or {}
logger.info("recognition.stub kind=%s keys=%s", kind, sorted(payload.keys()) if isinstance(payload, dict) else [])
if kind == "quote_template_parse":
return _process_quote_template_parse(payload, allow_network=allow_network)
logger.info(
"recognition.stub kind=%s keys=%s",
kind,
sorted(payload.keys()),
)
return {
"ok": True,
"stub": True,
"kind": kind,
"note": "识别壳:未跑真实抽取",
}
def _process_quote_template_parse(payload: dict[str, Any], *, allow_network: bool) -> dict[str, Any]:
"""跑 sheet_map 并回写主账;失败也必须回写 failed+原因。"""
from agent.config import get_settings
from agent.llm.mode_sheet_map import invoke_sheet_map
version_id = str(payload.get("versionId") or "").strip()
settings = get_settings()
net = bool(allow_network and settings.llm_allow_network)
result = invoke_sheet_map(payload, allow_network=net)
body: dict[str, Any]
if result.get("ok") and isinstance(result.get("mapping"), dict):
mapping = dict(result["mapping"])
gaps = result.get("gaps_after")
if not isinstance(gaps, list):
gaps = mapping.get("mappingGaps") if isinstance(mapping.get("mappingGaps"), list) else []
mapping["mappingGaps"] = [str(g) for g in gaps if g]
# 运营可读摘要:点数之外固定带上未解析项
n_fields = len(mapping.get("fields") or {})
n_fee = len(mapping.get("feeRegions") or []) if isinstance(mapping.get("feeRegions"), list) else 0
n_route = len(mapping.get("routeRegions") or []) if isinstance(mapping.get("routeRegions"), list) else 0
points = n_fields + n_fee + n_route
parse_message = f"已解析 {points} 个映射点"
if mapping["mappingGaps"]:
preview = ";".join(mapping["mappingGaps"][:8])
more = len(mapping["mappingGaps"]) - 8
parse_message += f";未解析到 {len(mapping['mappingGaps'])} 项:{preview}"
if more > 0:
parse_message += f" 等共{len(mapping['mappingGaps'])}项"
body = {
"versionId": version_id,
"ok": True,
"mappingJson": mapping,
"parseStatus": "parsed",
"parseMessage": parse_message,
}
else:
err = str(result.get("error") or "AI 解析失败")
body = {
"versionId": version_id,
"ok": False,
"parseStatus": "failed",
"parseMessage": err,
}
posted = _post_parse_result(body)
logger.info(
"quote_template_parse done versionId=%s ok=%s posted=%s",
version_id,
body.get("ok"),
posted.get("ok"),
)
return {"ok": bool(body.get("ok")), "posted": posted, "kind": "quote_template_parse"}
def _post_parse_result(body: dict[str, Any]) -> dict[str, Any]:
"""PUT /inquiry/agent/quoteTemplate/parseResult。"""
try:
from agent.ledger import LedgerClient
client = LedgerClient.from_settings()
resp = client.request_json(
"PUT",
"/inquiry/agent/quoteTemplate/parseResult",
json_body=body,
idempotency_key=f"parseResult:{body.get('versionId')}",
)
if not resp.ok:
logger.warning(
"parseResult 回写失败 status=%s err=%s",
resp.status_code,
resp.error,
)
return {"ok": False, "error": resp.error or f"http_{resp.status_code}"}
return {"ok": True, "data": resp.data}
except Exception as exc:
logger.exception("parseResult 回写异常")
return {"ok": False, "error": str(exc)}
+4
View File
@@ -154,6 +154,10 @@ class LlmHttpClient:
if provider == "qwen":
# 识图只要可见原文。思考开着时 content 常为空,销售侧就会「认不到」。
body["enable_thinking"] = False
# 报价模板 mappingJson 体积大,显式抬高上限,降低半截 JSON
mode_val = mode.value if isinstance(mode, LlmMode) else str(mode)
if mode_val == LlmMode.SHEET_MAP.value:
body["max_tokens"] = 8192
if tools:
body["tools"] = tools
if tool_choice is not None:
+326 -4
View File
@@ -1,30 +1,352 @@
"""
报价工作表映射 mode 壳。
报价工作表映射 mode:上传时用千问产出 mappingJson。
本文件职责:加载映射分析提示词、拼用户消息、调千问、剥 Markdown、校验 JSON、
对照单元格清单做缺项检测,必要时再补跑一轮。
禁止:发明运价;在企微回调线程同步调用;出单阶段再调本 mode。
"""
from __future__ import annotations
from typing import Any
import json
import logging
import re
from pathlib import Path
from typing import Any, Optional
from agent.llm.modes import LlmMode
logger = logging.getLogger(__name__)
MODE = LlmMode.SHEET_MAP
_PROMPT_CACHE: str | None = None
# 清单原文片段 → 白名单 code(用于缺项检测;不要求全覆盖词典)
_LABEL_TO_CODE: list[tuple[tuple[str, ...], str]] = [
(("提货地址", "Place of Receipt", "place of receipt"), "pickup_address"),
(("送货地址", "派送地址", "Place of Delivery", "place of delivery"), "delivery_address"),
(("货值", "Cargo Value", "cargo value"), "cargo_value"),
(("海关编码", "HS Code", "HS编码", "hs code"), "hs_code"),
(("油、电、磁", "油,电,磁", "油电磁", "IL, ELEC", "L,ELEC", "oil"), "oil_elec_mag"),
(("特殊说明", "特别说明", "Special Remarks"), "special_remarks"),
(("要求货物到达", "Requested Arriv", "抵达时间"), "eta_request"),
(("运输时效", "Transit Time", "航程时间", "Navigation time"), "transit_time"),
(("船期", "Sailing", "Requested Sh"), "sailing_schedule"),
(("开船日", "On Board"), "on_board_date"),
(("船公司", "Shipping Line", "Shipping Co"), "shipping_line"),
(("货好时间", "Cargo Ready"), "cargo_ready_date"),
(("贸易条款", "Terms"), "trade_terms"),
(("整柜或散货", "整柜或拼柜", "FCL or LCL"), "fcl_or_lcl"),
(("箱型", "柜量", "Type & Qty", "Cont."), "container_type_qty"),
(("起运港", "POL"), "pol"),
(("目的港", "POD"), "pod"),
(("品名", "Commodity"), "commodity"),
(("货物数量", "Quantity"), "quantity"),
(("询价日期", "Inquiry Date", "报价日期"), "quote_date"),
(("RFQ", "询价单号", "报价单编号"), "quote_no"),
(("有效期", "period"), "valid_until"),
]
_SEA_FEE_MARKERS: list[tuple[tuple[str, ...], tuple[str, ...]]] = [
# (清单关键词, 可匹配的 feeRegion id/title/tmsSource 子串)
(
("始发港费用", "Origin Charges"),
("origin_charges", "polcost", "始发港", "origin"),
),
(
("海运费用", "Ocean Freight"),
("ocean_freight", "oceancost", "海运费用", "ocean"),
),
(
("目的港费用", "Destination Charges"),
("destination_charges", "podcost", "目的港", "destination"),
),
]
def mode_meta() -> dict[str, Any]:
return {
"mode": MODE.value,
"provider": "qwen",
"purpose": "sheet_map",
"implemented": False,
"implemented": True,
}
def _prompt_path() -> Path:
return Path(__file__).resolve().parents[1] / "quote_templates" / "prompts" / "mapping_analyzer.txt"
def load_system_prompt() -> str:
"""加载映射分析器系统提示词(仓库内只读资源)。"""
global _PROMPT_CACHE
if _PROMPT_CACHE is not None:
return _PROMPT_CACHE
path = _prompt_path()
text = path.read_text(encoding="utf-8")
_PROMPT_CACHE = text
return text
def _inventory_lines(payload: dict[str, Any]) -> list[str]:
inventory = payload.get("cellInventory") or payload.get("cell_inventory") or []
if isinstance(inventory, list):
return [str(x) for x in inventory]
return [str(inventory)]
def _inventory_blob(payload: dict[str, Any]) -> str:
return "\n".join(_inventory_lines(payload))
def build_user_message(payload: dict[str, Any], *, gaps: list[str] | None = None) -> str:
"""
组装用户消息:运输方式 + 文件名 + 工作表 + 单元格清单。
gaps 非空时追加补全指令(第二轮)。
"""
mode = str(payload.get("transportMode") or payload.get("transport_mode") or "sea").strip()
mode_cn = {"sea": "海运", "air": "空运", "land": "陆运"}.get(mode, mode)
file_name = str(payload.get("fileName") or payload.get("file_name") or "")
sheet = str(payload.get("sheet") or "")
lines = _inventory_lines(payload)
max_lines = 2200
truncated_note = ""
if len(lines) > max_lines:
lines = lines[:max_lines]
truncated_note = f"\n(单元格清单已截断至前 {max_lines} 行)\n"
body = "\n".join(lines)
gap_block = ""
if gaps:
gap_lines = "\n".join(f"- {g}" for g in gaps)
gap_block = (
"\n【补跑缺口】上一轮 mapping 缺少以下项,请输出完整 mappingJson(不要只回增量):\n"
f"{gap_lines}\n"
)
return (
f"运输方式:{mode_cn}\n"
f"模板文件名:{file_name}\n"
f"工作表名:{sheet}\n"
f"单元格清单:地址 | 原文 | 合并范围 | 填充色\n"
f"{body}"
f"{truncated_note}"
f"{gap_block}\n"
"输出前自检 fields 与 feeRegions/routeRegions 是否覆盖清单中的可见业务标签,"
"再只输出 mappingJson。"
)
def strip_json_fence(raw: str) -> str:
"""去掉模型可能包的 Markdown 代码围栏。"""
text = (raw or "").strip()
if not text:
return ""
fence = re.match(r"^```(?:json)?\s*([\s\S]*?)\s*```$", text, re.IGNORECASE)
if fence:
return fence.group(1).strip()
# 偶发前后解释:取第一个 { 到最后一个 }
start = text.find("{")
end = text.rfind("}")
if start >= 0 and end > start:
return text[start : end + 1]
return text
def parse_mapping_json(raw: str) -> dict[str, Any]:
"""
解析模型正文为 mapping 对象。
成功:{ok:True, mapping:dict}
失败:{ok:False, error:str}
"""
cleaned = strip_json_fence(raw)
if not cleaned:
return {"ok": False, "error": "模型返回空内容"}
try:
data = json.loads(cleaned)
except json.JSONDecodeError as exc:
return {"ok": False, "error": f"mappingJson 不是合法 JSON: {exc}"}
if not isinstance(data, dict):
return {"ok": False, "error": "mappingJson 不是对象"}
if not isinstance(data.get("fields"), dict):
data["fields"] = data.get("fields") if isinstance(data.get("fields"), dict) else {}
data.setdefault("schemaVersion", "quote-template-mapping-v1")
return {"ok": True, "mapping": data}
def assess_mapping_gaps(payload: dict[str, Any], mapping: dict[str, Any] | None) -> list[str]:
"""
对照单元格清单,列出 mapping 明显缺口(中文说明,供补跑用户消息)。
不打外网。海运:费用段标题出现则要求对应 feeRegion;常见业务标签出现则要求 fields code。
"""
blob = _inventory_blob(payload)
if not blob.strip():
return []
mapping = mapping or {}
fields = mapping.get("fields") if isinstance(mapping.get("fields"), dict) else {}
field_codes = {str(k) for k in fields.keys()}
fee_regions = mapping.get("feeRegions") if isinstance(mapping.get("feeRegions"), list) else []
fee_blob = " ".join(
str(r.get("id") or "")
+ " "
+ str(r.get("title") or "")
+ " "
+ str(r.get("tmsSource") or "")
for r in fee_regions
if isinstance(r, dict)
).lower()
gaps: list[str] = []
mode = str(payload.get("transportMode") or payload.get("transport_mode") or "sea").strip().lower()
if mode in ("sea", "海运"):
for markers, matchers in _SEA_FEE_MARKERS:
if not any(m in blob for m in markers):
continue
if not any(x in fee_blob for x in matchers):
gaps.append(f"费用区缺失:清单有「{markers[0]}」,须建对应 feeRegion")
for labels, code in _LABEL_TO_CODE:
if code in field_codes:
continue
if any(lab in blob for lab in labels):
gaps.append(f"字段缺失:清单有「{labels[0]}」→ 应映射 {code}")
return gaps
def invoke_shell(*, payload: dict[str, Any]) -> dict[str, Any]:
"""
无网/单测入口:若带 assistant_json 则直接解析;否则返回未实现。
"""
raw = payload.get("assistant_json") or payload.get("content")
if raw is not None:
parsed = parse_mapping_json(str(raw))
parsed["stub"] = True
parsed["mode"] = MODE.value
return parsed
return {
"ok": False,
"stub": True,
"mode": MODE.value,
"error": "sheet_map_shell_not_implemented",
"error": "sheet_map_shell_needs_assistant_json_or_network",
"echo_keys": sorted(payload.keys()),
}
def _chat_once(
*,
chat: Any,
payload: dict[str, Any],
gaps: list[str] | None = None,
) -> dict[str, Any]:
messages = [
{"role": "system", "content": load_system_prompt()},
{"role": "user", "content": build_user_message(payload, gaps=gaps)},
]
result = chat(mode=MODE, messages=messages, temperature=0.0)
if not getattr(result, "ok", False):
err = str(getattr(result, "error", "") or "qwen_failed")
logger.warning("sheet_map 失败 versionId=%s err=%s", payload.get("versionId"), err)
return {
"ok": False,
"error": _humanize_llm_error(err),
"mapping": None,
"provider": getattr(result, "provider", "qwen"),
}
parsed = parse_mapping_json(str(result.content or ""))
if not parsed.get("ok"):
return {
"ok": False,
"error": str(parsed.get("error") or "解析 mapping 失败"),
"mapping": None,
"raw_preview": str(result.content or "")[:240],
}
return {
"ok": True,
"mapping": parsed["mapping"],
"provider": getattr(result, "provider", "qwen"),
}
def invoke_sheet_map(
payload: dict[str, Any],
*,
allow_network: bool = False,
chat_fn: Optional[Any] = None,
timeout_seconds: float = 180.0,
) -> dict[str, Any]:
"""
调用千问产出 mappingJson;若对照清单有明显缺口,再补跑一轮(最多 2 次 chat)。
allow_network=False 且无 chat_fn:不打外网。
副作用:可能出站 HTTPS;应在 Worker HTTP 槽内调用。
"""
if chat_fn is None and not allow_network:
return {"ok": False, "error": "sheet_map_network_disabled", "mapping": None}
from agent.llm.http_client import LlmHttpClient
if chat_fn is None:
client = LlmHttpClient.from_settings()
client.timeout_seconds = float(timeout_seconds)
chat = client.chat
else:
chat = chat_fn
first = _chat_once(chat=chat, payload=payload, gaps=None)
if not first.get("ok"):
return first
mapping = first["mapping"]
gaps = assess_mapping_gaps(payload, mapping)
rounds = 1
if gaps:
logger.info(
"sheet_map gaps versionId=%s n=%s sample=%s",
payload.get("versionId"),
len(gaps),
gaps[:5],
)
second = _chat_once(chat=chat, payload=payload, gaps=gaps)
rounds = 2
if second.get("ok") and second.get("mapping"):
mapping = second["mapping"]
# 第二轮仍失败结构时保留第一轮
elif not second.get("ok"):
logger.warning(
"sheet_map 补跑失败,保留第一轮 versionId=%s err=%s",
payload.get("versionId"),
second.get("error"),
)
remaining = assess_mapping_gaps(payload, mapping)
# 缺口落进 mappingJson,主账/后台可直接展示「表上有、未映射上」
if isinstance(mapping, dict):
mapping["mappingGaps"] = list(remaining)
logger.info(
"sheet_map ok versionId=%s rounds=%s fields=%s feeRegions=%s routeRegions=%s remain_gaps=%s",
payload.get("versionId"),
rounds,
len((mapping or {}).get("fields") or {}),
len((mapping or {}).get("feeRegions") or []),
len((mapping or {}).get("routeRegions") or []),
len(remaining),
)
return {
"ok": True,
"mapping": mapping,
"provider": first.get("provider", "qwen"),
"rounds": rounds,
"gaps_after": remaining,
}
def _humanize_llm_error(err: str) -> str:
e = (err or "").strip()
if e == "llm_data_usage_not_confirmed":
return "未确认 LLM 数据用途(LLM_DATA_USAGE_CONFIRMED),无法调用千问"
if e == "qwen_not_configured":
return "千问未配置(QWEN_API_KEY / QWEN_BASE_URL)"
if e.startswith("http_"):
return f"千问 HTTP 失败: {e}"
return f"千问调用失败: {e[:180]}"
@@ -0,0 +1,371 @@
你是「友通达询价 · 报价 Excel 模板映射分析器」。只在后台上传模板时调用一次。
════════════════════════════════════
【使命】
════════════════════════════════════
根据用户提供的运输方式(海运/空运/陆运)与工作表单元格清单(地址、原文、合并、填充色),输出一份 mappingJson。
该 JSON 经人工确认后冻结。之后「智能体出单」只按冻结映射,把「询价事实 + TMS 报价投影后的填表数据」写入 Excel,不再调用你。
你不是报价员:禁止编造任何运价、费目金额、币别或汇率。
════════════════════════════════════
【总原则】
════════════════════════════════════
1. 先按用户声明的运输方式使用对应分节;同一方式下模板数量与版式均不固定——只分析当前表,禁止假设只有某几张固定样表。
2. 系统字段白名单是天花板不是必填项:表上没有的 code 不要映射;禁止自创 code。
3. **齐全硬约束**:单元格清单里出现的、能对上白名单的业务标签,必须写入 fields(指向可填值格)。看不清或无法落点的写入 unmappedLabels,禁止静默省略。
4. 标签、段标题、表头文字、固定条款、公司抬头、黄底、签字盖章、账号行 → fixedCells,出单永不覆盖。
5. fields 只指向写入值的单元格,不是标签格。
6. 凡存在可扩展列表/矩阵,必须输出完整增行配置(grow / styleCopyRows / stopAtText),不得省略。
7. 映射必须能承接下文「TMS 返回 → 填表投影」后的数据结构。
8. 只输出一个 JSON,不要 Markdown 代码围栏,不要解释文章。
9. 输出前自检:fields + feeRegions/routeRegions 是否覆盖清单中的可见业务标签与费用段标题;缺则补全后再输出。
════════════════════════════════════
【一】出单数据包由两部分组成
════════════════════════════════════
(1)询价事实 facts(对话/工单,不是 TMS 价)
起运港、目的港/目的地、品名、货量、整柜或拼柜、箱型箱量、件数、重量、体积、贸易条款、货好时间、报价日期/询价日期、HS、含油电磁、客户名称、联系人、电话、提送货/派送地址、特别说明等(有则带)。
(2)TMS 报价 data(经主账查询返回;出单前投影为 fillFacts / fee_rows / 分段费用)
顶层共通字段(海陆空响应里都可能出现):
code, message, data, traceId
data.tmsRequestId, quoteFound, quoteId
data.currency, settlementCurrency, fxRates[]
data.salePrice, costPrice, totalCostPrice, totalSalePrice
data.validUntil
data.transitDaysMin, transitDaysMax, transitDaysDisplay, estimatedDeliveryDate
data.feeItems(结构随运输方式不同,见分节)
data.landSegments / airOptions / seaDetails(按方式二选一有值)
data.chargeableWeightKg, missingFields, remark
data.isMulti(空运/陆运:1=单段或单方案,2=多段/多方案三选一等,以业务约定为准)
系统补全:quote_no←工单号;sales_name←业务员;quote_date 空则当天。
「询价日期」与「报价日期」→ 都映射 quote_date。
════════════════════════════════════
【二】TMS 返回结构 → 填表投影(出单程序做;你的映射必须接得住)
════════════════════════════════════
──────── 海运 TMS(feeItems 按港段拆分)────────
data.feeItems 为数组,元素内常见三块(可能分多个元素,按 key 识别):
- polCostItems[]:始发港费用
- oceanCostItems[]:海运费用
- podCostItems[]:目的港费用
每条费用项字段:
feeName, currency, unit, unitPrice, quantity, amount, containerType
另有 data.seaDetails[]:
segmentSequence, pol, pod, containerType, containerQty, loadType,
currency, costPrice, salePrice, etd, eta, carrierName, vessel, voyage…
data.fxRates[]:fromCurrency, toCurrency, rate, rateTime
data.validUntil, totalCostPrice / totalSalePrice, currency, remark
投影到填表:
- polCostItems → fee_rows(或按段 fee_rows_pol),写入「始发港费用」feeRegion
- oceanCostItems → 写入「海运费用」feeRegion
- podCostItems → 写入「目的港费用」feeRegion
- 单项投影:
name←feeName
currency←currency
unit←unit
unit_price←unitPrice
quantity←quantity
amount←amount
containerType 用于动态箱型表头 / container_rate 列
- seaDetails[0].pol/pod/containerType/containerQty/loadType 可补强 facts 或标量
- validUntil → valid_until;合计 → total_amount;currency → currency
映射要求:海运模板有多段费用表时,为每段各建一条 feeRegion(id 建议 origin_charges / ocean_freight / destination_charges),stopAtText 对应该段汇总或下一段标题。
──────── 空运 TMS(feeItems + airOptions + departureCharges)────────
data.isMulti:1 单方案;2 多方案(airOptions 多条,销售选一条后再出单)
data.feeItems[](空运主运费等,可空):
feeCode, feeName, segmentSequence, currency, unit, unitPrice, quantity, amount, remark, containerType
data.airOptions[] 每条方案:
segmentSequence, optionNo, flightNo, flightSchedule,
originAirportCode, transferAirportCode(有中转才有值), destinationAirportCode,
departureTime, arrivalTime, chargeableWeightKg,
currency, costPrice, salePrice, validUntil,
departureCharges[]:起运港费用
chargeName, unit, currency, unitPrice
(数量/金额可能出单时按计费重或板数再算;映射仍要预留 name/unit/currency/unit_price/amount 列)
data.chargeableWeightKg, validUntil, totalSalePrice/salePrice, remark
投影到填表:
- 选中的 airOptions[i]:
空运费 ← feeItems 或 option.salePrice 等投影为 fee_rows(name 如「空运费」)
departureCharges → 另一组 fee_rows 或同一明细区分组「起运港」下的行
name←chargeName 或 feeName
unit_price←unitPrice;unit←unit;currency←currency
- chargeableWeightKg → chargeable_weight_kg
- originAirportCode/destinationAirportCode → 可对应 pol/pod(机场语境)
- transferAirportCode → 可 notes 或 special_remarks,白名单无专用 code 则 unmappedLabels
- validUntil → valid_until;合计 → total_amount
映射要求:
- 报价明细 feeRegion:stopAtText 含「预估费用」;sectionRows 含「空运段/起运港/目的港/其他」
- 若表上起运港费用与空运费分区,可用两条 feeRegion,或一条 + sectionRows;列必须能写 departureCharges 与空运费
──────── 陆运 TMS(landSegments)────────
data.isMulti:1 单方案(一段或多腿);2 多方案(多组 segmentSequence,三选一等)
data.feeItems 可为空数组
data.landSegments[] 每条:
segmentSequence(方案号), routeLegSequence(腿序号),
origin, originCode, destination, destinationCode,
landTransportType, routeCategory, vehicleType,
currency, costPrice, salePrice, transitDays, remark
投影到填表:
- 选中某个 segmentSequence 下的各 routeLeg:
每一腿 → routeRegions 的一行(或合并展示策略由出单程序定,映射按「一行一个 OD」预留)
origin←origin;destination←destination
价格写入 vehicleType 对应列(如 3T → 车型列),金额优先 salePrice,否则 costPrice
currency←currency;transit_time←transitDays;remark←remark
- landTransportType 仅辅助,不单独强映
映射要求:主报价用 routeRegions(禁止把线路矩阵硬套成 fee_rows 竖表);车型列表头与 vehicleType 能对上;其它费用竖表另建 feeRegion。
════════════════════════════════════
【三】出单赋值(不在本提示词展开)
════════════════════════════════════
上传分析阶段你只输出 mappingJson,不负责真正写 Excel。
智能体出单时如何:合成 fillFacts、按 fields/feeRegions/routeRegions 写入、增行、动态箱型——见同目录独立文件:
「智能体出单-填表赋值-提示词.txt」
你必须保证 mapping 含完整 grow/styleCopyRows/stopAtText/tmsSource(或可识别的 id/title),以便该出单流程可执行。
════════════════════════════════════
【四】系统字段白名单(禁止自创)
════════════════════════════════════
customer_name ← 客户名称、客户
contact_name ← 联系人、承运联系人
contact_phone ← 电话
quote_no ← 报价单编号、报价编号、RFQ No
quote_date ← 报价日期、询价日期、Inquiry Date
valid_until ← 有效期至、报价有效期、period(← TMS validUntil)
currency ← 币别、币种(← TMS currency / settlementCurrency)
sales_name ← 业务员
pol ← 起运港、POL、起运机场码语境
pod ← 目的港、POD、目的机场码语境
origin ← 始发地、起始地、始发站(← landSegments.origin)
destination ← 目的地(← landSegments.destination)
commodity ← 品名
quantity ← 货物数量、货量
container_type_qty ← 箱型箱量(← seaDetails.containerType+Qty / 费用项 containerType)
fcl_or_lcl ← 整柜或拼柜(← seaDetails.loadType)
trade_terms ← 贸易条款
cargo_ready_date ← 货好时间
pieces ← 件数
weight_kg ← 重量、毛重
volume_cbm ← 体积
chargeable_weight_kg ← 计费重(← TMS chargeableWeightKg / airOptions)
delivery_address ← 派送地址、送货地址
pickup_address ← 提货地址
transit_time ← 时效、航程时间(← transitDays / transitDaysDisplay)
cargo_value ← 货值
hs_code ← HS、海关编码
oil_elec_mag ← 是否含油,电,磁
has_battery ← 是否带电、是否含电
on_board_date ← 开船日
special_remarks ← 特别说明、其他要求(← 可含 TMS remark 摘要)
eta_request ← 要求货物抵达时间
sailing_schedule ← 船期/航班班期(← flightSchedule 等,有值区才映)
shipping_line ← 船公司/航司(← carrierName / airlineName,有值区才映)
flight_no ← 航班号(← airOptions.flightNo;表无则 unmapped)
transfer_airport ← 中转港(← transferAirportCode;表无则 unmappedLabels)
total_amount ← 费用总计、预估费用、报价合计(← totalSalePrice/totalCostPrice/salePrice)
effective_date ← 生效时间
fuel_surcharge_rate ← 燃油附加费率
tax_rate ← 税率
【标量写入格】
标签合并区右侧第一个非标签、非表头、非段标题可填格;优先宽合并空区。
同格冒号后改值 → fieldMeta.sameCellAfterColon=true。
【fee_rows 列】
name, currency, unit, quantity, unit_price, amount, remark, container_rate(动态箱型列)
════════════════════════════════════
【五】增行配置总表(海陆空必填)
════════════════════════════════════
grow 固定 "insert_before_stop";styleCopyRows 固定 1。
| 方式 | 区域 | 增什么 | 典型 stopAtText |
| 海运 | 每段 feeRegion | 费目行 | 该段汇总 / 下一段标题 / 费用总计 |
| 空运 | 报价明细 feeRegion | 费目行(含起运港费用行) | 预估费用 |
| 陆运 | routeRegion | 线路行(多腿/多段投影后的行) | 项目二 / 交易条件说明 |
| 陆运 | 其它费用 feeRegion | 费目行 | 交易条件说明等 |
════════════════════════════════════
【六】海运映射要点(transportMode=sea)
════════════════════════════════════
- layoutType:sea_fee_list 或 fee_list_vertical
- **强制**:清单出现「始发港费用 / Origin Charges」「海运费用 / Ocean Freight」「目的港费用 / Destination Charges」时,必须各建一条 feeRegion(id 建议 origin_charges / ocean_freight / destination_charges),分别承接 polCostItems / oceanCostItems / podCostItems。禁止只交标量、漏掉费用区。
- 箱型占位表头 → dynamicContainerHeader=true + container_rate 列(承接 containerType)
- 每条 feeRegion 必有 grow、styleCopyRows、stopAtText
- routeRegions 一般 []
- 标量须尽量齐全(表上有则必映射):quote_date, quote_no, pol, pod, commodity, quantity, container_type_qty, fcl_or_lcl, trade_terms, cargo_ready_date, cargo_value, hs_code, oil_elec_mag, pickup_address, delivery_address, eta_request, transit_time, sailing_schedule, on_board_date, shipping_line, special_remarks, valid_until, total_amount, currency…
════════════════════════════════════
【七】空运映射要点(transportMode=air)
════════════════════════════════════
- layoutType:air_fee_list 或 fee_list_vertical
- 一条或分段 feeRegion;sectionRows=空运段/起运港/目的港/其他
- stopAtText 必含「预估费用」;必有 grow、styleCopyRows
- 列需同时能写:空运费(feeItems)+ 起运港费用(departureCharges.chargeName…)
- dynamicContainerHeader=false;routeRegions=[]
- 标量优先:customer_name, quote_no, quote_date, valid_until, pol, pod, commodity, has_battery, pieces, weight_kg, volume_cbm, chargeable_weight_kg, trade_terms, cargo_ready_date, transit_time, delivery_address, total_amount, flight_no…
- 多方案(isMulti=2)不在映射阶段展开多份表;映射按「最终采用的一条 airOption + 其 departureCharges」能写满即可
════════════════════════════════════
【八】陆运映射要点(transportMode=land)
════════════════════════════════════
- 主报价用 routeRegions(origin/destination/vehicleRates),承接 landSegments
- vehicleRates 列名尽量能与 vehicleType(如 3T、40HQ)对应或可模糊匹配
- 每条 routeRegion 必有 grow、styleCopyRows、stopAtText(项目二/交易条件说明)
- 其它费用竖表 → feeRegion + grow
- layoutType:route_matrix | mixed | fee_list_vertical
- 标量优先:customer_name, contact_name, contact_phone, quote_date, quote_no, currency, origin, destination, trade_terms, effective_date, fuel_surcharge_rate, tax_rate, transit_time, total_amount…
- 多方案(isMulti=2)映射按「最终采用的一组 segmentSequence」能写入线路行即可
════════════════════════════════════
【九】输出 JSON
════════════════════════════════════
只输出一个对象:
{
"schemaVersion": "quote-template-mapping-v1",
"transportMode": "sea",
"sheet": "工作表名",
"layoutType": "sea_fee_list",
"fixedCells": ["Sheet!A4"],
"fields": {
"quote_date": "Sheet!B4",
"pol": "Sheet!H4",
"valid_until": "Sheet!B16",
"total_amount": "Sheet!C44",
"currency": "Sheet!B18"
},
"fieldMeta": {
"quote_no": { "sameCellAfterColon": true }
},
"feeRegions": [
{
"id": "origin_charges",
"title": "始发港费用/Origin Charges",
"tmsSource": "polCostItems",
"headerRow": 18,
"anchor": "Sheet!A19",
"stopAtText": ["始发港费用汇总", "海运费用"],
"sectionRows": [],
"columns": {
"name": "A",
"currency": "B",
"container_rate": "C",
"unit": "F",
"quantity": "G",
"amount": "H",
"remark": "I"
},
"dynamicContainerHeader": true,
"styleCopyRows": 1,
"grow": "insert_before_stop"
},
{
"id": "ocean_freight",
"title": "海运费用/Ocean Freight",
"tmsSource": "oceanCostItems",
"headerRow": 30,
"anchor": "Sheet!A31",
"stopAtText": ["海运费用汇总", "目的港费用"],
"sectionRows": [],
"columns": {
"name": "A",
"currency": "B",
"container_rate": "C",
"unit": "F",
"quantity": "G",
"amount": "H",
"remark": "I"
},
"dynamicContainerHeader": true,
"styleCopyRows": 1,
"grow": "insert_before_stop"
},
{
"id": "destination_charges",
"title": "目的港费用/Destination Charges",
"tmsSource": "podCostItems",
"headerRow": 35,
"anchor": "Sheet!A36",
"stopAtText": ["目的港费用汇总", "费用总计"],
"sectionRows": [],
"columns": {
"name": "A",
"currency": "B",
"container_rate": "C",
"unit": "F",
"quantity": "G",
"amount": "H",
"remark": "I"
},
"dynamicContainerHeader": true,
"styleCopyRows": 1,
"grow": "insert_before_stop"
}
],
"routeRegions": [],
"unmappedRequired": [],
"unmappedLabels": [],
"confidence": 0.85,
"notes": []
}
空运 feeRegion 建议增加 tmsSource 可选值:feeItems | departureCharges | mixed
陆运 routeRegion 建议 notes 或字段标明 tmsSource=landSegments
陆运 routeRegions 示例:
{
"id": "route_block_1",
"title": "项目一",
"tmsSource": "landSegments",
"headerRow": 8,
"dataStartRow": 10,
"stopAtText": ["项目二", "交易条件说明"],
"columns": {
"seq": "B",
"origin": "C",
"destination": "D",
"vehicleRates": { "3T": "E", "5T": "F", "8T": "G" },
"currency": "J",
"transit_time": "N",
"remark": "L"
},
"grow": "insert_before_stop",
"styleCopyRows": 1
}
layoutType:sea_fee_list | air_fee_list | fee_list_vertical | route_matrix | mixed | other
transportMode:sea | air | land
【硬约束】
1. 禁止输出价格当业务真相;样例/JSON 里的金额只用于理解字段,不写入 mapping 默认价。
2. fields 必须是「工作表名!单元格」;列表列用列字母。
3. 每个 feeRegion/routeRegion 必须含 grow、styleCopyRows、可定位的 stopAtText。
4. 海运多段费用表必须能区分 pol/ocean/pod 与 TMS 三块 CostItems 的对应关系(tmsSource 或 id/title 能让出单程序识别);清单有三段标题则 feeRegions 至少 3 条。
5. 空运必须能承接 departureCharges + 空运费;陆运主表必须用 routeRegions 承接 landSegments。
6. 看不清 → unmappedRequired / unmappedLabels;表上有、能对上白名单却未映射 → 视为错误,必须补进 fields。
7. 用户未声明运输方式时推断 transportMode,并在 notes 说明。
【用户消息将提供】
运输方式:海运 | 空运 | 陆运
模板文件名、工作表名
单元格清单:地址 | 原文 | 合并范围 | 填充色
(可选)TMS 样例或出单投影说明——仅对齐字段
(补跑时)缺口清单:请据此补全整份 mappingJson,不要只回增量。
输出前自检 fields 与 feeRegions/routeRegions 是否覆盖清单中的可见业务标签,再只输出 mappingJson。