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

停发填写样例图,同群只跟当前工单,重量体积统一为 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
@@ -13,6 +13,7 @@ from typing import Any, Optional
from agent.channel.wecom.models import InboundMessage
from agent.policy import inquiry_copy as copy
from agent.policy.air_group_ops import (
is_air_ticket,
activate_ticket,
apply_air_confirm_tms,
apply_air_deal,
@@ -167,7 +168,7 @@ def handle_air_group(
return phase
else:
ticket = current_ticket(engine, chat_id)
if ticket is None:
if ticket is None or not is_air_ticket(ticket):
send_group_text(group_client_of(engine), chat_id, copy.air_need_work_order())
return "need_work_order"
if is_handoff_ticket(ticket):
@@ -210,7 +211,7 @@ def _handle_file(engine, message: InboundMessage) -> str:
return phase
else:
ticket = current_ticket(engine, chat_id)
if ticket is None:
if ticket is None or not is_air_ticket(ticket):
send_group_text(group_client_of(engine), chat_id, copy.air_need_work_order())
return "need_work_order"
if is_handoff_ticket(ticket):
+21 -5
View File
@@ -665,7 +665,10 @@ class MemoryLedger:
ticket = self._tickets.get(work_order_no)
if ticket is None:
return {"ok": False, "error": "ticket_not_found"}
ticket.collab_chat_id = (chat_id or "").strip()
room = (chat_id or "").strip()
# 一群同时只跟一张单:陆运/海运旧绑定也要清掉,否则群里补字段会写到旧单。
self._unbind_other_collab_rooms(room=room, keep_work_order_no=work_order_no)
ticket.collab_chat_id = room
ticket.collab_member_ids = [str(x) for x in member_ids]
ticket.product_ids = [str(x) for x in (product_ids or [])]
ticket.product_names = [str(x).strip() for x in (product_names or []) if str(x).strip()]
@@ -743,6 +746,22 @@ class MemoryLedger:
ticket.collab_facts.pop(name, None)
return {"ok": True, "facts": dict(ticket.collab_facts)}
def _unbind_other_collab_rooms(self, *, room: str, keep_work_order_no: str) -> None:
"""
同一群只留 keep 这一张单。调用方必须已持 self._lock。
陆运/海运旧单也要摘掉。测服 WO202609230023 补协同写到了同群陆运 WO202609230007。
"""
room_id = (room or "").strip()
keep = (keep_work_order_no or "").strip()
if not room_id:
return
for other in self._tickets.values():
if other.work_order_no == keep:
continue
if other.collab_chat_id == room_id:
other.collab_chat_id = ""
def find_by_collab_chat(self, *, chat_id: str) -> MemoryTicket | None:
"""用工微群 chat_id 找回工单。"""
raw = (chat_id or "").strip()
@@ -772,10 +791,7 @@ class MemoryLedger:
return {"ok": False, "error": "terminal", "status": ticket.status}
if ticket.collab_chat_id and ticket.collab_chat_id != room:
return {"ok": False, "error": "occupied", "other_chat_id": ticket.collab_chat_id}
for other in self._tickets.values():
if other.work_order_no != no and other.collab_chat_id == room:
if (other.business_line or "").upper() == "AIR":
other.collab_chat_id = ""
self._unbind_other_collab_rooms(room=room, keep_work_order_no=no)
ticket.collab_chat_id = room
return {
"ok": True,
+134 -18
View File
@@ -11,27 +11,43 @@ import logging
import re
from typing import Any, Optional
from agent.schema.field_validate import QUOTE_DATE_SYNONYMS
logger = logging.getLogger(__name__)
# 报价日期同义词。长的在前,避免短词截断。货好时间、提货地址不在这里。
_QUOTE_DATE_LABEL_RE = "|".join(
re.escape(name)
for name in sorted(("报价日期",) + QUOTE_DATE_SYNONYMS, key=len, reverse=True)
)
_MODE_WORDS = (
("空运", "AIR"),
("海运", "SEA"),
("陆运", "LAND"),
)
# 未定运输方式时:整句只回 1/2/3 对应陆运/海运/空运。10件、1.件数 不算。
_MODE_INDEX = re.compile(r"^\s*([1231-3])\s*[\.、.]?\s*$")
_MODE_BY_INDEX = {
"1": "LAND",
"2": "SEA",
"3": "AIR",
"1": "LAND",
"2": "SEA",
"3": "AIR",
}
# 仅「键:值」一行一条,键必须是合同字段或展示名
_LABEL_LINE = re.compile(
r"^(起运地|起运港|目的地|目的港|品名|件数|毛重|体积|重量|重量\(KG\)|体积\(CBM\)|"
r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|运输分类|车型/数量|车型数量|"
# 仅「键:值」一行一条,键必须是合同字段或展示名。报价日期含询价/走货/发货等同义词。
_LABEL_KEYS = (
r"起运地|起运港|目的地|目的港|品名|件数|毛重|体积(CBM)|体积\(CBM\)|体积|重量(KG)|重量\(KG\)|重量|"
r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|"
+ _QUOTE_DATE_LABEL_RE
+ r"|运输分类|车型/数量|车型数量|"
r"贸易条款|货好时间|货源地|提货地址|收货地址|送货地址|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁|"
r"客户名称|货值|是否为危险品|通关口岸(非必填)|通关口岸)\s*[::]\s*(.+)$"
)
_LABEL_INLINE = re.compile(
r"(起运地|起运港|目的地|目的港|品名|件数|毛重|体积|重量|重量\(KG\)|体积\(CBM\)|"
r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|运输分类|车型/数量|车型数量|"
r"贸易条款|货好时间|货源地|提货地址|收货地址|送货地址|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁|"
r"客户名称|货值|是否为危险品|通关口岸(非必填)|通关口岸)\s*[::]\s*(\S+)"
r"客户名称|货值|是否为危险品|通关口岸(非必填)|通关口岸"
)
_LABEL_LINE = re.compile(rf"^({_LABEL_KEYS})\s*[::]\s*(.+)$")
_LABEL_INLINE = re.compile(rf"({_LABEL_KEYS})\s*[::]\s*(\S+)")
# 陆运核对卡非必填:销售可能写「通关口岸皇岗」或照抄「通关口岸(非必填):皇岗」。长词优先。
_OPTIONAL_INQUIRY_LABELS = (
"通关口岸(非必填)",
@@ -52,6 +68,48 @@ _OPTIONAL_INQUIRY_LABELS = (
)
# 图上常见「4. 希望送到…」:编号项不是询价字段,值收到这里为止。
_NUMBERED_ITEM = re.compile(r"(?m)^\s*\d+[\.、.]\s+\S")
# 补问复制清单:1.件数(必填):10 ;下一行 (如:10)不算值。
_CLARIFY_EXAMPLE_LINE = re.compile(r"^\s*[((]如\s*[::]")
_CLARIFY_INDEX = re.compile(r"^\s*\d+[\.、.]\s*")
_CLARIFY_DECOR = re.compile(r"((?:KG|CBM|kg|cbm|必填|非必填))|\((?:KG|CBM|必填|非必填)\)", re.I)
_CLARIFY_NAMES = frozenset(
{
"起运地",
"起运港",
"目的地",
"目的港",
"品名",
"货物品名",
"件数",
"毛重",
"体积(CBM)",
"体积",
"重量(KG)",
"重量",
"包装方式",
"包装类型",
"货量",
"货物数量",
"整柜或拼柜",
"箱型箱量",
"报价日期",
*QUOTE_DATE_SYNONYMS,
"运输分类",
"运输方式",
"运输类型",
"线路类别",
"车型/数量",
"车型数量",
"贸易条款",
"货源地",
"货好时间",
"提货地址",
"收货地址",
"送货地址",
"通关口岸",
"始发站",
}
)
# 报价单表头和格子在同一行:只洗这些询价键,不碰提货/收货地址。
_SHEET_CLEAN_KEYS = frozenset(
{
@@ -62,7 +120,10 @@ _SHEET_CLEAN_KEYS = frozenset(
"品名",
"件数",
"毛重",
"体积(CBM)",
"体积",
"重量(KG)",
"重量",
"货量",
"货物数量",
"整柜或拼柜",
@@ -70,6 +131,7 @@ _SHEET_CLEAN_KEYS = frozenset(
"贸易条款",
"运输分类",
"报价日期",
*QUOTE_DATE_SYNONYMS,
"包装方式",
"包装类型",
}
@@ -121,7 +183,9 @@ def detect_transport_mode(text: str) -> str:
"""
运输方式:空运/海运/陆运,或空运口语「飞」(南京飞吉隆坡)。
未定方式时整句回 1/2/3 分别是陆运/海运/空运;「1.陆运」仍按文字认。
写了海运/陆运以书面为准。只说飞就是空运,补问件数时不得再问运输方式。
「10件」「1.件数」不是运输方式。
"""
raw = text or ""
found = [code for word, code in _MODE_WORDS if word in raw]
@@ -129,30 +193,79 @@ def detect_transport_mode(text: str) -> str:
return found[0]
if len(found) > 1:
return ""
hit = _MODE_INDEX.match(raw.strip())
if hit:
return _MODE_BY_INDEX.get(hit.group(1), "")
if "飞" in raw:
return "AIR"
return ""
def parse_clarify_paste_facts(text: str) -> dict[str, str]:
"""
认补问复制清单:编号 + 字段名 +(单位/必填)+ 冒号后的值。
「(如:10)」整行丢掉。冒号后为空也不收,避免把示例当成已填。
不猜港口,不改六态。
"""
facts: dict[str, str] = {}
for line in (text or "").splitlines():
raw = (line or "").strip()
if not raw or _CLARIFY_EXAMPLE_LINE.match(raw):
continue
raw = _CLARIFY_INDEX.sub("", raw, count=1)
if ":" not in raw and ":" not in raw:
continue
if ":" in raw:
name, val = raw.split(":", 1)
else:
name, val = raw.split(":", 1)
name = _CLARIFY_DECOR.sub("", name).strip()
val = (val or "").strip()
if not name or name not in _CLARIFY_NAMES:
continue
if not val or _CLARIFY_EXAMPLE_LINE.match(val):
continue
facts[name] = clip_inquiry_value(val)
return {key: val for key, val in facts.items() if val}
def parse_labeled_facts(text: str) -> dict[str, str]:
"""
只采集「字段名:值」,整行优先;同一句里的标签也收。不猜港口。
补问复制清单(1.件数(必填):10)优先。
报价单常把表头和格子写在一行(起运港/POL(必填) 纽约港),
冒号对不上时再按表头切一刀,值只留格子。
"""
facts: dict[str, str] = {}
raw = text or ""
facts: dict[str, str] = parse_clarify_paste_facts(text)
raw = "\n".join(
line
for line in (text or "").splitlines()
if not _CLARIFY_EXAMPLE_LINE.match(line.strip())
)
for line in raw.splitlines():
m = _LABEL_LINE.match(line.strip())
if m:
shown = line.strip()
m = _LABEL_LINE.match(shown)
if m and not str(facts.get(m.group(1)) or "").strip():
facts[m.group(1)] = clip_sheet_field_value(m.group(2).strip())
if not facts:
for m in _LABEL_INLINE.finditer(raw):
facts[m.group(1)] = clip_sheet_field_value(m.group(2).strip())
from agent.schema.field_validate import normalize_facts
taken = set(normalize_facts(facts))
for key, val in harvest_sheet_inquiry_cells(raw).items():
if val and not str(facts.get(key) or "").strip():
facts[key] = val
if not val:
continue
mapped = next(iter(normalize_facts({key: val})), "")
if mapped and mapped in taken:
continue
if str(facts.get(key) or "").strip():
continue
facts[key] = val
if mapped:
taken.add(mapped)
return {key: val for key, val in facts.items() if val}
@@ -287,6 +400,9 @@ def clip_inquiry_value(raw: str) -> str:
text = (raw or "").strip()
if not text:
return ""
text = re.sub(r"[((]如\s*[::][^))]*[))]?\s*$", "", text).strip()
if _CLARIFY_EXAMPLE_LINE.match(text):
return ""
hit = _NUMBERED_ITEM.search(text)
if hit and hit.start() > 0:
text = text[: hit.start()]
@@ -384,7 +500,7 @@ def _label_is_token(raw: str, pos: int, label: str) -> bool:
return False
end = pos + len(label)
nxt = raw[end : end + 6]
if label in {"重量", "体积"} and nxt.startswith("("):
if label in {"重量", "体积"} and (nxt.startswith("(") or nxt.startswith("(")):
return False
return True
@@ -128,6 +128,8 @@ def build_extract_messages(text: str) -> list[dict[str, str]]:
"10件/3pcs→空运件数;海运同样数字+件也要写入货量;100KGS/180kg/1.5KG/100公斤→毛重;2CBM/4.2cbm/2.5立方/2方→体积;"
"托盘/散货/卡板/纸箱/木箱→包装方式(销售说什么记什么,散货也要抽)。"
"9月15日/9月15号/2026-09-15→报价日期,规范成 yyyy-MM-dd,未写年用今年。"
"询价日期、走货日期、询价时间、走货时间、发货时间、发货日期、出货日期、出货时间、提货时间、提货日期一律写入报价日期。"
"货好时间、提货地址不要写成报价日期。"
"陆运「通关口岸:皇岗」「通关口岸皇岗」「过皇岗」有说就写入通关口岸,没说留空,禁止编造口岸。"
"「从南京飞吉隆坡」起运港=南京、目的港=吉隆坡;「北京飞吉隆坡」起运港=北京。"
"原话是机场三字码或航线就原样记,禁止翻译成城市名:"
+32 -2
View File
@@ -25,7 +25,24 @@ _PROMPT_CACHE: str | None = None
# 清单原文片段 → 白名单 code(按运输方式过滤;关键词避免过短英文误报)
_LABEL_TO_CODE_COMMON: list[tuple[tuple[str, ...], str]] = [
(("报价单编号", "询价单号", "RFQ No", "Quote No"), "quote_no"),
(("报价日期", "询价日期", "Inquiry Date", "Quote Date"), "quote_date"),
(
(
"报价日期",
"询价日期",
"走货日期",
"询价时间",
"走货时间",
"发货时间",
"发货日期",
"出货日期",
"出货时间",
"提货时间",
"提货日期",
"Inquiry Date",
"Quote Date",
),
"quote_date",
),
(("有效期至", "有效期", "Validity"), "valid_until"),
(("客户名称", "Customer Name", "Customer"), "customer_name"),
(("联系人", "Contact Person", "Contact Name"), "contact_name"),
@@ -351,7 +368,20 @@ def merge_mapping_prefer_existing(
# 与主账 FILL_BY_LABEL 对齐的常用码→标签(智能合并校验用)
_FIELD_LABELS: dict[str, tuple[str, ...]] = {
"quote_no": ("RFQ No", "报价单编号", "报价编号"),
"quote_date": ("询价日期", "报价日期", "Inquiry Date"),
"quote_date": (
"报价日期",
"询价日期",
"走货日期",
"询价时间",
"走货时间",
"发货时间",
"发货日期",
"出货日期",
"出货时间",
"提货时间",
"提货日期",
"Inquiry Date",
),
"pol": ("起运港", "POL"),
"pod": ("目的港", "POD"),
"commodity": ("品名", "货物品名", "Commodity"),
+65 -20
View File
@@ -94,6 +94,13 @@ def operator_name(flow: Any, sender_id: str) -> str:
return (sender_id or "").strip() or "航线"
def is_air_ticket(ticket: object) -> bool:
"""按群找回的工单必须是空运。陆运旧单不能当空运当前单补含电含磁。"""
view = ticket_view(ticket)
line = str(view.get("business_line") or view.get("businessLine") or "").strip().upper()
return not line or line == "AIR"
def can_fill_fields(flow: Any, ticket: object, sender_id: str) -> bool:
return is_sales(ticket, sender_id) or is_airline(flow, sender_id)
@@ -147,6 +154,8 @@ def activate_ticket(
ticket = hydrate_ticket(flow, _ticket_from_activate(book, no, out))
if is_handoff_ticket(ticket):
return None, "handoff_ignore"
# 先落默认协同,再决定要不要发摘要。发文件切单不说话,工单上仍要有含电/含磁。
_seed_air_activate_collab(flow, ticket)
if speak:
send_air_brief_and_sheet(flow=flow, ticket=ticket, chat_id=chat_id)
_watch_archive_room(chat_id, no)
@@ -156,6 +165,54 @@ def activate_ticket(
return ticket, "activated"
def _collab_slot_empty(val: object) -> bool:
"""协同项没写,或只写了横杠。横杠是摘要占位,不能当成已经填过。"""
return str(val or "").strip() in {"", "-", "—", "-"}
def _seed_air_activate_collab(flow: Any, ticket: object) -> dict[str, str]:
"""
空运群激活时预填协同,并记到主账。
私聊已经写过的含电/含磁先带上。
货物品名正好是「普货」、该项还空着,默认否。
群里已经记下的值不覆盖。只补空项。
"""
from agent.schema.field_validate import (
AIR_COLLAB_FIELDS,
air_general_cargo_collab_defaults,
pick_collab_from_facts,
)
view = ticket_view(ticket)
no = ticket_no(ticket)
facts = dict(view.get("facts") or {})
# 主账有时品名在货物名称列,事实里没带「品名」。群摘要仍能看到普货。
if not str(facts.get("品名") or facts.get("货物品名") or "").strip():
cargo = str(view.get("goodsName") or view.get("goods_name") or "").strip()
if cargo:
facts["品名"] = cargo
seeded = pick_collab_from_facts(facts, AIR_COLLAB_FIELDS)
for key, val in air_general_cargo_collab_defaults(facts).items():
if _collab_slot_empty(seeded.get(key)):
seeded[key] = val
collab = dict(view.get("collab_facts") or {})
to_write: dict[str, str] = {}
for key, val in seeded.items():
if val and _collab_slot_empty(collab.get(key)):
collab[key] = val
to_write[key] = val
if to_write:
patcher = getattr(getattr(flow, "ledger", None), "patch_collab_facts", None)
if callable(patcher):
patcher(
work_order_no=no,
facts=to_write,
sender_id=str(view.get("sales_wecom_id") or view.get("salesWecomId") or ""),
)
return collab
def send_air_brief_and_sheet(
*,
flow: Any,
@@ -177,25 +234,7 @@ def send_air_brief_and_sheet(
if room and not view.get("collab_chat_id"):
view["collab_chat_id"] = room
facts = dict(view.get("facts") or {})
from agent.schema.field_validate import AIR_COLLAB_FIELDS, pick_collab_from_facts
seeded = pick_collab_from_facts(facts, AIR_COLLAB_FIELDS)
collab = dict(view.get("collab_facts") or {})
if seeded:
changed = False
for k, v in seeded.items():
if v and not str(collab.get(k) or "").strip():
collab[k] = v
changed = True
if changed:
view["collab_facts"] = collab
patcher = getattr(getattr(flow, "ledger", None), "patch_collab_facts", None)
if callable(patcher):
patcher(
work_order_no=no,
facts=seeded,
sender_id=str(view.get("sales_wecom_id") or view.get("salesWecomId") or ""),
)
collab = _seed_air_activate_collab(flow, view)
view["collab_facts"] = collab
view_q = dict(view.get("quote") or {})
override = dict(quote_override or {})
@@ -358,6 +397,9 @@ def apply_air_fields(*, flow: Any, ticket: object, text: str, sender_id: str, ch
ticket = hydrate_ticket(flow, ticket)
no = ticket_no(ticket)
client = group_client_of(flow)
if not is_air_ticket(ticket):
send_group_text(client, chat_id, copy.air_need_work_order())
return "need_work_order"
stopped = halt_closed_group(flow=flow, ticket=ticket, chat_id=chat_id)
if stopped:
return stopped
@@ -369,7 +411,10 @@ def apply_air_fields(*, flow: Any, ticket: object, text: str, sender_id: str, ch
send_group_text(client, chat_id, copy.air_fields_not_recognized())
return "fields_empty"
flow.ledger.patch_collab_facts(work_order_no=no, facts=fields, sender_id=sender_id)
ticket2 = current_ticket(flow, chat_id) or ticket
# 必须回读本单。按群再查会拿到同群更早的陆运/海运旧单(测服 WO202609230007)。
getter = getattr(flow.ledger, "get_ticket", None)
reloaded = getter(work_order_no=no) if callable(getter) else None
ticket2 = hydrate_ticket(flow, reloaded or ticket)
view = ticket_view(ticket2)
collab = dict(view.get("collab_facts") or {})
collab.update(fields)
+1 -1
View File
@@ -817,7 +817,7 @@ class AirTextInquiryFlow:
chat_fn=chat_fn,
inbound_raw=inbound_raw,
)
reply("请补充运输方式:空运、海运或陆运。")
reply(copy.ASK_TRANSPORT)
return "need_mode"
check = validate_required_fields(facts=facts, business_line="AIR")
+92 -28
View File
@@ -26,23 +26,22 @@ from agent.schema.field_validate import (
)
from agent.schema.land_options import option_prompt as _land_option_prompt
from agent.schema.quote_adjust import total_price_label
from agent.schema.sea_options import sea_class_prompt
# 空运补问:必填 7 项 + 非必填。报价日期默认当天,不进待补充。
_AIR_EXAMPLES = {
"起运港": "南京",
"目的港": "吉隆坡",
"品名": "茶叶",
"件数": "10件",
"毛重": "100KGS",
"体积": "2CBM",
"包装方式": "托盘",
"件数": "10",
"毛重": "100",
"体积": "2",
"包装方式": "托盘、散货",
"报价日期": "2026-09-15",
"货源地": "东莞",
"货好时间": "随时可提",
"提货地址": "东莞仓库",
"收货地址": "马尼拉",
"贸易条款": "FOB",
"贸易条款": "DDP、DAP、CIF、CIP",
}
_AIR_CLARIFY = tuple(
(key, label, _AIR_EXAMPLES[key]) for key, label in AIR_REQUIRED_FIELDS
@@ -114,7 +113,15 @@ _CONTINUE_HINT = (
)
ASK_TRANSPORT = "请补充运输方式:海运 / 空运 / 陆运。"
ASK_TRANSPORT = (
"请回复数字或文字,告知我询价的运输方式:\n"
"1.陆运\n"
"2.海运\n"
"3.空运"
)
# 待补充清单:请销售整段复制,只在冒号后填值;示例行不算已填。
CLARIFY_PASTE_HINT = "请复制粘贴以下内容,通过回复数字或文字方式,告知我以下字段值:"
_PENDING_UNITS = {}
HANDOFF_ALREADY_COLLAB = "该工单已拉产品进群协同。"
HANDOFF_CLARIFY_REASON = "参数提取不全(连续 3 次反问仍不完整)"
HANDOFF_INTENT_REASON = "已识别您希望转人工处理"
@@ -179,10 +186,9 @@ ASK_LAND_SUBTYPE = _land_option_prompt(current={})
def land_sample_image_payload() -> dict[str, Any]:
"""
填写样例表图片出站。
填写样例表图片载荷。编号清单上线后不再主动出站,保留给旧调用。
走企微 image,不走文件气泡。路径随发布包,出站线程再读字节。
禁止在回调线程上传。
走企微 image,不走文件气泡。禁止在回调线程上传。
"""
from agent.schema.land_options import sample_image_path
@@ -195,7 +201,7 @@ def land_sample_image_payload() -> dict[str, Any]:
def land_sample_image_fallback() -> str:
"""图片失败时回文字样例,避免销售什么都看不到。"""
"""旧图失败兜底。新补问不再发图,一般不会走到这里。"""
from agent.schema.land_options import sample_fallback_text
return sample_fallback_text()
@@ -278,14 +284,40 @@ def _land_optional_keys(land_type: str) -> tuple[str, ...]:
return ()
def _pending_field_block(
index: int,
label: str,
example: str = "",
*,
optional: bool = False,
unit: str = "",
) -> str:
"""
待补充一项:编号 + 字段名 + 必填/单位,示例另起一行。
销售侧如:
1.件数(必填):
(如:10)
2.重量(KG)(必填):
(如:100)
复制回去时只认冒号后面的值;下一行「(如:…)」不算已填。
"""
mark = "(非必填)" if optional else "(必填)"
extra = f"({unit})" if (unit or "").strip() else ""
head = f"{index}.{label}{extra}{mark}:"
shown = (example or "").strip()
if shown:
return f"{head}\n(如:{shown})"
return head
def _land_field_line(key: str, value: str, *, required: bool, example: str = "") -> str:
"""销售侧一行:名称(必填/非必填):值或示例。"""
"""已识别一行:名称(必填/非必填):值。待补充走 _pending_field_block。"""
label = LAND_DISPLAY.get(key, key)
mark = "必填" if required else "非必填"
if value:
return f"{label}({mark}):{value}"
if example:
return f"{label}({mark}):(如:{example})"
_ = example
return f"{label}({mark}):"
@@ -350,11 +382,22 @@ def ask_land_fields(
if not pending:
return ""
lines.append("")
lines.append("待补充:")
lines.append(CLARIFY_PASTE_HINT)
seq = 0
for key in pending:
seq += 1
required = key not in optional
example = _LAND_EXAMPLES.get(key, "")
lines.append(_land_field_line(key, "", required=required, example=example))
label = LAND_DISPLAY.get(key, key)
lines.append(
_pending_field_block(
seq,
label,
example,
optional=not required,
unit=_PENDING_UNITS.get(key, ""),
)
)
return "\n".join(lines)
@@ -394,14 +437,13 @@ def _sea_known_value(facts: dict[str, str], key: str) -> str:
def _air_field_line(key: str, value: str, *, required: bool, example: str = "") -> str:
"""空运销售侧一行:名称(必填/非必填):值或示例。"""
"""空运核对卡一行:名称(必填/非必填):值。待补充走 _pending_field_block。"""
label = AIR_DISPLAY_LABEL(key)
mark = "必填" if required else "非必填"
text = (value or "").strip()
if text:
return f"{label}({mark}):{text}"
if example:
return f"{label}({mark}):(如:{example})"
_ = example
return f"{label}({mark}):"
@@ -440,7 +482,7 @@ def ask_clarify(
transport_mode: str = "空运",
) -> str:
"""
缺项补问:先回显已识别,再列待补充并给示例。
缺项补问:先回显已识别,再给可复制清单;示例另起一行,如「1.件数(必填):」下一行「(如:10)」。
报价日期写进已识别(销售说了用原话,没说用当天),不出现在待补充。
空运非必填一并列出,没填进待补充,不挡查价。
@@ -470,18 +512,28 @@ def ask_clarify(
if val:
lines.append(f"{label}:{val}")
lines.append("")
lines.append("待补充:")
lines.append(CLARIFY_PASTE_HINT)
any_missing = False
seq = 0
if "运输方式" in missing_set:
any_missing = True
lines.append("运输方式:(如:空运)")
seq += 1
lines.append(_pending_field_block(seq, "运输方式", "空运"))
for key, label, example in rows:
if key in missing_set:
any_missing = True
seq += 1
if sea and key == "运输分类":
lines.append(sea_class_prompt())
lines.append(_pending_field_block(seq, "运输分类", "Port to Port、其他"))
else:
lines.append(f"{label}:(如:{example})")
lines.append(
_pending_field_block(
seq,
label,
example,
unit=_PENDING_UNITS.get(key, ""),
)
)
if not sea:
optional_pending = [
(key, label, example)
@@ -490,8 +542,9 @@ def ask_clarify(
]
for key, label, example in optional_pending:
any_missing = True
seq += 1
shown = "起运港" if key == "起运港" else label
lines.append(f"{shown}(非必填):(如:{example})")
lines.append(_pending_field_block(seq, shown, example, optional=True))
if not any_missing:
return ""
return "\n".join(lines)
@@ -507,8 +560,10 @@ def ask_missing(display_names: Iterable[str]) -> str:
"毛重": "毛重",
"重量": "毛重",
"重量(KG)": "毛重",
"重量(KG)": "毛重",
"体积": "体积",
"体积(CBM)": "体积",
"体积(CBM)": "体积",
"包装类型": "包装方式",
"包装方式": "包装方式",
"货物数量": "货量",
@@ -610,8 +665,8 @@ def inquiry_field_lines(
f"目的地:{dest}",
f"品名:{cargo}",
f"件数:{_with_unit(str(facts.get('件数') or ''), '件')}",
f"重量:{_with_unit(str(facts.get('毛重') or ''), 'KGS')}",
f"体积:{_with_unit(str(facts.get('体积') or ''), 'CBM')}",
f"重量(KG):{_with_unit(str(facts.get('毛重') or ''), 'KGS')}",
f"体积(CBM):{_with_unit(str(facts.get('体积') or ''), 'CBM')}",
f"包装类型:{pack}",
f"报价日期:{quote_date}",
]
@@ -2601,8 +2656,17 @@ def air_group_brief(
lines.append("")
lines.append("协同补充字段:")
collab = dict(collab_facts or {})
from agent.schema.field_validate import air_general_cargo_collab_defaults
# 品名是普货时,摘要里不能再把没写的含电/含磁显示成横杠。
for key, val in air_general_cargo_collab_defaults(facts).items():
current = (collab.get(key) or "").strip()
if current in {"", "-", "—", "-"}:
collab[key] = val
for key in AIR_COLLAB_FIELDS:
val = (collab.get(key) or "").strip() or "-"
val = (collab.get(key) or "").strip()
if val in {"", "-", "—", "-"}:
val = "-"
lines.append(f"{key}:{val}")
tms, manual, adjust = _split_brief_quotes(
quote=quote,
+2 -5
View File
@@ -24,7 +24,6 @@ from agent.schema.land_options import (
harvest_land_options,
looks_like_confirm,
option_prompt,
option_prompt_needs_sample,
sample_pair_allowed,
validate_land_options,
)
@@ -225,14 +224,12 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
message: str = "",
) -> None:
"""
选项补问:先发文字清单,需要样例时再发表格图。
选项补问:只发复制填空清单。填写样例图不再另发。
图片失败由出站回退文字样例。不在本方法上传素材。
不在本方法上传素材。
"""
body = (message or "").strip() or option_prompt(current=facts)
reply(body)
if option_prompt_needs_sample(current=facts):
reply(copy.land_sample_image_fallback(), copy.land_sample_image_payload())
def _confirm_or_hold(
self,
+5 -3
View File
@@ -22,7 +22,6 @@ from agent.llm.extract_text import parse_labeled_facts
from agent.schema.land_options import (
harvest_land_options,
option_prompt,
option_prompt_needs_sample,
scrub_land_placeholder_ports,
validate_land_options,
)
@@ -155,7 +154,7 @@ def refresh_segments(segments: list[Segment]) -> tuple[bool, list[Segment], str]
按各段运输方式校验必填,并把默认报价日期写回。
返回:是否已齐、更新后的段、缺口文案。缺口一条消息说完,不套群摘要。
待补充的举例与单聊相同,例如「品名:(如:茶叶)」。
待补充的举例与单聊相同:字段名一行,下一行「(如:茶叶)」。
"""
updated: list[Segment] = []
blocks: list[str] = []
@@ -369,7 +368,10 @@ def _segment_clarify(seg: Segment, check: dict[str, Any]) -> str:
return ""
missing = [str(x) for x in (check.get("missing") or []) if str(x).strip()]
if check.get("need_land_subtype"):
return "待补充:\n运输类型(必填):(如:国内运输拼车)"
return (
copy.CLARIFY_PASTE_HINT
+ "\n1.运输类型(必填):\n(如:国内运输拼车)"
)
if seg.mode == "LAND":
if check.get("need_land_options"):
# 和单聊一样先列出线路类别、运输分类,选完再问询价字段。
@@ -37,7 +37,6 @@ from agent.policy.multi_segments import (
)
from agent.schema.land_options import (
looks_like_confirm,
option_prompt_needs_sample,
sample_pair_allowed,
)
@@ -291,17 +290,11 @@ class MultiTextInquiryFlow:
def _reply_gaps(self, segments: list[Segment], gaps: str, reply: ReplyFn) -> None:
"""
补问正文。陆运还缺类型或线路时,再发一张和单聊相同的填写样例图。
补问正文。陆运选项走复制清单,不再另发填写样例图。
副作用:出站文字,可能再出一张图片。只在 Worker 里调。
副作用:出站文字。只在 Worker 里调。
"""
reply(gaps)
for seg in segments:
if seg.mode != "LAND":
continue
if option_prompt_needs_sample(current=seg.facts):
reply(copy.land_sample_image_fallback(), copy.land_sample_image_payload())
return
def _enrich_segments(
self,
@@ -26,7 +26,7 @@
════════════════════════════════════
(1)询价事实 facts(对话/工单,不是 TMS 价)
起运港、目的港/目的地、品名、货量、整柜或拼柜、箱型箱量、件数、重量、体积、贸易条款、货好时间、报价日期/询价日期、HS、含油电磁、客户名称、联系人、电话、提送货/派送地址、特别说明等(有则带)。
起运港、目的港/目的地、品名、货量、整柜或拼柜、箱型箱量、件数、重量、体积、贸易条款、货好时间、报价日期/询价日期/走货日期/发货日期/出货日期/提货日期、HS、含油电磁、客户名称、联系人、电话、提送货/派送地址、特别说明等(有则带)。
(2)TMS 报价 data(经主账查询返回;出单前投影为 fillFacts / fee_rows / 分段费用)
顶层共通字段(海陆空响应里都可能出现):
@@ -42,7 +42,7 @@ data.chargeableWeightKg, missingFields, remark
data.isMulti(空运/陆运:1=单段或单方案,2=多段/多方案三选一等,以业务约定为准)
系统补全:quote_no←工单号;sales_name←业务员;quote_date 空则当天。
「询价日期」与「报价日期」→ 都映射 quote_date。
「报价日期」「询价日期」「走货日期」「询价时间」「走货时间」「发货时间」「发货日期」「出货日期」「出货时间」「提货时间」「提货日期」→ 都映射 quote_date。货好时间、提货地址不要映射成 quote_date。
════════════════════════════════════
【二】TMS 返回结构 → 填表投影(出单程序做;你的映射必须接得住)
@@ -145,7 +145,7 @@ customer_name ← 客户名称、客户
contact_name ← 联系人、承运联系人
contact_phone ← 电话
quote_no ← 报价单编号、报价编号、RFQ No
quote_date ← 报价日期、询价日期、Inquiry Date
quote_date ← 报价日期、询价日期、走货日期、询价时间、走货时间、发货时间、发货日期、出货日期、出货时间、提货时间、提货日期、Inquiry Date
valid_until ← 有效期至、报价有效期、period(← TMS validUntil)
currency ← 币别、币种(← TMS currency / settlementCurrency)
sales_name ← 业务员
+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("包装类型")