""" 陆运选项规则:运输类型、线路类别、运输分类,以及和它们同一张清单里的货物品名。 本文件职责:从销售原话或复制填空清单收取标准项、校验三项是否在名单内、生成补问清单、判断「确定查价」意图。 三项各自必填,组合不拦截查价。货物品名六个运输类型都要,和三项同一张复制清单里填;没填完不进始发站补问。 已填项回显销售刚写的数字或文字,并附上完整选项,方便对着编号填线路类别。 拉群必须对上填写样例。运输分类句里的「中港/东南亚」不得冒充类型或线路。 禁止:改六态;发明枚举;把选项当货物字段;再要单独的中港装载方式。 线程:纯函数,无共享可变状态,可在 Worker 槽调用。 """ from __future__ import annotations import re from pathlib import Path from typing import Any TRANSPORT_TYPES = ( "国内运输拼车", "国内运输整车", "跨境集拼", "跨境整车", "中港整车", "中港零担/集拼", ) ROUTES = ( "国内长途/零担", "国内城配/拖车/打包", "中港/中亚/中欧", "东南亚", ) LAND_CLASSES = ( "普件(常用线路/常规车型)", "普件(东南亚、中港常用线路/常规车型)", "普件(东南亚、中亚、中欧涉及国内+国外段)", "普件(东南亚、中亚、中欧涉及境外段清派等特殊要求)", "急件", ) # 报价单模板码,不能当始发站/目的地。 _TEMPLATE_PORT_CODES = frozenset({"DOM", "FTL", "LTL", "INTL"}) def scrub_land_placeholder_ports(facts: dict[str, str] | None) -> dict[str, str]: """ 丢掉报价模板里的 DOM/FTL 这类站点码。 这些不是城市或港口,当已填会让多段误出确定卡。返回新 dict,不改入参。 """ out: dict[str, str] = {} for key, raw in dict(facts or {}).items(): val = str(raw).strip() if not val: continue if key in {"起运港", "目的港", "始发站", "目的地"} and val.upper() in _TEMPLATE_PORT_CODES: continue 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 = ( ("国内运输拼车", "国内长途/零担"), ("国内运输拼车", "国内城配/拖车/打包"), ("国内运输整车", "国内长途/零担"), ("国内运输整车", "国内城配/拖车/打包"), ("跨境集拼", "中港/中亚/中欧"), ("跨境集拼", "东南亚"), ("跨境整车", "中港/中亚/中欧"), ("跨境整车", "东南亚"), ("中港整车", "中港/中亚/中欧"), ("中港零担/集拼", "中港/中亚/中欧"), ) # 口语别名 → 标准运输类型。长词优先。禁止把单独「中港」收成类型。 _TYPE_ALIASES = ( ("国内运输拼车", "国内运输拼车"), ("国内运输整车", "国内运输整车"), ("中港零担/集拼", "中港零担/集拼"), ("中港零担", "中港零担/集拼"), ("中港集拼", "中港零担/集拼"), ("中港整车", "中港整车"), ("国内拼车", "国内运输拼车"), ("国内整车", "国内运输整车"), ("跨境集拼", "跨境集拼"), ("跨境整车", "跨境整车"), ) _ROUTE_ALIASES = ( ("国内城配/拖车/打包", "国内城配/拖车/打包"), ("国内长途/零担", "国内长途/零担"), ("中港/中亚/中欧", "中港/中亚/中欧"), ("国内城配", "国内城配/拖车/打包"), ("国内长途/零担", "国内长途/零担"), ("国内长途", "国内长途/零担"), ("国内零担", "国内长途/零担"), ("东南亚", "东南亚"), ) # 分类全称从长到短;「急件」单独出现才算急件。 _CLASS_CANON = tuple( sorted(LAND_CLASSES, key=len, reverse=True) ) _CONFIRM_PHRASES = ("可以查了", "确定", "确认", "查价") _CONFIRM_EXCLUDE = ("始发", "目的", "体积", "车型", "改成", "重量") def canonicalize_transport_type(raw: str) -> str: """ 把销售原话收成标准运输类型。 参数:任意一句。返回 TRANSPORT_TYPES 之一或空串。 只说「中港」不算选完,避免分类句里的中港把类型改掉。 副作用:无。先匹配更长别名。 """ text = (raw or "").strip() if not text: return "" for needle, canon in _TYPE_ALIASES: if needle in text: return canon return "" def canonicalize_route(raw: str) -> str: """把原话收成标准线路类别;对不上返回空串。不把单独「中港」收成中港/中亚/中欧。""" text = (raw or "").strip() if not text: return "" for needle, canon in _ROUTE_ALIASES: if needle in text: return canon return "" def canonicalize_land_class(raw: str) -> str: """ 把原话收成标准运输分类。 先匹配完整分类句;整句只有「急件」才收急件,避免普件描述被截成急件。 """ text = (raw or "").strip() if not text: return "" for canon in _CLASS_CANON: if canon == "急件": continue if canon in text: return canon stripped = text.replace("运输分类", "").replace(":", "").replace(":", "").strip() if stripped == "急件" or text.strip() == "急件": return "急件" if "急件" in text and "普件" not in text: return "急件" return "" def sample_pair_allowed(land_type: str, route: str) -> bool: """ 类型+线路是否在填写样例里。 查价不调用本函数;点「拉产品进群协同」时必须为真,否则不建群。 入参先收成标准项。副作用:无。 """ t = canonicalize_transport_type(land_type) or (land_type or "").strip() r = canonicalize_route(route) or (route or "").strip() return (t, r) in SAMPLE_PAIRS def canonicalize_hk_load(raw: str) -> str: """ 旧中港装载方式不再收取。 保留函数名避免旧调用处报缺符号;恒返回空串。新单用运输类型「中港整车」「中港零担/集拼」。 """ _ = raw 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) -> 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 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 if hit: continue decoded = _decode_option_token(key, token) if decoded: found[key] = decoded shown[key] = token if inferred_type and not found.get("运输类型"): # 编号带出的类型不是销售本行写下的字。已有回显时不要改成标准名称。 found["运输类型"] = inferred_type shown.setdefault("运输类型", "") return found, shown 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。 """ merged = { k: str(v).strip() for k, v in dict(current or {}).items() if str(v).strip() } _keep_canonical_options(merged) labeled, shown = _parse_land_option_labels(text or "") for key in ("运输类型", "线路类别", "运输分类"): 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: 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: 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] _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 found_class = canonicalize_land_class(blob) remainder = blob.replace(found_class, " ") if found_class else blob found_type = canonicalize_transport_type(remainder) found_route = canonicalize_route(remainder) _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 "") if t: 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) def validate_land_options(opts: dict[str, str]) -> dict[str, Any]: """ 校验三项是否都在名单内。组合不拦截。 返回 ok / missing / errors。对不上名单的项当缺项,不写联动纠正句。 副作用:无。 """ src = {k: str(v).strip() for k, v in dict(opts 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 "") missing: list[str] = [] if t not in TRANSPORT_TYPES: missing.append("运输类型") t = "" if r not in ROUTES: missing.append("线路类别") r = "" if c not in LAND_CLASSES: missing.append("运输分类") c = "" ok = not missing return { "ok": ok, "missing": missing, "errors": [], "normalized": { "运输类型": t, "线路类别": r, "运输分类": c, "中港装载方式": "", }, } _OPTION_PASTE_HEAD = ( "Step1: 请复制以下内容,粘贴到输入框;", "Step2:在粘贴内容的每个字段的冒号后,回复数字或文字,告诉我对应字段的值:", "---请复制以下内容---", ) 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]: return ["运输类型(必填):", *_type_menu_lines()] def _route_prompt_lines() -> list[str]: return ["线路类别(必填):", *_route_menu_lines()] def _class_prompt_lines() -> list[str]: 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 # 编号清单已覆盖类型+线路,补问不再发填写样例图。验收脚本认这个开关。 SAMPLE_IMAGE_DISABLED = True def option_prompt_needs_sample(*, current: dict[str, str]) -> bool: """ 填写样例图已停发。编号清单已经覆盖类型+线路搭配,不再另出图。 保留函数名避免旧调用处报缺符号;恒为假。current 不使用。 """ _ = current return False def sample_image_path() -> Path: """填写样例表 PNG。和源码放一起,随 agent 发布包走。""" return Path(__file__).resolve().parent.parent / "assets" / "land_sample_pairs.png" def sample_fallback_text() -> str: """ 图片发不出去时的文字兜底。 一行一种搭配,不再画框线。 """ lines = ["填写样例"] for i, (typ, route) in enumerate(SAMPLE_PAIRS, start=1): lines.append(f"{i}.{typ} + {route}") return "\n".join(lines) def _sample_table() -> str: """兼容旧名:补问正文不再内嵌样例,只作图片失败兜底。""" return sample_fallback_text() 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 "") 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 "" 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) return "\n".join(body).strip() def looks_like_confirm(text: str) -> bool: """ 判断是否在表示确定查价。 「确定」「确认」「查价」「可以查了」及相近短句才算。 长询价里「需确认/请确认」不算点核对卡,避免误回「核对已过期」。 同一句像在改始发/目的/体积/车型/「改成」则不当确定。 纯函数,无副作用。 """ raw = (text or "").strip() if not raw: return False if any(x in raw for x in _CONFIRM_EXCLUDE): return False # 超过约 20 字一定是需求正文,不是「确定」短答。 if len(raw) > 20: return False compact = re.sub(r"[\s。!?!?,,、]+", "", raw) if not compact: return False return any(p in compact for p in _CONFIRM_PHRASES)