""" 多段联运的分段与文案。 本文件职责:按原文出现顺序把不同运输方式切成段;汇总各段必填缺口; 拼私聊核对(只有询价字段)和群摘要(询价字段 + 协同字段,不写 TMS 报价)。 禁止:猜港口、查 TMS、建群、改六态。调用方在 Worker 线程使用,本模块无共享写状态。 """ from __future__ import annotations import re from dataclasses import dataclass, field from typing import Any from agent.policy import inquiry_copy as copy from agent.schema.field_validate import ( harvest_oral_measures, normalize_facts, validate_required_fields, ) from agent.llm.extract_text import parse_labeled_facts from agent.schema.land_options import ( harvest_land_options, option_prompt, scrub_land_placeholder_ports, validate_land_options, ) from agent.schema.sea_options import harvest_sea_class # 行首或「段N:」后面的运输方式。排除「空运费」,避免费用词被当成新的一段。 _MODE_RE = re.compile(r"(?:段\s*(\d+)\s*[::]\s*)?(空运|海运|陆运)(?!费)") # 已经写了「段1:陆运」时,段内再写「运输方式:海运」不能再切一段。 _EXPLICIT_SEG = re.compile(r"段\s*(\d+)\s*[::]\s*(空运|海运|陆运)(?!费)") # 单段标签行没覆盖的陆运销售叫法。内部键仍交给字段校验去归一。 _EXTRA_LABEL = re.compile(r"^(始发站|货物品名|运输类型|线路类别)\s*[::]\s*(.+)$") _SEG_MARK = re.compile(r"段\s*(\d+)") _MODE_BY_WORD = {"空运": "AIR", "海运": "SEA", "陆运": "LAND"} _WORD_BY_MODE = {"AIR": "空运", "SEA": "海运", "LAND": "陆运"} _LAND_OPTION_KEYS = frozenset({"运输类型", "线路类别", "运输分类"}) @dataclass class Segment: """一段询价。index 从 1 起,在同一张工单里保持不变。""" index: int mode: str facts: dict[str, str] = field(default_factory=dict) source: str = "" def word(self) -> str: return _WORD_BY_MODE.get(self.mode, self.mode) @dataclass class SplitOutcome: """切段结果。ok 才是可继续的多段;same_mode 表示同一种方式出现了两次。""" ok: bool = False same_mode: bool = False segments: list[Segment] = field(default_factory=list) def classify_multi_text(text: str) -> str: """ 这句话是不是多段。 返回 multi / same_mode / single。单段仍走原来的海运、陆运、空运流程。 """ outcome = split_segments(text) if outcome.same_mode: return "same_mode" if outcome.ok: return "multi" return "single" def split_segments(text: str) -> SplitOutcome: """ 按运输方式出现的先后切段。 「段1:海运」和同一句里的「陆运+海运」都认。同一方式出现两次则 same_mode,不建单。 已经标了两段以上时,只按「段N:」切,表里重复的「海运/陆运」不当新段。 每段正文只收「字段名:值」,不猜没写标签的港口。 """ raw = text or "" explicit = list(_EXPLICIT_SEG.finditer(raw)) hits = explicit if len(explicit) >= 2 else list(_MODE_RE.finditer(raw)) if len(hits) < 2: return SplitOutcome() modes = [_MODE_BY_WORD[hit.group(2)] for hit in hits] if len(modes) != len(set(modes)): return SplitOutcome(same_mode=True) segments: list[Segment] = [] for i, hit in enumerate(hits): end = hits[i + 1].start() if i + 1 < len(hits) else len(raw) body = raw[hit.end() : end] explicit = hit.group(1) index = int(explicit) if explicit else i + 1 segments.append( Segment( index=index, mode=modes[i], facts=_facts_from_body(body, modes[i]), source=body, ) ) segments.sort(key=lambda seg: seg.index) return SplitOutcome(ok=True, segments=segments) def _segment_required_check(seg: Segment) -> tuple[dict[str, Any], dict[str, str]]: """ 一段是否齐了询价必填。陆运还要三项选项,模板码站点当没填。 海运/空运仍走字段合同。返回校验结果和写回后的事实,不改入参。 """ if seg.mode == "LAND": facts = scrub_land_placeholder_ports(normalize_facts(dict(seg.facts))) opt = validate_land_options(facts) facts.update({k: v for k, v in dict(opt.get("normalized") or {}).items() if v}) check = dict( validate_required_fields( facts=facts, business_line="LAND", land_subtype=str(facts.get("运输类型") or ""), ) ) option_missing = [str(x) for x in (opt.get("missing") or []) if str(x).strip()] cargo_missing = [str(x) for x in (check.get("missing") or []) if str(x).strip()] missing = option_missing + [k for k in cargo_missing if k not in option_missing] merged = dict(check.get("facts") or facts) merged.update({k: facts[k] for k in ("运输类型", "线路类别", "运输分类") if facts.get(k)}) check["facts"] = merged check["missing"] = missing check["ok"] = not missing check["need_land_subtype"] = False check["need_land_options"] = bool(option_missing) return check, merged check = validate_required_fields(facts=dict(seg.facts), business_line=seg.mode) facts = dict(seg.facts) for key, val in dict(check.get("facts") or {}).items(): if val is None: continue text = str(val).strip() if text: facts[key] = text return check, facts def refresh_segments(segments: list[Segment]) -> tuple[bool, list[Segment], str]: """ 按各段运输方式校验必填,并把默认报价日期写回。 返回:是否已齐、更新后的段、缺口文案。缺口一条消息说完,不套群摘要。 待补充的举例与单聊相同:字段名一行,下一行「(如:茶叶)」。 """ updated: list[Segment] = [] blocks: list[str] = [] ready = True for seg in segments: check, facts = _segment_required_check(seg) filled = Segment(index=seg.index, mode=seg.mode, facts=facts, source=seg.source) updated.append(filled) body = _segment_clarify(filled, check) if body: ready = False blocks.append(f"段{filled.index}:{filled.word()}\n{body}") if ready: return True, updated, "" return False, updated, "请确认并补充以下信息:\n\n" + "\n\n".join(blocks) def merge_supplement( segments: list[Segment], text: str, *, extra_facts: dict[str, str] | None = None, ) -> list[Segment]: """ 把销售补上的字段写回对应段。 已有草稿时,再写段1/段2只把新字段叠上去,不整单替换。 没有旧草稿才按切段结果当新开。写了段号就按段号归。 只有一段还缺、且没写段号时,标签行、口语机读和抽取结果都补到那一段。 各段已齐或两段都缺时:货物数量/箱型只补海运,件数/体积只补空运。对不上不猜。 extra_facts 来自单聊同一套抽取(DeepSeek B / 单测注入),不在这里猜港口。 """ resent = split_segments(text) if resent.ok: if segments: return _overlay_segments(segments, resent.segments, extra_facts) return resent.segments marked = _split_by_segment_mark(text) if marked: out = [Segment(index=s.index, mode=s.mode, facts=dict(s.facts)) for s in segments] by_index = {s.index: s for s in out} for index, facts in marked.items(): target = by_index.get(index) if target is None: continue for key, val in facts.items(): if str(val or "").strip(): target.facts[key] = str(val).strip() return out parsed = _unlabeled_supplement_facts(text, extra_facts) if not parsed: return segments lacking = _lacking_indexes(segments) if len(lacking) == 1: targets = lacking else: # 核对后改海运格子、或两段都缺时只补海运字段:按字段归属落到唯一一段。 targets = _indexes_for_supplement(segments, parsed) if len(targets) != 1: return segments out = [Segment(index=s.index, mode=s.mode, facts=dict(s.facts)) for s in segments] for seg in out: if seg.index != targets[0]: continue for key, val in parsed.items(): if str(val or "").strip(): seg.facts[key] = str(val).strip() return out def _overlay_segments( existing: list[Segment], incoming: list[Segment], extra_facts: dict[str, str] | None = None, ) -> list[Segment]: """ 把新切出来的段叠到旧草稿上。同号优先,否则同运输方式。 只覆盖非空新值,旧港口/品名保住。陆运选项从新段正文再收一遍。 """ _ = extra_facts out = [ Segment(index=seg.index, mode=seg.mode, facts=dict(seg.facts), source=seg.source) for seg in existing ] by_index = {seg.index: seg for seg in out} by_mode: dict[str, Segment] = {} for seg in out: by_mode.setdefault(seg.mode, seg) for neu in incoming: target = by_index.get(neu.index) or by_mode.get(neu.mode) if target is None: continue blob = (neu.source or "").strip() if target.mode == "LAND": overlay = harvest_land_options(blob, dict(neu.facts)) elif target.mode == "SEA": overlay = harvest_sea_class(blob, dict(neu.facts)) else: overlay = dict(neu.facts) overlay = normalize_facts(overlay) for key, val in overlay.items(): token = str(val or "").strip() if token: target.facts[key] = token if target.mode == "SEA": target.facts = harvest_sea_class(blob, target.facts) if target.mode == "LAND": target.facts = harvest_land_options(blob, target.facts) return out def confirm_text(segments: list[Segment]) -> str: """ 私聊核对。每段只列询价字段,最后一句是创建工单。 不写协同字段,不写 TMS。各段沿用该运输方式现有核对卡的字段,去掉原「查价」尾句。 """ blocks: list[str] = [] for seg in segments: body = _segment_inquiry_block(seg) blocks.append(f"段{seg.index}:{seg.word()}\n{body}".rstrip()) blocks.append(copy.MULTI_CONFIRM_TAIL) return "\n\n".join(blocks) def group_brief( *, work_order_no: str, segments: list[Segment], sales_name: str = "", mention_names: list[str] | None = None, ) -> str: """ 拉群后发到群里的摘要。 结构对齐私聊核对:@销售和各段产品,下一行直接接段1询价字段。 不写 TMS,不写协同段。工单号已在私聊提示过,群里不再重复一行。 """ _ = work_order_no names: list[str] = [] sales = (sales_name or "").strip() if sales: names.append(sales) for name in mention_names or []: token = (name or "").strip() if token and token not in names: names.append(token) lines: list[str] = [] if names: lines.append("".join(f"@{n}" for n in names)) for i, seg in enumerate(segments): # @人下面不要空行;段与段之间仍空一行,方便扫。 if i > 0: lines.append("") lines.append(f"段{seg.index}:{seg.word()}") body = _segment_inquiry_block(seg).strip() if body: lines.extend(body.splitlines()) return "\n".join(lines) def segments_to_payload(segments: list[Segment]) -> list[dict[str, Any]]: """书签和工单里保存的段。只保留字符串,避免书签序列化掉类型。""" return [ { "index": seg.index, "mode": seg.mode, "facts": {str(k): str(v) for k, v in (seg.facts or {}).items() if str(v).strip()}, } for seg in segments ] def segments_from_payload(raw: Any) -> list[Segment]: """从书签读回段。坏数据当空,调用方不得据此建单。""" if not isinstance(raw, list): return [] out: list[Segment] = [] for item in raw: if not isinstance(item, dict): continue mode = str(item.get("mode") or "").strip().upper() if mode not in _WORD_BY_MODE: continue facts = { str(k): str(v) for k, v in dict(item.get("facts") or {}).items() if str(v).strip() } try: index = int(item.get("index") or len(out) + 1) except (TypeError, ValueError): index = len(out) + 1 out.append(Segment(index=index, mode=mode, facts=facts)) return out def includes_air(segments: list[Segment]) -> bool: """任一段是空运,就走手动拉群,不点「拉产品进群」。""" return any(seg.mode == "AIR" for seg in segments) def _segment_clarify(seg: Segment, check: dict[str, Any]) -> str: """ 一段的补问正文,格式对齐单聊:已识别 + 待补充带举例。 不要再写一遍「请确认并补充」和运输方式,外层已经有段号。 """ if check.get("ok") and not check.get("need_land_subtype"): return "" missing = [str(x) for x in (check.get("missing") or []) if str(x).strip()] if check.get("need_land_subtype"): return ( copy.CLARIFY_PASTE_HINT + "\n1.运输类型(必填):\n(如:国内运输拼车)" ) if seg.mode == "LAND": if check.get("need_land_options"): # 和单聊一样先列出线路类别、运输分类,选完再问询价字段。 return option_prompt(current=seg.facts) cargo_missing = [k for k in missing if k not in _LAND_OPTION_KEYS] raw = copy.ask_land_fields( facts=seg.facts, missing_keys=cargo_missing, land_subtype=str(seg.facts.get("运输类型") or ""), ) else: raw = copy.ask_clarify( facts=seg.facts, missing_keys=missing, transport_mode=seg.word(), ) if not raw: return "" skip = {"请确认并补充以下信息:", f"运输方式:{seg.word()}"} kept = [row for row in raw.splitlines() if row.strip() not in skip] return "\n".join(kept).strip() def _segment_inquiry_block(seg: Segment) -> str: """一段的询价字段正文,去掉运输方式行和单段「去查价」尾句。""" if seg.mode == "AIR": text = copy.air_confirm_card(seg.facts) tail = copy.AIR_CONFIRM_TAIL elif seg.mode == "LAND": text = copy.land_confirm_card(seg.facts) tail = copy.LAND_CONFIRM_TAIL else: rows = copy.inquiry_field_lines( work_order_no="", facts=seg.facts, transport_mode="海运", ) kept = [ row for row in rows if row and not row.startswith("工单号") and not row.startswith("运输方式") ] return "\n".join(kept) lines = [] for row in text.splitlines(): if not row.strip() or row.strip() == tail: continue if row.startswith("运输方式"): continue lines.append(row) return "\n".join(lines) def _unlabeled_supplement_facts( text: str, extra_facts: dict[str, str] | None, ) -> dict[str, str]: """ 没写段号时的补字段:标签行 + 体积重量包装 + 抽取结果。 运输类型等选项只认原话枚举,抽取结果不得把「陆运」盖掉已选的国内运输拼车。 海运运输分类与单聊相同:原话 Port to Port / 港到港 / 其他 就要收下。 """ parsed = parse_labeled_facts(text) parsed = harvest_oral_measures(text, parsed) parsed = harvest_land_options(text, parsed) parsed = harvest_sea_class(text, parsed) extra = normalize_facts(dict(extra_facts or {})) for key, val in extra.items(): token = str(val or "").strip() if not token or key in _LAND_OPTION_KEYS: continue if not str(parsed.get(key) or "").strip(): parsed[key] = token return {key: str(val).strip() for key, val in parsed.items() if str(val or "").strip()} # 核对后只改正文、没写段号时,用这些键判断补到哪一段。 _SEA_ONLY_KEYS = frozenset({"货量", "货物数量", "整柜或拼柜", "箱型箱量"}) _LAND_ONLY_KEYS = frozenset({"运输类型", "线路类别", "车型/数量", "通关口岸"}) _AIR_ONLY_KEYS = frozenset({"件数", "毛重", "体积", "包装方式", "包装类型"}) def _indexes_for_supplement(segments: list[Segment], parsed: dict[str, str]) -> list[int]: """ 没写段号时,按字段判断补到哪一段。 货物数量/箱型只属于海运;件数/体积只属于空运。对不上或对上两段则空,不猜。 """ keys = {str(k).strip() for k in parsed if str(k).strip()} if keys & _SEA_ONLY_KEYS: return [seg.index for seg in segments if seg.mode == "SEA"] if keys & _LAND_ONLY_KEYS: return [seg.index for seg in segments if seg.mode == "LAND"] if keys & _AIR_ONLY_KEYS: return [seg.index for seg in segments if seg.mode == "AIR"] if keys & {"起运港", "目的港", "品名", "贸易条款"}: sea_air = [seg.index for seg in segments if seg.mode in {"SEA", "AIR"}] if len(sea_air) == 1: return sea_air land = [seg.index for seg in segments if seg.mode == "LAND"] if not sea_air and len(land) == 1: return land return [] def _lacking_indexes(segments: list[Segment]) -> list[int]: """还缺必填的段号。只用来决定口语补句写进哪一段。""" lacking: list[int] = [] for seg in segments: ok, _, _ = refresh_segments([seg]) if not ok: lacking.append(seg.index) return lacking def _facts_from_body(body: str, mode: str) -> dict[str, str]: """一段正文里的「字段名:值」。陆运再收运输类型、线路类别和销售侧叫法。""" facts = parse_labeled_facts(body) for line in (body or "").splitlines(): matched = _EXTRA_LABEL.match(line.strip()) if not matched: continue key, val = matched.group(1), matched.group(2).strip() if val and not str(facts.get(key) or "").strip(): facts[key] = val if mode == "LAND": facts = harvest_land_options(body, facts) if mode == "SEA": facts = harvest_sea_class(body, facts) return facts def _split_by_segment_mark(text: str) -> dict[int, dict[str, str]]: """ 补充消息里「段1」「段2」后面的内容。 除「字段名:值」外,还收陆运选项(如「国内运输拼车」)和包装词(如「托盘」)。 没有段号则返回空。 """ raw = text or "" marks = list(_SEG_MARK.finditer(raw)) if not marks: return {} out: dict[int, dict[str, str]] = {} for i, mark in enumerate(marks): end = marks[i + 1].start() if i + 1 < len(marks) else len(raw) body = raw[mark.end() : end] facts = parse_labeled_facts(body) facts = harvest_land_options(body, facts) facts = harvest_sea_class(body, facts) facts = harvest_oral_measures(body, facts) if facts: out[int(mark.group(1))] = facts return out