机器人显示名与站点标题可配;多段联运列表投影;协同群监听对账;工单附件落存;模板与工单后台补强

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
linmianxun
2026-10-19 09:41:59 +08:00
co-authored by Cursor
parent 277cdc1c28
commit 2b528758b4
119 changed files with 6760 additions and 3516 deletions
+119 -14
View File
@@ -137,6 +137,7 @@ _SHEET_CLEAN_KEYS = frozenset(
"整柜或拼柜",
"箱型箱量",
"贸易条款",
"增值服务",
"货好时间",
"运输分类",
"报价日期",
@@ -280,7 +281,8 @@ def parse_clarify_paste_facts(text: str) -> dict[str, str]:
continue
if not val or _CLARIFY_EXAMPLE_LINE.match(val):
continue
facts[name] = clip_inquiry_value(val)
clipped = clip_inquiry_value(val)
facts[name] = clipped
return {key: val for key, val in facts.items() if val}
@@ -522,6 +524,14 @@ def extract_inquiry_snapshot(
# 模型常把「郑州-吉隆坡」猜成空运,不能拿来默认。
source = "deepseek_b"
facts = harvest_oral_measures(text, normalize_facts(facts))
if (mode or "").upper() == "LAND":
from agent.schema.field_validate import (
harvest_oral_land_vehicle,
promote_land_vehicle_from_container,
)
facts = promote_land_vehicle_from_container(facts)
facts = harvest_oral_land_vehicle(text, facts)
facts = _merge_spoken_collab(text, facts)
facts = _merge_optional_inquiry_labels(text, facts)
facts = apply_cargo_handover_synonym(text, facts, business_line=mode)
@@ -570,12 +580,35 @@ def harvest_inquiry_optional_labels(text: str) -> dict[str, str]:
return _harvest_labels(text, _OPTIONAL_INQUIRY_LABELS)
def is_clarify_ui_prompt(raw: str | None) -> bool:
"""
补问/核对卡上的系统提示,不是任何询价字段的值。
空「货物品名(必填):」后若没换行,下一句「请确认并补充以下信息」会粘成品名
(多段陆运核对卡运行证据)。报价日期误收「请复制粘贴…」同属此类。
"""
text = str(raw or "").strip().strip("::")
if not text:
return False
if "请复制粘贴" in text or "告知我以下字段" in text:
return True
if "请确认并补充以下信息" in text:
return True
if "请复制以下内容" in text:
return True
if re.match(r"^Step\s*[12]\s*:", text, flags=re.I):
return True
return False
def clip_inquiry_value(raw: str) -> str:
"""
字段值收到下一个编号项为止。
图上常在货好时间后面接着「4. 希望送到…」「6. 送货地址」。
那些不是询价字段,不能进补问。地址里的「602、603」没有点号后空格,会留下。
补问复制清单「10.增值服务」点后可无空格;空贸易条款不能把下一栏标签收成值。
空报价日期不能把下一行「请复制粘贴…」收成日期值。
"""
text = (raw or "").strip()
if not text:
@@ -583,6 +616,20 @@ def clip_inquiry_value(raw: str) -> str:
text = re.sub(r"[((]如\s*[::][^))]*[))]?\s*$", "", text).strip()
if _CLARIFY_EXAMPLE_LINE.match(text):
return ""
# 补问 UI 提示不是任何字段的值(空栏后没换行会粘上标题)。
if is_clarify_ui_prompt(text):
return ""
# 整段就是下一栏「10.增值服务」或「增值服务(非必填):…」→ 本栏未填。
if _starts_with_inquiry_label(text):
return ""
# 值中间换行后出现「10.增值服务」:切到下一栏之前(点后可无空格)。
cut_at = -1
for match in re.finditer(r"(?m)^\s*\d+[\.、.]\s*", text):
if match.start() > 0 and _starts_with_inquiry_label(text[match.start() :]):
cut_at = match.start()
break
if cut_at > 0:
text = text[:cut_at]
hit = _NUMBERED_ITEM.search(text)
if hit and hit.start() > 0:
text = text[: hit.start()]
@@ -594,6 +641,37 @@ def clip_inquiry_value(raw: str) -> str:
return text
def _starts_with_inquiry_label(text: str) -> bool:
"""
值是否以「另一询价栏目标签」开头(可带补问编号、冒号、必填标记)。
空起运地被误收成「目的地:CRK」时走这里丢掉,避免 TMS 按脏起运地查价。
「10.增值服务」「10.增值服务(非必填):卡转」也算下一栏,不能进贸易条款。
「目的地仓库」这种地名不含冒号/必填标记,不算标签,照常保留。
"""
raw = (text or "").lstrip()
if not raw:
return False
# 补问复制清单编号「10.」可无空格,先剥掉再认标签。
raw = _CLARIFY_INDEX.sub("", raw, count=1).lstrip()
if not raw:
return False
labels = sorted(_SHEET_CLEAN_KEYS | _CLARIFY_NAMES, key=len, reverse=True)
for label in labels:
if len(label) < 2 or not raw.startswith(label):
continue
if not _label_is_token(raw, 0, label):
continue
rest = raw[len(label) :]
if re.match(r"^\s*$", rest):
return True
if re.match(r"^\s*[::]", rest):
return True
if re.match(r"^\s*[((][^))]*(?:必填|非必填)", rest):
return True
return False
def clip_sheet_field_value(raw: str) -> str:
"""
报价单格子:去掉 /POL、(必填说明)、Terms 和同一行下一列表头,只留紧挨的值。
@@ -612,6 +690,9 @@ def clip_sheet_field_value(raw: str) -> str:
if "必填" in text:
text = re.sub(r"^[^)\n]*必填[^)\n]*)\s*", "", text)
text = re.sub(r"^[\s::]+", "", text)
# 本栏为空却吞进「目的地:CRK」时,整段丢掉(标签在 pos=0,后面切栏逻辑碰不到)。
if _starts_with_inquiry_label(text):
return ""
for label in _SHEET_CLEAN_KEYS:
idx = 0
while True:
@@ -673,7 +754,11 @@ def harvest_sheet_inquiry_cells(text: str) -> dict[str, str]:
for i, (pos, label) in enumerate(kept):
begin = pos + len(label)
end = kept[i + 1][0] if i + 1 < len(kept) else len(raw)
val = clip_sheet_field_value(raw[begin:end])
raw_chunk = raw[begin:end]
# 报价日期只认同行:空栏后的粘贴提示不能进格子值。
if label in {"报价日期", *QUOTE_DATE_SYNONYMS}:
raw_chunk = raw_chunk.split("\n", 1)[0]
val = clip_sheet_field_value(raw_chunk)
if val:
out[label] = val
return out
@@ -773,18 +858,21 @@ def _harvest_labels(text: str, labels: tuple[str, ...]) -> dict[str, str]:
begin = pos + len(label)
end = kept[i + 1][0] if i + 1 < len(kept) else len(raw)
chunk = raw[begin:end]
# 增值服务只认冒号后同一行。空行不能把下一行「报价日期(非必填):」收成服务。
if label.startswith("增值服务"):
# 增值服务 / 报价日期只认冒号后同一行。空行不能把下一行「请复制粘贴…」收成日期。
if label.startswith("增值服务") or label in {"报价日期", *QUOTE_DATE_SYNONYMS}:
chunk = chunk.split("\n", 1)[0]
same_line = chunk.split("\n", 1)[0]
same_line_body = re.sub(r"^[\s::,,、]+", "", same_line).strip()
# 冒号后本行为空 = 未填。禁止跨行把下一栏「目的地:吉隆坡」收成起运地。
# 核对卡复制粘贴时非必填常整行只有「起运地(非必填):」,下一行才是必填目的地。
if not same_line_body:
# 核对卡无换行粘贴时「起运地(非必填): 目的地(必填):CRK」也视为未填。
if not same_line_body or _starts_with_inquiry_label(same_line_body):
chunk = ""
else:
chunk = re.sub(r"^[\s::,,、]+", "", chunk)
chunk = re.sub(r"[\s,,、]+$", "", chunk)
# 报价日期同行若误收成「请复制粘贴…」补问提示,必须丢掉(WO202610170068)。
if label in {"报价日期", *QUOTE_DATE_SYNONYMS}:
chunk = clip_inquiry_value(chunk)
if chunk:
out[label] = chunk
return out
@@ -899,11 +987,17 @@ _RE_FEE_LINE = re.compile(
r"(?P<name>[\u4e00-\u9fffA-Za-z][\u4e00-\u9fffA-Za-z0-9/()()++\--]{0,24}费|[A-Za-z]{2,10})"
r"\s*(?:改为|改成|调整为|调整成)?"
r"\s*[::=]?"
rf"\s*(?:(?P<ccy1>{_CCY_TOKEN})\s+)?"
# 群里常写「陆运费 CNY1000」币种与数字贴在一起;\s+ 会漏匹配再退回把「段1」当金额。
rf"\s*(?:(?P<ccy1>{_CCY_TOKEN})\s*)?"
r"(?P<amount>\d+(?:\.\d+)?)"
rf"\s*(?P<ccy2>{_CCY_TOKEN}|元)?",
re.I,
)
# 多段报价「段1:」「第一段」只是段号,数字不能当费用金额。
_RE_SEGMENT_INDEX_LABEL = re.compile(
r"(?:段\s*\d+\s*[::]?|第\s*[一二三四五六七八九\d]+\s*段\s*[::]?)",
re.I,
)
# 「文件发改为:500」漏了「费」、中间还有冒号,不能丢掉。
_RE_FEE_CHANGE = re.compile(
r"(?P<name>[\u4e00-\u9fff]{2,16}?)"
@@ -998,14 +1092,15 @@ def _normalize_fee_project(raw: str) -> str:
def _strip_group_bot_mentions(text: str) -> str:
"""
去掉群里 @询价小助手 前缀,避免挡住句首「起运港、」分段标签。
去掉群里 @机器人显示名 前缀,避免挡住句首「起运港、」分段标签。
企微协同句常以 @ 开头;不剥的话 project 抽不到,出单只能靠附加费默认「起运港」,
且二次盖价时更容易丢掉分段。
且二次盖价时更容易丢掉分段。显示名以后台为准,过渡期仍剥旧名。
副作用:无。
"""
cleaned = (text or "").replace("@询价小助手", "").replace("询价小助手", "")
return cleaned.strip()
from agent.policy.bot_name import strip_bot_mention
return strip_bot_mention(text or "").strip()
# 报价日期整段:标签+日期。费用抽取前剥掉,避免把年份 2026 当成金额。
@@ -1110,6 +1205,16 @@ def _split_fee_project_prefix(text: str) -> tuple[str, str]:
return project, rest
def _strip_segment_index_labels(text: str) -> str:
"""
去掉「段1:」「第一段」段号标签,避免金额正则吃到段序号。
运行证据:@机器人 段1:陆运费 CNY1000 → 曾抽成 amount=1。
副作用:无。
"""
return _RE_SEGMENT_INDEX_LABEL.sub(" ", text or "").strip()
def _parse_fee_rows(text: str, baseline_currency: str = "") -> list[dict[str, str]]:
"""
按行抽费用:码头操作费100CNY、报关费改为300CNY。
@@ -1233,8 +1338,9 @@ def extract_product_quote(
# 费率句若仍落到普通费用行,会把「燃油附加费」配上句首公斤数。先剥掉再抽。
# 必须用剥掉 @询价小助手 后的正文,否则句首「起运港、」分段标签匹配不到。
# 顺带剥掉报价日期/有效期/时效要求,避免年份、天数再抽成金额。
scrubbed = _strip_rate_and_band_noise(
_strip_non_fee_meta_chunks(stripped or raw)
# 多段「段1:」先去掉,禁止把段序号当成陆运费/空运费金额。
scrubbed = _strip_segment_index_labels(
_strip_rate_and_band_noise(_strip_non_fee_meta_chunks(stripped or raw))
)
rows = _parse_fee_rows(scrubbed, baseline_currency=base_ccy) if scrubbed.strip() else []
if rows:
@@ -1291,7 +1397,6 @@ def extract_product_quote(
return out
def _format_percent_rate(raw: str) -> str:
"""费率展示统一带 %;已有 % 保留。"""
text = (raw or "").strip()