角色业务范围收紧工单与模板;报价日期不当费用;未选舱不迁已报价;另开一票可清违禁 hold
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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 {}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user