角色业务范围收紧工单与模板;报价日期不当费用;未选舱不迁已报价;另开一票可清违禁 hold

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-10-15 21:00:01 +08:00
co-authored by Cursor
parent a0c705683f
commit 277cdc1c28
39 changed files with 2083 additions and 217 deletions
+120 -24
View File
@@ -3,6 +3,7 @@
本文件职责:把销售原话收成多项 added_services,供确认、工单和 TMS 的 addedServices 使用。
12 个服务词只要完整出现就记;名单外的必须写在「增值服务」后面才记,并保留原词。
服务词后面紧跟的「+数字」(如 TC +1.5、托盘+1)并进同一项,写成 TC+1.5、托盘+1。
包装类型/包装方式这一栏里的「托盘」只算包装,不抄进增值服务。
「增值服务:」同一行为空时,不把下一行收进来,也不沿用上一句已经记下的服务。
不挡查价。不改六态。海运、陆运不走这里。
@@ -30,6 +31,8 @@ _CN_RE = re.compile("|".join(re.escape(word) for word in _CN_TERMS))
_MUST_GO_RE = re.compile(r"(?i)MUST\s*GO")
_DG_RE = re.compile(r"(?i)(?<![A-Za-z])DG(?![A-Za-z])")
_TC_RE = re.compile(r"(?i)(?<![A-Za-z])TC(?![A-Za-z])")
# 服务词后的加价/数量:允许空格,记成「词+数字」,不把空格留进销售可见文案。
_QTY_SUFFIX_RE = re.compile(r"[^\S\n]*\+[^\S\n]*(\d+(?:\.\d+)?)")
# 冒号后只吃同一行。\s 会跨行,空着的增值服务会把下一行「报价日期(非必填):」吞进来。
_LABEL_RE = re.compile(
r"增值服务(?:(非必填)|\(非必填\))?[^\S\n]*[::][^\S\n]*([^\n]*)"
@@ -76,12 +79,13 @@ def harvest_added_services(text: str) -> list[str]:
补问示例行「(如:托盘、…)」整行不看,避免清单被当成已填。
「不带电」不算带电;「卡转低消」不算再加一项卡转。
「TC +1.5」记成 TC+1.5,数量跟服务名同一项。
副作用:无。
"""
raw = _without_clarify_examples(text or "")
events: list[tuple[int, str]] = []
# 包装栏里的托盘已在 _canonical_events 排除,这里只收正文里真正的服务词。
events.extend(_canonical_events(raw))
events.extend((start, word) for start, _end, word in _canonical_events(raw))
for match in _LABEL_RE.finditer(raw):
value = (match.group(1) or "").strip()
if not value or value.startswith("(如") or value.startswith("(如"):
@@ -100,7 +104,52 @@ def harvest_added_services(text: str) -> list[str]:
if extra:
events.append((base + at + max(piece.find(extra), 0), extra))
events.sort(key=lambda item: item[0])
return _dedupe([word for _pos, word in events])
result = _dedupe([word for _pos, word in events])
# #region agent log
try:
import json as _dbg_json
import time as _dbg_time
from pathlib import Path as _DbgPath
_tails: list[dict[str, str]] = []
for _m in list(_TC_RE.finditer(raw)) + list(_CN_RE.finditer(raw)):
if _m.group(0) not in {"TC", "tc", "托盘"} and _m.group(0).casefold() != "tc":
continue
_end = _m.end()
_tails.append(
{
"word": _m.group(0),
"after": raw[_end : _end + 12],
}
)
_payload = {
"sessionId": "5ea5b3",
"runId": "post-fix",
"hypothesisId": "H1",
"location": "added_services.py:harvest_added_services",
"message": "harvest result vs trailing modifiers",
"data": {
"text_preview": (raw or "")[:120],
"found": result,
"match_tails": _tails,
},
"timestamp": int(_dbg_time.time() * 1000),
}
for _path in (
_DbgPath(__file__).resolve().parents[3] / "debug-5ea5b3.log",
_DbgPath(r"C:/Users/ytd_extdev_0903/incoming/debug-5ea5b3.log"),
_DbgPath(r"d:/own/友通达询价智能体(新)/debug-5ea5b3.log"),
):
try:
_path.parent.mkdir(parents=True, exist_ok=True)
with _path.open("a", encoding="utf-8") as _f:
_f.write(_dbg_json.dumps(_payload, ensure_ascii=False) + "\n")
except Exception:
pass
except Exception:
pass
# #endregion
return result
def apply_air_value_added(
@@ -115,6 +164,7 @@ def apply_air_value_added(
只应在空运调用。副作用:返回新字典,不改入参。
"""
merged = dict(facts or {})
before_added = str(merged.get("added_services") or "")
found = harvest_added_services(text)
labeled = _labeled_added_value(text)
# 销售把「增值服务:」留空:这一栏就是空的。
@@ -122,23 +172,81 @@ def apply_air_value_added(
# 本句没写这一栏、也没提到服务时,才保留已经记下的旧值。
if labeled == "":
items = found
branch = "labeled_empty"
elif found:
items = _dedupe(split_added_services(previous) + found)
branch = "found_merge"
else:
items = _dedupe(
split_added_services(previous)
+ split_added_services(str(merged.get("added_services") or ""))
)
branch = "keep_previous"
if items:
merged["added_services"] = "、".join(items)
elif not str(merged.get("added_services") or "").strip():
merged.pop("added_services", None)
_keep_stated_package(text, merged)
# #region agent log
try:
import json as _dbg_json
import time as _dbg_time
from pathlib import Path as _DbgPath
_payload = {
"sessionId": "5ea5b3",
"runId": "post-fix",
"hypothesisId": "H2",
"location": "added_services.py:apply_air_value_added",
"message": "apply merge before/after",
"data": {
"text_preview": (text or "")[:120],
"before_added": before_added,
"found": found,
"previous": previous,
"labeled": labeled,
"branch": branch,
"after_added": str(merged.get("added_services") or ""),
"pack": str(merged.get("包装方式") or ""),
},
"timestamp": int(_dbg_time.time() * 1000),
}
for _path in (
_DbgPath(__file__).resolve().parents[3] / "debug-5ea5b3.log",
_DbgPath(r"C:/Users/ytd_extdev_0903/incoming/debug-5ea5b3.log"),
_DbgPath(r"d:/own/友通达询价智能体(新)/debug-5ea5b3.log"),
):
try:
_path.parent.mkdir(parents=True, exist_ok=True)
with _path.open("a", encoding="utf-8") as _f:
_f.write(_dbg_json.dumps(_payload, ensure_ascii=False) + "\n")
except Exception:
pass
except Exception:
pass
# #endregion
return merged
def _canonical_events(raw: str) -> list[tuple[int, str]]:
"""按原文位置列出 12 个标准词。重叠区间只留更长的那一项。"""
def _attach_qty_suffix(raw: str, start: int, end: int, word: str) -> tuple[int, int, str]:
"""
服务词后面若有 +数字,并进同一项。
例:TC +1.5 → (起点, 终点, "TC+1.5")。没有数量则原样返回。
为何:销售口播常写「TC +1.5、托盘 +1」,只记服务名会丢数量。
"""
match = _QTY_SUFFIX_RE.match(raw or "", end)
if not match:
return start, end, word
return start, match.end(), f"{word}+{match.group(1)}"
def _canonical_events(raw: str) -> list[tuple[int, int, str]]:
"""
按原文位置列出 12 个标准词(可带 +数量)。
返回 (起点, 终点, 展示词)。重叠区间只留更长的那一项。
"""
found: list[tuple[int, int, str]] = []
package_pallets = _package_pallet_spans(raw)
for match in _CN_RE.finditer(raw):
@@ -147,21 +255,21 @@ def _canonical_events(raw: str) -> list[tuple[int, str]]:
continue
if word == "托盘" and _span_inside(match.start(), match.end(), package_pallets):
continue
found.append((match.start(), match.end(), word))
found.append(_attach_qty_suffix(raw, match.start(), match.end(), word))
for match in _MUST_GO_RE.finditer(raw):
found.append((match.start(), match.end(), "MUST GO"))
found.append(_attach_qty_suffix(raw, match.start(), match.end(), "MUST GO"))
for match in _DG_RE.finditer(raw):
found.append((match.start(), match.end(), "DG"))
found.append(_attach_qty_suffix(raw, match.start(), match.end(), "DG"))
for match in _TC_RE.finditer(raw):
found.append((match.start(), match.end(), "TC"))
found.append(_attach_qty_suffix(raw, match.start(), match.end(), "TC"))
found.sort(key=lambda item: (item[0], -(item[1] - item[0])))
accepted: list[tuple[int, int]] = []
events: list[tuple[int, str]] = []
events: list[tuple[int, int, str]] = []
for start, end, word in found:
if any(not (end <= left or start >= right) for left, right in accepted):
continue
accepted.append((start, end))
events.append((start, word))
events.append((start, end, word))
return events
@@ -171,31 +279,19 @@ def _outside_list_text(piece: str) -> str:
「托盘」本身会被标准词扫到,这里不重复收。
「打木架和带电」只把打木架留下来,带电仍由标准词按出现位置记。
带 +数量 的标准词整段擦掉,避免残留「+1.5」再当成名单外服务。
"""
spans = _canonical_events(piece)
if not spans:
return piece.strip()
chars = list(piece)
for start, word in spans:
# _canonical_events 只有起点。用标准词长度擦掉;MUST GO 用原文长度。
end = start + _span_len(piece, start, word)
for start, end, _word in spans:
for index in range(start, min(end, len(chars))):
chars[index] = " "
rest = _FILLER.sub("", "".join(chars))
return rest.strip()
def _span_len(piece: str, start: int, word: str) -> int:
"""标准词在原文里占几位。MUST GO 中间空格可有可无。"""
if word == "MUST GO":
match = _MUST_GO_RE.match(piece, start)
if match:
return match.end() - match.start()
if word in {"DG", "TC"}:
return 2
return len(word)
def _labeled_added_value(text: str) -> str | None:
"""
原话里有没有单独的「增值服务」栏。