角色业务范围收紧工单与模板;报价日期不当费用;未选舱不迁已报价;另开一票可清违禁 hold
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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:
|
||||
"""
|
||||
原话里有没有单独的「增值服务」栏。
|
||||
|
||||
@@ -432,6 +432,8 @@ def project_selected_quote(quote: dict[str, Any] | None, option: dict[str, Any]
|
||||
base["valid_until"] = src.get("validUntil")
|
||||
base["selected_air_option"] = src
|
||||
base["route_line"] = route_line(src, {})
|
||||
# 选定后不再是待选舱快照。
|
||||
base.pop("pending_cabin_pick", None)
|
||||
return base
|
||||
|
||||
|
||||
|
||||
@@ -76,7 +76,8 @@ LAND_DISPLAY = {
|
||||
|
||||
# 协同补充字段:拉群后可静默写入,不挡首次查价。内部键仍是 HS编码。
|
||||
SEA_COLLAB_FIELDS = ("货好时间", "HS编码", "是否含油", "是否含电", "是否含磁")
|
||||
# 空运群只补含电/含磁,摘要不要把海运协同五项搬过去。
|
||||
# 空运群只补含电/含磁(及报价日期),摘要不要把海运协同五项搬过去。
|
||||
# 货好时间、贸易条款、客户名称不在群里补(产品口径)。
|
||||
AIR_COLLAB_FIELDS = ("是否含电", "是否含磁")
|
||||
# 群摘要对外展示名。内部继续用 HS编码,避免改合同键。
|
||||
SEA_COLLAB_DISPLAY = {
|
||||
@@ -222,6 +223,10 @@ _ALIASES = {
|
||||
"海关编码": "HS编码",
|
||||
"客户名称": "客户名称",
|
||||
"货值": "货值",
|
||||
# 模板/口语常写「是否带电」,合同内部键仍是是否含电。
|
||||
"是否带电": "是否含电",
|
||||
"是否含电": "是否含电",
|
||||
"是否含磁": "是否含磁",
|
||||
"是否为危险品": "是否为危险品",
|
||||
"通关口岸": "通关口岸",
|
||||
"通关口岸(非必填)": "通关口岸",
|
||||
@@ -253,11 +258,15 @@ def shanghai_today() -> str:
|
||||
return datetime.now(_SHANGHAI).date().isoformat()
|
||||
|
||||
|
||||
# 销售卡片/摘要占位横杠:等于没写,禁止落进 facts 再当起运地出站(运行证据 WO202610150086)。
|
||||
_PLACEHOLDER_DASHES = frozenset({"-", "—", "-"})
|
||||
|
||||
|
||||
def normalize_facts(facts: dict[str, Any] | None) -> dict[str, str]:
|
||||
"""
|
||||
把展示名/别名折成合同内部键,去掉首尾空白。
|
||||
|
||||
空值丢弃;不解释语义。
|
||||
空值与摘要占位「-」丢弃;不解释语义。
|
||||
"""
|
||||
out: dict[str, str] = {}
|
||||
for raw_key, raw_val in (facts or {}).items():
|
||||
@@ -268,7 +277,7 @@ def normalize_facts(facts: dict[str, Any] | None) -> dict[str, str]:
|
||||
if raw_val is None:
|
||||
continue
|
||||
val = str(raw_val).strip()
|
||||
if not val:
|
||||
if not val or val in _PLACEHOLDER_DASHES:
|
||||
continue
|
||||
out[key] = val
|
||||
_fold_land_vehicle_qty(out)
|
||||
@@ -527,9 +536,16 @@ def harvest_oral_collab(text: str) -> dict[str, str]:
|
||||
只认原话里已经说死的词(不含电、随时可提、普货),不猜贸易条款和海关编码。
|
||||
「(如:随时可提)」这种补问示例行不算销售说了货好时间。
|
||||
危险品:否词(不是危险品/非危/普货)先于是词,避免「不是危险品」收成是。
|
||||
「是否带电:否」里的「带电」不能当成肯定(运行证据 session 717349)。
|
||||
"""
|
||||
raw = _without_clarify_examples(text or "")
|
||||
out: dict[str, str] = {}
|
||||
# 先剥掉「是否带电/含电/含磁:是|否」标签行,再口语匹配,避免标签里的「带电」误判成是。
|
||||
oral_src = re.sub(
|
||||
r"是否(?:带电|含电|含磁|含油)\s*[::]\s*[是否]",
|
||||
" ",
|
||||
raw,
|
||||
)
|
||||
yes_no = (
|
||||
("是否含电", ("不含电", "不带电", "无电"), ("含电", "带电")),
|
||||
("是否含油", ("不含油", "不带油", "无油"), ("含油", "带油")),
|
||||
@@ -541,9 +557,9 @@ def harvest_oral_collab(text: str) -> dict[str, str]:
|
||||
),
|
||||
)
|
||||
for key, no_words, yes_words in yes_no:
|
||||
if any(w in raw for w in no_words):
|
||||
if any(w in oral_src for w in no_words):
|
||||
out[key] = "否"
|
||||
elif any(w in raw for w in yes_words):
|
||||
elif any(w in oral_src for w in yes_words):
|
||||
out[key] = "是"
|
||||
ready = ("随时可提", "货好随时", "随时货好", "货已好", "已经货好")
|
||||
for word in ready:
|
||||
|
||||
@@ -202,15 +202,27 @@ def assemble_air_query(facts: dict[str, Any] | None) -> dict[str, Any]:
|
||||
包装只看销售是否说了,散货等原文原样保留。
|
||||
"""
|
||||
src = dict(facts or {})
|
||||
# 摘要占位「-」当空:不能拿去 resolve 机场,否则 TMS 回 1003 起飞机场无法匹配。
|
||||
_dash = frozenset({"-", "—", "-"})
|
||||
origin = str(src.get("起运港") or src.get("起运地") or "").strip()
|
||||
if origin in _dash:
|
||||
origin = ""
|
||||
dest = str(src.get("目的港") or "").strip()
|
||||
if dest in _dash:
|
||||
dest = ""
|
||||
cargo = str(src.get("品名") or "").strip()
|
||||
if cargo in _dash:
|
||||
cargo = ""
|
||||
pieces = _first_number(str(src.get("件数") or ""))
|
||||
weight = _first_number(str(src.get("毛重") or ""))
|
||||
volume = volume_cbm_number(str(src.get("体积") or ""))
|
||||
pack_raw = str(src.get("包装方式") or src.get("包装类型") or "").strip()
|
||||
if pack_raw in _dash:
|
||||
pack_raw = ""
|
||||
pack_code = resolve_package_type(pack_raw)
|
||||
quote_date = str(src.get("报价日期") or "").strip()
|
||||
if quote_date in _dash:
|
||||
quote_date = ""
|
||||
# 三字码必须按当前地名重算,禁止沿用上一句的 NKG/KUL
|
||||
origin_code = resolve_airport_code(origin)
|
||||
dest_code = resolve_airport_code(dest)
|
||||
@@ -240,6 +252,10 @@ def assemble_air_query(facts: dict[str, Any] | None) -> dict[str, Any]:
|
||||
|
||||
ok = not missing
|
||||
out_facts = dict(src)
|
||||
# 横杠占位从出站 facts 清掉,避免建单把「-」写入起运港列。
|
||||
for _k in ("起运港", "起运地"):
|
||||
if str(out_facts.get(_k) or "").strip() in _dash:
|
||||
out_facts.pop(_k, None)
|
||||
if origin:
|
||||
out_facts["起运港"] = origin
|
||||
if origin_code:
|
||||
@@ -247,6 +263,8 @@ def assemble_air_query(facts: dict[str, Any] | None) -> dict[str, Any]:
|
||||
else:
|
||||
out_facts.pop("originCode", None)
|
||||
else:
|
||||
out_facts.pop("起运港", None)
|
||||
out_facts.pop("起运地", None)
|
||||
out_facts.pop("originCode", None)
|
||||
if dest:
|
||||
out_facts["目的港"] = dest
|
||||
|
||||
Reference in New Issue
Block a user