停发填写样例图,同群只跟当前工单,重量体积统一为 KG/CBM。 Co-authored-by: Cursor <cursoragent@cursor.com>
530 lines
19 KiB
Python
530 lines
19 KiB
Python
"""
|
||
多段联运的分段与文案。
|
||
|
||
本文件职责:按原文出现顺序把不同运输方式切成段;汇总各段必填缺口;
|
||
拼私聊核对(只有询价字段)和群摘要(询价字段 + 协同字段,不写 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
|