角色业务范围收紧工单与模板;报价日期不当费用;未选舱不迁已报价;另开一票可清违禁 hold

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-10-15 21:00:01 +08:00
co-authored by Cursor
parent a0c705683f
commit 277cdc1c28
39 changed files with 2083 additions and 217 deletions
+142 -21
View File
@@ -46,7 +46,8 @@ _LABEL_KEYS = (
r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|"
+ _QUOTE_DATE_LABEL_RE
+ r"|运输分类|车型/数量|车型数量|"
r"贸易条款|货好时间|货源地|提货地址|收货地址|送货地址|增值服务(非必填)|增值服务|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁|"
r"贸易条款|货好时间|货源地|提货地址|收货地址|送货地址|增值服务(非必填)|增值服务|商品海关编码|海关编码|HS编码|HS|"
r"是否含油|是否含电|是否带电|是否含磁|"
r"客户名称|货值|是否为危险品|通关口岸(非必填)|通关口岸"
)
_LABEL_LINE = re.compile(rf"^({_LABEL_KEYS})\s*[::]\s*(.+)$")
@@ -739,7 +740,11 @@ def _merge_optional_inquiry_labels(text: str, facts: dict[str, str]) -> dict[str
def _harvest_labels(text: str, labels: tuple[str, ...]) -> dict[str, str]:
"""按标签切原话。同一起点只留最长标签。"""
"""
按标签切原话。同一起点只留最长标签。
冒号后本行为空视为未填,不跨行吞下一栏(核对卡复制粘贴时空起运地不能变成「目的地:…」)。
"""
raw = text or ""
hits: list[tuple[int, str]] = []
for label in labels:
@@ -771,8 +776,15 @@ def _harvest_labels(text: str, labels: tuple[str, ...]) -> dict[str, str]:
# 增值服务只认冒号后同一行。空行不能把下一行「报价日期(非必填):」收成服务。
if label.startswith("增值服务"):
chunk = chunk.split("\n", 1)[0]
chunk = re.sub(r"^[\s::,,、]+", "", chunk)
chunk = re.sub(r"[\s,,、]+$", "", chunk)
same_line = chunk.split("\n", 1)[0]
same_line_body = re.sub(r"^[\s::,,、]+", "", same_line).strip()
# 冒号后本行为空 = 未填。禁止跨行把下一栏「目的地:吉隆坡」收成起运地。
# 核对卡复制粘贴时非必填常整行只有「起运地(非必填):」,下一行才是必填目的地。
if not same_line_body:
chunk = ""
else:
chunk = re.sub(r"^[\s::,,、]+", "", chunk)
chunk = re.sub(r"[\s,,、]+$", "", chunk)
if chunk:
out[label] = chunk
return out
@@ -834,15 +846,18 @@ def extract_air_collab_fields(
injected: Optional[dict[str, str]] = None,
) -> dict[str, str]:
"""
空运群只认是否含电、是否含磁。
空运群协同项:是否含电、是否含磁(「是否带电」折成是否含电)。
复用海运口语收割,再滤成这两项。
"""
keys = ("是否含电", "是否含磁")
if injected:
from agent.schema.field_validate import normalize_facts
norm = normalize_facts(dict(injected))
return {
k: str(v).strip()
for k, v in (injected or {}).items()
for k, v in norm.items()
if k in keys and str(v or "").strip()
}
got = extract_collab_fields(text, allow_b=allow_b)
@@ -933,28 +948,38 @@ def _ascii_amount(raw: str) -> str:
def _currency_of(text: str, hint: str = "") -> str:
"""
从正文/提示抽三字码。正文没写币种时只回传 hint,禁止默认编造 USD。
从正文抽三字码;正文没写币种时才用 hint,禁止默认编造 USD。
正文显式币种优先于 baseline:TMS 已是 HKD 时,产品写「1RMB/KG」必须落 CNY,
禁止把 hint 拼进正文导致 HKD 盖掉 RMB(WO202610140040)。
RMB 是人民币拼音缩写,统一映射 CNY。
港币必须先于「元」判断,避免「港元」被误判成 CNY。
WO202610090107:TMS 是 HKD,产品只写「运输费调整为1100」时必须继承 HKD。
WO202610090107:TMS 是 HKD,产品只写「运输费调整为1100」时继承 HKD。
"""
blob = f"{hint} {text}".upper()
raw = f"{hint} {text}"
if any(x in blob for x in ("HKD", "港币", "港元", "HK$")) or "港币" in raw or "港元" in raw:
text_only = (text or "").upper()
text_raw = text or ""
# 正文显式币种(不含 hint),与「仅继承 TMS」区分开。
text_has_hkd = any(x in text_only for x in ("HKD", "港币", "港元", "HK$")) or (
"港币" in text_raw or "港元" in text_raw
)
text_has_cny = any(x in text_only for x in ("CNY", "RMB", "人民币")) or (
"元" in text_raw and "港元" not in text_raw and "港币" not in text_raw
)
# 先看正文,再退回 hint;禁止 hint+正文混检让 baseline HKD 压过正文 RMB。
if text_has_hkd:
return "HKD"
if any(x in blob for x in ("CNY", "RMB", "人民币")) or (
"元" in raw and "港元" not in raw and "港币" not in raw
):
if text_has_cny:
return "CNY"
if any(x in blob for x in ("USD", "美金", "美元")):
if any(x in text_only for x in ("USD", "美金", "美元")):
return "USD"
if any(x in blob for x in ("EUR", "欧元")):
if any(x in text_only for x in ("EUR", "欧元")):
return "EUR"
if any(x in blob for x in ("JPY", "日元")):
if any(x in text_only for x in ("JPY", "日元")):
return "JPY"
if any(x in blob for x in ("GBP", "英镑")):
if any(x in text_only for x in ("GBP", "英镑")):
return "GBP"
return str(hint or "").strip().upper()
hint_u = str(hint or "").strip().upper()
return "CNY" if hint_u == "RMB" else hint_u
def _normalize_fee_project(raw: str) -> str:
@@ -983,6 +1008,94 @@ def _strip_group_bot_mentions(text: str) -> str:
return cleaned.strip()
# 报价日期整段:标签+日期。费用抽取前剥掉,避免把年份 2026 当成金额。
_RE_QUOTE_DATE_CHUNK = re.compile(
rf"(?:{_QUOTE_DATE_LABEL_RE})(?:(非必填)|\(非必填\))?\s*[::]?\s*"
r"(?:\d{4}\s*[-/.年]\s*\d{1,2}\s*[-/.月]\s*\d{1,2}\s*日?"
r"|\d{1,2}\s*[-/.月]\s*\d{1,2}\s*日?)",
re.I,
)
# 报价有效期(TMS 展示用,不是航线报价格);剥掉避免年份当金额。
_RE_VALID_UNTIL_CHUNK = re.compile(
r"报价有效期\s*[::]?\s*"
r"(?:\d{4}\s*[-/.年]\s*\d{1,2}\s*[-/.月]\s*\d{1,2}\s*日?"
r"|\d{1,2}\s*[-/.月]\s*\d{1,2}\s*日?)",
re.I,
)
# 时效要求:空运无此字段;剥掉避免「3天」被抽成费用 3。
_RE_TRANSIT_REQ_CHUNK = re.compile(
r"(?:时效要求|要求运输时效)\s*[::]?\s*"
r"\d+(?:\.\d+)?\s*(?:天|日|工作日|个工作日)?",
re.I,
)
def _strip_quote_date_chunks(text: str) -> str:
"""去掉「报价日期:2026-10-15」整段,留给费用行抽取。"""
return _RE_QUOTE_DATE_CHUNK.sub(" ", text or "")
def _strip_non_fee_meta_chunks(text: str) -> str:
"""
去掉不能当费用金额的元数据段:报价日期、报价有效期、时效要求。
运行证据(717349):「报价有效期:2026-10-31」抽成 total=2026;
「时效要求:3天」抽成 total=3。
"""
out = _strip_quote_date_chunks(text or "")
out = _RE_VALID_UNTIL_CHUNK.sub(" ", out)
out = _RE_TRANSIT_REQ_CHUNK.sub(" ", out)
return out
def labeled_quote_date(text: str) -> str:
"""
群里带标签的报价日期,规范成 yyyy-MM-dd;没有则空串。
给空运协同意图与补字段用。不改主账。
"""
from agent.schema.field_validate import _quote_date_labeled_value
return str(_quote_date_labeled_value(text) or "").strip()
def is_quote_date_only_message(text: str) -> bool:
"""
整句只是在补报价日期(可带 @、工单号),没有费用金额。
运行证据:报价日期:2026-10-15 曾被抽成空运费 2026。
"""
raw = (text or "").strip()
if not raw or not labeled_quote_date(raw):
return False
rest = _strip_group_bot_mentions(_strip_non_fee_meta_chunks(raw))
rest = re.sub(r"WO\d{12}", "", rest, flags=re.I)
rest = re.sub(r"[\s::,,。、;;@#]+", "", rest)
return not rest.strip()
def is_non_fee_meta_only_message(text: str) -> bool:
"""
整句只是报价日期 / 报价有效期 / 时效要求(可带 @、工单号),没有费用。
这类句不能走航线报价;有效期来自 TMS,时效空运不写工单。
"""
raw = (text or "").strip()
if not raw:
return False
has_meta = bool(
labeled_quote_date(raw)
or _RE_VALID_UNTIL_CHUNK.search(raw)
or _RE_TRANSIT_REQ_CHUNK.search(raw)
)
if not has_meta:
return False
rest = _strip_group_bot_mentions(_strip_non_fee_meta_chunks(raw))
rest = re.sub(r"WO\d{12}", "", rest, flags=re.I)
rest = re.sub(r"[\s::,,。、;;@#]+", "", rest)
return not rest.strip()
def _split_fee_project_prefix(text: str) -> tuple[str, str]:
"""
拆句首分段标签。例:起运港、增值费_板,单价… → (起运港, 增值费_板,单价…)
@@ -1111,12 +1224,18 @@ def extract_product_quote(
compact = stripped.replace(" ", "").replace(" ", "")
if re.fullmatch(r"WO\d{12}", compact, re.I):
return {}
# 只补报价日期/有效期/时效要求时直接空:年份、天数不能当费用金额。
if is_quote_date_only_message(raw) or is_non_fee_meta_only_message(raw):
return {}
band = _parse_weight_band_quote(stripped or raw, baseline_currency=base_ccy)
if band:
return band
# 费率句若仍落到普通费用行,会把「燃油附加费」配上句首公斤数。先剥掉再抽。
# 必须用剥掉 @询价小助手 后的正文,否则句首「起运港、」分段标签匹配不到。
scrubbed = _strip_rate_and_band_noise(stripped or raw)
# 顺带剥掉报价日期/有效期/时效要求,避免年份、天数再抽成金额。
scrubbed = _strip_rate_and_band_noise(
_strip_non_fee_meta_chunks(stripped or raw)
)
rows = _parse_fee_rows(scrubbed, baseline_currency=base_ccy) if scrubbed.strip() else []
if rows:
ccy = rows[0].get("currency") or _currency_of(raw, base_ccy) or base_ccy
@@ -1137,7 +1256,9 @@ def extract_product_quote(
}
out.update(_extract_rate_fields(raw))
return out
hit = _RE_MONEY.search(_ascii_amount(scrubbed or raw))
# 剥掉报价日期后只剩空白时,禁止回退到原文再抽金额(否则年份 2026 会当空运费)。
money_src = scrubbed if scrubbed.strip() else ""
hit = _RE_MONEY.search(_ascii_amount(money_src)) if money_src else None
if not hit:
rates = _extract_rate_fields(raw)
return rates if rates else {}
+36 -2
View File
@@ -135,8 +135,11 @@ def build_extract_messages(text: str) -> list[dict[str, str]]:
"陆运「通关口岸:皇岗」「通关口岸皇岗」「过皇岗」有说就写入通关口岸,没说留空,禁止编造口岸。"
"原话单独出现 DAP、DDP、CIF、CIP、FOB、EXW 时写入贸易条款;没出现留空,禁止编造。"
"「从南京飞吉隆坡」起运港=南京、目的港=吉隆坡;「北京飞吉隆坡」起运港=北京。"
"「货交上海」「货交:上海」「货交到上海」是提货地址=上海,不是起运港。"
"没有另写起运地、三字码航线或「从哪飞/到」时,起运港必须空串。"
"陆运口语「广州到深圳」「广东省深圳市福田区到香港」→起运港=左侧地名、目的港=右侧地名;"
"不要把左侧写成提货地址,右侧目的地也不能丢。"
"「货交上海」「货交:上海」「货交到上海」才是提货地址=上海,不是起运港。"
"仅空运且只有「货交…」、没有另写起运地/三字码航线/「从哪飞」时,起运港必须空串;"
"陆运「A到B」必须写入起运港和目的港。"
"原话是机场三字码或航线就原样记,禁止翻译成城市名:"
"SZX-CGK / SZX/CGK / SZX到CGK → 起运港=SZX、目的港=CGK,不要写成深圳、雅加达。"
"换城市必须覆盖旧值,不要沿用上一句的港口。"
@@ -214,4 +217,35 @@ def invoke_extract_fields(
parsed.get("business_line") or "-",
sorted((parsed.get("facts") or {}).keys()),
)
# #region agent log
try:
import json
import time
from pathlib import Path
_facts = dict(parsed.get("facts") or {})
_log = {
"sessionId": "0593f5",
"runId": "post-fix",
"hypothesisId": "A",
"location": "mode_extract_fields.py:invoke_extract_fields",
"message": "DeepSeek B place fields",
"data": {
"text_head": (text or "")[:80],
"ok": parsed.get("ok"),
"business_line": parsed.get("business_line"),
"land_subtype": parsed.get("land_subtype"),
"起运港": _facts.get("起运港"),
"目的港": _facts.get("目的港"),
"提货地址": _facts.get("提货地址"),
"fact_keys": sorted(_facts.keys()),
},
"timestamp": int(time.time() * 1000),
}
Path(__file__).resolve().parents[3].joinpath("debug-0593f5.log").open(
"a", encoding="utf-8"
).write(json.dumps(_log, ensure_ascii=False) + "\n")
except Exception:
pass
# #endregion
return parsed