""" 文字询价字段抽取入口。 本文件职责:生产走 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 _RE_MONEY = re.compile( r"(?:USD|CNY|RMB|美金|美元|人民币)?\s*(\d+(?:\.\d+)?)\s*(?:USD|CNY|RMB|美金|美元|人民币|元)?" r"|(?:USD|CNY|RMB|美金|美元)\s*(\d+(?:\.\d+)?)", re.I, ) def extract_product_quote(text: str, *, injected: Optional[dict[str, Any]] = None) -> dict[str, Any]: """ 产品文字报价:抽出合计/币别。注入优先。 认不出价格返回空 dict,调用方回「没有识别到报价」。 """ 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 {} hit = _RE_MONEY.search(raw) if not hit: return {} amount = (hit.group(1) or hit.group(2) or "").strip() if not amount: return {} ccy = "USD" if any(x in raw.upper() for x in ("CNY", "RMB", "人民币", "元")): ccy = "CNY" return { "total": amount, "currency": ccy, "source_label": "产品报价", "raw_text": raw, "fee_rows": [{"name": "海运费", "amount": amount, "currency": ccy}], }