陆运选项不全时改为复制填空清单,方便销售按编号回填。
停发填写样例图,同群只跟当前工单,重量体积统一为 KG/CBM。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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]:
|
||||
"""
|
||||
群里口语协同:含油/含电/含磁、货好时间、是否为危险品。
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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("包装类型")
|
||||
|
||||
Reference in New Issue
Block a user