""" 文字询价字段抽取入口。 本文件职责:生产走 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[\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9/()()++\--]{0,24}费|[A-Za-z]{2,10})" r"\s*(?:改为|改成|调整为|调整成)?" r"\s*[::=]?" r"\s*(?:(?PUSD|CNY|RMB|美金|美元|人民币)\s+)?" r"(?P\d+(?:\.\d+)?)" r"\s*(?PUSD|CNY|RMB|美金|美元|人民币|元)?", re.I, ) # 「文件发改为:500」漏了「费」、中间还有冒号,不能丢掉。 _RE_FEE_CHANGE = re.compile( r"(?P[\u4e00-\u9fff]{2,16}?)" r"\s*(?:改为|改成|调整为|调整成)" r"\s*[::=]?" r"\s*(?P\d+(?:\.\d+)?)" r"\s*(?PUSD|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}], }