Files
inquiry_robot/inquiry-agent/agent/schema/added_services.py
T

364 lines
13 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.
"""
空运增值服务。
本文件职责:把销售原话收成多项 added_services,供确认、工单和 TMS 的 addedServices 使用。
12 个服务词只要完整出现就记;名单外的必须写在「增值服务」后面才记,并保留原词。
服务词后面紧跟的「+数字」(如 TC +1.5、托盘+1)并进同一项,写成 TC+1.5、托盘+1。
包装类型/包装方式这一栏里的「托盘」只算包装,不抄进增值服务。
「增值服务:」同一行为空时,不把下一行收进来,也不沿用上一句已经记下的服务。
不挡查价。不改六态。海运、陆运不走这里。
"""
from __future__ import annotations
import re
from agent.schema.field_validate import _PACK_WORDS, _without_clarify_examples
# 长词在前,避免「卡转低消」再拆出「卡转」。
_CN_TERMS = (
"卡转低消",
"前置仓",
"托盘",
"带电",
"卡转",
"备装",
"分泡",
"温控",
"快件",
)
_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]*)"
)
# 包装类型/包装方式这一栏里的托盘只算包装。确认卡上增值服务留空时不能再抄一份过去。
_PACKAGE_PALLET_RE = re.compile(
r"包装(?:类型|方式)?(?:((?:非)?必填)|\((?:非)?必填\))?[^\S\n]*[::][^\S\n]*托盘"
)
_SPLIT_RE = re.compile(r"[、,,]")
_NEG_PREFIX = frozenset("不无非")
_FILLER = re.compile(r"[、,,\s和及与::]+")
def split_added_services(raw: str) -> list[str]:
"""
把一栏增值服务拆成多项。
顿号、中文逗号、英文逗号都是分隔。空格不拆,MUST GO 仍是一项。
副作用:无。
"""
text = (raw or "").strip()
if not text:
return []
out: list[str] = []
seen: set[str] = set()
for part in _SPLIT_RE.split(text):
token = part.strip().strip("。;; ")
key = token.casefold()
if not token or key in seen:
continue
seen.add(key)
out.append(token)
return out
def join_added_services(items: list[str]) -> str:
"""多项用顿号连成销售看到的一行。空则空串。"""
return "、".join(split_added_services("、".join(items)))
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((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("(如"):
continue
base = match.start(1)
cursor = 0
for part in _SPLIT_RE.split(value):
piece = part.strip().strip("。;; ")
if not piece:
continue
at = value.find(part, cursor)
if at < 0:
at = cursor
cursor = at + len(part)
extra = _outside_list_text(piece)
if extra:
events.append((base + at + max(piece.find(extra), 0), extra))
events.sort(key=lambda item: item[0])
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(
text: str,
facts: dict[str, str] | None,
previous: str = "",
) -> dict[str, str]:
"""
把本句增值服务并进事实,并避免托盘盖住已经说出的其他包装。
previous 是上一句已经记下的 added_services。本句没提服务时保留旧值。
只应在空运调用。副作用:返回新字典,不改入参。
"""
merged = dict(facts or {})
before_added = str(merged.get("added_services") or "")
found = harvest_added_services(text)
labeled = _labeled_added_value(text)
# 销售把「增值服务:」留空:这一栏就是空的。
# 不沿用上一句,也不把包装类型里的托盘、下一行报价日期算进来。
# 本句没写这一栏、也没提到服务时,才保留已经记下的旧值。
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 _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):
word = match.group(0)
if word == "带电" and match.start() > 0 and raw[match.start() - 1] in _NEG_PREFIX:
continue
if word == "托盘" and _span_inside(match.start(), match.end(), package_pallets):
continue
found.append(_attach_qty_suffix(raw, match.start(), match.end(), word))
for match in _MUST_GO_RE.finditer(raw):
found.append(_attach_qty_suffix(raw, match.start(), match.end(), "MUST GO"))
for match in _DG_RE.finditer(raw):
found.append(_attach_qty_suffix(raw, match.start(), match.end(), "DG"))
for match in _TC_RE.finditer(raw):
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, 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, end, word))
return events
def _outside_list_text(piece: str) -> str:
"""
增值服务标签里、12 个词没有覆盖到的原话。
「托盘」本身会被标准词扫到,这里不重复收。
「打木架和带电」只把打木架留下来,带电仍由标准词按出现位置记。
带 +数量 的标准词整段擦掉,避免残留「+1.5」再当成名单外服务。
"""
spans = _canonical_events(piece)
if not spans:
return piece.strip()
chars = list(piece)
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 _labeled_added_value(text: str) -> str | None:
"""
原话里有没有单独的「增值服务」栏。
没写这一栏返回 None。写了但冒号后同一行为空,返回空串。
只看同一行,避免空栏把下一行报价日期收进来。
"""
raw = _without_clarify_examples(text or "")
seen = False
value = ""
for match in _LABEL_RE.finditer(raw):
seen = True
piece = (match.group(1) or "").strip().strip("。;; ")
if not piece or piece.startswith("(如") or piece.startswith("(如"):
continue
value = piece
if not seen:
return None
return value
def _package_pallet_spans(raw: str) -> list[tuple[int, int]]:
"""「包装类型:托盘」里那个托盘的起止。只算包装,不算增值服务。"""
spans: list[tuple[int, int]] = []
for match in _PACKAGE_PALLET_RE.finditer(raw or ""):
end = match.end()
spans.append((end - len("托盘"), end))
return spans
def _span_inside(start: int, end: int, spans: list[tuple[int, int]]) -> bool:
"""当前词是否落在给定区间里。"""
return any(start >= left and end <= right for left, right in spans)
def _keep_stated_package(text: str, facts: dict[str, str]) -> None:
"""
同一句里还有纸箱、木箱、卡板、散货时,包装类型保持那个词。
包装栏里的托盘不进增值服务,只在没另说包装时由机读记成包装类型。
"""
raw = _without_clarify_examples(text or "")
if "托盘" not in raw:
return
last = ""
last_pos = -1
for word in _PACK_WORDS:
if word == "托盘":
continue
pos = raw.rfind(word)
if pos > last_pos:
last_pos = pos
last = word
if last:
facts["包装方式"] = last
def _dedupe(items: list[str]) -> list[str]:
"""按第一次出现保留,英文忽略大小写。"""
out: list[str] = []
seen: set[str] = set()
for item in items:
token = (item or "").strip()
key = token.casefold()
if not token or key in seen:
continue
seen.add(key)
out.append(token)
return out