陆运选项不全时改为复制填空清单,方便销售按编号回填。

停发填写样例图,同群只跟当前工单,重量体积统一为 KG/CBM。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-23 17:10:13 +08:00
co-authored by Cursor
parent 2334524bb8
commit 378bb892b3
33 changed files with 1204 additions and 265 deletions
+134 -18
View File
@@ -11,27 +11,43 @@ import logging
import re
from typing import Any, Optional
from agent.schema.field_validate import QUOTE_DATE_SYNONYMS
logger = logging.getLogger(__name__)
# 报价日期同义词。长的在前,避免短词截断。货好时间、提货地址不在这里。
_QUOTE_DATE_LABEL_RE = "|".join(
re.escape(name)
for name in sorted(("报价日期",) + QUOTE_DATE_SYNONYMS, key=len, reverse=True)
)
_MODE_WORDS = (
("空运", "AIR"),
("海运", "SEA"),
("陆运", "LAND"),
)
# 未定运输方式时:整句只回 1/2/3 对应陆运/海运/空运。10件、1.件数 不算。
_MODE_INDEX = re.compile(r"^\s*([1231-3])\s*[\.、.]?\s*$")
_MODE_BY_INDEX = {
"1": "LAND",
"2": "SEA",
"3": "AIR",
"1": "LAND",
"2": "SEA",
"3": "AIR",
}
# 仅「键:值」一行一条,键必须是合同字段或展示名
_LABEL_LINE = re.compile(
r"^(起运地|起运港|目的地|目的港|品名|件数|毛重|体积|重量|重量\(KG\)|体积\(CBM\)|"
r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|运输分类|车型/数量|车型数量|"
# 仅「键:值」一行一条,键必须是合同字段或展示名。报价日期含询价/走货/发货等同义词。
_LABEL_KEYS = (
r"起运地|起运港|目的地|目的港|品名|件数|毛重|体积(CBM)|体积\(CBM\)|体积|重量(KG)|重量\(KG\)|重量|"
r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|"
+ _QUOTE_DATE_LABEL_RE
+ r"|运输分类|车型/数量|车型数量|"
r"贸易条款|货好时间|货源地|提货地址|收货地址|送货地址|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁|"
r"客户名称|货值|是否为危险品|通关口岸(非必填)|通关口岸)\s*[::]\s*(.+)$"
)
_LABEL_INLINE = re.compile(
r"(起运地|起运港|目的地|目的港|品名|件数|毛重|体积|重量|重量\(KG\)|体积\(CBM\)|"
r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|运输分类|车型/数量|车型数量|"
r"贸易条款|货好时间|货源地|提货地址|收货地址|送货地址|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁|"
r"客户名称|货值|是否为危险品|通关口岸(非必填)|通关口岸)\s*[::]\s*(\S+)"
r"客户名称|货值|是否为危险品|通关口岸(非必填)|通关口岸"
)
_LABEL_LINE = re.compile(rf"^({_LABEL_KEYS})\s*[::]\s*(.+)$")
_LABEL_INLINE = re.compile(rf"({_LABEL_KEYS})\s*[::]\s*(\S+)")
# 陆运核对卡非必填:销售可能写「通关口岸皇岗」或照抄「通关口岸(非必填):皇岗」。长词优先。
_OPTIONAL_INQUIRY_LABELS = (
"通关口岸(非必填)",
@@ -52,6 +68,48 @@ _OPTIONAL_INQUIRY_LABELS = (
)
# 图上常见「4. 希望送到…」:编号项不是询价字段,值收到这里为止。
_NUMBERED_ITEM = re.compile(r"(?m)^\s*\d+[\.、.]\s+\S")
# 补问复制清单:1.件数(必填):10 ;下一行 (如:10)不算值。
_CLARIFY_EXAMPLE_LINE = re.compile(r"^\s*[((]如\s*[::]")
_CLARIFY_INDEX = re.compile(r"^\s*\d+[\.、.]\s*")
_CLARIFY_DECOR = re.compile(r"((?:KG|CBM|kg|cbm|必填|非必填))|\((?:KG|CBM|必填|非必填)\)", re.I)
_CLARIFY_NAMES = frozenset(
{
"起运地",
"起运港",
"目的地",
"目的港",
"品名",
"货物品名",
"件数",
"毛重",
"体积(CBM)",
"体积",
"重量(KG)",
"重量",
"包装方式",
"包装类型",
"货量",
"货物数量",
"整柜或拼柜",
"箱型箱量",
"报价日期",
*QUOTE_DATE_SYNONYMS,
"运输分类",
"运输方式",
"运输类型",
"线路类别",
"车型/数量",
"车型数量",
"贸易条款",
"货源地",
"货好时间",
"提货地址",
"收货地址",
"送货地址",
"通关口岸",
"始发站",
}
)
# 报价单表头和格子在同一行:只洗这些询价键,不碰提货/收货地址。
_SHEET_CLEAN_KEYS = frozenset(
{
@@ -62,7 +120,10 @@ _SHEET_CLEAN_KEYS = frozenset(
"品名",
"件数",
"毛重",
"体积(CBM)",
"体积",
"重量(KG)",
"重量",
"货量",
"货物数量",
"整柜或拼柜",
@@ -70,6 +131,7 @@ _SHEET_CLEAN_KEYS = frozenset(
"贸易条款",
"运输分类",
"报价日期",
*QUOTE_DATE_SYNONYMS,
"包装方式",
"包装类型",
}
@@ -121,7 +183,9 @@ def detect_transport_mode(text: str) -> str:
"""
运输方式:空运/海运/陆运,或空运口语「飞」(南京飞吉隆坡)。
未定方式时整句回 1/2/3 分别是陆运/海运/空运;「1.陆运」仍按文字认。
写了海运/陆运以书面为准。只说飞就是空运,补问件数时不得再问运输方式。
「10件」「1.件数」不是运输方式。
"""
raw = text or ""
found = [code for word, code in _MODE_WORDS if word in raw]
@@ -129,30 +193,79 @@ def detect_transport_mode(text: str) -> str:
return found[0]
if len(found) > 1:
return ""
hit = _MODE_INDEX.match(raw.strip())
if hit:
return _MODE_BY_INDEX.get(hit.group(1), "")
if "飞" in raw:
return "AIR"
return ""
def parse_clarify_paste_facts(text: str) -> dict[str, str]:
"""
认补问复制清单:编号 + 字段名 +(单位/必填)+ 冒号后的值。
「(如:10)」整行丢掉。冒号后为空也不收,避免把示例当成已填。
不猜港口,不改六态。
"""
facts: dict[str, str] = {}
for line in (text or "").splitlines():
raw = (line or "").strip()
if not raw or _CLARIFY_EXAMPLE_LINE.match(raw):
continue
raw = _CLARIFY_INDEX.sub("", raw, count=1)
if ":" not in raw and ":" not in raw:
continue
if ":" in raw:
name, val = raw.split(":", 1)
else:
name, val = raw.split(":", 1)
name = _CLARIFY_DECOR.sub("", name).strip()
val = (val or "").strip()
if not name or name not in _CLARIFY_NAMES:
continue
if not val or _CLARIFY_EXAMPLE_LINE.match(val):
continue
facts[name] = clip_inquiry_value(val)
return {key: val for key, val in facts.items() if val}
def parse_labeled_facts(text: str) -> dict[str, str]:
"""
只采集「字段名:值」,整行优先;同一句里的标签也收。不猜港口。
补问复制清单(1.件数(必填):10)优先。
报价单常把表头和格子写在一行(起运港/POL(必填) 纽约港),
冒号对不上时再按表头切一刀,值只留格子。
"""
facts: dict[str, str] = {}
raw = text or ""
facts: dict[str, str] = parse_clarify_paste_facts(text)
raw = "\n".join(
line
for line in (text or "").splitlines()
if not _CLARIFY_EXAMPLE_LINE.match(line.strip())
)
for line in raw.splitlines():
m = _LABEL_LINE.match(line.strip())
if m:
shown = line.strip()
m = _LABEL_LINE.match(shown)
if m and not str(facts.get(m.group(1)) or "").strip():
facts[m.group(1)] = clip_sheet_field_value(m.group(2).strip())
if not facts:
for m in _LABEL_INLINE.finditer(raw):
facts[m.group(1)] = clip_sheet_field_value(m.group(2).strip())
from agent.schema.field_validate import normalize_facts
taken = set(normalize_facts(facts))
for key, val in harvest_sheet_inquiry_cells(raw).items():
if val and not str(facts.get(key) or "").strip():
facts[key] = val
if not val:
continue
mapped = next(iter(normalize_facts({key: val})), "")
if mapped and mapped in taken:
continue
if str(facts.get(key) or "").strip():
continue
facts[key] = val
if mapped:
taken.add(mapped)
return {key: val for key, val in facts.items() if val}
@@ -287,6 +400,9 @@ def clip_inquiry_value(raw: str) -> str:
text = (raw or "").strip()
if not text:
return ""
text = re.sub(r"[((]如\s*[::][^))]*[))]?\s*$", "", text).strip()
if _CLARIFY_EXAMPLE_LINE.match(text):
return ""
hit = _NUMBERED_ITEM.search(text)
if hit and hit.start() > 0:
text = text[: hit.start()]
@@ -384,7 +500,7 @@ def _label_is_token(raw: str, pos: int, label: str) -> bool:
return False
end = pos + len(label)
nxt = raw[end : end + 6]
if label in {"重量", "体积"} and nxt.startswith("("):
if label in {"重量", "体积"} and (nxt.startswith("(") or nxt.startswith("(")):
return False
return True
@@ -128,6 +128,8 @@ def build_extract_messages(text: str) -> list[dict[str, str]]:
"10件/3pcs→空运件数;海运同样数字+件也要写入货量;100KGS/180kg/1.5KG/100公斤→毛重;2CBM/4.2cbm/2.5立方/2方→体积;"
"托盘/散货/卡板/纸箱/木箱→包装方式(销售说什么记什么,散货也要抽)。"
"9月15日/9月15号/2026-09-15→报价日期,规范成 yyyy-MM-dd,未写年用今年。"
"询价日期、走货日期、询价时间、走货时间、发货时间、发货日期、出货日期、出货时间、提货时间、提货日期一律写入报价日期。"
"货好时间、提货地址不要写成报价日期。"
"陆运「通关口岸:皇岗」「通关口岸皇岗」「过皇岗」有说就写入通关口岸,没说留空,禁止编造口岸。"
"「从南京飞吉隆坡」起运港=南京、目的港=吉隆坡;「北京飞吉隆坡」起运港=北京。"
"原话是机场三字码或航线就原样记,禁止翻译成城市名:"
+32 -2
View File
@@ -25,7 +25,24 @@ _PROMPT_CACHE: str | None = None
# 清单原文片段 → 白名单 code(按运输方式过滤;关键词避免过短英文误报)
_LABEL_TO_CODE_COMMON: list[tuple[tuple[str, ...], str]] = [
(("报价单编号", "询价单号", "RFQ No", "Quote No"), "quote_no"),
(("报价日期", "询价日期", "Inquiry Date", "Quote Date"), "quote_date"),
(
(
"报价日期",
"询价日期",
"走货日期",
"询价时间",
"走货时间",
"发货时间",
"发货日期",
"出货日期",
"出货时间",
"提货时间",
"提货日期",
"Inquiry Date",
"Quote Date",
),
"quote_date",
),
(("有效期至", "有效期", "Validity"), "valid_until"),
(("客户名称", "Customer Name", "Customer"), "customer_name"),
(("联系人", "Contact Person", "Contact Name"), "contact_name"),
@@ -351,7 +368,20 @@ def merge_mapping_prefer_existing(
# 与主账 FILL_BY_LABEL 对齐的常用码→标签(智能合并校验用)
_FIELD_LABELS: dict[str, tuple[str, ...]] = {
"quote_no": ("RFQ No", "报价单编号", "报价编号"),
"quote_date": ("询价日期", "报价日期", "Inquiry Date"),
"quote_date": (
"报价日期",
"询价日期",
"走货日期",
"询价时间",
"走货时间",
"发货时间",
"发货日期",
"出货日期",
"出货时间",
"提货时间",
"提货日期",
"Inquiry Date",
),
"pol": ("起运港", "POL"),
"pod": ("目的港", "POD"),
"commodity": ("品名", "货物品名", "Commodity"),