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

停发填写样例图,同群只跟当前工单,重量体积统一为 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
+87 -8
View File
@@ -24,8 +24,8 @@ AIR_REQUIRED_FIELDS = (
("目的港", "目的地"),
("品名", "品名"),
("件数", "件数"),
("毛重", "重量"),
("体积", "体积"),
("毛重", "重量(KG)"),
("体积", "体积(CBM)"),
("包装方式", "包装类型"),
("报价日期", "报价日期"),
)
@@ -65,8 +65,8 @@ LAND_DISPLAY = {
"起运港": "始发站",
"目的港": "目的地",
"品名": "货物品名",
"毛重": "重量",
"体积": "体积",
"毛重": "重量(KG)",
"体积": "体积(CBM)",
"车型/数量": "车型/数量",
"通关口岸": "通关口岸",
"报价日期": "报价日期",
@@ -178,6 +178,20 @@ _RE_WEIGHT_WORD = re.compile(r"(?:毛重|重量)\s*[::]?\s*(\d+(?:\.\d+)?)")
_RE_VOLUME_WORD = re.compile(r"体积\s*[::]?\s*(\d+(?:\.\d+)?)")
# 销售原话里的包装词:当前句出现则覆盖旧值,散货也要收下
_PACK_WORDS = ("纸箱", "木箱", "托盘", "卡板", "散货")
# 销售口中的报价日期。内部键仍是报价日期。货好时间、提货地址不是这里。
QUOTE_DATE_SYNONYMS = (
"询价日期",
"走货日期",
"询价时间",
"走货时间",
"发货时间",
"发货日期",
"出货日期",
"出货时间",
"提货时间",
"提货日期",
)
# 报价日期:9月15日 / 2026-09-15;未写年用上海当年
_RE_DATE_YMD = re.compile(
r"(20\d{2})\s*[-/.年]\s*(\d{1,2})\s*[-/.月]\s*(\d{1,2})\s*[日号]?"
@@ -193,8 +207,10 @@ _ALIASES = {
"始发站": "起运港",
"货物品名": "品名",
"重量(KG)": "毛重",
"重量(KG)": "毛重",
"重量": "毛重",
"体积(CBM)": "体积",
"体积(CBM)": "体积",
"车型/数量": "车型/数量",
"车型数量": "车型/数量",
"数量": "数量",
@@ -220,6 +236,8 @@ _ALIASES = {
"贸易条款(非必填)": "贸易条款",
"起运港(非必填)": "起运港",
}
for _quote_alias in QUOTE_DATE_SYNONYMS:
_ALIASES[_quote_alias] = "报价日期"
def shanghai_today() -> str:
@@ -239,6 +257,7 @@ def normalize_facts(facts: dict[str, Any] | None) -> dict[str, str]:
for raw_key, raw_val in (facts or {}).items():
key = str(raw_key).strip()
key = re.sub(r"(必填)|(非必填)|\(必填\)|\(非必填\)", "", key).strip()
key = re.sub(r"(KG)|(CBM)|\(KG\)|\(CBM\)", "", key, flags=re.I).strip()
key = _ALIASES.get(key, key)
if raw_val is None:
continue
@@ -285,6 +304,32 @@ def _harvest_packaging(raw: str) -> str:
return last
def _quote_date_labeled_value(raw: str) -> str:
"""
原话里「报价日期」或同义词后面的值。
询价日期、走货日期、发货时间、提货日期等都算报价日期。
能写成 yyyy-MM-dd 就规范;「明天」这类留原文。
没有这些标签则空串,交给句子里的裸日期。
同一句出现多次,用最后一次,避免前面的货好时间把日期抢走。
"""
text = raw or ""
labels = ("报价日期",) + QUOTE_DATE_SYNONYMS
best_at = -1
best_val = ""
for label in labels:
pattern = re.compile(
rf"(?<![\u4e00-\u9fffA-Za-z0-9]){re.escape(label)}\s*[::]?\s*(\S+)"
)
for hit in pattern.finditer(text):
if hit.start() >= best_at:
best_at = hit.start()
best_val = hit.group(1).strip().strip(",,。;;、")
if not best_val:
return ""
return _harvest_quote_date(best_val) or best_val
def _harvest_quote_date(raw: str) -> str:
"""当前原话里的报价日期,规范成 yyyy-MM-dd;没有则空串。"""
from datetime import datetime
@@ -304,15 +349,26 @@ def _harvest_quote_date(raw: str) -> str:
return ""
_RE_CLARIFY_EXAMPLE = re.compile(r"^\s*[((]如\s*[::]")
def _without_clarify_examples(text: str) -> str:
"""补问清单里的「(如:托盘、散货)」整行丢掉,避免示例被当成已填。"""
return "\n".join(
line for line in (text or "").splitlines() if not _RE_CLARIFY_EXAMPLE.match(line)
)
def harvest_oral_measures(text: str, facts: dict[str, str] | None = None) -> dict[str, str]:
"""
从当前原话机读件数/重量/体积/包装/报价日期。
当前句写了的覆盖旧值:散货替换托盘,9月16日写入报价日期。
补问复制清单的示例行不参与机读。
副作用:无。不改主账。不猜港口。
"""
merged = dict(facts or {})
raw = text or ""
raw = _without_clarify_examples(text or "")
hit = _RE_PIECES.search(raw)
if hit:
merged["件数"] = f"{hit.group(1)}{hit.group(2)}"
@@ -334,9 +390,13 @@ def harvest_oral_measures(text: str, facts: dict[str, str] | None = None) -> dic
pack = _harvest_packaging(raw)
if pack:
merged["包装方式"] = pack
quote_date = _harvest_quote_date(raw)
if quote_date:
merged["报价日期"] = quote_date
labeled_date = _quote_date_labeled_value(raw)
if labeled_date:
merged["报价日期"] = labeled_date
else:
quote_date = _harvest_quote_date(raw)
if quote_date:
merged["报价日期"] = quote_date
from agent.schema.tms_air_query import harvest_named_air_route
return harvest_named_air_route(raw, merged)
@@ -405,6 +465,25 @@ def pick_collab_from_facts(
return out
def air_general_cargo_collab_defaults(facts: dict[str, str] | None) -> dict[str, str]:
"""
空运货物品名正好是「普货」时,含电、含磁缺省为否。
只补事实里还没写的项。已经写了「是」或「否」的不在这里改。
品名多一个字(如普货纸箱)不算普货,不默认。
调用方仍须遵守:群里已记下的协同值优先,本函数只给空项。
"""
src = normalize_facts(facts)
if (src.get("品名") or "").strip() != "普货":
return {}
out: dict[str, str] = {}
for key in AIR_COLLAB_FIELDS:
# 摘要占位「-」等于没写,不能挡住默认否。
if (src.get(key) or "").strip() in {"", "-", "—", "-"}:
out[key] = "否"
return out
def harvest_oral_collab(text: str) -> dict[str, str]:
"""
群里口语协同:含油/含电/含磁、货好时间、是否为危险品。
+195 -36
View File
@@ -1,7 +1,7 @@
"""
陆运选项规则:运输类型、线路类别、运输分类。
本文件职责:从销售原话收取标准项、校验是否在名单内、生成补问清单(含填写样例表)、判断「确定查价」意图。
本文件职责:从销售原话或复制填空清单收取标准项、校验是否在名单内、生成补问清单、判断「确定查价」意图。
三项各自必填,组合不拦截查价。拉群必须对上填写样例。运输分类句里的「中港/东南亚」不得冒充类型或线路。
禁止:改六态;发明枚举;把选项当货物字段;再要单独的中港装载方式。
线程:纯函数,无共享可变状态,可在 Worker 槽调用。
@@ -9,6 +9,7 @@
from __future__ import annotations
import re
from pathlib import Path
from typing import Any
@@ -53,6 +54,21 @@ def scrub_land_placeholder_ports(facts: dict[str, str] | None) -> dict[str, str]
out[str(key)] = val
return out
# 复制清单里的线路编号:1.x=拼车 2.x=整车,编号比文字更短,必须先认。
ROUTE_CODES = (
("1.1", "国内运输拼车", "国内长途/零担"),
("1.2", "国内运输拼车", "国内城配/拖车/打包"),
("2.1", "国内运输整车", "国内长途/零担"),
("2.2", "国内运输整车", "国内城配/拖车/打包"),
("3.1", "跨境集拼", "中港/中亚/中欧"),
("3.2", "跨境集拼", "东南亚"),
("4.1", "跨境整车", "中港/中亚/中欧"),
("4.2", "跨境整车", "东南亚"),
("5.1", "中港整车", "中港/中亚/中欧"),
("6.1", "中港零担/集拼", "中港/中亚/中欧"),
)
_ROUTE_CODE_MAP = {code: (typ, route) for code, typ, route in ROUTE_CODES}
# 填写样例,同时作为拉群门禁:对不上的类型+线路可以查价,但不能拉群。
SAMPLE_PAIRS = (
("国内运输拼车", "国内长途/零担"),
@@ -173,10 +189,118 @@ def canonicalize_hk_load(raw: str) -> str:
return ""
_PASTE_LABEL = re.compile(
r"(运输类型|线路类别|运输分类)(?:(必填))?[ \t]*[::][ \t]*([^\n]*)"
)
# 1.国内拼车 / 1.1国内长途 都是清单选项行,不能当销售已答。
_MENU_LINE = re.compile(r"^\s*(?:\d+\.\d+|\d+\.)")
_STEP_LINE = re.compile(r"^\s*Step[12]\s*:", re.I)
_COPY_FENCE = re.compile(r"^\s*-{2,}请复制以下内容-{2,}\s*$")
def _apply_route_code(merged: dict[str, str], raw: str, *, fill_type: bool) -> bool:
"""编号 1.1~6.1 收成线路;类型还空着时按编号补上。"""
token = (raw or "").strip()
if not token:
return False
code = ""
for key in sorted(_ROUTE_CODE_MAP, key=len, reverse=True):
if token == key or token.startswith(key):
code = key
break
if not code:
return False
typ, route = _ROUTE_CODE_MAP[code]
merged["线路类别"] = route
if fill_type and not str(merged.get("运输类型") or "").strip():
merged["运输类型"] = typ
return True
def _decode_option_token(key: str, raw: str) -> str:
"""冒号后的数字或文字收成标准项。认不到返回空串。"""
token = (raw or "").strip()
if not token:
return ""
if key == "运输类型":
if token.isdigit():
idx = int(token)
if 1 <= idx <= len(TRANSPORT_TYPES):
return TRANSPORT_TYPES[idx - 1]
return canonicalize_transport_type(token)
if key == "线路类别":
for code, _typ, route in ROUTE_CODES:
if token == code or token.startswith(code):
return route
return canonicalize_route(token)
if key == "运输分类":
if token.isdigit():
idx = int(token)
if 1 <= idx <= len(LAND_CLASSES):
return LAND_CLASSES[idx - 1]
return canonicalize_land_class(token)
return ""
def _parse_land_option_labels(text: str) -> dict[str, str]:
"""复制清单里「字段(必填):值」只取冒号同行,空着的不当已填。"""
found: 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:
continue
if key == "线路类别":
hit = ""
for code, typ, route in ROUTE_CODES:
if token == code or token.startswith(code):
found["线路类别"] = route
inferred_type = typ
hit = code
break
if hit:
continue
decoded = _decode_option_token(key, token)
if decoded:
found[key] = decoded
if inferred_type and not found.get("运输类型"):
found["运输类型"] = inferred_type
return found
def _strip_land_option_prompt(text: str) -> str:
"""
丢掉补问清单的 Step 和编号选项行,避免未填清单被收成已选。
整句只有一个编号(如 1.1)要留下,那是销售在答题。
"""
raw = (text or "").strip()
if not raw:
return ""
compact = raw.replace(" ", "")
if compact in _ROUTE_CODE_MAP or compact.isdigit():
return raw
kept: list[str] = []
for line in raw.splitlines():
if _STEP_LINE.match(line):
continue
if _COPY_FENCE.match(line):
continue
if _PASTE_LABEL.match(line):
continue
if _MENU_LINE.match(line):
continue
kept.append(line)
return "\n".join(kept).strip()
def harvest_land_options(text: str, current: dict[str, str] | None = None) -> dict[str, str]:
"""
从一句原话收取陆运选项,合并到已有值上。
先收复制清单冒号后的数字/编号/文字,再收口语。
未填的补问清单本身不能当成已选。
先收运输分类并把命中句从原文挖掉,再收类型/线路,避免分类里的东南亚、中港冒充选项。
已有非空项默认保留;本句明确写出另一标准项时才覆盖。
已有值对不上标准枚举则丢掉。不改货物字段。返回新 dict,不修改 current。
@@ -187,7 +311,28 @@ def harvest_land_options(text: str, current: dict[str, str] | None = None) -> di
if str(v).strip()
}
_keep_canonical_options(merged)
blob = text or ""
labeled = _parse_land_option_labels(text or "")
for key in ("运输类型", "线路类别", "运输分类"):
if labeled.get(key):
merged[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("运输类型")))
merged.pop("中港装载方式", None)
return merged
if compact.isdigit() and len(compact) == 1:
idx = int(compact)
missing_type = not merged.get("运输类型")
missing_class = not merged.get("运输分类")
if missing_type and not missing_class and 1 <= idx <= len(TRANSPORT_TYPES):
merged["运输类型"] = TRANSPORT_TYPES[idx - 1]
elif missing_class and not missing_type and 1 <= idx <= len(LAND_CLASSES):
merged["运输分类"] = LAND_CLASSES[idx - 1]
merged.pop("中港装载方式", None)
return merged
found_class = canonicalize_land_class(blob)
remainder = blob.replace(found_class, " ") if found_class else blob
found_type = canonicalize_transport_type(remainder)
@@ -259,19 +404,46 @@ def validate_land_options(opts: dict[str, str]) -> dict[str, Any]:
}
_OPTION_PASTE_HEAD = (
"Step1: 请复制以下内容,粘贴到输入框;",
"Step2:在粘贴内容的每个字段的冒号后,回复数字或文字,告诉我对应字段的值:",
"---请复制以下内容---",
)
def _type_prompt_lines() -> list[str]:
lines = ["运输类型(必填):"]
for i, name in enumerate(TRANSPORT_TYPES, start=1):
lines.append(f"{i}.{name}")
return lines
def _route_prompt_lines() -> list[str]:
lines = ["线路类别(必填):"]
for code, _typ, route in ROUTE_CODES:
lines.append(f"{code}{route}")
return lines
def _class_prompt_lines() -> list[str]:
lines = ["运输分类(必填):可选择回复,"]
lines = ["运输分类(必填):"]
for i, name in enumerate(LAND_CLASSES, start=1):
lines.append(f"{i}.{name}")
return lines
# 编号清单已覆盖类型+线路,补问不再发填写样例图。验收脚本认这个开关。
SAMPLE_IMAGE_DISABLED = True
def option_prompt_needs_sample(*, current: dict[str, str]) -> bool:
"""运输类型或线路类别缺任一,就要发填写样例图。只缺分类不发。"""
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 "")
return t not in TRANSPORT_TYPES or r not in ROUTES
"""
填写样例图已停发。编号清单已经覆盖类型+线路搭配,不再另出图。
保留函数名避免旧调用处报缺符号;恒为假。current 不使用。
"""
_ = current
return False
def sample_image_path() -> Path:
@@ -298,10 +470,10 @@ def _sample_table() -> str:
def option_prompt(*, current: dict[str, str]) -> str:
"""
按已识别项生成补问清单。
按已识别项生成复制填空清单。
缺哪项列哪项。运输类型或线路类别缺任一则提示见图,样例图另发出站。只缺分类不附图。
不修改 current。
缺哪项列哪项。销售把数字或文字填在字段冒号后面。
填写样例图不再另发。不修改 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 "")
@@ -310,34 +482,21 @@ def option_prompt(*, current: dict[str, str]) -> str:
need_type = t not in TRANSPORT_TYPES
need_route = r not in ROUTES
need_class = c not in LAND_CLASSES
lines: list[str] = []
if need_type and need_route and need_class:
lines.append("请一次选择:运输类型、线路类别、运输分类")
lines.append("")
lines.append("运输类型(必填):可选择回复," + "、".join(TRANSPORT_TYPES))
lines.append("")
lines.append("线路类别(必填):可选择回复," + "、".join(ROUTES))
lines.append("")
lines.extend(_class_prompt_lines())
lines.append("")
lines.append("填写样例见图。")
return "\n".join(lines).strip()
if not (need_type or need_route or need_class):
return ""
chunks: list[list[str]] = []
if need_type:
lines.append("运输类型(必填):可选择回复," + "、".join(TRANSPORT_TYPES))
chunks.append(_type_prompt_lines())
if need_route:
if lines:
lines.append("")
lines.append("线路类别(必填):可选择回复," + "、".join(ROUTES))
chunks.append(_route_prompt_lines())
if need_class:
if lines:
lines.append("")
lines.extend(_class_prompt_lines())
if need_type or need_route:
lines.append("")
lines.append("填写样例见图。")
return "\n".join(lines).strip()
chunks.append(_class_prompt_lines())
body: list[str] = list(_OPTION_PASTE_HEAD)
for i, block in enumerate(chunks):
if i:
body.append("")
body.extend(block)
return "\n".join(body).strip()
def looks_like_confirm(text: str) -> bool:
+2 -2
View File
@@ -183,10 +183,10 @@ def assemble_air_query(facts: dict[str, Any] | None) -> dict[str, Any]:
missing_display.append("件数")
if not weight:
missing.append("毛重")
missing_display.append("重量")
missing_display.append("重量(KG)")
if not volume:
missing.append("体积")
missing_display.append("体积")
missing_display.append("体积(CBM)")
if not pack_raw:
missing.append("包装方式")
missing_display.append("包装类型")