""" 私聊建单前的违禁品拦截。 本文件职责:用固定词表判断当前这句话、尚未改口的原话,以及品名、起运地、备注 是否涉及真家伙(枪支、弹药、爆炸物、毒品)。命中则不能建单。 群聊、已经建成的工单不在这里判断,由调用方避开。 调用: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 = ("品名", "货物品名", "起运港", "起运地", "始发站", "备注") # 新开一票的说法。命中且本句没有违禁词时,丢掉上一句没改口的 hold。 _FRESH_INQUIRY = re.compile( r"(查一下|帮我查|询一下|询个价|询价|请报价|报一下价|报个价|费用)" ) # 只补量尺、件数、包装:不算新询价,旧 hold 继续有效。 _MEASURE_SUPPLEMENT = re.compile( r"(?:" r"重量|毛重|体积|件数|货量|包装(?:方式|类型)?" r")\s*[::]?\s*\d" r"|\d+\s*(?:kgs?|kg|公斤|千克|吨|t|cbm|方|件|箱|托)", re.I, ) def looks_like_fresh_inquiry(text: str) -> bool: """ 当前句是不是另开一票,而不是接着补上一票的字段。 「查一下目的地吉隆坡的费用」算新开;「重量100KGS」不算。 用来在本句已无违禁词时清掉 sticky hold,避免正常询价被上一句炸弹拖死。 """ body = (text or "").strip() if not body: return False if _MEASURE_SUPPLEMENT.search(body) and not _FRESH_INQUIRY.search(body): return False return bool(_FRESH_INQUIRY.search(body)) def scrub_blocked_scan_fields(facts: dict[str, Any]) -> None: """新开一票时,把品名/起运/备注里仍带着违禁词的旧值清掉,避免 field_hit 再拦。""" for key in _SCAN_KEYS: if contains_blocked(str((facts or {}).get(key) or "")): facts[key] = "" 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]: """ 决定这一轮私聊能不能继续往建单走。 返回 (是否拦截, 新的未改口原话, 本句写下的字段)。 当前句仍只是在说违禁词:拦住,并把这句记下来。 当前句已经把品名说成普通货(运送衣服、改成衣服、换成衣服):前面的违禁原话作废。 同一句里先提到旧词再改口,按改口后的品名放行。 只补重量、件数:旧原话继续有效。 本句无违禁词且像另开一票(查一下…费用):旧 hold 作废,并清掉字段里残留的违禁词。 品名改干净之后,起运地或备注里还留着违禁词,仍然拦截。 会就地更新 facts 里本句写明的品名、起运地、备注。 第三个返回值是本句写下的键,多段用来写回每一段,避免把别的字段盖掉。 """ edits = apply_field_edits(text, facts) replaced = bool(spoken_cargo(text)) or is_cargo_rename(text) message_hit = contains_blocked(text) fresh = False 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 "") elif hold and looks_like_fresh_inquiry(text): # 本句没有违禁词,且是另开一票(查一下…费用),上一句违禁原话作废。 # 只补重量/件数不走这里,旧 hold 继续拦。 fresh = True new_hold = "" scrub_blocked_scan_fields(facts) else: new_hold = hold or "" field_text = "\n".join(str((facts or {}).get(key) or "") for key in _SCAN_KEYS) hold_hit = contains_blocked(new_hold) field_hit = contains_blocked(field_text) blocked = message_hit or hold_hit or field_hit return blocked, new_hold, edits