From 617c5e2619865879145f18850026145be5cff737 Mon Sep 17 00:00:00 2001 From: jillion <439912596@qq.com> Date: Wed, 14 Oct 2026 20:24:13 +0800 Subject: [PATCH] =?UTF-8?q?=E7=A7=81=E8=81=8A=E5=BB=BA=E5=8D=95=E5=89=8D?= =?UTF-8?q?=E6=8B=A6=E4=BD=8F=E6=9E=AA=E6=94=AF=E5=BC=B9=E8=8D=AF=E7=88=86?= =?UTF-8?q?=E7=82=B8=E7=89=A9=E5=92=8C=E6=AF=92=E5=93=81=EF=BC=8C=E9=81=BF?= =?UTF-8?q?=E5=85=8D=E7=9C=9F=E5=AE=B6=E4=BC=99=E8=BF=9B=E5=85=A5=E6=8A=A5?= =?UTF-8?q?=E4=BB=B7=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- inquiry-agent/agent/handlers/text_inquiry.py | 11 +- inquiry-agent/agent/policy/air_text_flow.py | 96 ++++++++- inquiry-agent/agent/policy/land_text_flow.py | 36 +++- inquiry-agent/agent/policy/multi_text_flow.py | 102 ++++++++- inquiry-agent/agent/policy/sea_text_flow.py | 34 ++- inquiry-agent/agent/policy/sensitive_cargo.py | 187 +++++++++++++++++ inquiry-agent/tests/test_sensitive_cargo.py | 193 ++++++++++++++++++ prompt/06-Python智能体.md | 4 + 8 files changed, 656 insertions(+), 7 deletions(-) create mode 100644 inquiry-agent/agent/policy/sensitive_cargo.py create mode 100644 inquiry-agent/tests/test_sensitive_cargo.py diff --git a/inquiry-agent/agent/handlers/text_inquiry.py b/inquiry-agent/agent/handlers/text_inquiry.py index 3ae6365..a278238 100644 --- a/inquiry-agent/agent/handlers/text_inquiry.py +++ b/inquiry-agent/agent/handlers/text_inquiry.py @@ -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 diff --git a/inquiry-agent/agent/policy/air_text_flow.py b/inquiry-agent/agent/policy/air_text_flow.py index abd7e40..56d6c63 100644 --- a/inquiry-agent/agent/policy/air_text_flow.py +++ b/inquiry-agent/agent/policy/air_text_flow.py @@ -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( diff --git a/inquiry-agent/agent/policy/land_text_flow.py b/inquiry-agent/agent/policy/land_text_flow.py index 579f88a..c6b1553 100644 --- a/inquiry-agent/agent/policy/land_text_flow.py +++ b/inquiry-agent/agent/policy/land_text_flow.py @@ -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( diff --git a/inquiry-agent/agent/policy/multi_text_flow.py b/inquiry-agent/agent/policy/multi_text_flow.py index d63e7e3..9d3478d 100644 --- a/inquiry-agent/agent/policy/multi_text_flow.py +++ b/inquiry-agent/agent/policy/multi_text_flow.py @@ -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) diff --git a/inquiry-agent/agent/policy/sea_text_flow.py b/inquiry-agent/agent/policy/sea_text_flow.py index ea21992..f099e58 100644 --- a/inquiry-agent/agent/policy/sea_text_flow.py +++ b/inquiry-agent/agent/policy/sea_text_flow.py @@ -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 () diff --git a/inquiry-agent/agent/policy/sensitive_cargo.py b/inquiry-agent/agent/policy/sensitive_cargo.py new file mode 100644 index 0000000..b3ff907 --- /dev/null +++ b/inquiry-agent/agent/policy/sensitive_cargo.py @@ -0,0 +1,187 @@ +""" +私聊建单前的违禁品拦截。 + +本文件职责:用固定词表判断当前这句话、尚未改口的原话,以及品名、起运地、备注 +是否涉及真家伙(枪支、弹药、爆炸物、毒品)。命中则不能建单。 +群聊、已经建成的工单不在这里判断,由调用方避开。 +调用: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*段") + +# 销售侧说法写回内部键。起运地在主账里仍是起运港。 +_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 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 + return written + + +def is_cargo_rename(text: str) -> bool: + """当前句有没有把品名重新说成一个新值。没说品名的补重量不算改口。""" + 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) + message_hit = contains_blocked(text) + if message_hit: + new_hold = (text or "").strip() or (hold or "") + elif is_cargo_rename(text): + new_hold = "" + 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 diff --git a/inquiry-agent/tests/test_sensitive_cargo.py b/inquiry-agent/tests/test_sensitive_cargo.py new file mode 100644 index 0000000..f280bea --- /dev/null +++ b/inquiry-agent/tests/test_sensitive_cargo.py @@ -0,0 +1,193 @@ +""" +私聊建单前违禁品:词表、改口、不建单。 + +不连企微、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_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) diff --git a/prompt/06-Python智能体.md b/prompt/06-Python智能体.md index 4b94a72..9a3b454 100644 --- a/prompt/06-Python智能体.md +++ b/prompt/06-Python智能体.md @@ -105,6 +105,10 @@ NormalizedInbound 实现与 `inquiry-required-fields-v1.json` 等价的条件激活(JSON 放在新仓库)。SEA 箱型箱量保持单原始字段 + B 模型投影 `containerItems`。协同点击用不可变询价原话重跑 B,去重补问。 +## 违禁品(建单前) + +企微私聊里,海运、空运、陆运、多段都一样:工单还没建成时,当前这句话、还没改口的原话,以及已经认定的品名、起运地、备注(含表格、图片、文件认出来的字)里出现枪支、弹药、爆炸物、毒品,只回「涉及危险品,无法发起报价。」,不建工单,后台看不到这票,这一轮也不追问缺项。同一条私聊里直接把品名改成普通货后,前面没落进字段的那句不再算;这句里仍写着违禁词,或起运地、备注里还留着,继续拦。只补重量清不掉。水枪、玩具枪、仿真枪、气枪、烟花、礼炮、鞭炮可以报价。化学品、电池、油品以及「是否为危险品:是」仍按原询价走。群聊不拦。工单已经建成之后不拦。英文、拼音、谐音这一轮不拦。 + ## 出站 - 主账:登录或 Token,所有写操作要幂等键(message_id / request_id / quoteVersion)。