253 lines
9.7 KiB
Python
253 lines
9.7 KiB
Python
"""
|
||
海运运输分类:只认 Port to Port、其他。
|
||
|
||
本文件职责:从原话收成标准词、生成补问清单;贸易条款标签行原样优先。
|
||
禁止:发明第三项;把分类带去 TMS;改六态。
|
||
线程:纯函数,可在 Worker 槽调用。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
|
||
SEA_CLASSES = ("Port to Port", "其他")
|
||
|
||
# 补问清单下一行「(如:Port to Port、其他)」只是举例,不能当成销售已选。
|
||
_CLARIFY_EXAMPLE_LINE = re.compile(r"^\s*[((]如\s*[::]")
|
||
|
||
# 标签行优先:避免「贸易条款:Port to Port」整句扫描时把运输分类也收成 Port to Port。
|
||
_LABELED_SEA_CLASS = re.compile(
|
||
r"(?im)^\s*运输分类(?:(必填)|\(必填\))?\s*[::]\s*(.+?)\s*$"
|
||
)
|
||
_LABELED_TRADE_TERMS = re.compile(
|
||
r"(?im)^\s*贸易条款(?:(必填)|\(必填\)|(非必填)|\(非必填\))?\s*[::]\s*(.+?)\s*$"
|
||
)
|
||
_TRADE_TERMS_LINE = re.compile(r"(?im)^\s*贸易条款")
|
||
|
||
# 长词优先,避免 P2P 误伤其它词;门到门不在名单里。
|
||
_ALIASES = (
|
||
("port to port", "Port to Port"),
|
||
("港到港", "Port to Port"),
|
||
("p2p", "Port to Port"),
|
||
("其它", "其他"),
|
||
("其他", "其他"),
|
||
)
|
||
|
||
|
||
def canonicalize_sea_class(raw: str) -> str:
|
||
"""
|
||
把销售原话收成 Port to Port 或 其他;对不上返回空串。
|
||
|
||
「门到门」不算。先匹配更长别名。副作用:无。
|
||
"""
|
||
text = (raw or "").strip()
|
||
if not text:
|
||
return ""
|
||
# 企微常把空格发成不换行空格,Port to Port 对不上普通空格就会整段丢掉。
|
||
folded = re.sub(r"\s+", " ", text).strip()
|
||
lowered = folded.lower()
|
||
squashed = re.sub(r"\s+", "", lowered)
|
||
if "port to port" in lowered or "porttoport" in squashed:
|
||
return "Port to Port"
|
||
for needle, canon in _ALIASES:
|
||
if needle == "port to port":
|
||
continue
|
||
if needle in lowered or needle in text or needle in squashed:
|
||
return canon
|
||
stripped = text.replace("运输分类", "").replace(":", "").replace(":", "").strip()
|
||
if stripped in SEA_CLASSES:
|
||
return stripped
|
||
return ""
|
||
|
||
|
||
def _without_clarify_examples(text: str) -> str:
|
||
"""丢掉补问举例行。销售只在冒号后作答,「(如:…)」整行不参与判断。"""
|
||
return "\n".join(
|
||
line for line in (text or "").splitlines() if not _CLARIFY_EXAMPLE_LINE.match(line)
|
||
)
|
||
|
||
|
||
def _without_trade_terms_lines(text: str) -> str:
|
||
"""整句扫运输分类时丢掉「贸易条款:…」行,避免条款里的 Port to Port 冒充分类。"""
|
||
return "\n".join(
|
||
line for line in (text or "").splitlines() if not _TRADE_TERMS_LINE.match(line.strip())
|
||
)
|
||
|
||
|
||
def _labeled_value(text: str, pattern: re.Pattern[str]) -> str:
|
||
"""取原文最后一次标签行冒号后的值;没有则空串。"""
|
||
last = ""
|
||
for hit in pattern.finditer(_without_clarify_examples(text or "")):
|
||
last = (hit.group(1) or "").strip()
|
||
return last
|
||
|
||
|
||
def harvest_sea_class(text: str, current: dict[str, str] | None = None) -> dict[str, str]:
|
||
"""
|
||
当前句能认则覆盖;认不出则保留已有标准值。
|
||
|
||
优先「运输分类:」标签行(WO202609280012:条款写 Port to Port、分类写其它)。
|
||
整句扫描时排除贸易条款行,避免条款里的 Port to Port 盖掉「其它」。
|
||
「其它」与「其他」都收成「其他」。非法旧值会丢掉,避免「门到门」一直占着必填位。
|
||
"""
|
||
merged = dict(current or {})
|
||
cleaned = _without_clarify_examples(text or "")
|
||
labeled = _labeled_value(cleaned, _LABELED_SEA_CLASS)
|
||
if labeled:
|
||
found_labeled = canonicalize_sea_class(labeled)
|
||
if found_labeled:
|
||
merged["运输分类"] = found_labeled
|
||
return merged
|
||
# 整句扫描:不含贸易条款行
|
||
found = canonicalize_sea_class(_without_trade_terms_lines(cleaned))
|
||
if found:
|
||
merged["运输分类"] = found
|
||
return merged
|
||
kept = canonicalize_sea_class(str(merged.get("运输分类") or ""))
|
||
if kept:
|
||
merged["运输分类"] = kept
|
||
return merged
|
||
from agent.schema.land_options import canonicalize_land_class
|
||
|
||
# 多段陆运补「急件」时,不能按海运名单把已收的陆运分类清掉。
|
||
land_kept = canonicalize_land_class(str(merged.get("运输分类") or ""))
|
||
if land_kept:
|
||
merged["运输分类"] = land_kept
|
||
return merged
|
||
merged.pop("运输分类", None)
|
||
return merged
|
||
|
||
|
||
# 运输分类用语,不能冒充贸易条款。销售原话写了「贸易条款」才保留。
|
||
_DOOR_TRADE_TERMS = frozenset(
|
||
{
|
||
"门到门",
|
||
"door to door",
|
||
"door-to-door",
|
||
"doortodoor",
|
||
"d2d",
|
||
}
|
||
)
|
||
|
||
|
||
def harvest_sea_trade_terms(text: str, facts: dict[str, str] | None = None) -> dict[str, str]:
|
||
"""
|
||
海运贸易条款:标签行冒号后原样优先。
|
||
|
||
WO202609280012:销售写「贸易条款:Port to Port」,模型却收成「门到门」。
|
||
有标签行则强制用标签值;门到门是否丢掉仍交给 drop_invented_door_trade_terms。
|
||
"""
|
||
merged = dict(facts or {})
|
||
labeled = _labeled_value(text or "", _LABELED_TRADE_TERMS)
|
||
if labeled:
|
||
merged["贸易条款"] = labeled
|
||
return merged
|
||
|
||
|
||
# 补问举例让销售直接回这两个词。整句只有其中一个,或长句里写了其中一个,都记成贸易条款。
|
||
_SHORT_SEA_TRADE_REPLY = re.compile(
|
||
r"^\s*(门到门|港到港|door\s*to\s*door|door-to-door|d2d)\s*[.。!!??]?\s*$",
|
||
re.IGNORECASE,
|
||
)
|
||
|
||
|
||
def short_sea_trade_reply(text: str) -> str:
|
||
"""
|
||
销售整句只回门到门或港到港时,收成贸易条款。
|
||
|
||
举例行不算。长句里没写这两个词时,不能从这里编出一个条款。
|
||
返回「门到门」或「港到港」;对不上返回空串。
|
||
"""
|
||
cleaned = _without_clarify_examples(text or "").strip()
|
||
hit = _SHORT_SEA_TRADE_REPLY.match(cleaned)
|
||
if not hit:
|
||
return ""
|
||
raw = (hit.group(1) or "").strip()
|
||
folded = re.sub(r"[\s-]+", "", raw).lower()
|
||
if raw == "港到港":
|
||
return "港到港"
|
||
if raw == "门到门" or folded in {"doortodoor", "d2d"}:
|
||
return "门到门"
|
||
return ""
|
||
|
||
|
||
def drop_invented_door_trade_terms(text: str, facts: dict[str, str] | None = None) -> dict[str, str]:
|
||
"""
|
||
丢掉冒充贸易条款的「门到门」。
|
||
|
||
测服 WO202609200013:原话没写贸易条款,确认卡却出现「贸易条款:门到门」。
|
||
门到门只是运输分类说法,不是 FOB/CIF 这类条款;原话没有「贸易条款」四字时不准收下。
|
||
销售明确写了「贸易条款:门到门」则保留原文,不改六态。
|
||
若标签行写的是 Port to Port / 港到港等,禁止再用模型的「门到门」盖掉。
|
||
"""
|
||
merged = harvest_sea_trade_terms(text, facts)
|
||
trade = str(merged.get("贸易条款") or "").strip()
|
||
if not trade:
|
||
return merged
|
||
labeled = _labeled_value(text or "", _LABELED_TRADE_TERMS)
|
||
if labeled:
|
||
# 标签已写死条款:以标签为准(含 Port to Port)
|
||
merged["贸易条款"] = labeled.strip()
|
||
return merged
|
||
if "贸易条款" in (text or ""):
|
||
return merged
|
||
if trade.lower().replace(" ", "") in {x.replace(" ", "") for x in _DOOR_TRADE_TERMS} or trade in _DOOR_TRADE_TERMS:
|
||
merged.pop("贸易条款", None)
|
||
return merged
|
||
|
||
|
||
def _spoken_door_in_text(text: str) -> str:
|
||
"""
|
||
长句里写了「门到门」或「港到港」就是贸易条款。
|
||
|
||
举例行「(如:门到门、港到港)」先丢掉,不能当成已填。
|
||
原话没有这两个词时返回空,禁止把模型猜的条款写回去。
|
||
同一句里两个词都有时,取靠后的那个。
|
||
「Port to Port」仍是运输分类,不在这里改写成贸易条款。
|
||
「广州港到巴生港」不含「港到港」这三个连续字,不能误收。
|
||
"""
|
||
cleaned = _without_clarify_examples(text or "")
|
||
found: list[tuple[int, str]] = []
|
||
for word in ("门到门", "港到港"):
|
||
idx = cleaned.rfind(word)
|
||
if idx >= 0:
|
||
found.append((idx, word))
|
||
door = re.search(r"door\s*[- ]?to\s*[- ]?door", cleaned, re.I)
|
||
if door:
|
||
found.append((door.start(), "门到门"))
|
||
if not found:
|
||
return ""
|
||
found.sort(key=lambda item: item[0])
|
||
return found[-1][1]
|
||
|
||
|
||
def apply_spoken_sea_trade_reply(text: str, facts: dict[str, str] | None = None) -> dict[str, str]:
|
||
"""
|
||
先丢掉模型冒充的门到门,再按原话收回贸易条款。
|
||
|
||
整句只回「门到门」或「港到港」要收下。长句里写了其中一个(例如先报货、再回「2」选海运)
|
||
也是贸易条款,不能因为原话没有「贸易条款」四个字就丢掉。
|
||
原话没出现「门到门」时,旧草稿里的门到门继续丢掉(WO202609200013)。
|
||
原话没出现「港到港」时,模型填的港到港同样丢掉。
|
||
销售已写「贸易条款:…」标签时以标签为准,不用句中的词覆盖。返回新 dict。
|
||
"""
|
||
merged = drop_invented_door_trade_terms(text, facts)
|
||
if _labeled_value(text or "", _LABELED_TRADE_TERMS):
|
||
return merged
|
||
spoken = short_sea_trade_reply(text)
|
||
if spoken:
|
||
merged["贸易条款"] = spoken
|
||
return merged
|
||
door = _spoken_door_in_text(text)
|
||
if door:
|
||
merged["贸易条款"] = door
|
||
return merged
|
||
if str(merged.get("贸易条款") or "").strip() == "港到港":
|
||
merged.pop("贸易条款", None)
|
||
return merged
|
||
|
||
|
||
def sea_class_prompt() -> str:
|
||
"""缺运输分类时只列两项,不再追问「其他是什么」。"""
|
||
return "运输分类(必填):请回复 Port to Port 或 其他。"
|