Compare commits

..
Author SHA1 Message Date
jillion886andCursor a0c705683f 私聊改口成普通货后不再被上一句违禁词拦住。
销售说运送衣服、改成衣服时,按新品名继续询价。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 20:30:53 +08:00
jillion886andCursor 617c5e2619 私聊建单前拦住枪支弹药爆炸物和毒品,避免真家伙进入报价。
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-10-14 20:24:13 +08:00
8 changed files with 706 additions and 7 deletions
+8 -3
View File
@@ -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
+95 -1
View File
@@ -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(
+35 -1
View File
@@ -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(
+101 -1
View File
@@ -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)
+33 -1
View File
@@ -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
+207
View File
@@ -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)
+4
View File
@@ -105,6 +105,10 @@ NormalizedInbound
实现与 `inquiry-required-fields-v1.json` 等价的条件激活(JSON 放在新仓库)。SEA 箱型箱量保持单原始字段 + B 模型投影 `containerItems`。协同点击用不可变询价原话重跑 B,去重补问。
## 违禁品(建单前)
企微私聊里,海运、空运、陆运、多段都一样:工单还没建成时,当前这句话、还没改口的原话,以及已经认定的品名、起运地、备注(含表格、图片、文件认出来的字)里出现枪支、弹药、爆炸物、毒品,只回「涉及危险品,无法发起报价。」,不建工单,后台看不到这票,这一轮也不追问缺项。同一条私聊里直接把品名改成普通货后,前面没落进字段的那句不再算;这句里仍写着违禁词,或起运地、备注里还留着,继续拦。只补重量清不掉。水枪、玩具枪、仿真枪、气枪、烟花、礼炮、鞭炮可以报价。化学品、电池、油品以及「是否为危险品:是」仍按原询价走。群聊不拦。工单已经建成之后不拦。英文、拼音、谐音这一轮不拦。
## 出站
- 主账:登录或 Token,所有写操作要幂等键(message_id / request_id / quoteVersion)。