Files
inquiry_robot/inquiry-agent/agent/llm/extract_text.py
T
jillion886andCursor cfbce2fd69 落地空运单聊询价字段与线路选择。
必填核对后再建单;多条线路走 H5 点选;费用只展示该线路 TMS 回包,不套用整单空运费。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 15:02:32 +08:00

467 lines
16 KiB
Python
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
"""
文字询价字段抽取入口。
本文件职责:生产走 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|是否含油|是否含电|是否含磁|"
r"客户名称|货值|是否为危险品|通关口岸(非必填)|通关口岸)\s*[::]\s*(.+)$"
)
_LABEL_INLINE = re.compile(
r"(起运地|起运港|目的地|目的港|品名|件数|毛重|体积|重量|重量\(KG\)|体积\(CBM\)|"
r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|报价日期|运输分类|车型/数量|车型数量|"
r"贸易条款|货好时间|货源地|提货地址|收货地址|商品海关编码|海关编码|HS编码|HS|是否含油|是否含电|是否含磁|"
r"客户名称|货值|是否为危险品|通关口岸(非必填)|通关口岸)\s*[::]\s*(\S+)"
)
# 陆运核对卡非必填:销售可能写「通关口岸皇岗」或照抄「通关口岸(非必填):皇岗」。长词优先。
_OPTIONAL_INQUIRY_LABELS = (
"通关口岸(非必填)",
"通关口岸",
"货源地(非必填)",
"货好时间(非必填)",
"提货地址(非必填)",
"收货地址(非必填)",
"贸易条款(非必填)",
"起运港(非必填)",
"货源地",
"货好时间",
"提货地址",
"收货地址",
"贸易条款",
"起运港",
)
# 群里协同标签,冒号可省略:「客户名称张三」「货值 10万」。长词优先。
_COLLAB_LABELS = (
"是否为危险品",
"商品海关编码",
"车型/数量",
"包装方式",
"客户名称",
"海关编码",
"贸易条款",
"货好时间",
"是否含油",
"是否含电",
"是否含磁",
"HS编码",
"货值",
"HS",
)
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 _merge_spoken_collab(text: str, facts: dict[str, str]) -> dict[str, str]:
"""
私聊已经说到的协同项写进事实,进群才能预填。
不覆盖已有询价键。不拿去补问、不挡查价。
"""
from agent.schema.field_validate import harvest_oral_collab, normalize_facts
merged = dict(facts or {})
extra = dict(harvest_oral_collab(text))
extra.update(normalize_facts(harvest_collab_labels(text)))
for key, val in extra.items():
if val and not str(merged.get(key) or "").strip():
merged[key] = val
return merged
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_collab, harvest_oral_measures, normalize_facts
if injected_facts is not None:
mode = (injected_mode or detect_transport_mode(text) or "").upper()
facts = harvest_oral_measures(text, normalize_facts(dict(injected_facts)))
facts = _merge_spoken_collab(text, facts)
facts = _merge_optional_inquiry_labels(text, facts)
subtype = str(facts.get("运输类型") or "").strip()
return {
"business_line": mode,
"land_subtype": subtype,
"facts": 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 isinstance(b_payload, dict) and not b_payload.get("ok"):
err = str(b_payload.get("error") or "").strip()
if err not in {"b_network_disabled", "extract_fields_shell_not_implemented"} and (
allow_b or chat_fn is not None
):
return {
"business_line": mode,
"land_subtype": "",
"facts": {},
"source": "llm_tech_fail",
"tech_fail": True,
"tech_error": err or "LLM_FAIL",
}
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))
facts = _merge_spoken_collab(text, facts)
facts = _merge_optional_inquiry_labels(text, facts)
land_subtype = str((b_payload or {}).get("land_subtype") or facts.get("运输类型") or "").strip()
if land_subtype and not str(facts.get("运输类型") or "").strip():
facts["运输类型"] = land_subtype
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": land_subtype,
"facts": facts,
"source": source,
}
def harvest_collab_labels(text: str) -> dict[str, str]:
"""
群里协同标签:有无冒号都收。
「客户名称张三 包装方式编织袋 货值 10万」切成三项。
只说「纸箱」没有标签则不收。值取到下一个标签之前。
"""
return _harvest_labels(text, _COLLAB_LABELS)
def harvest_inquiry_optional_labels(text: str) -> dict[str, str]:
"""
陆运询价非必填标签:通关口岸。
核对卡写「通关口岸(非必填):」,销售可能照抄或只写「通关口岸皇岗」。
有无冒号都收。不猜口岸名。
"""
return _harvest_labels(text, _OPTIONAL_INQUIRY_LABELS)
def _merge_optional_inquiry_labels(text: str, facts: dict[str, str]) -> dict[str, str]:
"""把原话里的通关口岸写进询价事实。有值才覆盖。"""
from agent.schema.field_validate import normalize_facts
merged = dict(facts or {})
extra = normalize_facts(harvest_inquiry_optional_labels(text))
for key, val in extra.items():
if val:
merged[key] = val
return merged
def _harvest_labels(text: str, labels: tuple[str, ...]) -> dict[str, str]:
"""按标签切原话。同一起点只留最长标签。"""
raw = text or ""
hits: list[tuple[int, str]] = []
for label in labels:
start = 0
while True:
pos = raw.find(label, start)
if pos < 0:
break
hits.append((pos, label))
start = pos + len(label)
if not hits:
return {}
hits.sort()
# 同一起点只留最长标签,避免 HS 抢 HS编码、通关口岸抢「通关口岸(非必填)」。
kept: list[tuple[int, str]] = []
for pos, label in hits:
if kept and kept[-1][0] == pos and len(label) <= len(kept[-1][1]):
continue
if kept and kept[-1][0] == pos:
kept[-1] = (pos, label)
continue
kept.append((pos, label))
out: dict[str, str] = {}
for i, (pos, label) in enumerate(kept):
begin = pos + len(label)
end = kept[i + 1][0] if i + 1 < len(kept) else len(raw)
chunk = raw[begin:end]
chunk = re.sub(r"^[\s::,,、]+", "", chunk)
chunk = re.sub(r"[\s,,、]+$", "", chunk)
if chunk:
out[label] = chunk
return out
def extract_collab_fields(
text: str,
*,
allow_b: bool = False,
injected: Optional[dict[str, str]] = None,
allowed_keys: Optional[tuple[str, ...]] = None,
) -> dict[str, str]:
"""
群里协同字段:标签(冒号可省)+ 口语含油/含电/含磁/危险品、货好时间。
allowed_keys 传入则只返回本单该有的项。不传则海运协同五项 ∪ 贸易条款(陆运跨境仍要)∪ 陆运协同键,给意图分类。
闲聊、只说「纸箱」不进这里。商品海关编码 / HS 折成 HS编码。
"""
from agent.schema.field_validate import (
ALL_COLLAB_KEYS,
harvest_oral_collab,
normalize_facts,
)
del allow_b
keys = tuple(allowed_keys) if allowed_keys is not None else ALL_COLLAB_KEYS
if injected:
return {
k: str(v).strip()
for k, v in normalize_facts(dict(injected)).items()
if k in keys and str(v or "").strip()
}
labeled = normalize_facts(parse_labeled_facts(text))
labeled.update(normalize_facts(harvest_collab_labels(text)))
oral = harvest_oral_collab(text)
if "是否为危险品" in oral:
if allowed_keys is not None and "是否为危险品" not in keys:
oral.pop("是否为危险品", None)
elif allowed_keys is None:
raw = text or ""
if "普货" in raw and not any(
w in raw for w in ("危险", "非危", "有危", "含危", "是否为危险品")
):
oral.pop("是否为危险品", None)
merged = dict(oral)
merged.update({k: v for k, v in labeled.items() if v})
out: dict[str, str] = {}
for key in keys:
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}],
}