调价后的新价格写入报价单;多段段前的货物带到各段,协同群不再把体积里的星号吃掉。海运字段齐了先核对再查价。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user