Files
inquiry_robot/inquiry-agent/agent/schema/field_validate.py
T

311 lines
10 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}
# 协同补充字段:拉群后可静默写入,不挡首次查价。
SEA_COLLAB_FIELDS = ("贸易条款", "货好时间", "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编码",
}
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 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,
)