feat: 词表数据化——AST 提取脚本 + wall.json + 编译器 + boundary/conversation 数据源切换(等价回归全绿)
- scripts/extract_config.py: AST 解析两老项目源码取常量(不 import 源模块),
生成 seed_profiles/<slug>/{wall.json, brand_values.json, prompts/};幂等可重跑
- app/engine/wall_schema.py: compile_wall(json)→CompiledWall,坏结构/坏 regex
编译期显性失败(WallConfigError 含字段路径)
- boundary_filter/conversation: 墙词表/话术数据源切换为 DEFAULT_WALL
(jiancai 出厂 seed),函数逻辑不动、常量名保留为 wall 视图(既有测试零修改);
wall 数据化新增 pivots/pivots_contact/zero_number/reask_patterns 字段
- 脆锚点迁移: test_inline_prohibitions 断言对象从 conversation.py 源码改为
wall.json 数据文件,语义等价(唯一允许的测试修改)
- 回归: 322 既有全绿 + 10 新增 wall_schema 测试 = 332;提取脚本两遍无 diff
Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
@@ -1,115 +1,49 @@
|
||||
"""职能边界输出过滤(方案B): AI 回复价格形态的确定性拦截。
|
||||
流式回复无法撤回,故在流出前全量缓冲过滤;违规整体替换为标准迂回话术。
|
||||
模式以 scripts/breach_rate.py 为基础并收严(预算复述/金融收严;公里等误报除外项)。
|
||||
|
||||
评审修正(13.6 复查):
|
||||
- 货币组尾部 \\b 对中文备选(万/块钱/美元)永远无法命中——中文无词边界,
|
||||
"2万左右"的 万与左 之间没有 \\b(实测漏检预算复述 6/6 的原形态)。
|
||||
\\b 只保留在拉丁备选 usd 之后(防 "usdroid" 类前缀误命中)。
|
||||
- 公里/km 排除必须直接挂在 万块? 之后: 挂在末尾裸 万 备选上会被
|
||||
万块? 的空块匹配绕过("3万公里"仍命中,实测)。
|
||||
- 预算复述(裸 budget+数字/中文数字万)、金融裸数(首付/月供)收严;
|
||||
k 形式的 km/miles 除外项保留(建材语境已无里程语义,兜底无害)。质保期
|
||||
不再豁免(政策变更 2026-09-10:
|
||||
保修期限是编造的公司政策承诺,一并墙级拦截,见 PRICE_PATTERNS 时间承诺组)。"""
|
||||
Task 4 词表数据化: 墙词表/话术的唯一数据源是 seed_profiles/<slug>/wall.json
|
||||
(由 scripts/extract_config.py 从 jc 源码 AST 提取生成,经 wall_schema.compile_wall
|
||||
编译)。本模块出厂默认 = jiancai 出厂墙(既有行为等价回归);调用方传
|
||||
wall=CompiledWall 即可换租户词表,函数逻辑不变、只换数据来源。
|
||||
每条 pattern 的防误杀断言设计说明以 comment 字段随 pattern 数据化,
|
||||
词表语义的历史注释见老项目源码(app/services/boundary_filter.py)。
|
||||
|
||||
评审修正(13.6 复查)要点(全文见 jc 源码):
|
||||
- 货币组尾部 \\b 对中文备选(万/块钱/美元)永远无法命中,\\b 只保留在
|
||||
拉丁备选 usd 之后;公里/km 排除直接挂在 万块? 之后。
|
||||
- 预算复述/金融裸数收严;质保期不再豁免(政策变更 2026-09-10,
|
||||
保修期限一并墙级拦截,见 wall.json 时间承诺组)。"""
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
PRICE_PATTERNS = [
|
||||
re.compile(r"\$\s?\d"),
|
||||
re.compile(r"\d[\d,.]*\s*(usd\b|美元|块钱|万块?(?!\s*(公里|km)))", re.I),
|
||||
re.compile(r"\d[\d,.]*\s?k\b(?!\s*(km|kilometers?|kilometres?|miles?|mi\b|公里))", re.I),
|
||||
re.compile(r"(price|cost|quote)[^.\d]{0,20}(?<![a-z])(?!(?:19|20)\d\d(?!\d))\d", re.I),
|
||||
# 时间承诺: 去掉 warranty 豁免(政策变更 2026-09-10——保修期限是编造的
|
||||
# 公司政策承诺,不再白名单)
|
||||
re.compile(r"\d+\s*[- ]?(day|week|month)s?\b", re.I),
|
||||
# 年限保修定向(双向): year 不能进时间模式(使用年限 "under 5 years old" 会误杀),
|
||||
# 只收 数字+年 与 warranty/质保/保修 的组合(含倒装"质保3年"/"warranty of 8 years")
|
||||
re.compile(r"\d+\s*years?\b[^.!?]{0,15}warranty", re.I),
|
||||
re.compile(r"warranty\s*(?:of|for|:)?\s*\d+\s*years?\b", re.I),
|
||||
re.compile(r"\d+\s*年[^。!?]{0,8}(?:质保|保修)"),
|
||||
re.compile(r"(?:质保|保修)[^。!?]{0,8}\d+\s*年"),
|
||||
# 中文时限承诺: 天/周/个月(年不收——裸 "2024年" 是年份/项目日期,
|
||||
# 带质保/保修的年限由上方定向组收)
|
||||
re.compile(r"\d+\s*(?:天|周|个月)"),
|
||||
# 裸预算复述(F3,方案B旗舰形态——冒烟 6/6 破防即此形,原模式完全漏检)
|
||||
re.compile(r"(budget|预算)[^\d\n]{0,12}\d", re.I),
|
||||
re.compile(r"(两|三|四|五|六|七|八|九|十)\s*万(?!\s*(公里|km))"),
|
||||
# 金融裸数(F4)
|
||||
re.compile(r"(down\s+payment|monthly\s+payment|首付|月供)[^\d\n]{0,10}\d", re.I),
|
||||
# ---- 多语言墙(建材市场: 欧/英/南美/俄语区/中东) ----
|
||||
# 货币符号: €/£(参考项目只有 $)
|
||||
re.compile(r"[€£]\s?\d"),
|
||||
# 价格名词前置(西/葡/俄/阿): 词后 0-20 非数字字符内出现数字
|
||||
# ⚠️ 拉丁词必须加 \b 词边界——custo 是英文 customs 的子串,外贸高频,
|
||||
# "customs paperwork for 20ft" 会被误杀(Task 3 质量审查发现)
|
||||
# ⚠️ 阿语定冠词 ال 与词连写(السعر): 词首 \b 落在 ل|س 之间(两者皆 \w)
|
||||
# 不成立,备选须收 (?:ال)? 形态——裸 \bسعر\b 实测漏检 "السعر 500 دولار"
|
||||
# ⚠️ (?<![a-z]) 字母后顾(与英文组 (?<![a-z]) 同款): 建材规格数字永远
|
||||
# 跟在拉丁字母后(DN800/SDR11/PN16/SCH40),后顾天然放行——实测
|
||||
# "El precio del DN800 PE100..." 旧形态误杀,加后顾全放行且 HITS 全保
|
||||
# ⚠️ 复数/变格(spec 审查加固): precio?s?/costo?s?/custo?s?/preço?s?
|
||||
# (西葡 +s 复数;preço 的 ç 是词干,只放开 s——避免 "preç" 类
|
||||
# 错拼形态);俄语 цен[аы]?(цена/цены/цен);阿语复数 أسعار。
|
||||
# \b 包裹不受影响——"customs" 中 costo?s? 无子串匹配(c 后是 u),
|
||||
# 词边界防线依旧成立
|
||||
# ⚠️ 无英文组的年份排除 (?!(?:19|20)\d\d): "precios 2025" 会命中
|
||||
# ——有意为之(价格词在前的宁拦勿放,与英文组从严方向一致但更保守)
|
||||
re.compile(r"\b(?:precio?s?|costo?s?|custo?s?|preço?s?|цен[аы]?|"
|
||||
r"стоимость|(?:ال)?سعر|(?:ال)?أسعار|(?:ال)?ثمن)\b"
|
||||
r"[^\d\n]{0,20}(?<![a-z])\d", re.I),
|
||||
# 货币名词后置(数字+货币词): dólares/euros/рублей/долларов
|
||||
# ⚠️ 西里尔 долларов 变格必须单列(质量审查发现): 前置组只有名词
|
||||
# стоимость,动词句 "стоит 500 долларов"/"будет стоить 800
|
||||
# долларов" 前无价格名词,靠前置组兜不住
|
||||
# ⚠️ euros 排除 pallets 邻接——"50 euro pallets"(欧标托盘)是建材
|
||||
# 物流高频词,非价格
|
||||
re.compile(r"\d[\d,.]*\s*(?:d[óo]lares?|euros?\b(?!\s*pallets?)|"
|
||||
r"рублей|руб\.?|доллар(?:ов|а|ы)?)", re.I),
|
||||
# 多语言时间承诺(西/葡 dias|semanas,俄 дн/недел,阿 أيام;与英文
|
||||
# days/weeks 组同构)
|
||||
# ⚠️ 不收 horas/hours/小时——system.md 的 "reply within 12 hours"
|
||||
# 依赖此豁免(响应时效承诺非交期承诺)
|
||||
# ⚠️ 俄语变格只收 дн(ей|я)/недел[ьи] 主形态,其余变格(дню/неделю
|
||||
# 等)不收——宁可少而准
|
||||
# ⚠️ re.I 必须有——大写形态 '15 Días'/'30 SEMANAS' 实测要拦(质量审查),
|
||||
# 与英文时间组 re.I 对齐
|
||||
re.compile(r"\d+\s*[- ]?(?:d[ií]as?|semanas?|дн(?:ей|я)|недел[ьи]|أيام)\b", re.I),
|
||||
]
|
||||
from app.engine.wall_schema import CompiledWall, load_wall_file
|
||||
|
||||
DEFLECTIONS_EN = [
|
||||
"Prices vary a lot by config and shipping — our manager will prepare an exact quote for you. What's your WhatsApp or email?",
|
||||
"For an exact price our manager needs your config and destination — drop your WhatsApp and you'll have the quote today.",
|
||||
"I'd rather give you an exact number than a rough guess — leave your WhatsApp and our manager will send it over.",
|
||||
]
|
||||
DEFLECTIONS_ZH = [
|
||||
"价格要看配置和目的地,客户经理会给你准确报价——留个WhatsApp或邮箱?",
|
||||
"我让客户经理按你的情况出准确报价,留个联系方式,今天就能发你。",
|
||||
]
|
||||
# 出厂默认墙 = jiancai seed(路径锚定模块位置,与 cwd 无关)
|
||||
DEFAULT_WALL: CompiledWall = load_wall_file(
|
||||
Path(__file__).resolve().parents[3] / "seed_profiles" / "jiancai" / "wall.json")
|
||||
|
||||
# 模块级常量保留为 DEFAULT_WALL 的视图(既有测试/调用方按名锚定,如
|
||||
# DEFLECTIONS_ZH/NO_CONTACT_WEB 与 test_no_auto_wording_left_in_any_pool 的
|
||||
# dir() 枚举)——值与数据化前逐字等价。
|
||||
PRICE_PATTERNS = list(DEFAULT_WALL.price_patterns)
|
||||
DEFLECTIONS_EN = list(DEFAULT_WALL.deflections["en"])
|
||||
DEFLECTIONS_ZH = list(DEFAULT_WALL.deflections["zh"])
|
||||
DEFLECTIONS_ES = list(DEFAULT_WALL.deflections["es"])
|
||||
|
||||
# F6(D6×过滤器矛盾): 客户已留联系方式时,迂回话术不得再索要——否则与
|
||||
# 注入的"禁止再索要联系方式"提醒直接打架
|
||||
DEFLECTIONS_EN_CONTACT = [
|
||||
"Our manager will send the exact quote to your WhatsApp shortly. Anything else about our pipes?",
|
||||
]
|
||||
DEFLECTIONS_ZH_CONTACT = [
|
||||
"客户经理稍后会把准确报价发到你的 WhatsApp。还想了解什么管道产品?",
|
||||
]
|
||||
DEFLECTIONS_EN_CONTACT = list(DEFAULT_WALL.deflections_contact["en"])
|
||||
DEFLECTIONS_ZH_CONTACT = list(DEFAULT_WALL.deflections_contact["zh"])
|
||||
|
||||
# ---- Task 5: ES 话术池(南美第一大流量) ----
|
||||
# 与 EN 池语义对齐的西语替换句(注意: 是"替换句",绝不含价格数字,
|
||||
# 且不得触发本墙自身——见 test_spanish_pools_are_wall_clean)
|
||||
DEFLECTIONS_ES = [
|
||||
"El precio exacto depende de la especificación y el flete — nuestro gerente te lo prepara. ¿Me dejas tu WhatsApp o correo?",
|
||||
"Para un precio exacto, nuestro gerente necesita tu especificación y destino — déjame tu WhatsApp y lo tienes hoy.",
|
||||
"Prefiero darte una cifra exacta y no un estimado — déjanos tu WhatsApp y nuestro gerente te lo envía.",
|
||||
]
|
||||
DEFLECTIONS_ES_CONTACT = [
|
||||
"Nuestro gerente te enviará el precio exacto a tu WhatsApp en breve. ¿Algo más sobre nuestras tuberías?",
|
||||
]
|
||||
DEFLECTIONS_ES_CONTACT = list(DEFAULT_WALL.deflections_contact["es"])
|
||||
|
||||
|
||||
def violates_boundary(reply: str) -> bool:
|
||||
return any(p.search(reply or "") for p in PRICE_PATTERNS)
|
||||
def violates_boundary(reply: str, wall: CompiledWall | None = None) -> bool:
|
||||
w = wall or DEFAULT_WALL
|
||||
return any(p.search(reply or "") for p in w.price_patterns)
|
||||
|
||||
|
||||
# ---- 13.7: 句子粒度门控 ----
|
||||
@@ -139,24 +73,12 @@ def split_sentences(text: str) -> tuple[list[str], str]:
|
||||
|
||||
# 过渡话术(pivot): 句子门控拦截时已流出干净前缀,接一句过渡收尾。
|
||||
# 与开场版迂回话术不同——读起来像回复中段的转向,故单条不轮换。
|
||||
PIVOTS_EN = [
|
||||
"Actually, for the exact price — what's your WhatsApp? Our manager will send it over.",
|
||||
]
|
||||
PIVOTS_ZH = [
|
||||
"具体的报价,客户经理会发给你——留个WhatsApp或邮箱?",
|
||||
]
|
||||
PIVOTS_EN_CONTACT = [
|
||||
"Our manager will send the exact price to your WhatsApp shortly.",
|
||||
]
|
||||
PIVOTS_ZH_CONTACT = [
|
||||
"客户经理稍后会把准确报价发到你的 WhatsApp。",
|
||||
]
|
||||
PIVOTS_ES = [
|
||||
"Para el precio exacto — ¿cuál es tu WhatsApp? Nuestro gerente te lo envía.",
|
||||
]
|
||||
PIVOTS_ES_CONTACT = [
|
||||
"Nuestro gerente te enviará el precio exacto a tu WhatsApp en breve.",
|
||||
]
|
||||
PIVOTS_EN = list(DEFAULT_WALL.pivots["en"])
|
||||
PIVOTS_ZH = list(DEFAULT_WALL.pivots["zh"])
|
||||
PIVOTS_EN_CONTACT = list(DEFAULT_WALL.pivots_contact["en"])
|
||||
PIVOTS_ZH_CONTACT = list(DEFAULT_WALL.pivots_contact["zh"])
|
||||
PIVOTS_ES = list(DEFAULT_WALL.pivots["es"])
|
||||
PIVOTS_ES_CONTACT = list(DEFAULT_WALL.pivots_contact["es"])
|
||||
|
||||
|
||||
# Task 5: 语言路由三分支——汉字→ZH;西语特征(重音符/ñ/ç/ã/¿¡ 粗匹配,与
|
||||
@@ -175,25 +97,20 @@ def _lang_pool(user_msg: str) -> str:
|
||||
return "en"
|
||||
|
||||
|
||||
def pivot_for(user_msg: str, has_contact: bool = False) -> str:
|
||||
def pivot_for(user_msg: str, has_contact: bool = False,
|
||||
wall: CompiledWall | None = None) -> str:
|
||||
w = wall or DEFAULT_WALL
|
||||
lang = _lang_pool(user_msg)
|
||||
if has_contact:
|
||||
pool = {"zh": PIVOTS_ZH_CONTACT, "es": PIVOTS_ES_CONTACT,
|
||||
"en": PIVOTS_EN_CONTACT}[lang]
|
||||
else:
|
||||
pool = {"zh": PIVOTS_ZH, "es": PIVOTS_ES, "en": PIVOTS_EN}[lang]
|
||||
pool = (w.pivots_contact if has_contact else w.pivots)[lang]
|
||||
return pool[0]
|
||||
|
||||
|
||||
def deflection_for(user_msg: str, rotate: int = 0,
|
||||
has_contact: bool = False) -> str:
|
||||
has_contact: bool = False,
|
||||
wall: CompiledWall | None = None) -> str:
|
||||
w = wall or DEFAULT_WALL
|
||||
lang = _lang_pool(user_msg)
|
||||
if has_contact:
|
||||
pool = {"zh": DEFLECTIONS_ZH_CONTACT, "es": DEFLECTIONS_ES_CONTACT,
|
||||
"en": DEFLECTIONS_EN_CONTACT}[lang]
|
||||
else:
|
||||
pool = {"zh": DEFLECTIONS_ZH, "es": DEFLECTIONS_ES,
|
||||
"en": DEFLECTIONS_EN}[lang]
|
||||
pool = (w.deflections_contact if has_contact else w.deflections)[lang]
|
||||
return pool[rotate % len(pool)]
|
||||
|
||||
|
||||
@@ -203,21 +120,19 @@ def deflection_for(user_msg: str, rotate: int = 0,
|
||||
# (沙特裸号 0501234567,CC 模板范例市场本体)必须命中。',' 不在字符类:
|
||||
# "50,000,000" 千分位形态放行(与 "45,000 km" 放行同机制),空格/横线
|
||||
# 分隔的 ≥10 位数字串命中;规格形态(DN110/5000m)经实测仍全放行。
|
||||
LONG_NUMBER_RE = re.compile(r"\+?\d[\d\s\-().]{8,17}\d") # ≥10位连续数字(允许分隔形态)
|
||||
ANY_EMAIL_RE = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
|
||||
LONG_NUMBER_RE = DEFAULT_WALL.zero_number["long_number"]
|
||||
ANY_EMAIL_RE = DEFAULT_WALL.zero_number["any_email"]
|
||||
|
||||
# 12 小时口径与 system.md 对齐(Task 3 质量审查 Minor 4: 原句 "replies fast"
|
||||
# 与 system.md 的 "reply within 12 hours" 承诺不一致)——hours 不触时间承诺墙
|
||||
NO_CONTACT_WEB = ("Just tap the WhatsApp button at the top of this chat — "
|
||||
"our manager will reply within 12 hours!")
|
||||
NO_CONTACT_APP = ("Drop your WhatsApp and our manager will reach out to you directly "
|
||||
"with the full quote!")
|
||||
NO_CONTACT_WEB = DEFAULT_WALL.no_contact["web"]
|
||||
NO_CONTACT_APP = DEFAULT_WALL.no_contact["app"]
|
||||
# F6 同款(15.3 补): 已留资时零号码墙替换句不得索要
|
||||
NO_CONTACT_ALREADY = ("Got it — our manager will reach out to you at your "
|
||||
"WhatsApp shortly with the full quote!")
|
||||
NO_CONTACT_ALREADY = DEFAULT_WALL.no_contact["already"]
|
||||
|
||||
|
||||
def no_contact_value(sentence: str, allow_number: str | None = None) -> bool:
|
||||
def no_contact_value(sentence: str, allow_number: str | None = None,
|
||||
wall: CompiledWall | None = None) -> bool:
|
||||
"""零号码墙(v2): AI 输出不得含任何 ≥10 位数字串或邮箱。
|
||||
回显豁免已移除(2026-10-08 增补设计 §3): 红线规定客户自己的号码/邮箱
|
||||
也不回显(确认收到即可),旧豁免与红线矛盾且被 GLM 稳定利用回显号码;
|
||||
@@ -230,15 +145,18 @@ def no_contact_value(sentence: str, allow_number: str | None = None) -> bool:
|
||||
allow_number 数字归一比对,**全部段命中才放行**(混入任何异号整句照拦,
|
||||
4b-6 防自锁报警线);邮箱不受白名单影响(回显确认不需要邮箱)。无
|
||||
allow_number → 原行为(任何 ≥10 位数字串即拦)。"""
|
||||
w = wall or DEFAULT_WALL
|
||||
long_number_re = w.zero_number["long_number"]
|
||||
any_email_re = w.zero_number["any_email"]
|
||||
s = sentence or ""
|
||||
if allow_number is None:
|
||||
return bool(LONG_NUMBER_RE.search(s) or ANY_EMAIL_RE.search(s))
|
||||
if ANY_EMAIL_RE.search(s):
|
||||
return bool(long_number_re.search(s) or any_email_re.search(s))
|
||||
if any_email_re.search(s):
|
||||
return True
|
||||
allowed = re.sub(r"\D", "", allow_number)
|
||||
if not allowed:
|
||||
return bool(LONG_NUMBER_RE.search(s))
|
||||
for m in LONG_NUMBER_RE.finditer(s):
|
||||
return bool(long_number_re.search(s))
|
||||
for m in long_number_re.finditer(s):
|
||||
if re.sub(r"\D", "", m.group(0)) != allowed:
|
||||
return True # 任一段不命中(混入异号) → 整句拦
|
||||
return False
|
||||
@@ -249,39 +167,7 @@ def no_contact_value(sentence: str, allow_number: str | None = None) -> bool:
|
||||
# 每条模式强制渠道词在场,防泛请求误杀("Can you send me the details?" 放行)。
|
||||
# 模式集与 system.md 全部留资话术模板逐句对齐并实证(13 命中/12 放行)——
|
||||
# GLM 背的就是这些模板,模板全集必须全收。
|
||||
REASK_PATTERNS = [
|
||||
# what's/which's your <渠道词>
|
||||
re.compile(r"(?:what|which)['′]?s\s+(?:your|the)\s+"
|
||||
r"(?:whatsapp|number|phone|e-?mail|contact|号码|联系方式)", re.I),
|
||||
# got/have your <渠道词> ... ?(问号兜尾——确认句 "Got your number, thanks!" 无问号放行)
|
||||
re.compile(r"(?:got|have)\s+(?:your|the)\s+"
|
||||
r"(?:whatsapp|number|phone|e-?mail|contact)[^!?.]{0,60}\?", re.I),
|
||||
# 陈述式需求: (I'll) need/want your <渠道词>——"I'll need your WhatsApp" 无问号也要拦
|
||||
re.compile(r"(?:i['′]ll\s+|i\s+)?(?:need|want)\s+(?:your|the)\s+"
|
||||
r"(?:whatsapp|number|phone|e-?mail|contact|号码|联系方式)", re.I),
|
||||
# Tell me your <渠道词>
|
||||
re.compile(r"tell\s+me\s+(?:your\s+)?"
|
||||
r"(?:whatsapp|number|phone|e-?mail|contact|号码|联系方式)", re.I),
|
||||
# could/can you + 给予动词 + ... 渠道词(动词表无 confirm/send——礼貌确认与泛请求放行)
|
||||
re.compile(r"(?:could|can|would|may)\s+you\s+"
|
||||
r"(?:leave|drop|share|give|provide|type|post)[^.!?\n]{0,30}"
|
||||
r"(?:whatsapp|number|phone|e-?mail|contact|号码|联系方式|微信|邮箱)", re.I),
|
||||
# 祈使: leave/drop/share/give (me) your/us a <渠道词>
|
||||
re.compile(r"(?:leave|drop|share|give)\s+(?:me\s+)?(?:your|us\s+your?|a)\s+"
|
||||
r"(?:whatsapp|contact|number|e-?mail|联系方式)", re.I),
|
||||
# 互留后缀 "Drop yours too..."(无渠道词,单列)
|
||||
re.compile(r"(?:drop|leave)\s+yours\b", re.I),
|
||||
# "WhatsApp or email?" 独立短问
|
||||
re.compile(r"(?:whatsapp|e-?mail|contact)\s+or\s+(?:whatsapp|e-?mail|contact)\s*\?", re.I),
|
||||
# 裸碎片问句: "Your number, please?"/"Your WhatsApp?"(Ghana 案例穿透补)
|
||||
# 后顾排除跟进确认句("reach/got/sent/did your X?" 是问进度不是要号)
|
||||
re.compile(r"(?<!reach )(?<!reached )(?<!got )(?<!sent )(?<!did )(?<!to )"
|
||||
r"your\s+(?:whatsapp|number|phone|e-?mail|contact)\b[^.!?]{0,20}[??]", re.I),
|
||||
# 中文
|
||||
re.compile(r"留[个下]?\s*(?:你的?)?\s*(?:whatsapp|微信|邮箱|联系方式|号码|电话)|"
|
||||
r"给[我一]?个?\s*(?:联系方式|whatsapp|微信|号码|邮箱|电话)|"
|
||||
r"你的?\s*(?:whatsapp|号码|电话|微信|邮箱|联系方式)[^。!?]{0,10}(?:是多少|多少|\?)", re.I),
|
||||
]
|
||||
REASK_PATTERNS = list(DEFAULT_WALL.reask_patterns)
|
||||
|
||||
|
||||
# 渠道精确化(2026-10-08 spec §4.4): REASK 模式命中后按渠道词分辨——
|
||||
@@ -308,13 +194,15 @@ def _precise_channels_in(s: str) -> set:
|
||||
return out
|
||||
|
||||
|
||||
def asks_contact(sentence: str, confirmed_channels: set | None = None) -> bool:
|
||||
def asks_contact(sentence: str, confirmed_channels: set | None = None,
|
||||
wall: CompiledWall | None = None) -> bool:
|
||||
"""输出句是否在索要联系方式(已留资时用于丢弃)。
|
||||
|
||||
confirmed_channels=None → 原行为(命中即丢,向后兼容);
|
||||
传入已确认渠道集合 → 渠道精确化(①②③④判定,见上注释)。"""
|
||||
w = wall or DEFAULT_WALL
|
||||
s = sentence or ""
|
||||
hit = any(p.search(s) for p in REASK_PATTERNS)
|
||||
hit = any(p.search(s) for p in w.reask_patterns)
|
||||
if confirmed_channels is None or not hit:
|
||||
return hit
|
||||
has_number = bool(confirmed_channels & {"whatsapp", "phone", "wechat"})
|
||||
|
||||
@@ -17,9 +17,9 @@ from app.engine.services import (
|
||||
)
|
||||
from app.engine.services.ai_service import AIService
|
||||
from app.engine.services.boundary_filter import (
|
||||
NO_CONTACT_ALREADY, NO_CONTACT_APP, NO_CONTACT_WEB, asks_contact,
|
||||
deflection_for, no_contact_value, pivot_for, split_sentences,
|
||||
violates_boundary,
|
||||
DEFAULT_WALL, NO_CONTACT_ALREADY, NO_CONTACT_APP, NO_CONTACT_WEB,
|
||||
asks_contact, deflection_for, no_contact_value, pivot_for,
|
||||
split_sentences, violates_boundary,
|
||||
)
|
||||
from app.engine.services.contact_pipeline import (
|
||||
canon, digits_only, latest_stated_contacts, merge_contacts,
|
||||
@@ -36,26 +36,12 @@ logger = logging.getLogger(__name__)
|
||||
# - 【评审#4】v1 补齐漏词: expensive/fee/贵/价钱/总价/落地/划算/免费(短语式)等;
|
||||
# cost/fee 用 \b 边界,costa rica 不再误命中("costa"含cost但无词边界)
|
||||
# - 原则: 误触发低害(多一条提醒),漏触发高危(红线失守),词表偏宽;上线后漏词案例回流补词
|
||||
BOUNDARY_TRIGGER_RE = re.compile(
|
||||
r"price|pricing|how\s+much|\bcost\b|discount|cheaper|expensive|\bfee\b|"
|
||||
r"quote|quotation|shipping\s+(fee|cost|time)|freight|tariff|customs\s+fee|"
|
||||
r"best\s+offer|any\s+offer|free\s+shipping|"
|
||||
r"sample|certificat|\bmoq\b|minimum\s+order|min\s+order|"
|
||||
r"container\s+loading|fit\s+in|per\s+container|warranty|质保|保修|"
|
||||
r"book(ing)?\s|reserv|arrange|"
|
||||
r"delivery|lead\s+time|arriv|"
|
||||
r"how\s+long\s+(does|will|would|to).*(take|arriv|deliver|ship)|"
|
||||
r"roughly|ballpark|just\s+tell|come\s+on|at\s+least\s+(a|some|give)|"
|
||||
r"budget|预算|价格|价钱|多少钱|报价|优惠|折扣|便宜|贵|划算|"
|
||||
r"总价|落地|运费|关税|免费|"
|
||||
r"到货|多久能到|交货|时效|发货|样品|证书|装柜|起订|"
|
||||
r"预约|下单|定金|安排|"
|
||||
r"大概|估个|估一|随便说|说个|求你|求了",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
# Task 4 词表数据化: 三组触发正则的数据源 = DEFAULT_WALL(jiancai 出厂)
|
||||
# .triggers 编译产物(seed_profiles/jiancai/wall.json),上方精修说明随数据留存
|
||||
BOUNDARY_TRIGGER_RE = DEFAULT_WALL.triggers["boundary"]
|
||||
|
||||
# 预算路径路由(评审#9): 命中 BOUNDARY 触发词且含预算词 → 走预算 playbook
|
||||
BUDGET_TRIGGER_RE = re.compile(r"budget|预算", re.IGNORECASE)
|
||||
BUDGET_TRIGGER_RE = DEFAULT_WALL.triggers["budget"]
|
||||
|
||||
# 索要我方联系方式触发(15.2, doctrine v2): 客户问"你们怎么联系/你的微信多少"
|
||||
# = 最高意向信号。提醒层按 source 分支: web 指按钮, App 无按钮走反向留资
|
||||
@@ -63,14 +49,7 @@ BUDGET_TRIGGER_RE = re.compile(r"budget|预算", re.IGNORECASE)
|
||||
# 必须用定宽 negative lookbehind——lookahead 在动词位置求值,永远看不到
|
||||
# 前面的 let me / i'll,不生效。四个 lookbehind 全部定宽合法: i'll(5)、
|
||||
# i’ll(5, 弯撇 U+2019 手机常见)、i will(7)、let me(7);IGNORECASE 兼容大写
|
||||
CONTACT_ASK_TRIGGER_RE = re.compile(
|
||||
r"(your|our)\s+(whatsapp|number|phone|email|contact)|"
|
||||
r"(?<!i'll )(?<!i’ll )(?<!i will )(?<!let me )"
|
||||
r"(contact|reach|call|email)\s+(you|us)|"
|
||||
r"你(们)?的?\s?(whatsapp|号码|电话|微信|邮箱|联系方式)|"
|
||||
r"(怎么|如何)联系|给我.{0,4}(号码|电话|联系方式)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
CONTACT_ASK_TRIGGER_RE = DEFAULT_WALL.triggers["contact_ask"]
|
||||
|
||||
# 语言跟随提醒(Task 11 真人验收发现): glm-4-flash 对拉丁字符占比高的中文
|
||||
# 消息(DN800/HDPE 规格混排)会整轮漂成英文,system.md 语言规则压不住。修复位 =
|
||||
@@ -78,19 +57,11 @@ CONTACT_ASK_TRIGGER_RE = re.compile(
|
||||
# 漂移"同款方法论。2026-10-08 Task 5 收尾: 语言规则放开(模板句跟随客户语言
|
||||
# 自然表达)后,ES note 的"整句使用英文原版/绝不翻译成西语"与新规则相反,
|
||||
# 已同步删除;两条 note 统一补"补全号码逐字照抄"(回显确认轮唯一例外)。
|
||||
# Task 4 词表数据化: 话术数据源 = DEFAULT_WALL.reminders.language_follow
|
||||
_CJK_RE = re.compile(r"[一-鿿]")
|
||||
_ES_FEATURE_RE = re.compile(r"[áéíóúñãç¿¡]")
|
||||
LANG_NOTE_ZH = (
|
||||
"⚠️ 语言提醒:客户这条消息是中文——你的回复必须全程使用中文,"
|
||||
"一个英文单词都不能出现;模板句跟随客户语言自然表达(系统提醒提供的"
|
||||
"逐字话术优先用其语言版本,无则自然翻译保持意思与礼貌度);系统提供的"
|
||||
"补全号码逐字照抄,绝不改动数字。"
|
||||
)
|
||||
LANG_NOTE_ES = (
|
||||
"⚠️ 语言提醒:客户这条消息是西语——你的回复全程用西语,模板句跟随客户"
|
||||
"语言自然表达(系统提醒提供的逐字话术优先用其语言版本,无则自然翻译"
|
||||
"保持意思与礼貌度);系统提供的补全号码逐字照抄,绝不改动数字。"
|
||||
)
|
||||
LANG_NOTE_ZH = DEFAULT_WALL.reminders["language_follow"]["zh"]
|
||||
LANG_NOTE_ES = DEFAULT_WALL.reminders["language_follow"]["es"]
|
||||
|
||||
|
||||
def _lang_note_for(msg: str) -> str:
|
||||
@@ -121,48 +92,28 @@ def _cc_lang_of(msg: str) -> str:
|
||||
# 2026-10-08 判定树设计 Task 3: cc 轻补注入分支已删(判定树接管),_CC_NOTE_*
|
||||
# 不再运行时注入——常量保留作 no_ip 路径/Task 4 回显模板的正文基准
|
||||
# (test_boundary_filter / test_system_prompt_dual_contact 仍锚定其正文)。
|
||||
_CC_ASK_EN = (
|
||||
"Could you send the full number with the country code, "
|
||||
"like +966 5x xxx xxxx? Just so our manager can reach you directly.")
|
||||
_CC_ASK_ZH = (
|
||||
"麻烦重发一下带国家代码的完整号码呗(比如 +966 5x xxx xxxx)?"
|
||||
"客户经理直接加你。")
|
||||
# Task 4 词表数据化: CC/邮箱模板数据源 = DEFAULT_WALL.cc_templates 与
|
||||
# .reminders.email_light(seed_profiles/jiancai/wall.json);_CC_WALL_*/_CC_NOTE_*
|
||||
# 的静态组合("Got it — " 前缀/外壳+正文)在数据文件里存整句,值逐字等价。
|
||||
_CC_ASK_EN = DEFAULT_WALL.cc_templates["ask"]["en"]
|
||||
_CC_ASK_ZH = DEFAULT_WALL.cc_templates["ask"]["zh"]
|
||||
# P3(2026-10-08 真实验收): 西语轮模板补齐——修复前 ECHO/ASK 只有 EN/ZH,
|
||||
# 西语轮 GLM 照抄英文。WALL 沿用 EN 形态(衔接头 + 追问句正文)。
|
||||
_CC_ASK_ES = (
|
||||
"¿Puedes enviar el número completo con el código de país, "
|
||||
"como +966 5x xxx xxxx? Así nuestro gerente puede contactarte directamente.")
|
||||
_CC_WALL_EN = "Got it — " + _CC_ASK_EN
|
||||
_CC_WALL_ZH = _CC_ASK_ZH
|
||||
_CC_WALL_ES = "Entendido — " + _CC_ASK_ES
|
||||
_CC_NOTE_EN = (
|
||||
"⚠️ 轻补指引(系统指令,绝对不要向客户提及这段说明):客户留下的号码不含"
|
||||
"国家代码,客户经理无法直接回拨。请逐字使用这句:\"" + _CC_ASK_EN + "\" "
|
||||
"仅此一次,客户不给就不再提。")
|
||||
_CC_NOTE_ZH = (
|
||||
"⚠️ 轻补指引(系统指令,绝对不要向客户提及这段说明):客户留下的号码不含"
|
||||
"国家代码,客户经理无法直接回拨。请逐字使用这句:\"" + _CC_ASK_ZH + "\" "
|
||||
"仅此一次,客户不给就不再提。")
|
||||
_CC_ASK_ES = DEFAULT_WALL.cc_templates["ask"]["es"]
|
||||
_CC_WALL_EN = DEFAULT_WALL.cc_templates["wall"]["en"]
|
||||
_CC_WALL_ZH = DEFAULT_WALL.cc_templates["wall"]["zh"]
|
||||
_CC_WALL_ES = DEFAULT_WALL.cc_templates["wall"]["es"]
|
||||
_CC_NOTE_EN = DEFAULT_WALL.cc_templates["note"]["en"]
|
||||
_CC_NOTE_ZH = DEFAULT_WALL.cc_templates["note"]["zh"]
|
||||
# Task 4(spec §4 回显确认轮/§4b): 判定树给出 e164 建议的等待态轮用回显确认
|
||||
# 模板——{intl} 由 Python format_intl 算好填入(reminder/墙替换/测试断言三方
|
||||
# 同源引用本常量),GLM 只逐字复述不拼号。no_ip 轮(无可回显号)仍走 _CC_ASK_*
|
||||
# (上方保留不拆),两条路径按 _cc.whatsapp 的 e164 在场分流。
|
||||
_CC_ECHO_EN = (
|
||||
"Got it — just to confirm: our manager will reach you at {intl} — is that right?")
|
||||
_CC_ECHO_ZH = (
|
||||
"收到——跟您确认下:客户经理会通过 {intl} 联系您,这个号码对吗?")
|
||||
_CC_ECHO_ES = (
|
||||
"Entendido — solo para confirmar: nuestro gerente te contactará "
|
||||
"al {intl} — ¿es correcto?")
|
||||
_EMAIL_NOTE_EN = (
|
||||
"⚠️ 轻补指引(系统指令,绝对不要向客户提及这段说明):客户已留号码但还没"
|
||||
"留邮箱。请逐字使用这句:\"Got it! And an email address would help too — "
|
||||
"our manager will send the formal quotation PDF there.\" "
|
||||
"仅此一次,客户不给就不再提。")
|
||||
_EMAIL_NOTE_ZH = (
|
||||
"⚠️ 轻补指引(系统指令,绝对不要向客户提及这段说明):客户已留号码但还没"
|
||||
"留邮箱。请逐字使用这句:\"收到!邮箱也留一个呗?客户经理把正式报价单 PDF "
|
||||
"发你邮箱。\" 仅此一次,客户不给就不再提。")
|
||||
_CC_ECHO_EN = DEFAULT_WALL.cc_templates["echo"]["en"]
|
||||
_CC_ECHO_ZH = DEFAULT_WALL.cc_templates["echo"]["zh"]
|
||||
_CC_ECHO_ES = DEFAULT_WALL.cc_templates["echo"]["es"]
|
||||
_EMAIL_NOTE_EN = DEFAULT_WALL.reminders["email_light"]["en"]
|
||||
_EMAIL_NOTE_ZH = DEFAULT_WALL.reminders["email_light"]["zh"]
|
||||
|
||||
|
||||
def _cc_intl(wa_state: dict | None) -> str:
|
||||
@@ -196,23 +147,17 @@ def _cc_confirm_note(wa_state: dict | None, lang: str) -> str:
|
||||
"""cc 等待态确认句注入(Task 4, spec §4/§4b): cc_due 才注入,与邮箱轻补
|
||||
cc_settled 串行互斥(两个"逐字使用这句"不同轮竞争)。外壳仿轻补指引:
|
||||
⚠️ 系统指令声明 + 逐字话术 + 客户答复后致谢不追问。回显分支带零号码
|
||||
唯一例外(4b-3 让位语义重建: 仅系统提供的补全号原文可写出)。"""
|
||||
唯一例外(4b-3 让位语义重建: 仅系统提供的补全号原文可写出)。
|
||||
Task 4 词表数据化: 指引话术数据源 = DEFAULT_WALL.reminders.cc_guide
|
||||
({intl}/{cc_script} 占位由本函数 .format() 回填,值逐字等价)。"""
|
||||
if not wa_state or not cc_completion.cc_due(wa_state):
|
||||
return ""
|
||||
if str(wa_state.get("e164") or ""):
|
||||
intl = _cc_intl(wa_state)
|
||||
return (
|
||||
"⚠️ 号码确认指引(系统指令,绝对不要向客户提及这段说明):客户留下"
|
||||
"的号码不含国家代码,系统已按 IP 推测补全为 " + intl +
|
||||
"。请逐字使用这句:\"" + _cc_script(wa_state, lang) + "\" "
|
||||
"本轮回复绝不写出任何号码数字——唯一例外是上面系统提供的补全号"
|
||||
"原文(逐字照抄,禁止自编或改动数字)。客户答复确认或纠正后,"
|
||||
"致谢即可,不再追问号码。")
|
||||
return (
|
||||
"⚠️ 号码确认指引(系统指令,绝对不要向客户提及这段说明):客户留下"
|
||||
"的号码不含国家代码,且系统无法按 IP 推测补全。请逐字使用这句:\""
|
||||
+ _cc_script(wa_state, lang) + "\" 仅此一次,客户不给就不再提。"
|
||||
"客户答复后致谢即可,不再追问号码。")
|
||||
return DEFAULT_WALL.reminders["cc_guide"]["echo"].format(
|
||||
intl=intl, cc_script=_cc_script(wa_state, lang))
|
||||
return DEFAULT_WALL.reminders["cc_guide"]["ask"].format(
|
||||
cc_script=_cc_script(wa_state, lang))
|
||||
|
||||
|
||||
def _contact_completion_note(
|
||||
@@ -1106,20 +1051,20 @@ class ConversationManager:
|
||||
def _build_context_reminder(raw_info: dict, latest_msg: str,
|
||||
history: list[dict] | None = None,
|
||||
source: str = "") -> str:
|
||||
"""Build a context reminder from extracted profile info."""
|
||||
"""Build a context reminder from extracted profile info.
|
||||
Task 4 词表数据化: 六类轮中提醒话术(boundary/contact_ask/
|
||||
language_follow/lead_given/cc_guide/email_light)的数据源 =
|
||||
DEFAULT_WALL.reminders(seed_profiles/jiancai/wall.json);
|
||||
{shown}/{no_contact_web}/{no_contact_already} 占位由本函数
|
||||
.format() 回填,值与数据化前逐字等价。"""
|
||||
# 职能边界提醒(评审#7: 前置到早退之前,首条消息画像为空也要生效)
|
||||
boundary_note = ""
|
||||
if BOUNDARY_TRIGGER_RE.search(latest_msg or ""):
|
||||
if BUDGET_TRIGGER_RE.search(latest_msg or ""):
|
||||
# 评审#9: 预算求推荐 → 预算路径(推荐+反问+引导),不一刀切要号
|
||||
boundary_note = (
|
||||
"⚠️ 职能边界提醒:客户报预算求推荐。推荐产品方案但绝不复述预算数字、"
|
||||
"不报价格 → 反问意向产品与需求规格 → 顺势引导留联系方式。")
|
||||
boundary_note = DEFAULT_WALL.reminders["boundary"]["budget"]
|
||||
else:
|
||||
boundary_note = (
|
||||
"⚠️ 职能边界提醒:客户正在询问价格/优惠/交付时效/实质安排。绝对禁止"
|
||||
"给出任何价格数字(包括复述客户自己的预算)、交付时间或任何承诺,"
|
||||
"按职能边界对应话术处理。")
|
||||
boundary_note = DEFAULT_WALL.reminders["boundary"]["normal"]
|
||||
|
||||
# 联系方式状态前移(供 boundary/contact 提醒共用判定)
|
||||
contacts_all = normalize_contacts(raw_info.get("联系方式"))
|
||||
@@ -1137,24 +1082,12 @@ class ConversationManager:
|
||||
# 与"禁止再索要"提醒不再打架
|
||||
# Task 3 收尾: 第三处禁令同步改写(禁令集完整)——与前两处
|
||||
# 同语义,只禁已提供过的渠道,缺失渠道放行轻补一次
|
||||
contact_note = (
|
||||
"⚠️ 客户正在索要我方联系方式,且客户已留过自己的联系方式。"
|
||||
"请逐字使用这句:\"Our manager will reach out to you at your "
|
||||
"WhatsApp shortly with the full quote!\"(中文同理)。"
|
||||
"绝对禁止输出任何号码或邮箱,也绝对禁止再索要客户已提供过"
|
||||
"的联系方式(缺失渠道可按轻补指引提一次;号码缺国家代码时"
|
||||
"按系统提醒回显补全后的完整号码请客户确认——仅系统提供的"
|
||||
"号码,禁止自编)。")
|
||||
contact_note = DEFAULT_WALL.reminders["contact_ask"]["app_already"]
|
||||
else:
|
||||
contact_note = ("⚠️ 客户正在索要我方联系方式(最高意向信号)。当前为 App 会话,"
|
||||
"没有页面按钮——禁止使用任何 tap/button 按钮话术!请逐字使用这句:"
|
||||
"\"Drop your WhatsApp and our manager will reach out to you directly "
|
||||
"with the full quote!\"(中文同理引导客户留联系方式)。"
|
||||
"绝对禁止输出任何号码或邮箱。")
|
||||
contact_note = DEFAULT_WALL.reminders["contact_ask"]["app"]
|
||||
else:
|
||||
contact_note = ("⚠️ 客户正在索要我方联系方式(最高意向信号)。请逐字使用这句指路:"
|
||||
f"\"{NO_CONTACT_WEB}\"(邮箱同理指 Email 按钮)。"
|
||||
"绝对禁止口头报出任何号码或邮箱。")
|
||||
contact_note = DEFAULT_WALL.reminders["contact_ask"]["web"].format(
|
||||
no_contact_web=NO_CONTACT_WEB)
|
||||
|
||||
# 语言跟随提醒(Task 11): 与 boundary/contact 提醒同挂载位——
|
||||
# 首条消息画像为空也要生效(中文客户开场即漂英文是实测重灾区)
|
||||
@@ -1198,25 +1131,19 @@ class ConversationManager:
|
||||
# 与零号码墙替换句同源,三处"经理主动联系"话术不漂移
|
||||
# Task 3 防自锁改写(spec 4.1-6): 原"绝对禁止再索要任何联系方式!"
|
||||
# 会抑制缺失渠道轻补——只禁已提供过的渠道,缺失渠道放行一次
|
||||
parts.append(f"⚠️ 客户已提供联系方式({shown})。绝对禁止再索要客户已提供过"
|
||||
f"的联系方式;缺失渠道仅可按轻补指引提一次;号码缺国家代码时"
|
||||
f"按系统提醒回显补全后的完整号码请客户确认(仅系统提供的"
|
||||
f"号码,禁止自编)。")
|
||||
parts.append(f"需要回应时可直接逐字使用:\"{NO_CONTACT_ALREADY}\"(中文同理)。"
|
||||
f"确认收到后继续解答客户问题即可,不必急着结束对话。")
|
||||
parts.append(DEFAULT_WALL.reminders["lead_given"]["forbidden"].format(
|
||||
shown=shown))
|
||||
parts.append(DEFAULT_WALL.reminders["lead_given"]["respond_script"].format(
|
||||
no_contact_already=NO_CONTACT_ALREADY))
|
||||
if boundary_note:
|
||||
parts.append(boundary_note)
|
||||
# Task 3 防自锁改写: 尾部补缺失渠道豁免(与上方改写同因)
|
||||
parts.append("客户已留过联系方式——边界请求直接告知'客户经理会把准确报价/"
|
||||
"安排发到你 WhatsApp'即可,禁止再索要联系方式"
|
||||
"(缺失渠道可按轻补指引提一次;号码缺国家代码时按系统"
|
||||
"提醒回显补全后的完整号码请客户确认——仅系统提供的号码,"
|
||||
"禁止自编)。")
|
||||
parts.append(DEFAULT_WALL.reminders["lead_given"]["boundary_tail"])
|
||||
elif suspect:
|
||||
shown = ", ".join(
|
||||
f"{k}: {' '.join(str(v).split())[:60]}" for k, v in suspect.items())
|
||||
parts.append(f"⚠️ 客户疑似已提供联系方式({shown})。禁止重新索要;"
|
||||
f"可礼貌地请客户确认一次。")
|
||||
parts.append(DEFAULT_WALL.reminders["lead_given"]["suspect"].format(
|
||||
shown=shown))
|
||||
if boundary_note:
|
||||
parts.append(boundary_note)
|
||||
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
"""wall.json → CompiledWall 编译器(Task 4 词表数据化)。
|
||||
|
||||
seed_profiles/<slug>/wall.json(由 scripts/extract_config.py 从老项目源码
|
||||
AST 提取生成)是租户墙词表/话术的唯一数据源。compile_wall 把 JSON 编译为
|
||||
运行时对象: pattern 字符串 → re.Pattern,模板话术(含 {shown}/{intl} 等
|
||||
占位符)原样承载、由 conversation.py 运行期 .format() 回填。
|
||||
|
||||
regex 条目两种形态(pattern 文本与源码逐字一致):
|
||||
- 纯字符串: 无标志位,re.compile(s)
|
||||
- {"pattern": "...", "flags": "i"}: flags 字符映射 re.IGNORECASE
|
||||
(提取脚本只产出 "i";comment 键为设计说明注释,编译时忽略)
|
||||
|
||||
结构/regex 无效 → WallConfigError,信息含字段路径(坏 regex 含 "regex"
|
||||
字样与 re.error 原因)——配置错误在编译期显性失败,不流入运行期。
|
||||
顶层 "_"-前缀键(如 auto 的 _meta 数据来源标注)为元信息,不参与编译。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
_FLAG_MAP = {"": 0, "i": re.IGNORECASE}
|
||||
|
||||
|
||||
class WallConfigError(Exception):
|
||||
"""wall.json 结构/regex 无效。"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CompiledWall:
|
||||
price_patterns: tuple[re.Pattern, ...]
|
||||
deflections: dict[str, tuple[str, ...]]
|
||||
deflections_contact: dict[str, tuple[str, ...]]
|
||||
pivots: dict[str, tuple[str, ...]]
|
||||
pivots_contact: dict[str, tuple[str, ...]]
|
||||
no_contact: dict[str, str]
|
||||
zero_number: dict[str, re.Pattern]
|
||||
reask_patterns: tuple[re.Pattern, ...]
|
||||
triggers: dict[str, re.Pattern]
|
||||
reminders: dict[str, dict[str, str]] # 六类轮中提醒话术(_build_context_reminder 数据源)
|
||||
cc_templates: dict[str, dict[str, str]]
|
||||
|
||||
|
||||
# 每字段必填的变体键(引擎代码按名引用,缺键=运行期 KeyError,前移到编译期)
|
||||
_REQUIRED_POOLS = {
|
||||
"deflections": ("en", "zh"),
|
||||
"deflections_contact": ("en", "zh"),
|
||||
"pivots": ("en", "zh"),
|
||||
"pivots_contact": ("en", "zh"),
|
||||
}
|
||||
_REMINDER_VARIANTS = {
|
||||
"boundary": ("budget", "normal"),
|
||||
"contact_ask": ("app_already", "app", "web"),
|
||||
"language_follow": ("zh", "es"),
|
||||
"lead_given": ("forbidden", "respond_script", "boundary_tail", "suspect"),
|
||||
"cc_guide": ("echo", "ask"),
|
||||
"email_light": ("en", "zh"),
|
||||
}
|
||||
_CC_TEMPLATE_VARIANTS = {
|
||||
"ask": ("en", "zh", "es"),
|
||||
"wall": ("en", "zh", "es"),
|
||||
"note": ("en", "zh"),
|
||||
"echo": ("en", "zh", "es"),
|
||||
}
|
||||
_KNOWN_FIELDS = frozenset(
|
||||
list(_REQUIRED_POOLS) + ["price_patterns", "no_contact", "zero_number",
|
||||
"reask_patterns", "triggers", "reminders",
|
||||
"cc_templates"])
|
||||
|
||||
|
||||
def _err(msg: str) -> WallConfigError:
|
||||
return WallConfigError(msg)
|
||||
|
||||
|
||||
def _compile_pattern(entry, field: str) -> re.Pattern:
|
||||
"""str | {pattern, flags[, comment]} → re.Pattern;失败含字段路径与原因。"""
|
||||
if isinstance(entry, str):
|
||||
pattern, flags = entry, 0
|
||||
elif isinstance(entry, dict):
|
||||
pattern = entry.get("pattern")
|
||||
flag_chars = str(entry.get("flags", ""))
|
||||
if not isinstance(pattern, str) or not pattern:
|
||||
raise _err(f"{field}: 条目缺非空 pattern 字符串: {entry!r}")
|
||||
if flag_chars not in _FLAG_MAP:
|
||||
raise _err(f"{field}: 不支持的 flags {flag_chars!r}(仅支持 'i')")
|
||||
flags = _FLAG_MAP[flag_chars]
|
||||
else:
|
||||
raise _err(f"{field}: 条目必须是字符串或 {{pattern, flags}}: {entry!r}")
|
||||
try:
|
||||
return re.compile(pattern, flags)
|
||||
except re.error as exc:
|
||||
raise _err(f"{field}: regex 编译失败: {pattern!r}: {exc}") from exc
|
||||
|
||||
|
||||
def _compile_patterns(entries, field: str) -> tuple[re.Pattern, ...]:
|
||||
if not isinstance(entries, list) or not entries:
|
||||
raise _err(f"{field}: 必须为非空列表")
|
||||
return tuple(_compile_pattern(e, f"{field}[{i}]") for i, e in enumerate(entries))
|
||||
|
||||
|
||||
def _compile_pool(cfg: dict, field: str) -> dict[str, tuple[str, ...]]:
|
||||
pool_cfg = cfg.get(field)
|
||||
if not isinstance(pool_cfg, dict) or not pool_cfg:
|
||||
raise _err(f"{field}: 必须为非空语言→列表映射")
|
||||
out: dict[str, tuple[str, ...]] = {}
|
||||
for lang, items in pool_cfg.items():
|
||||
if (not isinstance(items, list) or not items
|
||||
or not all(isinstance(t, str) and t for t in items)):
|
||||
raise _err(f"{field}.{lang}: 必须为非空字符串列表")
|
||||
out[lang] = tuple(items)
|
||||
for lang in _REQUIRED_POOLS[field]:
|
||||
if lang not in out:
|
||||
raise _err(f"{field}: 缺语言 {lang}")
|
||||
return out
|
||||
|
||||
|
||||
def _str_mapping(sub, label: str, required: tuple[str, ...]) -> dict[str, str]:
|
||||
"""一层 str 映射校验(no_contact 各键/reminders 各类变体/cc_templates 各组)。"""
|
||||
if not isinstance(sub, dict):
|
||||
raise _err(f"{label}: 必须为映射(缺类别或类型错误)")
|
||||
out: dict[str, str] = {}
|
||||
for key in required:
|
||||
val = sub.get(key)
|
||||
if not isinstance(val, str) or not val:
|
||||
raise _err(f"{label}.{key}: 缺非空字符串")
|
||||
out[key] = val
|
||||
for key in sub:
|
||||
if key not in required:
|
||||
raise _err(f"{label}.{key}: 未知变体键(合法: {', '.join(required)})")
|
||||
return out
|
||||
|
||||
|
||||
def compile_wall(cfg: dict) -> CompiledWall:
|
||||
"""wall.json 反序列化后的 dict → CompiledWall。任何结构/regex 问题显性失败。"""
|
||||
if not isinstance(cfg, dict):
|
||||
raise _err("wall 配置必须为 JSON 对象")
|
||||
unknown = [k for k in cfg if k not in _KNOWN_FIELDS and not k.startswith("_")]
|
||||
if unknown:
|
||||
raise _err(f"wall 未知字段: {', '.join(sorted(unknown))}")
|
||||
missing = [k for k in sorted(_KNOWN_FIELDS) if k not in cfg]
|
||||
if missing:
|
||||
raise _err(f"wall 缺字段: {', '.join(missing)}")
|
||||
|
||||
zero_cfg = cfg["zero_number"]
|
||||
if not isinstance(zero_cfg, dict):
|
||||
raise _err("zero_number: 必须为映射")
|
||||
zero = {key: _compile_pattern(zero_cfg.get(key), f"zero_number.{key}")
|
||||
for key in ("long_number", "any_email")}
|
||||
|
||||
triggers_cfg = cfg["triggers"]
|
||||
if not isinstance(triggers_cfg, dict):
|
||||
raise _err("triggers: 必须为映射")
|
||||
triggers = {key: _compile_pattern(triggers_cfg.get(key), f"triggers.{key}")
|
||||
for key in ("boundary", "budget", "contact_ask")}
|
||||
|
||||
if not isinstance(cfg["reminders"], dict):
|
||||
raise _err("reminders: 必须为映射")
|
||||
reminders = {cat: _str_mapping(cfg["reminders"].get(cat), f"reminders.{cat}",
|
||||
variants)
|
||||
for cat, variants in _REMINDER_VARIANTS.items()}
|
||||
extra = set(cfg["reminders"]) - set(_REMINDER_VARIANTS)
|
||||
if extra:
|
||||
raise _err(f"reminders: 未知类别 {', '.join(sorted(extra))}")
|
||||
|
||||
if not isinstance(cfg["cc_templates"], dict):
|
||||
raise _err("cc_templates: 必须为映射")
|
||||
cc_templates = {group: _str_mapping(cfg["cc_templates"].get(group),
|
||||
f"cc_templates.{group}", variants)
|
||||
for group, variants in _CC_TEMPLATE_VARIANTS.items()}
|
||||
extra = set(cfg["cc_templates"]) - set(_CC_TEMPLATE_VARIANTS)
|
||||
if extra:
|
||||
raise _err(f"cc_templates: 未知组 {', '.join(sorted(extra))}")
|
||||
|
||||
return CompiledWall(
|
||||
price_patterns=_compile_patterns(cfg["price_patterns"], "price_patterns"),
|
||||
deflections=_compile_pool(cfg, "deflections"),
|
||||
deflections_contact=_compile_pool(cfg, "deflections_contact"),
|
||||
pivots=_compile_pool(cfg, "pivots"),
|
||||
pivots_contact=_compile_pool(cfg, "pivots_contact"),
|
||||
no_contact=_str_mapping(cfg.get("no_contact"), "no_contact",
|
||||
("web", "app", "already")),
|
||||
zero_number=zero,
|
||||
reask_patterns=_compile_patterns(cfg["reask_patterns"], "reask_patterns"),
|
||||
triggers=triggers,
|
||||
reminders=reminders,
|
||||
cc_templates=cc_templates,
|
||||
)
|
||||
|
||||
|
||||
def load_wall_file(path: str | Path) -> CompiledWall:
|
||||
"""从 wall.json 文件编译(出厂默认/测试共用;调用方负责路径锚定)。"""
|
||||
return compile_wall(
|
||||
json.loads(Path(path).read_text(encoding="utf-8")))
|
||||
@@ -1,5 +1,6 @@
|
||||
"""出厂默认配置。结构 = spec §5.1 的 9 个配置组。
|
||||
本阶段 brand/wall 的默认值由 Task 4 从 jc 代码提取填充。
|
||||
brand 由租户 seed(seed_profiles/<slug>/)提供;wall 由租户 seed 的
|
||||
wall.json 提供(app/engine/wall_schema.compile_wall 编译),出厂均留空。
|
||||
只读约定:消费者不得修改本 dict,需要定制走 deep_merge 拷贝。"""
|
||||
import copy
|
||||
|
||||
@@ -9,7 +10,7 @@ DEFAULT_CONFIG: dict = {
|
||||
"welcome": "", "topics": [], "placeholder": "",
|
||||
"whatsapp": "", "email": "", "privacy_url": "",
|
||||
},
|
||||
"wall": {}, # Task 4 填充
|
||||
"wall": {}, # wall 由租户 seed 的 wall.json 提供,出厂留空
|
||||
"contact": {"cc_completion": True, "email_light_ask": True, "template_languages": ["en", "zh", "es"]},
|
||||
"email": {"smtp": None, "sender_name": "", "notify_emails": []},
|
||||
"push": {"enabled": False, "url": "", "api_key": "", "prefix": "", "timeout": 10,
|
||||
|
||||
Reference in New Issue
Block a user