落地多段联运私聊:齐了才建单拉群,附件只收格子值,核对改正不再整单重开。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-23 15:08:15 +08:00
co-authored by Cursor
parent 6e3e09a316
commit 2334524bb8
30 changed files with 4033 additions and 40 deletions
+214 -14
View File
@@ -23,13 +23,13 @@ _MODE_WORDS = (
_LABEL_LINE = re.compile(
r"^(起运地|起运港|目的地|目的港|品名|件数|毛重|体积|重量|重量\(KG\)|体积\(CBM\)|"
r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|运输分类|车型/数量|车型数量|"
r"贸易条款|货好时间|货源地|提货地址|收货地址|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁|"
r"贸易条款|货好时间|货源地|提货地址|收货地址|送货地址|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁|"
r"客户名称|货值|是否为危险品|通关口岸(非必填)|通关口岸)\s*[::]\s*(.+)$"
)
_LABEL_INLINE = re.compile(
r"(起运地|起运港|目的地|目的港|品名|件数|毛重|体积|重量|重量\(KG\)|体积\(CBM\)|"
r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|运输分类|车型/数量|车型数量|"
r"贸易条款|货好时间|货源地|提货地址|收货地址|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁|"
r"贸易条款|货好时间|货源地|提货地址|收货地址|送货地址|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁|"
r"客户名称|货值|是否为危险品|通关口岸(非必填)|通关口岸)\s*[::]\s*(\S+)"
)
# 陆运核对卡非必填:销售可能写「通关口岸皇岗」或照抄「通关口岸(非必填):皇岗」。长词优先。
@@ -46,9 +46,58 @@ _OPTIONAL_INQUIRY_LABELS = (
"货好时间",
"提货地址",
"收货地址",
"送货地址",
"贸易条款",
"起运港",
)
# 图上常见「4. 希望送到…」:编号项不是询价字段,值收到这里为止。
_NUMBERED_ITEM = re.compile(r"(?m)^\s*\d+[\.、.]\s+\S")
# 报价单表头和格子在同一行:只洗这些询价键,不碰提货/收货地址。
_SHEET_CLEAN_KEYS = frozenset(
{
"起运地",
"起运港",
"目的地",
"目的港",
"品名",
"件数",
"毛重",
"体积",
"货量",
"货物数量",
"整柜或拼柜",
"箱型箱量",
"贸易条款",
"运输分类",
"报价日期",
"包装方式",
"包装类型",
}
)
_SHEET_STOP = (
"要求运输时效",
"Requested Transit",
"费用汇总",
"POD Local Charges",
"提货地址",
"Place of Receipt",
"送货地址",
"派送地址",
"Place of Delivery",
"要求货物抵达",
"Requested Arrival",
)
_SHEET_EN_ALIAS = re.compile(r"^[//][A-Za-z][A-Za-z ]*")
_SHEET_REQUIRED_HINT = re.compile(r"([^)]*必填[^)]*)")
_SHEET_REQUIRED_HINT_EN = re.compile(
r"\([^)]*(?:必填|Packing List|Quantity)[^)]*\)",
re.I,
)
_SHEET_HEADER_NOISE = re.compile(
r"(费用汇总|提货地址|送货地址|派送地址|要求货物抵达|要求运输时效|"
r"Local Charges|Place of Receipt|Place of Delivery|Requested Arrival|Requested Transit)",
re.I,
)
# 群里协同标签,冒号可省略:「客户名称张三」「货值 10万」。长词优先。
_COLLAB_LABELS = (
"是否为危险品",
@@ -86,18 +135,25 @@ def detect_transport_mode(text: str) -> str:
def parse_labeled_facts(text: str) -> dict[str, str]:
"""只采集「字段名:值」,整行优先;同一句里的标签也收。不猜港口。"""
"""
只采集「字段名:值」,整行优先;同一句里的标签也收。不猜港口。
报价单常把表头和格子写在一行(起运港/POL(必填) 纽约港),
冒号对不上时再按表头切一刀,值只留格子。
"""
facts: dict[str, str] = {}
raw = text or ""
for line in raw.splitlines():
m = _LABEL_LINE.match(line.strip())
if m:
facts[m.group(1)] = m.group(2).strip()
if facts:
return facts
for m in _LABEL_INLINE.finditer(raw):
facts[m.group(1)] = m.group(2).strip()
return facts
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())
for key, val in harvest_sheet_inquiry_cells(raw).items():
if val and not str(facts.get(key) or "").strip():
facts[key] = val
return {key: val for key, val in facts.items() if val}
def _merge_spoken_collab(text: str, facts: dict[str, str]) -> dict[str, str]:
@@ -140,6 +196,10 @@ def extract_inquiry_snapshot(
facts = harvest_oral_measures(text, normalize_facts(dict(injected_facts)))
facts = _merge_spoken_collab(text, facts)
facts = _merge_optional_inquiry_labels(text, facts)
for key in ("货好时间", "提货地址", "收货地址", "货源地", "贸易条款"):
if str(facts.get(key) or "").strip():
facts[key] = clip_inquiry_value(str(facts[key]))
facts = _clip_sheet_inquiry_facts(facts)
subtype = str(facts.get("运输类型") or "").strip()
return {
"business_line": mode,
@@ -179,6 +239,10 @@ def extract_inquiry_snapshot(
facts = harvest_oral_measures(text, normalize_facts(facts))
facts = _merge_spoken_collab(text, facts)
facts = _merge_optional_inquiry_labels(text, facts)
for key in ("货好时间", "提货地址", "收货地址", "货源地", "贸易条款"):
if str(facts.get(key) or "").strip():
facts[key] = clip_inquiry_value(str(facts[key]))
facts = _clip_sheet_inquiry_facts(facts)
land_subtype = str((b_payload or {}).get("land_subtype") or facts.get("运输类型") or "").strip()
if land_subtype and not str(facts.get("运输类型") or "").strip():
facts["运输类型"] = land_subtype
@@ -213,15 +277,149 @@ def harvest_inquiry_optional_labels(text: str) -> dict[str, str]:
return _harvest_labels(text, _OPTIONAL_INQUIRY_LABELS)
def clip_inquiry_value(raw: str) -> str:
"""
字段值收到下一个编号项为止。
图上常在货好时间后面接着「4. 希望送到…」「6. 送货地址」。
那些不是询价字段,不能进补问。地址里的「602、603」没有点号后空格,会留下。
"""
text = (raw or "").strip()
if not text:
return ""
hit = _NUMBERED_ITEM.search(text)
if hit and hit.start() > 0:
text = text[: hit.start()]
text = re.sub(r"(?:\n|\r)+\s*\d+[\.、.]\s*$", "", text)
return text.strip()
def clip_sheet_field_value(raw: str) -> str:
"""
报价单格子:去掉 /POL、(必填说明)、Terms 和同一行下一列表头,只留紧挨的值。
测服手工海运单:起运港/POL(必填) 纽约港 目的港… 巴生西港 费用汇总 → 纽约港 / 巴生西港。
取第一格有效值,不要最后一格(最后一格常是费用汇总、提货地址)。
地址字段不要走这里。
"""
text = clip_inquiry_value(raw)
if not text:
return ""
text = _SHEET_EN_ALIAS.sub("", text)
text = _SHEET_REQUIRED_HINT.sub("", text)
text = _SHEET_REQUIRED_HINT_EN.sub("", text)
text = re.sub(r"^\((?:KG|CBM)\)\s*[::]?\s*", "", text, flags=re.I)
if "必填" in text:
text = re.sub(r"^[^)\n]*必填[^)\n]*)\s*", "", text)
text = re.sub(r"^[\s::]+", "", text)
for label in _SHEET_CLEAN_KEYS:
idx = 0
while True:
pos = text.find(label, idx)
if pos < 0:
break
if pos > 0 and _label_is_token(text, pos, label):
text = text[:pos]
break
idx = pos + len(label)
for stop in _SHEET_STOP:
pos = text.find(stop)
if pos >= 0:
text = text[:pos]
text = text.strip().strip("::/*")
parts = [p.strip().strip("::/*") for p in re.split(r"[\t]+|\s{2,}", text) if p.strip().strip("::/*")]
parts = [p for p in parts if p and not _SHEET_HEADER_NOISE.search(p)]
if not parts:
return ""
return parts[0].strip()
def harvest_sheet_inquiry_cells(text: str) -> dict[str, str]:
"""
按询价字段名切报价单原文。标签必须是独立词,不能切「指定起运港口」的中间。
一行两个字段时,值收到下一个字段名为止。副作用:无。
"""
raw = text or ""
hits: list[tuple[int, str]] = []
for label in _SHEET_CLEAN_KEYS:
start = 0
while True:
pos = raw.find(label, start)
if pos < 0:
break
if _label_is_token(raw, pos, label):
hits.append((pos, label))
start = pos + len(label)
if not hits:
return {}
hits.sort()
kept: list[tuple[int, str]] = []
for pos, label in hits:
if kept and kept[-1][0] == pos and len(label) <= len(kept[-1][1]):
continue
if kept and kept[-1][0] == pos:
kept[-1] = (pos, label)
continue
kept.append((pos, label))
out: dict[str, str] = {}
for i, (pos, label) in enumerate(kept):
begin = pos + len(label)
end = kept[i + 1][0] if i + 1 < len(kept) else len(raw)
val = clip_sheet_field_value(raw[begin:end])
if val:
out[label] = val
return out
def _label_is_token(raw: str, pos: int, label: str) -> bool:
"""
标签必须是独立词。
「指定起运港口」中间的起运港不要。重量(KG)/体积(CBM) 留给更长标签。
"""
if pos > 0:
prev = raw[pos - 1]
if prev.isalnum() or "\u4e00" <= prev <= "\u9fff":
return False
end = pos + len(label)
nxt = raw[end : end + 6]
if label in {"重量", "体积"} and nxt.startswith("("):
return False
return True
def _clip_sheet_inquiry_facts(facts: dict[str, str]) -> dict[str, str]:
"""已抽出的询价键再洗一遍表头。不改地址。"""
out = dict(facts or {})
for key in list(out):
if key not in _SHEET_CLEAN_KEYS:
continue
val = str(out.get(key) or "").strip()
if not val:
continue
out[key] = clip_sheet_field_value(val)
return {key: val for key, val in out.items() if str(val or "").strip()}
def _merge_optional_inquiry_labels(text: str, facts: dict[str, str]) -> dict[str, str]:
"""把原话里的通关口岸写进询价事实。有值才覆盖。"""
"""把原话里的通关口岸、货好时间、提货/收货地址写进询价事实。有值才覆盖。"""
from agent.schema.field_validate import normalize_facts
merged = dict(facts or {})
extra = normalize_facts(harvest_inquiry_optional_labels(text))
for key, val in extra.items():
if val:
merged[key] = val
cleaned = clip_inquiry_value(val)
if key in _SHEET_CLEAN_KEYS:
cleaned = clip_sheet_field_value(cleaned)
if not cleaned:
continue
old = str(merged.get(key) or "").strip()
if old and cleaned.startswith(old) and len(cleaned) > len(old):
continue
if old and ("必填" in cleaned or "\n" in cleaned or len(cleaned) > len(old) * 3):
continue
merged[key] = cleaned
return merged
@@ -235,7 +433,8 @@ def _harvest_labels(text: str, labels: tuple[str, ...]) -> dict[str, str]:
pos = raw.find(label, start)
if pos < 0:
break
hits.append((pos, label))
if _label_is_token(raw, pos, label):
hits.append((pos, label))
start = pos + len(label)
if not hits:
return {}
@@ -429,7 +628,8 @@ def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = Non
raw = (text or "").strip()
if not raw:
return {}
compact = raw.replace(" ", "").replace(" ", "")
stripped = raw.replace("@询价小助手", "").replace("询价小助手", "")
compact = stripped.replace(" ", "").replace(" ", "")
if re.fullmatch(r"WO\d{12}", compact, re.I):
return {}
rows = _parse_fee_rows(raw)