Files
inquiry_robot/inquiry-agent/agent/policy/multi_segments.py
T
jillion886andCursor 378bb892b3 陆运选项不全时改为复制填空清单,方便销售按编号回填。
停发填写样例图,同群只跟当前工单,重量体积统一为 KG/CBM。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-23 17:10:13 +08:00

530 lines
19 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
# 行首或「段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