Files
inquiry_robot/inquiry-agent/agent/schema/field_validate.py
T
jillion886andCursor d9029dec89 落地空运群内协同与锁舱出站。
群里按 BOT 通道激活当前工单、航线报价和成交跟进;没有 TMS 报价编号时用工单字段补齐仍调锁舱,避免被本地规则拦住。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 17:25:42 +08:00

345 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
字段合同校验:读 inquiry-required-fields-v1,只做作用域/非空/默认值。
本文件职责:判断首次查价缺项;报价日期未说则填当天,不进入补问。
口语已写 TMS 单位(件/KGS/CBM)时,空着的件数/毛重/体积可机读补上。
禁止:正则猜港口/箱型;禁止用封闭枚举拒绝用户原文;禁止改六态。
"""
from __future__ import annotations
import logging
import re
from typing import Any
from zoneinfo import ZoneInfo
from agent.schema.contracts_loader import require_formal_contract
logger = logging.getLogger(__name__)
_SHANGHAI = ZoneInfo("Asia/Shanghai")
# 空运询价必填 8 项:销售侧名称。内部仍用合同键。
AIR_REQUIRED_FIELDS = (
("起运港", "起运地"),
("目的港", "目的地"),
("品名", "品名"),
("件数", "件数"),
("毛重", "重量"),
("体积", "体积"),
("包装方式", "包装类型"),
("报价日期", "报价日期"),
)
AIR_DISPLAY = {key: label for key, label in AIR_REQUIRED_FIELDS}
# 海运询价必填 7 项:销售侧名称。内部键「货量」对外叫「货物数量」。
SEA_REQUIRED_FIELDS = (
("起运港", "起运港"),
("目的港", "目的港"),
("品名", "品名"),
("货量", "货物数量"),
("整柜或拼柜", "整柜或拼柜"),
("箱型箱量", "箱型箱量"),
("报价日期", "报价日期"),
)
SEA_DISPLAY = {key: label for key, label in SEA_REQUIRED_FIELDS}
# 协同补充字段:拉群后可静默写入,不挡首次查价。内部键仍是 HS编码。
SEA_COLLAB_FIELDS = ("贸易条款", "货好时间", "HS编码", "是否含油", "是否含电", "是否含磁")
# 空运群只补含电/含磁,摘要不要把海运六项搬过去。
AIR_COLLAB_FIELDS = ("是否含电", "是否含磁")
# 群摘要对外展示名。内部继续用 HS编码,避免改合同键。
SEA_COLLAB_DISPLAY = {
"HS编码": "商品海关编码",
}
# 口语里已经带上 TMS 文档规定的单位时,补进空字段。
# 只认「数字+单位」,不猜港口,不把长宽高换算成立方。
_RE_PIECES = re.compile(r"(?<![\d.])(\d+)\s*(件|pcs)\b", re.I)
_RE_WEIGHT = re.compile(r"(?<![\d.])(\d+(?:\.\d+)?)\s*(kgs|kg|公斤|千克)\b", re.I)
_RE_VOLUME = re.compile(
r"(?<![\d.])(\d+(?:\.\d+)?)\s*(cbm|立方米|立方|m3|m³)\b",
re.I,
)
_RE_VOLUME_FANG = re.compile(r"(?<![\d.])(\d+(?:\.\d+)?)\s*方(?!向)")
_RE_WEIGHT_WORD = re.compile(r"(?:毛重|重量)\s*[::]?\s*(\d+(?:\.\d+)?)")
_RE_VOLUME_WORD = re.compile(r"体积\s*[::]?\s*(\d+(?:\.\d+)?)")
# 销售原话里的包装词:当前句出现则覆盖旧值,散货也要收下
_PACK_WORDS = ("纸箱", "木箱", "托盘", "卡板", "散货")
# 报价日期: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*[日号]?"
)
_RE_DATE_MD = re.compile(r"(?<!\d)(\d{1,2})\s*月\s*(\d{1,2})\s*[日号]?")
# 展示名 / 别名 → 内部键
_ALIASES = {
"起运地": "起运港",
"目的地": "目的港",
"包装类型": "包装方式",
"货物数量": "货量",
"始发站": "起运港",
"货物品名": "品名",
"重量(KG)": "毛重",
"重量": "毛重",
"体积(CBM)": "体积",
"车型/数量": "车型数量",
"HS": "HS编码",
"HS CODE": "HS编码",
"商品海关编码": "HS编码",
"海关编码": "HS编码",
}
def shanghai_today() -> str:
"""报价日期系统默认:Asia/Shanghai 当天。"""
from datetime import datetime
return datetime.now(_SHANGHAI).date().isoformat()
def normalize_facts(facts: dict[str, Any] | None) -> dict[str, str]:
"""
把展示名/别名折成合同内部键,去掉首尾空白。
空值丢弃;不解释语义。
"""
out: dict[str, str] = {}
for raw_key, raw_val in (facts or {}).items():
key = str(raw_key).strip()
key = _ALIASES.get(key, key)
if raw_val is None:
continue
val = str(raw_val).strip()
if not val:
continue
out[key] = val
return out
def _harvest_packaging(raw: str) -> str:
"""当前原话里最后出现的包装词;没有则空串。"""
last = ""
last_pos = -1
for word in _PACK_WORDS:
pos = raw.rfind(word)
if pos > last_pos:
last_pos = pos
last = word
return last
def _harvest_quote_date(raw: str) -> str:
"""当前原话里的报价日期,规范成 yyyy-MM-dd;没有则空串。"""
from datetime import datetime
hit = _RE_DATE_YMD.search(raw)
if hit:
year, month, day = int(hit.group(1)), int(hit.group(2)), int(hit.group(3))
else:
hit = _RE_DATE_MD.search(raw)
if not hit:
return ""
year = datetime.now(_SHANGHAI).year
month, day = int(hit.group(1)), int(hit.group(2))
try:
return datetime(year, month, day).date().isoformat()
except ValueError:
return ""
def harvest_oral_measures(text: str, facts: dict[str, str] | None = None) -> dict[str, str]:
"""
从当前原话机读件数/重量/体积/包装/报价日期。
当前句写了的覆盖旧值:散货替换托盘,9月16日写入报价日期。
副作用:无。不改主账。不猜港口。
"""
merged = dict(facts or {})
raw = text or ""
hit = _RE_PIECES.search(raw)
if hit:
merged["件数"] = f"{hit.group(1)}{hit.group(2)}"
hit = _RE_WEIGHT.search(raw)
if hit:
# 原样留下用户单位:1.5KG 不要改成 1.5KGS
merged["毛重"] = f"{hit.group(1)}{hit.group(2)}"
else:
hit = _RE_WEIGHT_WORD.search(raw)
if hit:
merged["毛重"] = hit.group(1)
hit = _RE_VOLUME.search(raw)
if hit:
merged["体积"] = f"{hit.group(1)}{hit.group(2)}"
else:
hit = _RE_VOLUME_FANG.search(raw) or _RE_VOLUME_WORD.search(raw)
if hit:
merged["体积"] = hit.group(1)
pack = _harvest_packaging(raw)
if pack:
merged["包装方式"] = pack
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)
def harvest_oral_collab(text: str) -> dict[str, str]:
"""
群里口语协同三项:含油/含电/含磁、货好时间。
只认原话里已经说死的词(不含电、随时可提),不猜贸易条款和海关编码。
"""
raw = text or ""
out: dict[str, str] = {}
yes_no = (
("是否含电", ("不含电", "不带电", "无电"), ("含电", "带电")),
("是否含油", ("不含油", "不带油", "无油"), ("含油", "带油")),
("是否含磁", ("不含磁", "不带磁", "无磁"), ("含磁", "带磁")),
)
for key, no_words, yes_words in yes_no:
if any(w in raw for w in no_words):
out[key] = "否"
elif any(w in raw for w in yes_words):
out[key] = "是"
ready = ("随时可提", "货好随时", "随时货好", "货已好", "已经货好")
for word in ready:
if word in raw:
out["货好时间"] = word
break
return out
def project_sea_cargo_qty(facts: dict[str, str] | None) -> dict[str, str]:
"""
海运货物数量投影:原话「15件」常被抽成空运「件数」。
货量已有则不覆盖。件数有、货量空时,货量用件数。不猜吨位、不改空运校验。
"""
merged = dict(facts or {})
qty = (merged.get("货量") or merged.get("货物数量") or "").strip()
pieces = (merged.get("件数") or "").strip()
if not qty and pieces:
merged["货量"] = pieces
return merged
def apply_quote_date_default(facts: dict[str, str]) -> dict[str, str]:
"""销售未提供报价日期时填当天;已有则不覆盖。"""
merged = dict(facts)
if not merged.get("报价日期"):
merged["报价日期"] = shanghai_today()
logger.info("报价日期缺省为当天 date=%s", merged["报价日期"])
return merged
def _pricing_required_keys(contract: dict[str, Any], business_line: str, land_subtype: str) -> list[str]:
"""当前激活的首次查价内部键列表。"""
line = (business_line or "").strip().upper()
pricing = contract.get("pricing_required") or {}
if line == "SEA":
return list((pricing.get("SEA") or {}).get("always") or [])
if line == "AIR":
return list((pricing.get("AIR") or {}).get("always") or [])
if line == "LAND":
land = pricing.get("LAND") or {}
block = land.get(land_subtype) or {}
if not block:
return []
return list(block.get("always") or [])
return []
def display_name(internal_key: str, business_line: str = "AIR") -> str:
"""补问时给销售看的名字。"""
line = (business_line or "").upper()
if line == "AIR":
return AIR_DISPLAY.get(internal_key, internal_key)
if line == "SEA":
return SEA_DISPLAY.get(internal_key, internal_key)
return internal_key
def validate_required_fields(
*,
facts: dict[str, Any],
business_line: str = "",
land_subtype: str = "",
require_formal: bool = True,
) -> dict[str, Any]:
"""
首次查价完整性。
返回:ok / implemented / missing(内部键)/ missing_display / facts(含默认报价日期)/ errors。
报价日期不进 missing。未识别业务线时 missing 为空、need_transport_mode=True。
"""
_ = require_formal
contract = require_formal_contract("inquiry-required-fields-v1")
normalized = apply_quote_date_default(normalize_facts(facts))
line = (business_line or "").strip().upper()
if line == "SEA":
normalized = project_sea_cargo_qty(normalized)
if line not in {"SEA", "AIR", "LAND"}:
return {
"ok": False,
"implemented": True,
"need_transport_mode": True,
"missing": [],
"missing_display": [],
"facts": normalized,
"errors": [],
"business_line": line,
}
if line == "LAND" and not (land_subtype or "").strip():
return {
"ok": False,
"implemented": True,
"need_transport_mode": False,
"need_land_subtype": True,
"missing": [],
"missing_display": [],
"facts": normalized,
"errors": [],
"business_line": line,
}
required = _pricing_required_keys(contract, line, land_subtype)
missing = [k for k in required if k != "报价日期" and not normalized.get(k)]
missing_display = [display_name(k, line) for k in missing]
ok = len(missing) == 0
logger.info(
"schema.validate line=%s land=%s ok=%s missing=%s",
line,
land_subtype or "-",
ok,
missing,
)
return {
"ok": ok,
"implemented": True,
"need_transport_mode": False,
"need_land_subtype": False,
"missing": missing,
"missing_display": missing_display,
"facts": normalized,
"errors": [],
"business_line": line,
"land_subtype": land_subtype,
}
def validate_required_fields_shell(
*,
facts: dict[str, Any],
business_line: str = "",
require_formal: bool = True,
) -> dict[str, Any]:
"""兼容骨架入口:现已接入正式激活规则。"""
return validate_required_fields(
facts=facts,
business_line=business_line,
require_formal=require_formal,
)