群里按 BOT 通道激活当前工单、航线报价和成交跟进;没有 TMS 报价编号时用工单字段补齐仍调锁舱,避免被本地规则拦住。 Co-authored-by: Cursor <cursoragent@cursor.com>
312 lines
11 KiB
Python
312 lines
11 KiB
Python
"""
|
||
文字询价字段抽取入口。
|
||
|
||
本文件职责:生产走 DeepSeek B;单测可注入 facts;无网时只认「键:值」结构化行,不猜港口。
|
||
禁止:用正则把「上海到洛杉矶」拆成起运目的;禁止改六态。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import logging
|
||
import re
|
||
from typing import Any, Optional
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
_MODE_WORDS = (
|
||
("空运", "AIR"),
|
||
("海运", "SEA"),
|
||
("陆运", "LAND"),
|
||
)
|
||
|
||
# 仅「键:值」一行一条,键必须是合同字段或展示名
|
||
_LABEL_LINE = re.compile(
|
||
r"^(起运地|起运港|目的地|目的港|品名|件数|毛重|体积|重量|重量\(KG\)|体积\(CBM\)|"
|
||
r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|车型数量|"
|
||
r"贸易条款|货好时间|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁)\s*[::]\s*(.+)$"
|
||
)
|
||
_LABEL_INLINE = re.compile(
|
||
r"(起运地|起运港|目的地|目的港|品名|件数|毛重|体积|重量|重量\(KG\)|体积\(CBM\)|"
|
||
r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|车型数量|"
|
||
r"贸易条款|货好时间|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁)\s*[::]\s*(\S+)"
|
||
)
|
||
|
||
|
||
def detect_transport_mode(text: str) -> str:
|
||
"""
|
||
运输方式:空运/海运/陆运,或空运口语「飞」(南京飞吉隆坡)。
|
||
|
||
写了海运/陆运以书面为准。只说飞就是空运,补问件数时不得再问运输方式。
|
||
"""
|
||
raw = text or ""
|
||
found = [code for word, code in _MODE_WORDS if word in raw]
|
||
if len(found) == 1:
|
||
return found[0]
|
||
if len(found) > 1:
|
||
return ""
|
||
if "飞" in raw:
|
||
return "AIR"
|
||
return ""
|
||
|
||
|
||
def parse_labeled_facts(text: str) -> dict[str, str]:
|
||
"""只采集「字段名:值」,整行优先;同一句里的标签也收。不猜港口。"""
|
||
facts: dict[str, str] = {}
|
||
raw = text or ""
|
||
for line in raw.splitlines():
|
||
m = _LABEL_LINE.match(line.strip())
|
||
if m:
|
||
facts[m.group(1)] = m.group(2).strip()
|
||
if facts:
|
||
return facts
|
||
for m in _LABEL_INLINE.finditer(raw):
|
||
facts[m.group(1)] = m.group(2).strip()
|
||
return facts
|
||
|
||
|
||
def extract_inquiry_snapshot(
|
||
text: str,
|
||
*,
|
||
injected_facts: Optional[dict[str, Any]] = None,
|
||
injected_mode: str = "",
|
||
b_result: Optional[dict[str, Any]] = None,
|
||
allow_b: bool = False,
|
||
chat_fn: Optional[Any] = None,
|
||
) -> dict[str, Any]:
|
||
"""
|
||
抽出业务线 + 字段快照。
|
||
|
||
优先 injected_*(单测);其次合并标签行与 DeepSeek B(口语)。
|
||
标签行覆盖 B 的同名字段。B 未出现的字段保持空,不本地猜港口。
|
||
件数/毛重/体积若原话已带 TMS 单位(件/KGS/CBM),空字段可机读补上。
|
||
"""
|
||
from agent.schema.field_validate import harvest_oral_measures, normalize_facts
|
||
|
||
if injected_facts is not None:
|
||
mode = (injected_mode or detect_transport_mode(text) or "").upper()
|
||
return {
|
||
"business_line": mode,
|
||
"land_subtype": "",
|
||
"facts": harvest_oral_measures(text, normalize_facts(dict(injected_facts))),
|
||
"source": "injected",
|
||
}
|
||
mode = detect_transport_mode(text)
|
||
facts = parse_labeled_facts(text)
|
||
source = "labeled_or_mode"
|
||
b_payload = b_result
|
||
if b_payload is None and (allow_b or chat_fn is not None):
|
||
from agent.llm.mode_extract_fields import invoke_extract_fields
|
||
|
||
b_payload = invoke_extract_fields(
|
||
text, allow_network=allow_b, chat_fn=chat_fn
|
||
)
|
||
if b_payload and b_payload.get("ok"):
|
||
merged = dict(b_payload.get("facts") or {})
|
||
merged.update(facts)
|
||
facts = merged
|
||
mode = mode or str(b_payload.get("business_line") or "")
|
||
source = "deepseek_b"
|
||
facts = harvest_oral_measures(text, normalize_facts(facts))
|
||
if not mode and facts.get("起运港") and facts.get("目的港") and "飞" in (text or ""):
|
||
mode = "AIR"
|
||
logger.info("extract.snapshot mode=%s keys=%s src=%s", mode or "-", sorted(facts), source)
|
||
return {
|
||
"business_line": mode,
|
||
"land_subtype": (b_payload or {}).get("land_subtype") or "",
|
||
"facts": facts,
|
||
"source": source,
|
||
}
|
||
|
||
|
||
def extract_collab_fields(
|
||
text: str,
|
||
*,
|
||
allow_b: bool = False,
|
||
injected: Optional[dict[str, str]] = None,
|
||
) -> dict[str, str]:
|
||
"""
|
||
群里协同字段:标签「键:值」+ 口语含油/含电/含磁、货好时间。
|
||
|
||
闲聊、报价金额不进这里。商品海关编码 / HS 折成 HS编码。
|
||
allow_b 保留给调用方;协同六项不走询价七项的 B 合同。
|
||
"""
|
||
from agent.schema.field_validate import (
|
||
SEA_COLLAB_FIELDS,
|
||
harvest_oral_collab,
|
||
normalize_facts,
|
||
)
|
||
|
||
del allow_b
|
||
if injected:
|
||
return {
|
||
k: str(v).strip()
|
||
for k, v in normalize_facts(dict(injected)).items()
|
||
if k in SEA_COLLAB_FIELDS and str(v or "").strip()
|
||
}
|
||
labeled = normalize_facts(parse_labeled_facts(text))
|
||
oral = harvest_oral_collab(text)
|
||
merged = dict(oral)
|
||
merged.update({k: v for k, v in labeled.items() if v})
|
||
out: dict[str, str] = {}
|
||
for key in SEA_COLLAB_FIELDS:
|
||
val = (merged.get(key) or "").strip()
|
||
if val:
|
||
out[key] = val
|
||
return out
|
||
|
||
|
||
def extract_air_collab_fields(
|
||
text: str,
|
||
*,
|
||
allow_b: bool = False,
|
||
injected: Optional[dict[str, str]] = None,
|
||
) -> dict[str, str]:
|
||
"""
|
||
空运群只认是否含电、是否含磁。
|
||
|
||
复用海运口语收割,再滤成这两项。
|
||
"""
|
||
keys = ("是否含电", "是否含磁")
|
||
if injected:
|
||
return {
|
||
k: str(v).strip()
|
||
for k, v in (injected or {}).items()
|
||
if k in keys and str(v or "").strip()
|
||
}
|
||
got = extract_collab_fields(text, allow_b=allow_b)
|
||
return {k: got[k] for k in keys if str(got.get(k) or "").strip()}
|
||
|
||
|
||
_RE_MONEY = re.compile(
|
||
r"(?:USD|CNY|RMB|美金|美元|人民币)?\s*(\d+(?:\.\d+)?)\s*(?:USD|CNY|RMB|美金|美元|人民币|元)?"
|
||
r"|(?:USD|CNY|RMB|美金|美元)\s*(\d+(?:\.\d+)?)",
|
||
re.I,
|
||
)
|
||
_RE_FEE_LINE = re.compile(
|
||
r"(?P<name>[\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9/()()++\--]{0,24}费|[A-Za-z]{2,10})"
|
||
r"\s*(?:改为|改成|调整为|调整成)?"
|
||
r"\s*[::=]?"
|
||
r"\s*(?:(?P<ccy1>USD|CNY|RMB|美金|美元|人民币)\s+)?"
|
||
r"(?P<amount>\d+(?:\.\d+)?)"
|
||
r"\s*(?P<ccy2>USD|CNY|RMB|美金|美元|人民币|元)?",
|
||
re.I,
|
||
)
|
||
# 「文件发改为:500」漏了「费」、中间还有冒号,不能丢掉。
|
||
_RE_FEE_CHANGE = re.compile(
|
||
r"(?P<name>[\u4e00-\u9fff]{2,16}?)"
|
||
r"\s*(?:改为|改成|调整为|调整成)"
|
||
r"\s*[::=]?"
|
||
r"\s*(?P<amount>\d+(?:\.\d+)?)"
|
||
r"\s*(?P<ccy>USD|CNY|RMB|美金|美元|人民币|元)?",
|
||
re.I,
|
||
)
|
||
_SKIP_FEE_NAMES = frozenset({"usd", "cny", "rmb", "美金", "美元", "人民币", "元"})
|
||
_FULLWIDTH_NUM = str.maketrans("0123456789.", "0123456789.")
|
||
|
||
|
||
def _ascii_amount(raw: str) -> str:
|
||
"""全角数字折成半角,方便和模板金额列对齐。"""
|
||
return (raw or "").translate(_FULLWIDTH_NUM).strip()
|
||
|
||
|
||
def _currency_of(text: str, hint: str = "") -> str:
|
||
blob = f"{hint} {text}".upper()
|
||
if any(x in blob for x in ("CNY", "RMB", "人民币", "元")):
|
||
return "CNY"
|
||
if any(x in blob for x in ("USD", "美金", "美元")):
|
||
return "USD"
|
||
return hint or "USD"
|
||
|
||
|
||
def _parse_fee_rows(text: str) -> list[dict[str, str]]:
|
||
"""
|
||
按行抽费用:码头操作费100CNY、报关费改为300CNY。
|
||
|
||
只认带「费」的中文名或短英文费用码,避免把工单号当金额。
|
||
"""
|
||
raw = _ascii_amount(text or "")
|
||
rows: list[dict[str, str]] = []
|
||
seen: set[str] = set()
|
||
|
||
def _add(name: str, amount: str, ccy: str) -> None:
|
||
if not name or not amount or name.lower() in _SKIP_FEE_NAMES:
|
||
return
|
||
# 「WO202609160001」会被 [A-Za-z]{2,10}+数字 抽成费用名 WO,群里发工单号不能当报价。
|
||
if name.upper() == "WO":
|
||
return
|
||
if name in seen:
|
||
return
|
||
seen.add(name)
|
||
rows.append(
|
||
{
|
||
"name": name,
|
||
"amount": amount,
|
||
"currency": _currency_of(raw, ccy),
|
||
}
|
||
)
|
||
|
||
for hit in _RE_FEE_LINE.finditer(raw):
|
||
_add(
|
||
(hit.group("name") or "").strip(),
|
||
_ascii_amount(hit.group("amount") or ""),
|
||
hit.group("ccy1") or hit.group("ccy2") or "",
|
||
)
|
||
for hit in _RE_FEE_CHANGE.finditer(raw):
|
||
_add(
|
||
(hit.group("name") or "").strip(),
|
||
_ascii_amount(hit.group("amount") or ""),
|
||
hit.group("ccy") or "",
|
||
)
|
||
return rows
|
||
|
||
|
||
def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = None) -> dict[str, Any]:
|
||
"""
|
||
产品文字报价:先按费用行抽,再退回合计/币别。注入优先。
|
||
|
||
认不出价格返回空 dict,调用方回「没有识别到报价」。
|
||
群里常见「码头操作费100CNY / 报关费改为300CNY」,不能只把第一笔数当成海运费。
|
||
"""
|
||
if isinstance(injected, dict) and (injected.get("total") or injected.get("fee_rows")):
|
||
out = dict(injected)
|
||
out.setdefault("source_label", "产品报价")
|
||
return out
|
||
raw = (text or "").strip()
|
||
if not raw:
|
||
return {}
|
||
compact = raw.replace(" ", "").replace(" ", "")
|
||
if re.fullmatch(r"WO\d{12}", compact, re.I):
|
||
return {}
|
||
rows = _parse_fee_rows(raw)
|
||
if rows:
|
||
ccy = rows[0].get("currency") or _currency_of(raw)
|
||
total = rows[0]["amount"]
|
||
if len({x.get("currency") for x in rows}) == 1:
|
||
try:
|
||
total = str(sum(float(x["amount"]) for x in rows))
|
||
except ValueError:
|
||
total = rows[0]["amount"]
|
||
return {
|
||
"total": total,
|
||
"currency": ccy,
|
||
"source_label": "产品报价",
|
||
"raw_text": raw,
|
||
"fee_rows": rows,
|
||
"fee_lines": list(rows),
|
||
}
|
||
hit = _RE_MONEY.search(_ascii_amount(raw))
|
||
if not hit:
|
||
return {}
|
||
amount = _ascii_amount(hit.group(1) or hit.group(2) or "")
|
||
if not amount:
|
||
return {}
|
||
ccy = _currency_of(raw)
|
||
return {
|
||
"total": amount,
|
||
"currency": ccy,
|
||
"source_label": "产品报价",
|
||
"raw_text": raw,
|
||
"fee_rows": [{"name": "海运费", "amount": amount, "currency": ccy}],
|
||
"fee_lines": [{"name": "海运费", "amount": amount, "currency": ccy}],
|
||
}
|