调价后的新价格写入报价单;多段段前的货物带到各段,协同群不再把体积里的星号吃掉。海运字段齐了先核对再查价。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-10-03 22:39:54 +08:00
co-authored by Cursor
parent 0a14b4cc15
commit 7c77e45e03
32 changed files with 1384 additions and 75 deletions
+106 -14
View File
@@ -27,13 +27,36 @@ from agent.schema.land_options import (
)
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*(空运|海运|陆运)(?!费)")
# 「段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+)")
_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": "陆运"}
@@ -85,7 +108,8 @@ def split_segments(text: str) -> SplitOutcome:
表里再写「运输方式:海运」仍是单段,不能报同一方式两段。
没写段标时,报价单反复出现「海运」也当单段;只有「陆运+海运」才切多段。
真的两段都是海运(段1:海运 + 段2:海运)才 same_mode,不建单。
每段正文只收「字段名:值」,不猜没写标签的港口。
「第一段陆运」和「段1:陆运」是同一种段标。
段标前面单独写的品名、件数、重量、体积,每一段都收下。不猜没写标签的港口。
"""
raw = text or ""
explicit = list(_EXPLICIT_SEG.finditer(raw))
@@ -93,31 +117,91 @@ def split_segments(text: str) -> SplitOutcome:
hits = explicit if explicit else list(_MODE_RE.finditer(raw))
if len(hits) < 2:
return SplitOutcome()
modes = [_MODE_BY_WORD[hit.group(2)] for hit in hits]
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]
explicit = hit.group(1)
index = int(explicit) if explicit else i + 1
mode = modes[i]
# 抽取要看得到段前那句货物,否则「茶叶,11kg」写在第一段前面会被丢掉。
source = f"{lead}\n{body}".strip() if lead else body
segments.append(
Segment(
index=index,
mode=modes[i],
facts=_facts_from_body(body, modes[i]),
source=body,
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]]:
"""
一段是否齐了询价必填。陆运还要三项选项,模板码站点当没填。
@@ -513,6 +597,14 @@ def _facts_from_body(body: str, mode: str) -> dict[str, str]:
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」后面的内容。
@@ -533,5 +625,5 @@ def _split_by_segment_mark(text: str) -> dict[int, dict[str, str]]:
facts = harvest_sea_class(body, facts)
facts = harvest_oral_measures(body, facts)
if facts:
out[int(mark.group(1))] = facts
out[_mark_index(mark)] = facts
return out