落地多段联运私聊:齐了才建单拉群,附件只收格子值,核对改正不再整单重开。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user