Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a0c705683f | ||
|
|
617c5e2619 |
@@ -31,6 +31,8 @@ def _front_bookmark(sess) -> bool:
|
||||
return False
|
||||
phase = (sess.phase or "").strip()
|
||||
line = (sess.business_line or "").upper()
|
||||
if phase == "sensitive_block":
|
||||
return True
|
||||
if phase == "need_mode" and not line:
|
||||
return False
|
||||
return phase in {"clarify", "need_mode", "need_land_options", "wait_confirm"}
|
||||
@@ -87,7 +89,9 @@ def _carry_waiting_inquiry_to_owner(*, owner: str, sender_id: str) -> None:
|
||||
raw = (sess.immutable_text or "").strip()
|
||||
if raw and raw not in texts:
|
||||
texts.append(raw)
|
||||
if not facts and not texts:
|
||||
holds = [str(getattr(item, "sensitive_hold", "") or "").strip() for item in drafts]
|
||||
holds = [item for item in holds if item]
|
||||
if not facts and not texts and not holds:
|
||||
return
|
||||
from agent.policy.air_text_flow import FlowSession
|
||||
|
||||
@@ -99,6 +103,7 @@ def _carry_waiting_inquiry_to_owner(*, owner: str, sender_id: str) -> None:
|
||||
business_line="",
|
||||
facts=facts,
|
||||
immutable_text="\n".join(texts),
|
||||
sensitive_hold="\n".join(holds),
|
||||
)
|
||||
)
|
||||
logger.info(
|
||||
@@ -245,7 +250,7 @@ def _pick_text_flow(*, sender_id: str, text: str, injected_mode: str = "", prefe
|
||||
if (
|
||||
multi_draft
|
||||
and (multi_draft.business_line or "").upper() == "MULTI"
|
||||
and (multi_draft.phase or "") == "clarify"
|
||||
and (multi_draft.phase or "") in {"clarify", "sensitive_block"}
|
||||
):
|
||||
return multi
|
||||
_drop_other_mode_drafts(owner=spoken, sender_id=sender_id)
|
||||
@@ -265,7 +270,7 @@ def _pick_text_flow(*, sender_id: str, text: str, injected_mode: str = "", prefe
|
||||
if (
|
||||
multi_sess
|
||||
and (multi_sess.business_line or "").upper() == "MULTI"
|
||||
and (multi_sess.phase or "") in {"clarify", "wait_confirm"}
|
||||
and (multi_sess.phase or "") in {"clarify", "wait_confirm", "sensitive_block"}
|
||||
and not (looks_like_confirm(text) and (multi_sess.phase or "") != "wait_confirm")
|
||||
):
|
||||
return multi
|
||||
|
||||
@@ -68,6 +68,8 @@ class FlowSession:
|
||||
virtual_work_order_no: str = ""
|
||||
# 核对卡还没有工单号时,图片/附件先记在这里,确定建单后再挂到主账。
|
||||
pending_files: tuple[dict[str, str], ...] = ()
|
||||
# 建单前出现过、还没被品名改口清掉的违禁原话。空表示当前没有。
|
||||
sensitive_hold: str = ""
|
||||
|
||||
|
||||
def is_waiting_mode_session(sess) -> bool:
|
||||
@@ -152,6 +154,7 @@ def _session_from_payload(raw: dict[str, Any]) -> FlowSession:
|
||||
for item in (raw.get("pending_files") or [])
|
||||
if isinstance(item, dict) and str(item.get("object_key") or "").strip()
|
||||
),
|
||||
sensitive_hold=str(raw.get("sensitive_hold") or ""),
|
||||
)
|
||||
|
||||
|
||||
@@ -306,6 +309,65 @@ class AirTextInquiryFlow:
|
||||
"invited_product_names": sess.invited_product_names,
|
||||
}
|
||||
|
||||
def _refuse_sensitive(
|
||||
self,
|
||||
*,
|
||||
sender_id: str,
|
||||
text: str,
|
||||
facts: dict[str, Any],
|
||||
reply: ReplyFn,
|
||||
sess: Optional[FlowSession],
|
||||
business_line: str = "",
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
私聊还没建单时,违禁品只回一句并停在原处。
|
||||
|
||||
已有正式工单号的会话直接放过,销售后面补的字按原流程。
|
||||
没拦住时把品名改口写进 facts,并清掉书签上的旧原话。
|
||||
拦住时保存已认定字段,方便改口后接着补问。不建单、不追问缺项。
|
||||
调用在 Worker 的 on_text 里,不占企微回调线程。
|
||||
"""
|
||||
from agent.policy.handoff_human import is_virtual_no
|
||||
from agent.policy.sensitive_cargo import BLOCK_REPLY, judge
|
||||
|
||||
if sess is not None:
|
||||
number = (sess.work_order_no or "").strip()
|
||||
if number and not is_virtual_no(number):
|
||||
return None
|
||||
blocked, new_hold, _edits = judge(
|
||||
text=text,
|
||||
facts=facts,
|
||||
hold=str(getattr(sess, "sensitive_hold", "") or "") if sess else "",
|
||||
)
|
||||
if sess is not None:
|
||||
sess.sensitive_hold = new_hold
|
||||
if not blocked:
|
||||
return None
|
||||
kept = self._handoff_state(sess)
|
||||
pending = tuple(getattr(sess, "pending_files", ()) or ()) if sess else ()
|
||||
line = (business_line or (getattr(sess, "business_line", "") if sess else "") or "").strip()
|
||||
self._save(
|
||||
FlowSession(
|
||||
sender_id=sender_id,
|
||||
thread_id=sender_id,
|
||||
phase="sensitive_block",
|
||||
business_line=line,
|
||||
facts={str(key): str(value) for key, value in (facts or {}).items()},
|
||||
immutable_text=(sess.immutable_text if sess and sess.immutable_text else text),
|
||||
sensitive_hold=new_hold,
|
||||
wait_version=(sess.wait_version + 1) if sess else 1,
|
||||
pending_files=pending,
|
||||
clarify_missing_sig=str(kept.get("clarify_missing_sig") or ""),
|
||||
clarify_retry=int(kept.get("clarify_retry") or 0),
|
||||
handoff_offer=str(kept.get("handoff_offer") or ""),
|
||||
handoff_committed=bool(kept.get("handoff_committed")),
|
||||
virtual_work_order_no=str(kept.get("virtual_work_order_no") or ""),
|
||||
)
|
||||
)
|
||||
reply(BLOCK_REPLY)
|
||||
logger.info("sensitive_block sender=%s line=%s", sender_id, line or "-")
|
||||
return "sensitive_block"
|
||||
|
||||
def _block_if_handoff_committed(self, sess: Optional[FlowSession], reply: ReplyFn) -> bool:
|
||||
"""真正转人工之后这一票不再跟。返回 True 表示已经拦住。"""
|
||||
from agent.policy.handoff_human import is_handoff_frozen
|
||||
@@ -963,6 +1025,16 @@ class AirTextInquiryFlow:
|
||||
if sess and sess.phase == "wait_confirm" and looks_like_confirm(text):
|
||||
# 确定查价不再抽字段,避免「确定」被模型改成空 facts。
|
||||
logger.info("air.confirm sender=%s", sender_id)
|
||||
refused = self._refuse_sensitive(
|
||||
sender_id=sender_id,
|
||||
text=text,
|
||||
facts=dict(sess.facts),
|
||||
reply=reply,
|
||||
sess=sess,
|
||||
business_line=sess.business_line,
|
||||
)
|
||||
if refused:
|
||||
return refused
|
||||
return self._confirm_or_hold(
|
||||
sender_id=sender_id,
|
||||
facts=dict(sess.facts),
|
||||
@@ -997,7 +1069,7 @@ class AirTextInquiryFlow:
|
||||
return llm_phase
|
||||
mode = (snap.get("business_line") or "").upper()
|
||||
facts = dict(snap.get("facts") or {})
|
||||
if sess and sess.phase in {"clarify", "need_mode", "wait_confirm"}:
|
||||
if sess and sess.phase in {"clarify", "need_mode", "wait_confirm", "sensitive_block"}:
|
||||
# 补问轮:旧字段打底;运输方式沿用上一句(飞=空运)
|
||||
# 增值服务按句累加,本句没提时不能把上一句已记下的服务冲掉。
|
||||
from agent.llm.extract_text import detect_transport_mode
|
||||
@@ -1019,6 +1091,18 @@ class AirTextInquiryFlow:
|
||||
|
||||
facts = apply_air_value_added(added_source, facts, previous=prev_added)
|
||||
|
||||
if not mode or mode == "AIR":
|
||||
refused = self._refuse_sensitive(
|
||||
sender_id=sender_id,
|
||||
text=text,
|
||||
facts=facts,
|
||||
reply=reply,
|
||||
sess=sess,
|
||||
business_line=mode or (sess.business_line if sess else ""),
|
||||
)
|
||||
if refused:
|
||||
return refused
|
||||
|
||||
if not mode:
|
||||
reply(copy.ask_transport())
|
||||
self._save(
|
||||
@@ -1195,6 +1279,16 @@ class AirTextInquiryFlow:
|
||||
from agent.policy.handoff_human import void_virtual_no
|
||||
|
||||
old = self.session_of(sender_id)
|
||||
refused = self._refuse_sensitive(
|
||||
sender_id=sender_id,
|
||||
text="",
|
||||
facts=facts,
|
||||
reply=reply,
|
||||
sess=old,
|
||||
business_line="AIR",
|
||||
)
|
||||
if refused:
|
||||
return refused
|
||||
if old:
|
||||
void_virtual_no(old)
|
||||
self._save(
|
||||
|
||||
@@ -40,7 +40,9 @@ from agent.schema.tms_land_query import assemble_land_query
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_LAND_FRONT = frozenset({"need_mode", "need_land_options", "clarify", "wait_confirm", "wait_land_route", "idle", ""})
|
||||
_LAND_FRONT = frozenset(
|
||||
{"need_mode", "need_land_options", "clarify", "wait_confirm", "wait_land_route", "sensitive_block", "idle", ""}
|
||||
)
|
||||
|
||||
|
||||
class LandTextInquiryFlow(SeaTextInquiryFlow):
|
||||
@@ -97,6 +99,16 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
|
||||
if sess and sess.phase == "wait_confirm" and looks_like_confirm(text):
|
||||
# 确定查价不再抽字段,避免「确定」被模型改成空运/空 facts。
|
||||
logger.info("land.confirm sender=%s", sender_id)
|
||||
refused = self._refuse_sensitive(
|
||||
sender_id=sender_id,
|
||||
text=text,
|
||||
facts=dict(sess.facts),
|
||||
reply=reply,
|
||||
sess=sess,
|
||||
business_line=sess.business_line,
|
||||
)
|
||||
if refused:
|
||||
return refused
|
||||
return self._confirm_or_hold(
|
||||
sender_id=sender_id,
|
||||
facts=dict(sess.facts),
|
||||
@@ -176,6 +188,18 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
|
||||
# 复制清单冒号空着的项,菜单文字和模型猜测都不能当成已填。
|
||||
facts = drop_blank_paste_options(facts, source)
|
||||
|
||||
if not mode or mode == "LAND":
|
||||
refused = self._refuse_sensitive(
|
||||
sender_id=sender_id,
|
||||
text=text,
|
||||
facts=facts,
|
||||
reply=reply,
|
||||
sess=sess,
|
||||
business_line=mode or (sess.business_line if sess else ""),
|
||||
)
|
||||
if refused:
|
||||
return refused
|
||||
|
||||
if not mode:
|
||||
mode = "LAND" if (sess and (sess.business_line or "").upper() == "LAND") else ""
|
||||
if not mode:
|
||||
@@ -296,6 +320,16 @@ class LandTextInquiryFlow(SeaTextInquiryFlow):
|
||||
"""
|
||||
import uuid
|
||||
|
||||
refused = self._refuse_sensitive(
|
||||
sender_id=sender_id,
|
||||
text="",
|
||||
facts=facts,
|
||||
reply=reply,
|
||||
sess=self.session_of(sender_id),
|
||||
business_line="LAND",
|
||||
)
|
||||
if refused:
|
||||
return refused
|
||||
tms_ready = assemble_land_query(facts)
|
||||
if not tms_ready.get("ok"):
|
||||
return self._advance_front(
|
||||
|
||||
@@ -43,7 +43,7 @@ from agent.schema.land_options import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# 只有补问/核对还能续同一张多段草稿。已出号的拉群停点不能拦住新询价。
|
||||
_FRONT = frozenset({"clarify", "wait_confirm"})
|
||||
_FRONT = frozenset({"clarify", "wait_confirm", "sensitive_block"})
|
||||
|
||||
|
||||
class MultiTextInquiryFlow:
|
||||
@@ -170,6 +170,52 @@ class MultiTextInquiryFlow:
|
||||
reply("多段询价请先把各段需求发齐并回复确定。需要人来接管时,请在工单创建后再说。")
|
||||
return "multi_handoff_later"
|
||||
|
||||
def _gate_sensitive(
|
||||
self,
|
||||
*,
|
||||
sender_id: str,
|
||||
text: str,
|
||||
reply: ReplyFn,
|
||||
segments: list[Segment],
|
||||
sess: Optional[FlowSession],
|
||||
immutable: str,
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
多段私聊建单前的违禁品拦截。
|
||||
|
||||
已有工单号的旧会话不把违禁原话带进新的一段。没命中则把品名改口写回各段。
|
||||
命中只回一句,不补问、不建单。
|
||||
"""
|
||||
from agent.policy.sensitive_cargo import BLOCK_REPLY, judge
|
||||
|
||||
facts: dict[str, str] = {}
|
||||
for seg in segments:
|
||||
for key, value in (seg.facts or {}).items():
|
||||
if str(value or "").strip():
|
||||
facts[str(key)] = str(value)
|
||||
use_old = sess is not None and not (sess.work_order_no or "").strip()
|
||||
blocked, new_hold, edits = judge(
|
||||
text=text,
|
||||
facts=facts,
|
||||
hold=str(sess.sensitive_hold or "") if use_old else "",
|
||||
)
|
||||
for seg in segments:
|
||||
for key, value in edits.items():
|
||||
seg.facts[key] = value
|
||||
if not blocked:
|
||||
if use_old and sess is not None:
|
||||
sess.sensitive_hold = new_hold
|
||||
if edits:
|
||||
self._put_segments(sess, segments)
|
||||
self._save(sess)
|
||||
return None
|
||||
draft = self._draft(sender_id, segments, phase="sensitive_block", immutable=immutable or text)
|
||||
draft.sensitive_hold = new_hold
|
||||
self._save(draft)
|
||||
reply(BLOCK_REPLY)
|
||||
logger.info("multi.sensitive_block sender=%s", sender_id)
|
||||
return "sensitive_block"
|
||||
|
||||
def on_text(
|
||||
self,
|
||||
*,
|
||||
@@ -196,6 +242,19 @@ class MultiTextInquiryFlow:
|
||||
sess = self.session_of(sid)
|
||||
if sess and self.match_button(raw, sess):
|
||||
return self._pull_group(sess, reply)
|
||||
if sess and (sess.phase or "") == "sensitive_block" and not (sess.work_order_no or "").strip():
|
||||
refused = self._gate_sensitive(
|
||||
sender_id=sid,
|
||||
text=raw,
|
||||
reply=reply,
|
||||
segments=self._segments(sess),
|
||||
sess=sess,
|
||||
immutable=sess.immutable_text or raw,
|
||||
)
|
||||
if refused:
|
||||
return refused
|
||||
sess.phase = "clarify"
|
||||
self._save(sess)
|
||||
verdict = classify_multi_text(raw)
|
||||
if verdict == "same_mode":
|
||||
reply(copy.MULTI_SAME_MODE)
|
||||
@@ -276,7 +335,28 @@ class MultiTextInquiryFlow:
|
||||
附件口播经常没有「起运港:」这种标签,要按段走和单聊一样的抽取,
|
||||
不能只认标签行,否则会整段待补充。
|
||||
"""
|
||||
current = self.session_of(sender_id)
|
||||
refused = self._gate_sensitive(
|
||||
sender_id=sender_id,
|
||||
text=immutable,
|
||||
reply=reply,
|
||||
segments=list(segments),
|
||||
sess=current if current and not (current.work_order_no or "").strip() else None,
|
||||
immutable=immutable,
|
||||
)
|
||||
if refused:
|
||||
return refused
|
||||
filled = self._enrich_segments(segments, allow_b=allow_b, chat_fn=chat_fn)
|
||||
refused = self._gate_sensitive(
|
||||
sender_id=sender_id,
|
||||
text="",
|
||||
reply=reply,
|
||||
segments=filled,
|
||||
sess=self.session_of(sender_id),
|
||||
immutable=immutable,
|
||||
)
|
||||
if refused:
|
||||
return refused
|
||||
ready, updated, gaps = refresh_segments(filled)
|
||||
if not ready:
|
||||
self._reply_gaps(updated, gaps, reply)
|
||||
@@ -358,6 +438,16 @@ class MultiTextInquiryFlow:
|
||||
|
||||
口语补句走单聊同一套抽取,不在这里猜「广州-深圳」。
|
||||
"""
|
||||
refused = self._gate_sensitive(
|
||||
sender_id=sess.sender_id,
|
||||
text=text,
|
||||
reply=reply,
|
||||
segments=self._segments(sess),
|
||||
sess=sess,
|
||||
immutable=sess.immutable_text or text,
|
||||
)
|
||||
if refused:
|
||||
return refused
|
||||
from agent.llm.extract_text import extract_inquiry_snapshot
|
||||
from agent.policy.system_exception import handle_extract_tech_fail
|
||||
|
||||
@@ -407,6 +497,16 @@ class MultiTextInquiryFlow:
|
||||
def _create_ticket(self, sess: FlowSession, reply: ReplyFn) -> str:
|
||||
"""回复确定后建一张 MULTI 工单。含空运只给手动拉群提示,不含空运出拉群卡。"""
|
||||
segments = self._segments(sess)
|
||||
refused = self._gate_sensitive(
|
||||
sender_id=sess.sender_id,
|
||||
text="",
|
||||
reply=reply,
|
||||
segments=segments,
|
||||
sess=sess,
|
||||
immutable=sess.immutable_text,
|
||||
)
|
||||
if refused:
|
||||
return refused
|
||||
ready, updated, gaps = refresh_segments(segments)
|
||||
if not ready:
|
||||
self._reply_gaps(updated, gaps, reply)
|
||||
|
||||
@@ -113,6 +113,16 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
if sess and sess.phase == "wait_confirm" and looks_like_confirm(text):
|
||||
# 确定查价不再抽字段,避免「确定」被模型改成空 facts。
|
||||
logger.info("sea.confirm sender=%s", sender_id)
|
||||
refused = self._refuse_sensitive(
|
||||
sender_id=sender_id,
|
||||
text=text,
|
||||
facts=dict(sess.facts),
|
||||
reply=reply,
|
||||
sess=sess,
|
||||
business_line=sess.business_line,
|
||||
)
|
||||
if refused:
|
||||
return refused
|
||||
return self._confirm_or_hold(
|
||||
sender_id=sender_id,
|
||||
facts=dict(sess.facts),
|
||||
@@ -145,7 +155,7 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
return llm_phase
|
||||
mode = (snap.get("business_line") or "").upper()
|
||||
facts = dict(snap.get("facts") or {})
|
||||
if sess and sess.phase in {"clarify", "need_mode", "wait_confirm"}:
|
||||
if sess and sess.phase in {"clarify", "need_mode", "wait_confirm", "sensitive_block"}:
|
||||
from agent.llm.extract_text import detect_transport_mode
|
||||
from agent.schema.field_validate import harvest_oral_measures
|
||||
|
||||
@@ -156,6 +166,18 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
if not mode:
|
||||
mode = detect_transport_mode(f"{sess.immutable_text or ''}\n{text}")
|
||||
|
||||
if not mode or mode == "SEA":
|
||||
refused = self._refuse_sensitive(
|
||||
sender_id=sender_id,
|
||||
text=text,
|
||||
facts=facts,
|
||||
reply=reply,
|
||||
sess=sess,
|
||||
business_line=mode or (sess.business_line if sess else ""),
|
||||
)
|
||||
if refused:
|
||||
return refused
|
||||
|
||||
if not mode:
|
||||
reply(copy.ask_transport())
|
||||
self._save(
|
||||
@@ -374,6 +396,16 @@ class SeaTextInquiryFlow(AirTextInquiryFlow):
|
||||
from agent.policy.handoff_human import void_virtual_no
|
||||
|
||||
old = self.session_of(sender_id)
|
||||
refused = self._refuse_sensitive(
|
||||
sender_id=sender_id,
|
||||
text="",
|
||||
facts=facts,
|
||||
reply=reply,
|
||||
sess=old,
|
||||
business_line="SEA",
|
||||
)
|
||||
if refused:
|
||||
return refused
|
||||
if old:
|
||||
void_virtual_no(old)
|
||||
pending = tuple(getattr(old, "pending_files", ()) or ()) if old else ()
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
"""
|
||||
私聊建单前的违禁品拦截。
|
||||
|
||||
本文件职责:用固定词表判断当前这句话、尚未改口的原话,以及品名、起运地、备注
|
||||
是否涉及真家伙(枪支、弹药、爆炸物、毒品)。命中则不能建单。
|
||||
群聊、已经建成的工单不在这里判断,由调用方避开。
|
||||
调用:Worker 里的私聊文字流程,在出补问或建单之前。只做字符串匹配,不调模型。
|
||||
禁止:把水枪、玩具枪、仿真枪、气枪、烟花、礼炮、鞭炮算进去;禁止拦化学品、电池、油品。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
# 销售私聊里只回这一句。不要再追问缺项,也不要带工单号。
|
||||
BLOCK_REPLY = "涉及危险品,无法发起报价。"
|
||||
|
||||
# 先圈出放行说法。词表里的「枪支」若落在这些说法内部,不算命中。
|
||||
_ALLOW = (
|
||||
"玩具枪",
|
||||
"水枪",
|
||||
"仿真枪",
|
||||
"气枪",
|
||||
"烟花",
|
||||
"礼炮",
|
||||
"鞭炮",
|
||||
"炮仗",
|
||||
)
|
||||
|
||||
# 真家伙。不用单字「枪」「炮」「弹」,避免误伤水枪和礼炮。
|
||||
_BLOCK = (
|
||||
"汽油弹",
|
||||
"燃烧弹",
|
||||
"原子弹",
|
||||
"手榴弹",
|
||||
"导火索",
|
||||
"甲基苯丙胺",
|
||||
"氯胺酮",
|
||||
"高射炮",
|
||||
"榴弹炮",
|
||||
"冲锋枪",
|
||||
"狙击枪",
|
||||
"霰弹枪",
|
||||
"火药枪",
|
||||
"摇头丸",
|
||||
"海洛因",
|
||||
"可卡因",
|
||||
"炸弹",
|
||||
"炸药",
|
||||
"爆破",
|
||||
"雷管",
|
||||
"手雷",
|
||||
"地雷",
|
||||
"导弹",
|
||||
"核弹",
|
||||
"枪支",
|
||||
"枪械",
|
||||
"手枪",
|
||||
"步枪",
|
||||
"机枪",
|
||||
"猎枪",
|
||||
"子弹",
|
||||
"弹药",
|
||||
"弹头",
|
||||
"大炮",
|
||||
"火炮",
|
||||
"毒品",
|
||||
"冰毒",
|
||||
"大麻",
|
||||
"鸦片",
|
||||
"吗啡",
|
||||
"K粉",
|
||||
)
|
||||
|
||||
# 改口动词。多段原文里的「品名:衣服」不能走这里,否则会抄到每一段。
|
||||
_FIELD_EDIT = re.compile(
|
||||
r"(货物品名|品名|起运地|起运港|始发站|备注)\s*(?:改为|改成|是|为)\s*(\S+)"
|
||||
)
|
||||
# 单票里销售按清单重写「品名:服装」。多段「段1 / 段2」整单不靠这个改字段。
|
||||
_LABEL_EDIT = re.compile(
|
||||
r"(货物品名|品名|起运地|起运港|始发站|备注)\s*[::]\s*(\S+)"
|
||||
)
|
||||
_MULTI_SHEET = re.compile(r"段\s*\d|第\s*[一二三四五六七八九\d]\s*段")
|
||||
# 口语改口:运送衣服、改成衣服、换成衣服、炸弹换衣服。不认「换一下」「换个港口」。
|
||||
_SPOKEN_CARGO = re.compile(
|
||||
r"(?:运送|托运|发运|改成|改为|换成|换为)\s*([^\s,,。;;、请]{1,16})"
|
||||
r"|换(?!成|为|个|一下)([^\s,,。;;、请]{1,16})"
|
||||
)
|
||||
_CARGO_TAIL = re.compile(r"(请报价|报价|一下|吧|了)+$")
|
||||
_CARGO_SKIP = frozenset({"一下", "个", "报价", "价", "询价", "港口", "地方"})
|
||||
|
||||
# 销售侧说法写回内部键。起运地在主账里仍是起运港。
|
||||
_WRITE_KEYS = {
|
||||
"品名": ("品名", "货物品名"),
|
||||
"货物品名": ("品名", "货物品名"),
|
||||
"起运地": ("起运港", "起运地", "始发站"),
|
||||
"起运港": ("起运港", "起运地", "始发站"),
|
||||
"始发站": ("起运港", "起运地", "始发站"),
|
||||
"备注": ("备注",),
|
||||
}
|
||||
|
||||
# 建单前要看的当前字段。没写进这几项、但原话里出现过的,靠 sensitive_hold 记住。
|
||||
_SCAN_KEYS = ("品名", "货物品名", "起运港", "起运地", "始发站", "备注")
|
||||
|
||||
|
||||
def contains_blocked(text: str) -> bool:
|
||||
"""
|
||||
这句话里有没有要拦的词。
|
||||
|
||||
放行说法内部的字不算。例如「玩具枪」放行,「玩具枪支」仍因「枪支」拦下。
|
||||
不做英文、拼音、谐音。纯字符串查找,可在 Worker 线程同步调用。
|
||||
"""
|
||||
raw = text or ""
|
||||
if not raw:
|
||||
return False
|
||||
allow_spans: list[tuple[int, int]] = []
|
||||
for phrase in _ALLOW:
|
||||
start = 0
|
||||
while True:
|
||||
found = raw.find(phrase, start)
|
||||
if found < 0:
|
||||
break
|
||||
allow_spans.append((found, found + len(phrase)))
|
||||
start = found + len(phrase)
|
||||
|
||||
def _inside(begin: int, end: int) -> bool:
|
||||
return any(left <= begin and end <= right for left, right in allow_spans)
|
||||
|
||||
for word in _BLOCK:
|
||||
start = 0
|
||||
while True:
|
||||
found = raw.find(word, start)
|
||||
if found < 0:
|
||||
break
|
||||
if not _inside(found, found + len(word)):
|
||||
return True
|
||||
start = found + len(word)
|
||||
return False
|
||||
|
||||
|
||||
def spoken_cargo(text: str) -> str:
|
||||
"""
|
||||
从这一句里取出销售刚说的品名。
|
||||
|
||||
「运送衣服」「改成衣服」「换成衣服」「炸弹换衣服」都算。
|
||||
取最后一个说法,所以「炸弹换衣服」得到衣服。
|
||||
说出来的还是违禁词,或者只是「换一下」,就当没改口。
|
||||
"""
|
||||
found = ""
|
||||
for match in _SPOKEN_CARGO.finditer(text or ""):
|
||||
raw = match.group(1) or match.group(2) or ""
|
||||
value = _CARGO_TAIL.sub("", raw).strip(",,。;;、 ")
|
||||
if not value or value in _CARGO_SKIP or contains_blocked(value):
|
||||
continue
|
||||
found = value
|
||||
return found
|
||||
|
||||
|
||||
def apply_field_edits(text: str, facts: dict[str, Any]) -> dict[str, str]:
|
||||
"""
|
||||
把当前这句话里的「品名改为…」「起运地改为…」「备注改为…」写进字段。
|
||||
|
||||
只改这一句写明的项。返回实际写下的键值,方便多段把品名写回每一段。
|
||||
会改传入的 facts。值末尾的标点去掉,避免「服装,」留在品名里。
|
||||
"""
|
||||
written: dict[str, str] = {}
|
||||
pairs = list(_FIELD_EDIT.findall(text or ""))
|
||||
if not _MULTI_SHEET.search(text or ""):
|
||||
pairs.extend(_LABEL_EDIT.findall(text or ""))
|
||||
for label, raw_value in pairs:
|
||||
value = str(raw_value or "").strip(",,。;;、 ")
|
||||
if not value:
|
||||
continue
|
||||
for key in _WRITE_KEYS.get(label, (label,)):
|
||||
facts[key] = value
|
||||
written[key] = value
|
||||
cargo = spoken_cargo(text)
|
||||
if cargo:
|
||||
for key in ("品名", "货物品名"):
|
||||
facts[key] = cargo
|
||||
written[key] = cargo
|
||||
return written
|
||||
|
||||
|
||||
def is_cargo_rename(text: str) -> bool:
|
||||
"""当前句有没有把品名重新说成一个新值。没说品名的补重量不算改口。"""
|
||||
if spoken_cargo(text):
|
||||
return True
|
||||
body = text or ""
|
||||
labels = [label for label, _value in _FIELD_EDIT.findall(body)]
|
||||
if not _MULTI_SHEET.search(body):
|
||||
labels.extend(label for label, _value in _LABEL_EDIT.findall(body))
|
||||
return any(label in {"品名", "货物品名"} for label in labels)
|
||||
|
||||
|
||||
def judge(*, text: str, facts: dict[str, Any], hold: str) -> tuple[bool, str]:
|
||||
"""
|
||||
决定这一轮私聊能不能继续往建单走。
|
||||
|
||||
返回 (是否拦截, 新的未改口原话, 本句写下的字段)。
|
||||
当前句仍只是在说违禁词:拦住,并把这句记下来。
|
||||
当前句已经把品名说成普通货(运送衣服、改成衣服、换成衣服):前面的违禁原话作废。
|
||||
同一句里先提到旧词再改口,按改口后的品名放行。
|
||||
只补重量、件数:旧原话继续有效。
|
||||
品名改干净之后,起运地或备注里还留着违禁词,仍然拦截。
|
||||
会就地更新 facts 里本句写明的品名、起运地、备注。
|
||||
第三个返回值是本句写下的键,多段用来写回每一段,避免把别的字段盖掉。
|
||||
"""
|
||||
edits = apply_field_edits(text, facts)
|
||||
replaced = bool(spoken_cargo(text)) or is_cargo_rename(text)
|
||||
message_hit = contains_blocked(text)
|
||||
if replaced and not contains_blocked(str(facts.get("品名") or "")):
|
||||
# 已经说出新的普通货品名。旧词留在这句里只是在改口,不再记住。
|
||||
new_hold = ""
|
||||
message_hit = False
|
||||
elif message_hit:
|
||||
new_hold = (text or "").strip() or (hold or "")
|
||||
else:
|
||||
new_hold = hold or ""
|
||||
field_text = "\n".join(str((facts or {}).get(key) or "") for key in _SCAN_KEYS)
|
||||
blocked = message_hit or contains_blocked(new_hold) or contains_blocked(field_text)
|
||||
return blocked, new_hold, edits
|
||||
@@ -0,0 +1,207 @@
|
||||
"""
|
||||
私聊建单前违禁品:词表、改口、不建单。
|
||||
|
||||
不连企微、TMS、主账。群聊不走这条拦截。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
import unittest
|
||||
|
||||
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
|
||||
if ROOT not in sys.path:
|
||||
sys.path.insert(0, ROOT)
|
||||
|
||||
os.environ.setdefault("YTD_ENV", "test")
|
||||
os.environ.setdefault("REDIS_BACKEND", "memory")
|
||||
os.environ.setdefault("CHECKPOINT_BACKEND", "memory")
|
||||
os.environ.setdefault("MESSAGE_STORE_BACKEND", "memory")
|
||||
os.environ.setdefault("LLM_DATA_USAGE_CONFIRMED", "false")
|
||||
os.environ.setdefault("LLM_ALLOW_NETWORK", "false")
|
||||
os.environ.setdefault("LEDGER_BACKEND", "memory")
|
||||
|
||||
from agent.ledger.memory_ledger import MemoryLedger
|
||||
from agent.policy.sensitive_cargo import BLOCK_REPLY, contains_blocked, judge
|
||||
from agent.policy.sea_text_flow import SeaTextInquiryFlow, reset_sea_text_flow_for_test
|
||||
|
||||
COMPLETE_SEA = {
|
||||
"起运港": "上海",
|
||||
"目的港": "洛杉矶",
|
||||
"品名": "普货",
|
||||
"货量": "20吨",
|
||||
"整柜或拼柜": "整柜",
|
||||
"箱型箱量": "1x40HQ",
|
||||
"贸易条款": "FOB",
|
||||
"运输分类": "Port to Port",
|
||||
}
|
||||
|
||||
|
||||
class WordListTests(unittest.TestCase):
|
||||
def test_real_weapons_and_drugs_block(self) -> None:
|
||||
for word in ("炸弹", "枪支", "手枪", "子弹", "海洛因", "火炮"):
|
||||
self.assertTrue(contains_blocked(f"上海到洛杉矶,品名{word}"), word)
|
||||
|
||||
def test_toys_and_fireworks_pass(self) -> None:
|
||||
for word in ("玩具枪", "水枪", "仿真枪", "气枪", "烟花", "礼炮", "鞭炮"):
|
||||
self.assertFalse(contains_blocked(f"品名{word}"), word)
|
||||
|
||||
def test_toy_prefix_does_not_hide_real_gun(self) -> None:
|
||||
self.assertTrue(contains_blocked("玩具枪支"))
|
||||
|
||||
def test_ordinary_dangerous_goods_pass(self) -> None:
|
||||
self.assertFalse(contains_blocked("这票是危险品,含电,锂电池"))
|
||||
|
||||
|
||||
class JudgeTests(unittest.TestCase):
|
||||
def test_stray_sentence_sticks_until_cargo_rename(self) -> None:
|
||||
facts = {"品名": "服装", "起运港": "上海"}
|
||||
blocked, hold, _edits = judge(text="客户提到有炸弹", facts=dict(facts), hold="")
|
||||
self.assertTrue(blocked)
|
||||
self.assertIn("炸弹", hold)
|
||||
still, hold2, _edits = judge(text="重量100KGS", facts=dict(facts), hold=hold)
|
||||
self.assertTrue(still)
|
||||
self.assertEqual(hold2, hold)
|
||||
released, hold3, edits = judge(text="品名改为服装", facts=dict(facts), hold=hold)
|
||||
self.assertFalse(released)
|
||||
self.assertEqual(hold3, "")
|
||||
self.assertEqual(edits.get("品名"), "服装")
|
||||
|
||||
def test_rename_sentence_that_still_says_the_word_stays_blocked(self) -> None:
|
||||
blocked, hold, _edits = judge(text="不是炸弹,品名服装", facts={"品名": "服装"}, hold="")
|
||||
self.assertTrue(blocked)
|
||||
self.assertIn("炸弹", hold)
|
||||
|
||||
def test_origin_and_remark_remain_after_cargo_rename(self) -> None:
|
||||
facts = {"品名": "炸弹", "起运港": "上海", "备注": "内有枪支"}
|
||||
blocked, hold, _edits = judge(text="品名改为服装", facts=facts, hold="品名炸弹")
|
||||
self.assertTrue(blocked)
|
||||
self.assertEqual(hold, "")
|
||||
self.assertEqual(facts["品名"], "服装")
|
||||
self.assertIn("枪支", facts["备注"])
|
||||
|
||||
def test_remark_can_be_rewritten(self) -> None:
|
||||
facts = {"品名": "服装", "备注": "枪支"}
|
||||
blocked, hold, _edits = judge(text="备注改为无", facts=facts, hold="")
|
||||
self.assertFalse(blocked)
|
||||
self.assertEqual(facts["备注"], "无")
|
||||
self.assertEqual(hold, "")
|
||||
|
||||
|
||||
class SeaPrivateFlowTests(unittest.TestCase):
|
||||
def setUp(self) -> None:
|
||||
reset_sea_text_flow_for_test()
|
||||
self.ledger = MemoryLedger()
|
||||
self.flow = SeaTextInquiryFlow(self.ledger)
|
||||
self.replies: list[str] = []
|
||||
|
||||
def _reply(self, text: str, extra=None) -> None:
|
||||
_ = extra
|
||||
self.replies.append(text)
|
||||
|
||||
def test_private_text_blocks_before_ticket(self) -> None:
|
||||
facts = dict(COMPLETE_SEA)
|
||||
facts["品名"] = "炸弹"
|
||||
phase = self.flow.on_text(
|
||||
sender_id="s-bomb",
|
||||
text="海运 上海到洛杉矶 品名炸弹",
|
||||
reply=self._reply,
|
||||
injected_facts=facts,
|
||||
injected_mode="SEA",
|
||||
)
|
||||
self.assertEqual(phase, "sensitive_block")
|
||||
self.assertEqual(self.replies, [BLOCK_REPLY])
|
||||
self.assertEqual(self.ledger._tickets, {})
|
||||
sess = self.flow.session_of("s-bomb")
|
||||
self.assertFalse((sess.work_order_no or "").strip())
|
||||
|
||||
def test_file_facts_block_even_if_caption_is_plain(self) -> None:
|
||||
facts = dict(COMPLETE_SEA)
|
||||
facts["品名"] = "枪支"
|
||||
phase = self.flow.on_text(
|
||||
sender_id="s-file",
|
||||
text="海运报价单",
|
||||
reply=self._reply,
|
||||
injected_facts=facts,
|
||||
injected_mode="SEA",
|
||||
)
|
||||
self.assertEqual(phase, "sensitive_block")
|
||||
self.assertEqual(self.ledger._tickets, {})
|
||||
|
||||
def test_rename_then_confirm_creates_ticket(self) -> None:
|
||||
facts = dict(COMPLETE_SEA)
|
||||
facts["品名"] = "炸弹"
|
||||
self.flow.on_text(
|
||||
sender_id="s-fix",
|
||||
text="海运 品名炸弹 上海到洛杉矶",
|
||||
reply=self._reply,
|
||||
injected_facts=facts,
|
||||
injected_mode="SEA",
|
||||
)
|
||||
self.replies.clear()
|
||||
fixed = dict(COMPLETE_SEA)
|
||||
phase = self.flow.on_text(
|
||||
sender_id="s-fix",
|
||||
text="品名改为服装",
|
||||
reply=self._reply,
|
||||
injected_facts=fixed,
|
||||
injected_mode="SEA",
|
||||
)
|
||||
self.assertNotEqual(phase, "sensitive_block")
|
||||
self.assertNotIn(BLOCK_REPLY, self.replies)
|
||||
self.replies.clear()
|
||||
created = self.flow.on_text(sender_id="s-fix", text="确定", reply=self._reply)
|
||||
self.assertNotEqual(created, "sensitive_block")
|
||||
self.assertTrue(self.ledger._tickets)
|
||||
joined = "\n".join(self.replies)
|
||||
self.assertNotIn(BLOCK_REPLY, joined)
|
||||
|
||||
def test_oral_restatement_clears_previous_bomb(self) -> None:
|
||||
"""销售不说「品名改为」,直接重说运送衣服,或说改成衣服。"""
|
||||
hold = "10月30日,从广州去深圳,运送炸弹,请报价"
|
||||
facts = {"品名": "炸弹", "起运港": "广州"}
|
||||
for text in (
|
||||
"10月30日,从广州去深圳,运送衣服,请报价",
|
||||
"炸弹错了,给我改成衣服",
|
||||
"炸弹换衣服",
|
||||
):
|
||||
blocked, new_hold, edits = judge(text=text, facts=dict(facts), hold=hold)
|
||||
self.assertFalse(blocked, text)
|
||||
self.assertEqual(new_hold, "", text)
|
||||
self.assertEqual(edits.get("品名"), "衣服", text)
|
||||
|
||||
def test_weight_alone_does_not_clear(self) -> None:
|
||||
facts = dict(COMPLETE_SEA)
|
||||
facts["品名"] = "弹药"
|
||||
self.flow.on_text(
|
||||
sender_id="s-hold",
|
||||
text="海运 品名弹药",
|
||||
reply=self._reply,
|
||||
injected_facts=facts,
|
||||
injected_mode="SEA",
|
||||
)
|
||||
self.replies.clear()
|
||||
phase = self.flow.on_text(
|
||||
sender_id="s-hold",
|
||||
text="重量 20吨",
|
||||
reply=self._reply,
|
||||
injected_facts=facts,
|
||||
injected_mode="SEA",
|
||||
)
|
||||
self.assertEqual(phase, "sensitive_block")
|
||||
self.assertEqual(self.replies, [BLOCK_REPLY])
|
||||
self.assertEqual(self.ledger._tickets, {})
|
||||
|
||||
def test_toy_gun_can_continue(self) -> None:
|
||||
facts = dict(COMPLETE_SEA)
|
||||
facts["品名"] = "玩具枪"
|
||||
phase = self.flow.on_text(
|
||||
sender_id="s-toy",
|
||||
text="海运 品名玩具枪 上海到洛杉矶",
|
||||
reply=self._reply,
|
||||
injected_facts=facts,
|
||||
injected_mode="SEA",
|
||||
)
|
||||
self.assertEqual(phase, "wait_confirm")
|
||||
self.assertNotIn(BLOCK_REPLY, self.replies)
|
||||
@@ -105,6 +105,10 @@ NormalizedInbound
|
||||
|
||||
实现与 `inquiry-required-fields-v1.json` 等价的条件激活(JSON 放在新仓库)。SEA 箱型箱量保持单原始字段 + B 模型投影 `containerItems`。协同点击用不可变询价原话重跑 B,去重补问。
|
||||
|
||||
## 违禁品(建单前)
|
||||
|
||||
企微私聊里,海运、空运、陆运、多段都一样:工单还没建成时,当前这句话、还没改口的原话,以及已经认定的品名、起运地、备注(含表格、图片、文件认出来的字)里出现枪支、弹药、爆炸物、毒品,只回「涉及危险品,无法发起报价。」,不建工单,后台看不到这票,这一轮也不追问缺项。同一条私聊里直接把品名改成普通货后,前面没落进字段的那句不再算;这句里仍写着违禁词,或起运地、备注里还留着,继续拦。只补重量清不掉。水枪、玩具枪、仿真枪、气枪、烟花、礼炮、鞭炮可以报价。化学品、电池、油品以及「是否为危险品:是」仍按原询价走。群聊不拦。工单已经建成之后不拦。英文、拼音、谐音这一轮不拦。
|
||||
|
||||
## 出站
|
||||
|
||||
- 主账:登录或 Token,所有写操作要幂等键(message_id / request_id / quoteVersion)。
|
||||
|
||||
Reference in New Issue
Block a user