""" 文字询价字段抽取入口。 本文件职责:生产走 DeepSeek B;单测可注入 facts;无网时只认「键:值」结构化行,不猜港口。 禁止:用正则把「上海到洛杉矶」拆成起运目的;禁止改六态。 """ from __future__ import annotations import logging import re from typing import Any, Optional try: from agent.schema.field_validate import QUOTE_DATE_SYNONYMS except ImportError: # 测服旧 release 未导出同义词时仍可抽产品价 QUOTE_DATE_SYNONYMS = () logger = logging.getLogger(__name__) # 报价日期同义词。长的在前,避免短词截断。货好时间、提货地址不在这里。 _QUOTE_DATE_LABEL_RE = "|".join( re.escape(name) for name in sorted(("报价日期",) + tuple(QUOTE_DATE_SYNONYMS or ()), key=len, reverse=True) ) _MODE_WORDS = ( ("空运", "AIR"), ("海运", "SEA"), ("陆运", "LAND"), ) # 未定运输方式时:整句只回 1/2/3 对应陆运/海运/空运。10件、1.件数 不算。 _MODE_INDEX = re.compile(r"^\s*([1231-3])\s*[\.、.]?\s*$") _MODE_BY_INDEX = { "1": "LAND", "2": "SEA", "3": "AIR", "1": "LAND", "2": "SEA", "3": "AIR", } # 仅「键:值」一行一条,键必须是合同字段或展示名。报价日期含询价/走货/发货等同义词。 _LABEL_KEYS = ( r"起运地|起运港|目的地|目的港|品名|件数|毛重|体积(CBM)|体积\(CBM\)|体积|重量(KG)|重量\(KG\)|重量|" r"包装方式|包装类型|货量|货物数量|整柜或拼柜|箱型箱量|" + _QUOTE_DATE_LABEL_RE + r"|运输分类|车型/数量|车型数量|" r"贸易条款|货好时间|货源地|提货地址|收货地址|送货地址|增值服务(非必填)|增值服务|商品海关编码|海关编码|HS编码|HS|" r"是否含油|是否含电|是否带电|是否含磁|" r"客户名称|货值|是否为危险品|通关口岸(非必填)|通关口岸" ) _LABEL_LINE = re.compile(rf"^({_LABEL_KEYS})\s*[::]\s*(.+)$") _LABEL_INLINE = re.compile(rf"({_LABEL_KEYS})\s*[::]\s*(\S+)") # 陆运核对卡非必填:销售可能写「通关口岸皇岗」或照抄「通关口岸(非必填):皇岗」。长词优先。 _OPTIONAL_INQUIRY_LABELS = ( "通关口岸(非必填)", "通关口岸", "货源地(非必填)", "货好时间(非必填)", "提货地址(非必填)", "收货地址(非必填)", "贸易条款(非必填)", "起运地(非必填)", "起运港(非必填)", "货源地", "货好时间", "提货地址", "收货地址", "送货地址", "贸易条款", "增值服务(非必填)", "增值服务", "起运港", ) # 图上常见「4. 希望送到…」:编号项不是询价字段,值收到这里为止。 _NUMBERED_ITEM = re.compile(r"(?m)^\s*\d+[\.、.]\s+\S") # 补问复制清单:1.件数(必填):10 ;下一行 (如:10)不算值。 _CLARIFY_EXAMPLE_LINE = re.compile(r"^\s*[((]如\s*[::]") _CLARIFY_INDEX = re.compile(r"^\s*\d+[\.、.]\s*") _CLARIFY_DECOR = re.compile(r"((?:KG|CBM|kg|cbm|必填|非必填))|\((?:KG|CBM|必填|非必填)\)", re.I) _CLARIFY_NAMES = frozenset( { "起运地", "起运港", "目的地", "目的港", "品名", "货物品名", "件数", "毛重", "体积(CBM)", "体积", "重量(KG)", "重量", "包装方式", "包装类型", "货量", "货物数量", "整柜或拼柜", "箱型箱量", "报价日期", *QUOTE_DATE_SYNONYMS, "运输分类", "运输方式", "运输类型", "线路类别", "车型/数量", "车型数量", "贸易条款", "货源地", "货好时间", "提货地址", "收货地址", "送货地址", "通关口岸", "始发站", "增值服务", } ) # 报价单表头和格子在同一行:只洗这些询价键,不碰提货/收货地址。 _SHEET_CLEAN_KEYS = frozenset( { "起运地", "起运港", "目的地", "目的港", "品名", "件数", "毛重", "体积(CBM)", "体积", "重量(KG)", "重量", "货量", "货物数量", "整柜或拼柜", "箱型箱量", "贸易条款", "增值服务", "货好时间", "运输分类", "报价日期", *QUOTE_DATE_SYNONYMS, "包装方式", "包装类型", } ) # 报价单从这里往下是费用和条款,不是询价栏。切掉后再抽字段。 _QUOTE_BODY_MARKS = ( "报价明细", "报价条件", "预估费用", "空舱费", "托运方", "承运方", ) # 表头常被拉成「体 积」「计 费 重」。只收这些词中间的空格,不跨行。 _TIGHTEN_LABELS = tuple( sorted( { *_QUOTE_BODY_MARKS, "计费重", "时效要求", "派送地址", "提货地址", "收货地址", "货好时间", "贸易条款", "报价日期", "起运地", "起运港", "目的地", "目的港", "包装类型", "包装方式", "体积", "重量", "品名", "件数", "货源地", "运输方式", }, key=len, reverse=True, ) ) _FEE_NAME = re.compile(r"^\*?[\u4e00-\u9fffA-Za-z0-9]{1,12}费$") # 识图/标签错位:报价编号、工单号、长数字进了起运地/目的地。三字码航线不动。 _QUOTE_LIKE_PLACE = re.compile( r"^(?:" r"(?:WO|VT)\d{10,}" r"|QT[-_]?\d{6,}" r"|\d{8,}" r"|[A-Za-z]{1,6}\d{8,}" r")$", re.I, ) _QUOTE_NO_IN_TEXT = re.compile( r"(?:报价编号|报价单编号|RFQ\s*No\.?|Quote\s*No\.?)\s*[::]?\s*([A-Za-z0-9\-_]{6,})", re.I, ) _SHEET_STOP = ( "报价明细", "报价条件", "预估费用", "计费重", "时效要求", "要求运输时效", "Requested Transit", "费用汇总", "POD Local Charges", "提货地址", "Place of Receipt", "送货地址", "派送地址", "Place of Delivery", "要求货物抵达", "Requested Arrival", ) _SHEET_EN_ALIAS = re.compile(r"^[//][A-Za-z][A-Za-z ]*") _SHEET_REQUIRED_HINT = re.compile(r"([^)]*必填[^)]*)") _SHEET_REQUIRED_HINT_EN = re.compile( r"\([^)]*(?:必填|Packing List|Quantity)[^)]*\)", re.I, ) _SHEET_HEADER_NOISE = re.compile( r"(费用汇总|提货地址|送货地址|派送地址|要求货物抵达|要求运输时效|" r"Local Charges|Place of Receipt|Place of Delivery|Requested Arrival|Requested Transit)", re.I, ) # 群里协同标签,冒号可省略:「客户名称张三」「货值 10万」。长词优先。 _COLLAB_LABELS = ( "是否为危险品", "商品海关编码", "车型/数量", "包装方式", "客户名称", "海关编码", "贸易条款", "货好时间", "是否含油", "是否含电", "是否含磁", "HS编码", "货值", "HS", ) def detect_transport_mode(text: str) -> str: """ 运输方式:空运/海运/陆运,或空运口语「飞」(南京飞吉隆坡)。 未定方式时整句回 1/2/3 分别是陆运/海运/空运;「1.陆运」仍按文字认。 写了海运/陆运以书面为准。只说飞就是空运,补问件数时不得再问运输方式。 「10件」「1.件数」不是运输方式。 """ 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 "" hit = _MODE_INDEX.match(raw.strip()) if hit: return _MODE_BY_INDEX.get(hit.group(1), "") if "飞" in raw: return "AIR" return "" def parse_clarify_paste_facts(text: str) -> dict[str, str]: """ 认补问复制清单:编号 + 字段名 +(单位/必填)+ 冒号后的值。 「(如:10)」整行丢掉。冒号后为空也不收,避免把示例当成已填。 不猜港口,不改六态。 """ facts: dict[str, str] = {} for line in (text or "").splitlines(): raw = (line or "").strip() if not raw or _CLARIFY_EXAMPLE_LINE.match(raw): continue raw = _CLARIFY_INDEX.sub("", raw, count=1) if ":" not in raw and ":" not in raw: continue if ":" in raw: name, val = raw.split(":", 1) else: name, val = raw.split(":", 1) name = _CLARIFY_DECOR.sub("", name).strip() val = (val or "").strip() if not name or name not in _CLARIFY_NAMES: continue if not val or _CLARIFY_EXAMPLE_LINE.match(val): continue clipped = clip_inquiry_value(val) facts[name] = clipped return {key: val for key, val in facts.items() if val} def parse_labeled_facts(text: str) -> dict[str, str]: """ 只采集「字段名:值」,整行优先;同一句里的标签也收。不猜港口。 补问复制清单(1.件数(必填):10)优先。 报价单常把表头和格子写在一行(起运港/POL(必填) 纽约港), 冒号对不上时再按表头切一刀,值只留格子。 """ facts: dict[str, str] = parse_clarify_paste_facts(text) raw = "\n".join( line for line in (text or "").splitlines() if not _CLARIFY_EXAMPLE_LINE.match(line.strip()) ) for line in raw.splitlines(): shown = line.strip() m = _LABEL_LINE.match(shown) if m and not str(facts.get(m.group(1)) or "").strip(): facts[m.group(1)] = clip_sheet_field_value(m.group(2).strip()) if not facts: for m in _LABEL_INLINE.finditer(raw): facts[m.group(1)] = clip_sheet_field_value(m.group(2).strip()) from agent.schema.field_validate import normalize_facts taken = set(normalize_facts(facts)) for key, val in harvest_sheet_inquiry_cells(raw).items(): if not val: continue mapped = next(iter(normalize_facts({key: val})), "") if mapped and mapped in taken: continue if str(facts.get(key) or "").strip(): continue facts[key] = val if mapped: taken.add(mapped) return {key: val for key, val in facts.items() if val} def _merge_spoken_collab(text: str, facts: dict[str, str]) -> dict[str, str]: """ 私聊已经说到的协同项写进事实,进群才能预填。 不覆盖已有询价键。不拿去补问、不挡查价。 """ from agent.schema.field_validate import ( harvest_oral_collab, harvest_spoken_trade_term, normalize_facts, ) merged = dict(facts or {}) extra = dict(harvest_oral_collab(text)) extra.update(harvest_spoken_trade_term(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 prepare_sheet_oral(text: str) -> str: """ 报价单只留询价栏。 费用明细、报价条件、落款整段丢掉。表头里被空格拉开的「体 积」收回成「体积」, 否则上一栏会把后面的报价条件整段吞进去。计费重不是毛重,单独拿掉。 副作用:无。 """ body = _tighten_sheet_labels(text or "") cut = len(body) for mark in _QUOTE_BODY_MARKS: pos = body.find(mark) if 0 <= pos < cut: cut = pos body = body[:cut] body = re.sub(r"计费重\s*[::]?\s*[0-9.]+\s*(?:KG|KGS|kg|公斤)?", " ", body) return body.strip() def _tighten_sheet_labels(text: str) -> str: """已知表头中间只允许空格。不跨行,避免把两行并成一个词。""" body = text or "" gap = r"[ \t\u3000]{0,6}" for label in _TIGHTEN_LABELS: if len(label) < 2: continue pat = re.compile( r"(? str: """货交后面的地名。去掉口头禅和首尾标点,空的不当地址。""" place = _RE_HANDOVER_TAIL.sub("", (raw or "").strip()) return place.strip(" ::,,。;;") def _has_explicit_origin(text: str) -> bool: """原话里另写了起运地、三字码航线或「从哪飞/到」。货交那一段先拿掉。""" stripped = _RE_CARGO_HANDOVER.sub(" ", text or "") return _RE_EXPLICIT_ORIGIN.search(stripped) is not None def apply_cargo_handover_synonym( text: str, facts: dict[str, str] | None, *, business_line: str = "", ) -> dict[str, str]: """ 货交 = 提货地址。例:货交上海 → 提货地址上海。 只认原话里的「货交」,不把裸城市猜成港口。 空运且没有另写起运地时,清掉被误收成起运地的同一处地名,否则 TMS 会按错误起运查价。 海运/陆运只补提货地址,不动起运港。副作用:无。 """ merged = dict(facts or {}) places: list[str] = [] for hit in _RE_CARGO_HANDOVER.finditer(text or ""): place = _handover_place(hit.group(1)) if place and place not in places: places.append(place) if not places: return merged merged["提货地址"] = "、".join(places) is_air = (business_line or "").strip().upper() == "AIR" or "空运" in (text or "") if is_air and not _has_explicit_origin(text): merged.pop("起运港", None) merged.pop("起运地", None) merged.pop("originCode", None) return merged def _drop_fee_place_values(facts: dict[str, str]) -> dict[str, str]: """起运地/目的地收成「操作费、文件费」时丢掉。那是费用栏,不是港口。""" out = dict(facts or {}) for key in ("起运港", "起运地", "目的港", "目的地"): val = str(out.get(key) or "").strip() if not val: continue bits = [p.strip(" *::\t") for p in re.split(r"[\n/、,,]+", val) if p.strip()] if bits and all(_FEE_NAME.match(p) for p in bits): out.pop(key, None) return out def looks_like_quote_id_place(raw: str | None) -> bool: """ 是否像报价编号/工单号,而不是起运地或目的地。 识图常把「报价编号:QT…」错贴到起运地。SZX、南京、SZX-CGK 不算。 """ text = str(raw or "").strip() if not text: return False if "报价编号" in text or "报价单编号" in text: return True # 去掉常见装饰后再判 cleaned = re.sub(r"[((]\s*(?:必填|非必填)\s*[))]", "", text).strip(" \t::,,。;;") if not cleaned: return False if _QUOTE_LIKE_PLACE.match(cleaned): return True # 纯数字超过 7 位多半是系统编号,不是港口名 if re.fullmatch(r"\d{8,}", cleaned.replace(" ", "")): return True return False def _quote_nos_mentioned(text: str) -> set[str]: """原话/识图正文里出现的报价编号值。""" return { m.group(1).strip() for m in _QUOTE_NO_IN_TEXT.finditer(text or "") if (m.group(1) or "").strip() } def _drop_quote_like_place_values( facts: dict[str, str], *, source_text: str = "", ) -> dict[str, str]: """ 起运地/目的地若是报价编号、工单号或原话「报价编号:」同一值,丢掉。 副作用:无写库;只清洗本轮 facts。 """ out = dict(facts or {}) mentioned = _quote_nos_mentioned(source_text) for key in ("起运港", "起运地", "目的港", "目的地"): val = str(out.get(key) or "").strip() if not val: continue if not (looks_like_quote_id_place(val) or val in mentioned): continue out.pop(key, None) if key == "起运港": out.pop("originCode", None) if key == "目的港": out.pop("destinationCode", None) return out 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) facts = apply_cargo_handover_synonym(text, facts, business_line=mode) for key in ("货好时间", "提货地址", "收货地址", "货源地", "贸易条款"): if str(facts.get(key) or "").strip(): facts[key] = clip_inquiry_value(str(facts[key])) facts = _drop_fee_place_values(_clip_sheet_inquiry_facts(facts)) facts = _drop_quote_like_place_values(facts, source_text=text) facts = _apply_air_added_services(text, facts, mode) subtype = str(facts.get("运输类型") or "").strip() return { "business_line": mode, "land_subtype": subtype, "facts": facts, "source": "injected", } # 附件常是整张报价单。先丢掉费用明细和条款,再抽询价栏。 text = prepare_sheet_oral(text) 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 # 运输方式只认原话里的空运/海运/陆运、1/2/3、飞。 # 模型常把「郑州-吉隆坡」猜成空运,不能拿来默认。 source = "deepseek_b" facts = harvest_oral_measures(text, normalize_facts(facts)) if (mode or "").upper() == "LAND": from agent.schema.field_validate import ( harvest_oral_land_vehicle, promote_land_vehicle_from_container, ) facts = promote_land_vehicle_from_container(facts) facts = harvest_oral_land_vehicle(text, facts) facts = _merge_spoken_collab(text, facts) facts = _merge_optional_inquiry_labels(text, facts) facts = apply_cargo_handover_synonym(text, facts, business_line=mode) for key in ("货好时间", "提货地址", "收货地址", "货源地", "贸易条款"): if str(facts.get(key) or "").strip(): facts[key] = clip_inquiry_value(str(facts[key])) facts = _drop_fee_place_values(_clip_sheet_inquiry_facts(facts)) facts = _drop_quote_like_place_values(facts, source_text=text) facts = _apply_air_added_services(text, facts, mode) 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万」切成三项。 只说「纸箱」没有标签则不收。值取到下一个标签之前。 「(如:随时可提)」整行丢掉,避免补问示例被当成已填。 """ kept = "\n".join( line for line in (text or "").splitlines() if not _CLARIFY_EXAMPLE_LINE.match(line.strip()) ) return _harvest_labels(kept, _COLLAB_LABELS) def harvest_inquiry_optional_labels(text: str) -> dict[str, str]: """ 陆运询价非必填标签:通关口岸。 核对卡写「通关口岸(非必填):」,销售可能照抄或只写「通关口岸皇岗」。 有无冒号都收。不猜口岸名。 """ return _harvest_labels(text, _OPTIONAL_INQUIRY_LABELS) def is_clarify_ui_prompt(raw: str | None) -> bool: """ 补问/核对卡上的系统提示,不是任何询价字段的值。 空「货物品名(必填):」后若没换行,下一句「请确认并补充以下信息」会粘成品名 (多段陆运核对卡运行证据)。报价日期误收「请复制粘贴…」同属此类。 """ text = str(raw or "").strip().strip("::") if not text: return False if "请复制粘贴" in text or "告知我以下字段" in text: return True if "请确认并补充以下信息" in text: return True if "请复制以下内容" in text: return True if re.match(r"^Step\s*[12]\s*:", text, flags=re.I): return True return False def clip_inquiry_value(raw: str) -> str: """ 字段值收到下一个编号项为止。 图上常在货好时间后面接着「4. 希望送到…」「6. 送货地址」。 那些不是询价字段,不能进补问。地址里的「602、603」没有点号后空格,会留下。 补问复制清单「10.增值服务」点后可无空格;空贸易条款不能把下一栏标签收成值。 空报价日期不能把下一行「请复制粘贴…」收成日期值。 """ text = (raw or "").strip() if not text: return "" text = re.sub(r"[((]如\s*[::][^))]*[))]?\s*$", "", text).strip() if _CLARIFY_EXAMPLE_LINE.match(text): return "" # 补问 UI 提示不是任何字段的值(空栏后没换行会粘上标题)。 if is_clarify_ui_prompt(text): return "" # 整段就是下一栏「10.增值服务」或「增值服务(非必填):…」→ 本栏未填。 if _starts_with_inquiry_label(text): return "" # 值中间换行后出现「10.增值服务」:切到下一栏之前(点后可无空格)。 cut_at = -1 for match in re.finditer(r"(?m)^\s*\d+[\.、.]\s*", text): if match.start() > 0 and _starts_with_inquiry_label(text[match.start() :]): cut_at = match.start() break if cut_at > 0: text = text[:cut_at] hit = _NUMBERED_ITEM.search(text) if hit and hit.start() > 0: text = text[: hit.start()] text = re.sub(r"(?:\n|\r)+\s*\d+[\.、.]\s*$", "", text) text = text.strip() # 复制补问清单时空着的下一行是「4.提货地址」。切到下一栏后只剩编号,不能当成货好时间。 if re.fullmatch(r"\d+[\.、.]", text): return "" return text def _starts_with_inquiry_label(text: str) -> bool: """ 值是否以「另一询价栏目标签」开头(可带补问编号、冒号、必填标记)。 空起运地被误收成「目的地:CRK」时走这里丢掉,避免 TMS 按脏起运地查价。 「10.增值服务」「10.增值服务(非必填):卡转」也算下一栏,不能进贸易条款。 「目的地仓库」这种地名不含冒号/必填标记,不算标签,照常保留。 """ raw = (text or "").lstrip() if not raw: return False # 补问复制清单编号「10.」可无空格,先剥掉再认标签。 raw = _CLARIFY_INDEX.sub("", raw, count=1).lstrip() if not raw: return False labels = sorted(_SHEET_CLEAN_KEYS | _CLARIFY_NAMES, key=len, reverse=True) for label in labels: if len(label) < 2 or not raw.startswith(label): continue if not _label_is_token(raw, 0, label): continue rest = raw[len(label) :] if re.match(r"^\s*$", rest): return True if re.match(r"^\s*[::]", rest): return True if re.match(r"^\s*[((][^))]*(?:必填|非必填)", rest): return True return False def clip_sheet_field_value(raw: str) -> str: """ 报价单格子:去掉 /POL、(必填说明)、Terms 和同一行下一列表头,只留紧挨的值。 测服手工海运单:起运港/POL(必填) 纽约港 目的港… 巴生西港 费用汇总 → 纽约港 / 巴生西港。 取第一格有效值,不要最后一格(最后一格常是费用汇总、提货地址)。 地址字段不要走这里。 """ text = clip_inquiry_value(raw) if not text: return "" text = _SHEET_EN_ALIAS.sub("", text) text = _SHEET_REQUIRED_HINT.sub("", text) text = _SHEET_REQUIRED_HINT_EN.sub("", text) text = re.sub(r"^\((?:KG|CBM)\)\s*[::]?\s*", "", text, flags=re.I) if "必填" in text: text = re.sub(r"^[^)\n]*必填[^)\n]*)\s*", "", text) text = re.sub(r"^[\s::]+", "", text) # 本栏为空却吞进「目的地:CRK」时,整段丢掉(标签在 pos=0,后面切栏逻辑碰不到)。 if _starts_with_inquiry_label(text): return "" for label in _SHEET_CLEAN_KEYS: idx = 0 while True: pos = text.find(label, idx) if pos < 0: break if pos > 0 and _label_is_token(text, pos, label): text = text[:pos] break idx = pos + len(label) for stop in _SHEET_STOP: pos = text.find(stop) if pos >= 0: text = text[:pos] text = text.strip().strip("::/*") parts = [p.strip().strip("::/*") for p in re.split(r"[\t]+|\s{2,}", text) if p.strip().strip("::/*")] parts = [p for p in parts if p and not _SHEET_HEADER_NOISE.search(p)] if not parts: return "" # 格子里只剩下一个字段名(体积、时效要求)时,这一栏是空的,不能把字段名当成值。 # 只剩「4.」也是空的:那是下一行「4.提货地址」被切掉字段名后留下的编号。 token = parts[0].strip().strip("::") if token in _SHEET_CLEAN_KEYS or token in _TIGHTEN_LABELS: return "" if re.fullmatch(r"\d+[\.、.]", token): return "" return parts[0].strip() def harvest_sheet_inquiry_cells(text: str) -> dict[str, str]: """ 按询价字段名切报价单原文。标签必须是独立词,不能切「指定起运港口」的中间。 一行两个字段时,值收到下一个字段名为止。副作用:无。 """ raw = text or "" hits: list[tuple[int, str]] = [] for label in _SHEET_CLEAN_KEYS: start = 0 while True: pos = raw.find(label, start) if pos < 0: break if _label_is_token(raw, pos, label): hits.append((pos, label)) start = pos + len(label) if not hits: return {} hits.sort() 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) raw_chunk = raw[begin:end] # 报价日期只认同行:空栏后的粘贴提示不能进格子值。 if label in {"报价日期", *QUOTE_DATE_SYNONYMS}: raw_chunk = raw_chunk.split("\n", 1)[0] val = clip_sheet_field_value(raw_chunk) if val: out[label] = val return out def _label_is_token(raw: str, pos: int, label: str) -> bool: """ 标签必须是独立词。 「指定起运港口」中间的起运港不要。重量(KG)/体积(CBM) 留给更长标签。 """ if pos > 0: prev = raw[pos - 1] if prev.isalnum() or "\u4e00" <= prev <= "\u9fff": return False end = pos + len(label) nxt = raw[end : end + 6] if label in {"重量", "体积"} and (nxt.startswith("(") or nxt.startswith("(")): return False return True def _clip_sheet_inquiry_facts(facts: dict[str, str]) -> dict[str, str]: """已抽出的询价键再洗一遍表头。不改地址。""" out = dict(facts or {}) for key in list(out): if key not in _SHEET_CLEAN_KEYS: continue val = str(out.get(key) or "").strip() if not val: continue out[key] = clip_sheet_field_value(val) return {key: val for key, val in out.items() if str(val or "").strip()} def _apply_air_added_services(text: str, facts: dict[str, str], mode: str) -> dict[str, str]: """空运才收增值服务。其他业务线原样返回,避免海运口播里的托盘被记进来。""" if (mode or "").strip().upper() != "AIR": return facts from agent.schema.added_services import apply_air_value_added return apply_air_value_added(text, facts) 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(): cleaned = clip_inquiry_value(val) if key in _SHEET_CLEAN_KEYS: cleaned = clip_sheet_field_value(cleaned) if not cleaned: continue old = str(merged.get(key) or "").strip() if old and cleaned.startswith(old) and len(cleaned) > len(old): continue if old and ("必填" in cleaned or "\n" in cleaned or len(cleaned) > len(old) * 3): continue merged[key] = cleaned 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 if _label_is_token(raw, pos, label): 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] # 增值服务 / 报价日期只认冒号后同一行。空行不能把下一行「请复制粘贴…」收成日期。 if label.startswith("增值服务") or label in {"报价日期", *QUOTE_DATE_SYNONYMS}: chunk = chunk.split("\n", 1)[0] same_line = chunk.split("\n", 1)[0] same_line_body = re.sub(r"^[\s::,,、]+", "", same_line).strip() # 冒号后本行为空 = 未填。禁止跨行把下一栏「目的地:吉隆坡」收成起运地。 # 核对卡无换行粘贴时「起运地(非必填): 目的地(必填):CRK」也视为未填。 if not same_line_body or _starts_with_inquiry_label(same_line_body): chunk = "" else: chunk = re.sub(r"^[\s::,,、]+", "", chunk) chunk = re.sub(r"[\s,,、]+$", "", chunk) # 报价日期同行若误收成「请复制粘贴…」补问提示,必须丢掉(WO202610170068)。 if label in {"报价日期", *QUOTE_DATE_SYNONYMS}: chunk = clip_inquiry_value(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: from agent.schema.field_validate import normalize_facts norm = normalize_facts(dict(injected)) return { k: str(v).strip() for k, v in norm.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()} # 金额旁币种词:含港币;禁止只认 USD/CNY 把港币单漏成美元。 _CCY_TOKEN = r"USD|CNY|RMB|HKD|EUR|JPY|GBP|美金|美元|人民币|港币|港元|欧元|日元|英镑" _RE_MONEY = re.compile( rf"(?:{_CCY_TOKEN}|元)?\s*(\d+(?:\.\d+)?)\s*(?:{_CCY_TOKEN}|元)?" rf"|(?:{_CCY_TOKEN})\s*(\d+(?:\.\d+)?)", re.I, ) # 陆运集拼:「2-500KG 单价CNY990」是分档单价,不是「燃油附加费=2」。 _RE_WEIGHT_BAND_PRICE = re.compile( r"(?P\d+)\s*[-~~—–到至]\s*(?P\d+)\s*K\s*G" r"(?:\s*以上)?" r"\s*(?:单价)?" rf"\s*(?:(?P{_CCY_TOKEN})\s*)?" r"(?P\d+(?:\.\d+)?)", re.I, ) _RE_WEIGHT_BAND_OPEN = re.compile( r"(?P\d+)\s*K\s*G\s*以上" r"\s*(?:单价)?" rf"\s*(?:(?P{_CCY_TOKEN})\s*)?" r"(?P\d+(?:\.\d+)?)", re.I, ) # 陆运整车产品口语:车型:5T/8T:13000 10T:14000 40HQ/45HQ:19500 # 标签可斜杠并价(5T/8T 同价);40/45HQ 要拆成 40HQ+45HQ。金额至少三位,避免吃到「段1」。 _RE_VEHICLE_RATE_ITEM = re.compile( r"(?P