Files
ai-kefu-admin/scripts/breach_rate.py
T
浅念 24902c3c43 feat: breach_rate随迁(多租户--tenant适配)
从 ai-kefu-jc/scripts/breach_rate.py 拷入并适配多租户: CLI 改为
--tenant <slug> 必填, 经 platform.db 门禁(租户须已 bootstrap 注册,
同 import_legacy/archive 纪律)后只读打开 tenants/<slug>.db; 新增
--platform-db/--tenants-dir 覆盖参数(默认 repo 根约定路径)。

其余逻辑与 jc 原版一致: PATTERNS 锚定式匹配(美元符/货币单位多语言/
价格名词前置/交期承诺/号码回显≥10位/邮箱回显), 逐 assistant 轮次
首模式命中即计, 汇总破防率(>10% 启动方案B 输出过滤)。观察漏放侧
偏差——误杀侧不在本脚本口径(人工复核)。

冒烟: 临时库验证 happy path(5 轮 3 中 60%, user 行排除/NULL 内容
守卫/中文标签)与四类门禁拒绝(未注册/库缺文件/platform.db 缺/
schema 未就位), 临时文件已清。全量 pytest 630 passed 2 skipped。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-10-14 10:27:41 +08:00

130 lines
5.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""职能边界破防率观测(只读): AI 回复中出现"价格形态数字"的轮次占比。
适配自 ai-kefu-jc/scripts/breach_rate.py(jc 全局库 → 多租户): 方案B
(输出过滤)的启动依据——>10% 即砌墙; 锚定式匹配, 年份/规格数字天然不
命中。与 boundary_filter 试金石(wall/test, 判定侧)互补, 本脚本观察
**漏放侧偏差**(线上 AI 回复实际漏出的价格/号码/邮箱形态); 误杀侧
(客户正常消息被墙误拦)不在本脚本口径, 用后台会话详情人工复核。
多租户适配: --tenant <slug> 必填, 经 platform.db 门禁(租户须已
bootstrap 注册, 同 import_legacy/archive 纪律——绝不摸未注册租户的库)
后只读打开 tenants/<slug>.db; 其余逻辑(模式表/统计口径)与 jc 原版
一致——含 Minor-4 的号码回显(≥10位)与邮箱回显观测。
用法(Windows 须 PYTHONUTF8=1, 输出含中文):
PYTHONUTF8=1 python scripts/breach_rate.py --tenant <slug> \
[--platform-db <路径>] [--tenants-dir <目录>]
"""
from __future__ import annotations
import argparse
import re
import sqlite3
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[1]
PATTERNS = [
(r"\$\s?\d", "美元符"),
(r"[€£]\s?\d", "欧元/英镑符"),
(r"\d[\d,.]*\s*(usd|美元|万块?|块钱)", "货币单位"),
(r"\d[\d,.]*\s*(d[óo]lares?|euros?\b(?!\s*pallets?)|рублей|руб\.?|"
r"доллар(?:ов|а|ы)?)", "货币单位(多语言)"),
(r"\b(?:precio?s?|costo?s?|custo?s?|preço?s?|цен[аы]?|стоимость|"
r"(?:ال)?سعر|(?:ال)?أسعار|(?:ال)?ثمن)\b"
r"[^\d\n]{0,20}(?<![a-z])\d", "价格名词前置(多语言)"),
(r"(price|cost|quote)[^.\d]{0,20}(?<![a-z])(?!(?:19|20)\d\d(?!\d))\d",
"价格动词邻接(年份排除)"),
(r"\d[\d,.]*\s*k\b(?!\w)", "25k形式"),
(r"\d+\s*[- ]?(day|week|month|d[ií]as?|semanas?|дн(?:ей|я)|недел[ьи]|"
r"أيام)s?\b", "交期/时间承诺"),
# Minor-4(审查修复): 号码回显漏放观测——与零号码墙 LONG_NUMBER_RE 同形
# ({8,17} 修复后口径),AI 回复中 ≥10 位数字串(含分隔/阿语数字形态)
(r"\+?\d[\d\s\-().]{8,17}\d", "号码回显(≥10位)"),
(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}", "邮箱回显"),
]
COMPILED = [(re.compile(p, re.I), name) for p, name in PATTERNS]
def _tenant_bootstrapped(platform_db: Path, slug: str) -> None:
"""门禁(同 archive.py): 租户须已在 platform.db 注册; 只读打开
(先看后动, 门禁绝不写 platform.db)。"""
if not platform_db.is_file():
raise SystemExit(
f"platform.db 不存在: {platform_db}\n"
f"租户须先 bootstrap: 先跑 scripts/bootstrap_platform.py")
try:
con = sqlite3.connect(
platform_db.resolve().as_uri() + "?mode=ro", uri=True)
try:
row = con.execute(
"SELECT 1 FROM tenants WHERE slug = ?", (slug,)).fetchone()
finally:
con.close()
except sqlite3.Error as exc:
raise SystemExit(
f"platform.db 读取失败({exc}): 先跑 scripts/bootstrap_platform.py"
) from exc
if row is None:
raise SystemExit(
f"租户 {slug} 未在 platform.db 注册: "
f"先跑 scripts/bootstrap_platform.py 导入该租户档案")
def main() -> None:
parser = argparse.ArgumentParser(
description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("--tenant", required=True,
help="租户 slug(须已 bootstrap 注册)")
parser.add_argument("--platform-db",
default=str(REPO_ROOT / "data" / "platform.db"),
help="platform.db 路径(默认 <repo>/data/, 门禁查"
"租户是否已 bootstrap)")
parser.add_argument("--tenants-dir",
default=str(REPO_ROOT / "tenants"),
help="租户库目录(默认 <repo>/tenants, 只读打开"
"<slug>.db)")
args = parser.parse_args()
platform_db = Path(args.platform_db)
tenants_dir = Path(args.tenants_dir)
_tenant_bootstrapped(platform_db, args.tenant)
db = tenants_dir / f"{args.tenant}.db"
if not db.is_file():
raise SystemExit(
f"租户库不存在: {db}\n(租户已注册但库未建——应用首次服务该"
f"租户时建库, 或经 scripts/import_legacy.py 导入老数据)")
conn = sqlite3.connect(db.resolve().as_uri() + "?mode=ro", uri=True)
try:
rows = conn.execute(
"SELECT conversation_id, content FROM messages"
" WHERE role='assistant'"
).fetchall()
except sqlite3.OperationalError as exc:
raise SystemExit(
f"租户库读取失败({exc}): {db}(schema 未就位, 先跑应用或"
f" import_legacy)") from exc
finally:
conn.close()
hits = []
for cid, content in rows:
text = content or ""
for pattern, name in COMPILED:
if pattern.search(text):
hits.append((cid, name, text[:60]))
break
for cid, name, preview in hits:
print(f" [{name}] conv={cid[:8]} {preview}")
total = len(rows)
rate = len(hits) / total * 100 if total else 0
print(f"租户 {args.tenant} | AI回复总轮次: {total} | "
f"价格形态命中: {len(hits)} ({rate:.0f}%)")
print("← 破防率。>10% 启动方案B(输出过滤)")
if __name__ == "__main__":
main()