陆运补问把货物品名放进选项清单,并按销售原文回显已填项。

空着的运输分类不再被菜单第一项认成已填。空运报价改为货物信息加价格方案,锁舱成功回执补起运地、目的地、航司和航班日期。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-14 17:39:54 +08:00
co-authored by Cursor
parent cc8529f569
commit 9ffba70741
21 changed files with 1129 additions and 130 deletions
+200 -44
View File
@@ -1,8 +1,10 @@
"""
陆运选项规则:运输类型、线路类别、运输分类。
陆运选项规则:运输类型、线路类别、运输分类,以及和它们同一张清单里的货物品名。
本文件职责:从销售原话或复制填空清单收取标准项、校验是否在名单内、生成补问清单、判断「确定查价」意图。
三项各自必填,组合不拦截查价。拉群必须对上填写样例。运输分类句里的「中港/东南亚」不得冒充类型或线路。
本文件职责:从销售原话或复制填空清单收取标准项、校验三项是否在名单内、生成补问清单、判断「确定查价」意图。
三项各自必填,组合不拦截查价。货物品名六个运输类型都要,和三项同一张复制清单里填;没填完不进始发站补问。
已填项回显销售刚写的数字或文字,并附上完整选项,方便对着编号填线路类别。
拉群必须对上填写样例。运输分类句里的「中港/东南亚」不得冒充类型或线路。
禁止:改六态;发明枚举;把选项当货物字段;再要单独的中港装载方式。
线程:纯函数,无共享可变状态,可在 Worker 槽调用。
"""
@@ -243,20 +245,26 @@ def _decode_option_token(key: str, raw: str) -> str:
return ""
def _parse_land_option_labels(text: str) -> dict[str, str]:
"""复制清单里「字段(必填):值」只取冒号同行,空着的不当已填。"""
def _parse_land_option_labels(text: str) -> tuple[dict[str, str], dict[str, str]]:
"""
复制清单里「字段(必填):值」只取冒号同行,空着的不当已填。
返回标准项,以及销售冒号后原文,供下一张清单原样回显。
"""
found: dict[str, str] = {}
shown: dict[str, str] = {}
inferred_type = ""
for match in _PASTE_LABEL.finditer(text or ""):
key = match.group(1)
token = (match.group(2) or "").strip()
if not token:
if not token or _CARGO_PLACEHOLDER.match(token):
continue
if key == "线路类别":
hit = ""
for code, typ, route in ROUTE_CODES:
if token == code or token.startswith(code):
found["线路类别"] = route
shown["线路类别"] = token
inferred_type = typ
hit = code
break
@@ -265,9 +273,12 @@ def _parse_land_option_labels(text: str) -> dict[str, str]:
decoded = _decode_option_token(key, token)
if decoded:
found[key] = decoded
shown[key] = token
if inferred_type and not found.get("运输类型"):
# 编号带出的类型不是销售本行写下的字。已有回显时不要改成标准名称。
found["运输类型"] = inferred_type
return found
shown.setdefault("运输类型", "")
return found, shown
def _strip_land_option_prompt(text: str) -> str:
@@ -304,7 +315,9 @@ def harvest_land_options(text: str, current: dict[str, str] | None = None) -> di
未填的补问清单本身不能当成已选。
先收运输分类并把命中句从原文挖掉,再收类型/线路,避免分类里的东南亚、中港冒充选项。
已有非空项默认保留;本句明确写出另一标准项时才覆盖。
已有值对不上标准枚举则丢掉。不改货物字段。返回新 dict,不修改 current。
已有值对不上标准枚举则丢掉。不改货物字段。
回显键只在本句真正改了该项时更新:复制清单留冒号后原文,口语留标准名称。
返回新 dict,不修改 current。
"""
merged = {
k: str(v).strip()
@@ -312,15 +325,26 @@ def harvest_land_options(text: str, current: dict[str, str] | None = None) -> di
if str(v).strip()
}
_keep_canonical_options(merged)
labeled = _parse_land_option_labels(text or "")
labeled, shown = _parse_land_option_labels(text or "")
for key in ("运输类型", "线路类别", "运输分类"):
if labeled.get(key):
merged[key] = labeled[key]
if not labeled.get(key):
continue
previous = str(merged.get(key) or "").strip()
merged[key] = labeled[key]
token = str(shown.get(key) or "").strip()
if token:
_remember_show(merged, key, token, labeled[key])
elif previous != labeled[key] or not str(merged.get(land_show_key(key)) or "").strip():
_remember_show(merged, key, labeled[key], labeled[key])
blob = _strip_land_option_prompt(text or "")
compact = blob.replace(" ", "")
if compact in _ROUTE_CODE_MAP:
_apply_route_code(merged, compact, fill_type=not bool(merged.get("运输类型")))
had_type = bool(merged.get("运输类型"))
_apply_route_code(merged, compact, fill_type=not had_type)
_remember_show(merged, "线路类别", compact, str(merged.get("线路类别") or ""))
if not had_type and merged.get("运输类型"):
_remember_show(merged, "运输类型", merged["运输类型"], merged["运输类型"])
merged.pop("中港装载方式", None)
return merged
if compact.isdigit() and len(compact) == 1:
@@ -329,8 +353,10 @@ def harvest_land_options(text: str, current: dict[str, str] | None = None) -> di
missing_class = not merged.get("运输分类")
if missing_type and not missing_class and 1 <= idx <= len(TRANSPORT_TYPES):
merged["运输类型"] = TRANSPORT_TYPES[idx - 1]
_remember_show(merged, "运输类型", compact, merged["运输类型"])
elif missing_class and not missing_type and 1 <= idx <= len(LAND_CLASSES):
merged["运输分类"] = LAND_CLASSES[idx - 1]
_remember_show(merged, "运输分类", compact, merged["运输分类"])
merged.pop("中港装载方式", None)
return merged
@@ -338,16 +364,105 @@ def harvest_land_options(text: str, current: dict[str, str] | None = None) -> di
remainder = blob.replace(found_class, " ") if found_class else blob
found_type = canonicalize_transport_type(remainder)
found_route = canonicalize_route(remainder)
if found_type:
merged["运输类型"] = found_type
if found_route:
merged["线路类别"] = found_route
if found_class:
merged["运输分类"] = found_class
_assign_spoken_option(merged, "运输类型", found_type)
_assign_spoken_option(merged, "线路类别", found_route)
_assign_spoken_option(merged, "运输分类", found_class)
merged.pop("中港装载方式", None)
return merged
def _assign_spoken_option(merged: dict[str, str], key: str, canonical: str) -> None:
"""口语命中才覆盖。标准名没变时保留销售上次写的数字回显。"""
if not canonical:
return
previous = str(merged.get(key) or "").strip()
merged[key] = canonical
if previous != canonical or not str(merged.get(land_show_key(key)) or "").strip():
_remember_show(merged, key, canonical, canonical)
# 回显用:销售写「5」就留 5,写「中港整车」就留这句。只进会话,建单前丢掉。
LAND_SHOW_PREFIX = "_land_show_"
_CARGO_PLACEHOLDER = re.compile(r"^[((]\s*如\s*[::]")
def land_show_key(field: str) -> str:
"""选项回显键。不进 TMS,也不出现在核对卡。"""
return LAND_SHOW_PREFIX + field
def drop_land_prompt_echo(facts: dict[str, Any] | None) -> dict[str, str]:
"""建单前去掉回显键。返回新 dict,不改入参。"""
out: dict[str, str] = {}
for key, raw in dict(facts or {}).items():
name = str(key)
if name.startswith(LAND_SHOW_PREFIX):
continue
val = str(raw).strip()
if val:
out[name] = val
return out
def blank_paste_option_fields(text: str) -> set[str]:
"""
复制清单里出现了选项标签、但冒号后没写值的那些项。
编号菜单和模型不能把「1.普件…」抄成已填。不是复制清单时返回空,口语「急件」仍可收。
"""
raw = text or ""
if "请复制以下内容" not in raw and "Step1" not in raw:
return set()
seen: set[str] = set()
filled: set[str] = set()
for match in _PASTE_LABEL.finditer(raw):
key = match.group(1)
token = (match.group(2) or "").strip()
seen.add(key)
if token and not _CARGO_PLACEHOLDER.match(token):
filled.add(key)
return seen - filled
def drop_blank_paste_options(facts: dict[str, str] | None, text: str) -> dict[str, str]:
"""
复制清单里空着的运输类型、线路类别、运输分类,从本句结果里拿掉。
模型若按菜单猜了一项,也不留。返回新 dict,不改入参。
"""
out = {
str(k): str(v).strip()
for k, v in dict(facts or {}).items()
if str(v).strip()
}
for key in blank_paste_option_fields(text):
out.pop(key, None)
out.pop(land_show_key(key), None)
return out
def land_cargo_name(facts: dict[str, Any] | None) -> str:
"""
陆运货物品名。六个运输类型都要。
示例行「(如:衣服)」原样贴回不算已填。没有则空串。
"""
src = facts or {}
name = str(src.get("品名") or src.get("货物品名") or "").strip()
if not name or _CARGO_PLACEHOLDER.match(name):
return ""
return name
def _remember_show(merged: dict[str, str], key: str, shown: str, canonical: str) -> None:
"""记下销售刚写的数字或文字。超长句子改回标准名称,避免把整段需求贴进回显。"""
token = (shown or "").strip()
if not token or "\n" in token or len(token) > 80:
token = canonical
if token:
merged[land_show_key(key)] = token
def _keep_canonical_options(merged: dict[str, str]) -> None:
"""只留能对上枚举的选项;模型胡写的「陆运」不能冒充运输类型。旧中港两项作废。"""
t = canonicalize_transport_type(merged.get("运输类型") or "")
@@ -355,16 +470,19 @@ def _keep_canonical_options(merged: dict[str, str]) -> None:
merged["运输类型"] = t
else:
merged.pop("运输类型", None)
merged.pop(land_show_key("运输类型"), None)
r = canonicalize_route(merged.get("线路类别") or "")
if r:
merged["线路类别"] = r
else:
merged.pop("线路类别", None)
merged.pop(land_show_key("线路类别"), None)
c = canonicalize_land_class(merged.get("运输分类") or "")
if c:
merged["运输分类"] = c
else:
merged.pop("运输分类", None)
merged.pop(land_show_key("运输分类"), None)
merged.pop("中港装载方式", None)
@@ -412,25 +530,39 @@ _OPTION_PASTE_HEAD = (
)
def _type_menu_lines() -> list[str]:
return [f"{i}.{name}" for i, name in enumerate(TRANSPORT_TYPES, start=1)]
def _route_menu_lines() -> list[str]:
return [f"{code}{route}" for code, _typ, route in ROUTE_CODES]
def _class_menu_lines() -> list[str]:
return [f"{i}.{name}" for i, name in enumerate(LAND_CLASSES, start=1)]
def _type_prompt_lines() -> list[str]:
lines = ["运输类型(必填):"]
for i, name in enumerate(TRANSPORT_TYPES, start=1):
lines.append(f"{i}.{name}")
return lines
return ["运输类型(必填):", *_type_menu_lines()]
def _route_prompt_lines() -> list[str]:
lines = ["线路类别(必填):"]
for code, _typ, route in ROUTE_CODES:
lines.append(f"{code}{route}")
return lines
return ["线路类别(必填):", *_route_menu_lines()]
def _class_prompt_lines() -> list[str]:
lines = ["运输分类(必填):"]
for i, name in enumerate(LAND_CLASSES, start=1):
lines.append(f"{i}.{name}")
return lines
return ["运输分类(必填):", *_class_menu_lines()]
def _cargo_prompt_lines() -> list[str]:
"""货物品名没有编号。示例单独一行,原样贴回不算已填。"""
return ["货物品名(必填):", "(如:衣服)"]
def _shown_option(current: dict[str, str], key: str, canonical: str) -> str:
"""回显销售上次写的数字或文字;没有则用标准名称。"""
raw = str(current.get(land_show_key(key)) or "").strip()
return raw or canonical
# 编号清单已覆盖类型+线路,补问不再发填写样例图。验收脚本认这个开关。
@@ -473,27 +605,51 @@ def option_prompt(*, current: dict[str, str]) -> str:
"""
按已识别项生成复制填空清单。
缺哪项列哪项。销售把数字或文字填在字段冒号后面。
运输类型、线路类别、运输分类、货物品名同一张清单。缺的留在复制框里。
已经填过的排在 Step1 上面:保留销售写的数字或文字,并附上完整选项。
货物品名没有选项,填了只回显一行。四个都齐了返回空串。
填写样例图不再另发。不修改 current。
"""
src = {k: str(v).strip() for k, v in dict(current or {}).items() if str(v).strip()}
t = canonicalize_transport_type(src.get("运输类型") or "")
r = canonicalize_route(src.get("线路类别") or "")
c = canonicalize_land_class(src.get("运输分类") or "")
need_type = t not in TRANSPORT_TYPES
need_route = r not in ROUTES
need_class = c not in LAND_CLASSES
if not (need_type or need_route or need_class):
cargo = land_cargo_name(src)
filled: list[list[str]] = []
missing: list[list[str]] = []
if t in TRANSPORT_TYPES:
filled.append(
[f"运输类型(必填):{_shown_option(src, '运输类型', t)}", *_type_menu_lines()]
)
else:
missing.append(_type_prompt_lines())
if r in ROUTES:
filled.append(
[f"线路类别(必填):{_shown_option(src, '线路类别', r)}", *_route_menu_lines()]
)
else:
missing.append(_route_prompt_lines())
if c in LAND_CLASSES:
filled.append(
[f"运输分类(必填):{_shown_option(src, '运输分类', c)}", *_class_menu_lines()]
)
else:
missing.append(_class_prompt_lines())
if cargo:
filled.append([f"货物品名(必填):{cargo}"])
else:
missing.append(_cargo_prompt_lines())
if not missing:
return ""
chunks: list[list[str]] = []
if need_type:
chunks.append(_type_prompt_lines())
if need_route:
chunks.append(_route_prompt_lines())
if need_class:
chunks.append(_class_prompt_lines())
body: list[str] = list(_OPTION_PASTE_HEAD)
for i, block in enumerate(chunks):
body: list[str] = []
for i, block in enumerate(filled):
if i:
body.append("")
body.extend(block)
if filled:
body.append("")
body.extend(_OPTION_PASTE_HEAD)
for i, block in enumerate(missing):
if i:
body.append("")
body.extend(block)