Files
inquiry_robot/inquiry-agent/agent/policy/multi_segments.py
T

630 lines
23 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
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.
"""
多段联运的分段与文案。
本文件职责:按原文出现顺序把不同运输方式切成段;汇总各段必填缺口;
拼私聊核对(只有询价字段)和群摘要(询价字段 + 协同字段,不写 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
# 「段1:」或「第一段」。第组是阿拉伯数字,第二组是「一」或单个数字。运输方式在第三组。
_SEG_PREFIX = (
r"(?:段\s*(\d+)\s*[::]\s*|第\s*([一二三四五六七八九]|\d+)\s*段\s*[::,,]?\s*)"
)
# 行首或段标后面的运输方式。排除「空运费」,避免费用词被当成新的一段。
_MODE_RE = re.compile(rf"(?:{_SEG_PREFIX})?(空运|海运|陆运)(?!费)")
# 已经写了「段1:陆运」或「第一段陆运」时,段内再写「运输方式:海运」不能再切一段。
_EXPLICIT_SEG = re.compile(rf"{_SEG_PREFIX}(空运|海运|陆运)(?!费)")
# 单段标签行没覆盖的陆运销售叫法。内部键仍交给字段校验去归一。
_EXTRA_LABEL = re.compile(r"^(始发站|货物品名|运输类型|线路类别)\s*[::]\s*(.+)$")
_SEG_MARK = re.compile(r"第\s*([一二三四五六七八九]|\d+)\s*段|段\s*(\d+)")
_CN_INDEX = {
"一": 1,
"二": 2,
"三": 3,
"四": 4,
"五": 5,
"六": 6,
"七": 7,
"八": 8,
"九": 9,
}
# 段标前面写一次、两段都要的货物。地名不在这里,避免把北京写进空运起运地。
_SHARED_CARGO_KEYS = ("品名", "件数", "毛重", "体积", "包装方式")
_SHARED_NOISE = re.compile(
r"\d+(?:\.\d+)?(?:\s*[*xX×]\s*\d+(?:\.\d+)?){1,2}"
r"|\d+(?:\.\d+)?\s*(?:kgs|kg|公斤|千克|cbm|立方米|立方|m3|m³|方|件|pcs)\b"
r"|纸箱|木箱|托盘|卡板|散货",
re.I,
)
_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:海运」和同一句里的「陆运+海运」都认。
写了「段N:」就只按段标切:一份海运报价单文件名会先标「段1:海运」,
表里再写「运输方式:海运」仍是单段,不能报同一方式两段。
没写段标时,报价单反复出现「海运」也当单段;只有「陆运+海运」才切多段。
真的两段都是海运(段1:海运 + 段2:海运)才 same_mode,不建单。
「第一段陆运」和「段1:陆运」是同一种段标。
段标前面单独写的品名、件数、重量、体积,每一段都收下。不猜没写标签的港口。
"""
raw = text or ""
explicit = list(_EXPLICIT_SEG.finditer(raw))
# explicit_only_segment_split:有段标就只认段标。一份附件常被标「段1:海运」,表里还会再写一次海运。
hits = explicit if explicit else list(_MODE_RE.finditer(raw))
if len(hits) < 2:
return SplitOutcome()
modes = [_MODE_BY_WORD[hit.group(3)] for hit in hits]
unique = set(modes)
if len(modes) != len(unique):
# 没标出两段时,同一种方式写两次仍是单段(海运报价单标题+运输方式)。
if len(explicit) < 2 and len(unique) == 1:
return SplitOutcome()
return SplitOutcome(same_mode=True)
shared = _shared_cargo_facts(raw[: hits[0].start()])
lead = raw[: hits[0].start()].strip()
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]
mode = modes[i]
# 抽取要看得到段前那句货物,否则「茶叶,11kg」写在第一段前面会被丢掉。
source = f"{lead}\n{body}".strip() if lead else body
segments.append(
Segment(
index=_segment_index(hit, i + 1),
mode=mode,
facts=_fill_blank(_facts_from_body(body, mode), shared),
source=source,
)
)
segments.sort(key=lambda seg: seg.index)
return SplitOutcome(ok=True, segments=segments)
def _segment_index(hit: re.Match[str], fallback: int) -> int:
"""段标上的序号。没有段标就按出现顺序,从 1 起。"""
token = hit.group(1) or hit.group(2) or ""
if token in _CN_INDEX:
return _CN_INDEX[token]
if token.isdigit():
return int(token)
return fallback
def _shared_cargo_facts(header: str) -> dict[str, str]:
"""
段标前面写一次的货物。
件数、重量、体积、包装用口语机读。尺寸 1.1*2.1*2.2 保持原文,不乘成立方。
去掉这些之后只剩一个词,就当品名。带「到」的地名不当品名。
只返回货物键,不把起运地抄到每一段。
"""
raw = (header or "").strip()
if not raw:
return {}
measured = harvest_oral_measures(raw, {})
name = _cargo_name_before_segments(raw)
if name and not str(measured.get("品名") or "").strip():
measured["品名"] = name
return {
key: str(measured.get(key) or "").strip()
for key in _SHARED_CARGO_KEYS
if str(measured.get(key) or "").strip()
}
def _cargo_name_before_segments(raw: str) -> str:
"""段前货物句里,去掉件数重量体积包装后剩下的那一个品名。"""
cleaned = _SHARED_NOISE.sub(" ", raw or "")
parts: list[str] = []
for part in re.split(r"[,,、;;\n]+", cleaned):
token = part.strip(" ::。.")
if not token or re.fullmatch(r"[\d.\s*xX×]+", token):
continue
if any(word in token for word in ("到", "飞", "空运", "海运", "陆运", "段")):
continue
parts.append(token)
if len(parts) == 1 and len(parts[0]) <= 30:
return parts[0]
return ""
def _fill_blank(base: dict[str, str], extra: dict[str, str]) -> dict[str, str]:
"""只补还空着的键。段内已经写了的品名、港口不被段前那句盖掉。"""
out = dict(base)
for key, val in extra.items():
if str(val or "").strip() and not str(out.get(key) or "").strip():
out[key] = str(val).strip()
return out
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 _mark_index(mark: re.Match[str]) -> int:
"""「段1」或「第一段」的序号。调用前已经匹配到段标。"""
token = mark.group(1) or mark.group(2) or ""
if token in _CN_INDEX:
return _CN_INDEX[token]
return int(token)
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[_mark_index(mark)] = facts
return out