落地私聊空运询价与TMS报价回填,按后台模板出单。

查到价后把币种、单位、数量、单价、备注写入报价单,避免销售只看到光秃秃的金额。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-14 20:25:35 +08:00
co-authored by Cursor
parent cea3a6e51e
commit 99da1faf30
78 changed files with 8838 additions and 321 deletions
+7 -1
View File
@@ -10,12 +10,18 @@ from agent.schema.contracts_loader import (
require_formal_contract,
resolve_contract_path,
)
from agent.schema.field_validate import validate_required_fields_shell
from agent.schema.field_validate import (
validate_required_fields,
validate_required_fields_shell,
)
from agent.schema.fingerprint import inquiry_fingerprint
__all__ = [
"contracts_dir",
"load_contract",
"require_formal_contract",
"resolve_contract_path",
"validate_required_fields",
"validate_required_fields_shell",
"inquiry_fingerprint",
]
@@ -25,7 +25,8 @@ def contracts_dir() -> Path:
here = Path(__file__).resolve()
for parent in here.parents:
candidate = parent / "contracts"
if candidate.is_dir():
# 空目录不算:发布包曾留下空 contracts,会挡住仓库根的真合同
if candidate.is_dir() and any(candidate.glob("*.json")):
return candidate
# 回退:与 agent 包同级仓库根的常见布局
return here.parents[3] / "contracts"
+255 -38
View File
@@ -1,19 +1,265 @@
"""
字段合同校验入口壳。
字段合同校验:读 inquiry-required-fields-v1,只做作用域/非空/默认值。
本文件职责:暴露 validate_required_fields_shell;正式规则读 contracts JSON 后再实现。
禁止:在未 require_formal_contract 时假装已激活字段;禁止用正则替代模型语义。
本文件职责:判断首次查价缺项;报价日期未说则填当天,不进入补问。
口语已写 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 load_contract, require_formal_contract
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}
# 口语里已经带上 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)": "体积",
"车型/数量": "车型数量",
}
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 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)
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 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(
*,
@@ -21,38 +267,9 @@ def validate_required_fields_shell(
business_line: str = "",
require_formal: bool = True,
) -> dict[str, Any]:
"""
字段校验空壳。
返回结构固定:ok / implemented / missing / errors。
implemented=False 表示规则引擎尚未接入(框架阶段预期)。
"""
stem = "inquiry-required-fields-v1"
try:
contract = (
require_formal_contract(stem) if require_formal else load_contract(stem)
)
except (FileNotFoundError, RuntimeError) as exc:
return {
"ok": False,
"implemented": False,
"missing": [],
"errors": [str(exc)],
"business_line": business_line,
}
is_ph = bool(contract.get("_meta", {}).get("is_placeholder"))
logger.info(
"schema.validate_shell line=%s placeholder=%s facts_keys=%s(未跑激活规则)",
business_line or "-",
is_ph,
sorted(facts.keys()),
"""兼容骨架入口:现已接入正式激活规则。"""
return validate_required_fields(
facts=facts,
business_line=business_line,
require_formal=require_formal,
)
return {
"ok": False,
"implemented": False,
"missing": [],
"errors": ["field_activation_rules_not_implemented"],
"business_line": business_line,
"contract_placeholder": is_ph,
}
+40
View File
@@ -0,0 +1,40 @@
"""
相同询价内容指纹。
本文件职责:用已确认查价字段生成可比较的键,供主账全库检索相同需求(展示用,不复用工单号)。
禁止:用自然语言模糊相似;禁止写六态。
"""
from __future__ import annotations
import hashlib
from typing import Any
def ascii_idempotency_key(raw: str) -> str:
"""
企微/主账 HTTP 头必须是 ASCII。中文指纹原样塞进 X-Idempotency-Key 会直接崩,销售收不到回复。
"""
text = (raw or "").strip()
if not text:
return ""
try:
text.encode("ascii")
return text
except UnicodeEncodeError:
return "sha256:" + hashlib.sha256(text.encode("utf-8")).hexdigest()
def inquiry_fingerprint(facts: dict[str, Any], *, business_line: str) -> str:
"""
生成相同内容键。
组成:业务线 + 起运 + 目的 + 品名 + 件数/货量 + 毛重 + 体积 + 报价日期。
空运箱型不在空运必填里;海运箱型箱量若有则纳入。
"""
line = (business_line or "").strip().upper()
keys = ["起运港", "目的港", "品名", "件数", "货量", "毛重", "体积", "箱型箱量", "报价日期"]
parts = [line]
for key in keys:
parts.append(str(facts.get(key) or "").strip())
return "|".join(parts)
+200
View File
@@ -0,0 +1,200 @@
"""
空运查价参数:对照 TMS V2 /quote/v2/query 是否带全。
本文件职责:询价卡片出现前,确认销售侧空运必填能拼出查价正文。
三字码能投影就带;对不上不拦建单、不拦查价。
包装:销售原文非空即收下,禁止白名单拦补问。
禁止:猜没写过的港口;因「对不上字典」跳过 TMS 或对销售说教;改六态。
"""
from __future__ import annotations
import logging
import re
from typing import Any
logger = logging.getLogger(__name__)
# 与 tms-air-exact-seed.json 对齐,只用于把已写地名填成 TMS 要的代码
_AIRPORTS = {
"上海": "SHA",
"浦东": "PVG",
"上海浦东": "PVG",
"虹桥": "SHA",
"上海虹桥": "SHA",
"洛杉矶": "LAX",
"深圳": "SZX",
"广州": "CAN",
"香港": "HKG",
"北京首都": "PEK",
"北京大兴": "PKX",
"珠海": "ZUH",
"克拉克": "CRK",
"南京": "NKG",
"吉隆坡": "KUL",
"北京": "PEK",
}
# TMS air_packaging_type 实测只认 1/2/3。1=全部,这条航线无价;
# 2/3 能出与现网相同的标准价。托盘走 2,纸箱/木箱走 3。
_PACKS = {
"全部": "1",
"托盘": "2",
"卡板": "2",
"散货": "2",
"纸箱": "3",
"木箱": "3",
}
_IATA = re.compile(r"^[A-Za-z]{3}$")
_NUMBER = re.compile(r"(\d+(?:\.\d+)?)")
def _first_number(raw: str) -> str:
hit = _NUMBER.search((raw or "").strip())
return hit.group(1) if hit else ""
def resolve_airport_code(raw: str) -> str:
"""销售原文是地名或三字码时,给出 TMS originCode/destinationCode。"""
text = (raw or "").strip()
if not text:
return ""
if _IATA.match(text):
return text.upper()
return _AIRPORTS.get(text, "")
def harvest_named_air_route(text: str, facts: dict[str, str] | None = None) -> dict[str, str]:
"""
当前原话里的「从南京飞吉隆坡 / 北京飞吉隆坡」覆盖起运地、目的地。
只认对照表里的地名,不猜没写过的港口。换城市必须丢掉旧三字码。
"""
merged = dict(facts or {})
raw = text or ""
names = sorted(_AIRPORTS.keys(), key=len, reverse=True)
for origin in names:
for dest in names:
if origin == dest:
continue
token = f"{origin}飞{dest}"
if token in raw or f"从{token}" in raw:
merged["起运港"] = origin
merged["目的港"] = dest
merged.pop("originCode", None)
merged.pop("destinationCode", None)
return merged
return merged
def resolve_package_type(raw: str) -> str:
"""
只给 TMS 出站用的 packageType,不决定销售是否算已填包装。
认得出代码就用;认不出默认 2(不要默认 1,1=全部在现网航线常无价)。
禁止:因不在对照表而返回空,从而再向销售补问。
"""
text = (raw or "").strip()
if not text:
return "2"
if text in _PACKS:
return _PACKS[text]
if text.isdigit():
return text
return "2"
def assemble_air_query(facts: dict[str, Any] | None) -> dict[str, Any]:
"""
把已采集字段拼成 TMS 空运查价参数。
不齐则 ok=False,missing_display 给销售补问;不建单、不出卡片。
齐了则 facts 带上 originCode/destinationCode/packageType 与纯数字件数毛重体积。
包装只看销售是否说了,散货等原文原样保留。
"""
src = dict(facts or {})
origin = str(src.get("起运港") or "").strip()
dest = str(src.get("目的港") or "").strip()
cargo = str(src.get("品名") or "").strip()
pieces = _first_number(str(src.get("件数") or ""))
weight = _first_number(str(src.get("毛重") or ""))
volume = _first_number(str(src.get("体积") or ""))
pack_raw = str(src.get("包装方式") or src.get("包装类型") or "").strip()
pack_code = resolve_package_type(pack_raw)
quote_date = str(src.get("报价日期") or "").strip()
# 三字码必须按当前地名重算,禁止沿用上一句的 NKG/KUL
origin_code = resolve_airport_code(origin)
dest_code = resolve_airport_code(dest)
missing: list[str] = []
missing_display: list[str] = []
# 销售侧必填只看 8 项是否非空。三字码是出站投影,对不上不拿来再补问起运地/目的地。
if not origin:
missing.append("起运港")
missing_display.append("起运地")
if not dest:
missing.append("目的港")
missing_display.append("目的地")
if not cargo:
missing.append("品名")
missing_display.append("品名")
if not pieces:
missing.append("件数")
missing_display.append("件数")
if not weight:
missing.append("毛重")
missing_display.append("重量")
if not volume:
missing.append("体积")
missing_display.append("体积")
if not pack_raw:
missing.append("包装方式")
missing_display.append("包装类型")
if not quote_date:
missing.append("报价日期")
missing_display.append("报价日期")
ok = not missing
out_facts = dict(src)
if origin:
out_facts["起运港"] = origin
if origin_code:
out_facts["originCode"] = origin_code
else:
out_facts.pop("originCode", None)
if dest:
out_facts["目的港"] = dest
if dest_code:
out_facts["destinationCode"] = dest_code
else:
out_facts.pop("destinationCode", None)
if cargo:
out_facts["品名"] = cargo
# 件数/重量/体积保留销售原文(1.5KG),数字只作出站,禁止回写成 1.5 再被补成 KGS
if str(src.get("件数") or "").strip():
out_facts["件数"] = str(src.get("件数")).strip()
if str(src.get("毛重") or "").strip():
out_facts["毛重"] = str(src.get("毛重")).strip()
if str(src.get("体积") or "").strip():
out_facts["体积"] = str(src.get("体积")).strip()
if pack_raw:
out_facts["包装方式"] = pack_raw
out_facts["packageType"] = pack_code
if quote_date:
out_facts["报价日期"] = quote_date
logger.info(
"tms.air.assemble ok=%s origin=%s dest=%s pack=%s missing=%s",
ok,
origin_code or "-",
dest_code or "-",
pack_raw or pack_code or "-",
missing,
)
return {
"ok": ok,
"missing": missing,
"missing_display": missing_display,
"facts": out_facts,
}