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:
@@ -0,0 +1,528 @@
|
||||
"""seed_profiles 骨架提取脚本(Task 4 词表数据化,Step 0)。
|
||||
|
||||
从两个老项目确定性生成每个租户的 seed_profiles/<slug>/ 目录:
|
||||
- wall.json 墙词表/话术(boundary_filter.py + conversation.py 的
|
||||
正则/话术常量,AST 解析源码取值,绝不 import 源模块——
|
||||
conversation.py 的 import 链会拉起 ai_service,它
|
||||
import 时读提示词文件,脚本会挂)
|
||||
- brand_values.json 品牌值草稿(chat.html / widget.js / chat.js 三处
|
||||
硬编码 + .env NOTIFY_EMAIL 归一;Task 5 人工校对消费)
|
||||
- prompts/ 提示词 md 逐字节拷贝(Task 6 消费)
|
||||
|
||||
幂等: 相同源 → 逐字节相同产出(JSON 固定键序/缩进,无时间戳)。
|
||||
用法: python scripts/extract_config.py [--jc DIR] [--auto DIR] [--out ROOT]
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import ast
|
||||
import html as html_mod
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
from bisect import bisect_right
|
||||
from io import StringIO
|
||||
from pathlib import Path
|
||||
from tokenize import COMMENT, generate_tokens
|
||||
|
||||
# ---- 占位符映射(函数体内联话术的插值表达式 → wall 模板占位符) ----
|
||||
# 源码里的 f"{shown}" / 拼接 NO_CONTACT_WEB 等,在 wall.json 里以
|
||||
# {shown} / {no_contact_web} 占位,运行期由 conversation.py .format() 回填。
|
||||
PLACEHOLDER_NAMES = {
|
||||
"shown": "shown",
|
||||
"intl": "intl",
|
||||
"NO_CONTACT_WEB": "no_contact_web",
|
||||
"NO_CONTACT_APP": "no_contact_app",
|
||||
"NO_CONTACT_ALREADY": "no_contact_already",
|
||||
}
|
||||
PLACEHOLDER_CALLS = {"_cc_script": "cc_script"}
|
||||
|
||||
|
||||
class NotExtractable(Exception):
|
||||
"""表达式超出受限求值语法(非目标常量,跳过)。"""
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# AST 受限求值
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _flag_chars(node: ast.AST) -> str:
|
||||
"""re.compile 的 flags 实参 → 标志字符串(仅支持 I/IGNORECASE)。"""
|
||||
names: set[str] = set()
|
||||
|
||||
def walk(n: ast.AST) -> None:
|
||||
if isinstance(n, ast.BinOp): # re.I | re.M 形态
|
||||
walk(n.left)
|
||||
walk(n.right)
|
||||
elif (isinstance(n, ast.Attribute)
|
||||
and isinstance(n.value, ast.Name) and n.value.id == "re"):
|
||||
names.add(n.attr)
|
||||
else:
|
||||
raise NotExtractable("不支持的 flags 形态")
|
||||
|
||||
walk(node)
|
||||
chars = []
|
||||
for name in sorted(names):
|
||||
if name in ("I", "IGNORECASE"):
|
||||
chars.append("i")
|
||||
else:
|
||||
raise NotExtractable(f"不支持的 re 标志: {name}")
|
||||
return "".join(chars)
|
||||
|
||||
|
||||
def eval_expr(node: ast.AST, constants: dict, mode: str = "bake"):
|
||||
"""受限求值: Constant/隐式拼接/JoinedStr/BinOp(+)/List/Dict/Name/re.compile。
|
||||
|
||||
bake 模式(模块级常量): Name 从已收集常量表解析取值(静态组合烘焙成串)。
|
||||
placeholder 模式(函数内联话术): Name/插值按 PLACEHOLDER_* 映射为占位符。
|
||||
"""
|
||||
if isinstance(node, ast.Constant):
|
||||
return node.value
|
||||
if isinstance(node, ast.JoinedStr):
|
||||
out = []
|
||||
for v in node.values:
|
||||
if isinstance(v, ast.Constant):
|
||||
out.append(str(v.value))
|
||||
elif isinstance(v, ast.FormattedValue):
|
||||
fv = v.value
|
||||
if isinstance(fv, ast.Name) and fv.id in PLACEHOLDER_NAMES:
|
||||
out.append("{" + PLACEHOLDER_NAMES[fv.id] + "}")
|
||||
else:
|
||||
raise NotExtractable("插值非常量名")
|
||||
else:
|
||||
raise NotExtractable("JoinedStr 内未知节点")
|
||||
return "".join(out)
|
||||
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
|
||||
left = eval_expr(node.left, constants, mode)
|
||||
right = eval_expr(node.right, constants, mode)
|
||||
if not isinstance(left, str) or not isinstance(right, str):
|
||||
raise NotExtractable("非字符串 +")
|
||||
return left + right
|
||||
if isinstance(node, ast.List):
|
||||
return [eval_expr(e, constants, mode) for e in node.elts]
|
||||
if isinstance(node, ast.Dict):
|
||||
return {eval_expr(k, constants, mode): eval_expr(v, constants, mode)
|
||||
for k, v in zip(node.keys, node.values)}
|
||||
if isinstance(node, ast.Name):
|
||||
if mode == "placeholder":
|
||||
if node.id in PLACEHOLDER_NAMES:
|
||||
return "{" + PLACEHOLDER_NAMES[node.id] + "}"
|
||||
raise NotExtractable("占位符模式未知 Name: " + node.id)
|
||||
if node.id in constants:
|
||||
return constants[node.id]
|
||||
raise NotExtractable("前向引用/未知常量: " + node.id)
|
||||
if isinstance(node, ast.Call):
|
||||
if (isinstance(node.func, ast.Attribute) and node.func.attr == "compile"
|
||||
and isinstance(node.func.value, ast.Name)
|
||||
and node.func.value.id == "re"):
|
||||
if not node.args:
|
||||
raise NotExtractable("re.compile 无参")
|
||||
pattern = eval_expr(node.args[0], constants, mode)
|
||||
if not isinstance(pattern, str):
|
||||
raise NotExtractable("re.compile 非字符串 pattern")
|
||||
flags_node = None
|
||||
if len(node.args) > 1:
|
||||
flags_node = node.args[1]
|
||||
else:
|
||||
flags_node = next((kw.value for kw in node.keywords
|
||||
if kw.arg == "flags"), None)
|
||||
return {"pattern": pattern,
|
||||
"flags": _flag_chars(flags_node) if flags_node is not None else ""}
|
||||
if (mode == "placeholder" and isinstance(node.func, ast.Name)
|
||||
and node.func.id in PLACEHOLDER_CALLS):
|
||||
return "{" + PLACEHOLDER_CALLS[node.func.id] + "}"
|
||||
raise NotExtractable("未知调用")
|
||||
raise NotExtractable("未支持节点: " + type(node).__name__)
|
||||
|
||||
|
||||
def extract_module_constants(tree: ast.Module) -> dict:
|
||||
"""模块顶层赋值按源序受限求值(引用仅限更早常量)。"""
|
||||
constants: dict = {}
|
||||
for node in tree.body:
|
||||
if (isinstance(node, ast.Assign) and len(node.targets) == 1
|
||||
and isinstance(node.targets[0], ast.Name)):
|
||||
try:
|
||||
constants[node.targets[0].id] = eval_expr(node.value, constants)
|
||||
except NotExtractable:
|
||||
continue # 非墙数据形态(函数引用等),按名取用时才报错
|
||||
return constants
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 函数体内联话术提取(指纹锚定: 内容取自源码,指纹只是定位器)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _iter_stmts(body: list):
|
||||
for stmt in body:
|
||||
yield stmt
|
||||
for field in ("body", "orelse", "finalbody"):
|
||||
sub = getattr(stmt, field, None)
|
||||
if isinstance(sub, list):
|
||||
yield from _iter_stmts(sub)
|
||||
|
||||
|
||||
def extract_function_templates(tree: ast.Module, func_name: str) -> list[str]:
|
||||
"""收集函数体内可求值的字符串模板(赋值/parts.append/return),按源序。
|
||||
|
||||
不可求值的语句(f"{key}: {val}" 等非目标插值)自然跳过——指纹匹配
|
||||
只在可求值候选里找,找不到/多处命中都是提取错误(显性失败)。
|
||||
"""
|
||||
# 目标是类方法(_build_context_reminder/_cc_confirm_note 在 Manager 类里)
|
||||
fn = next((n for n in ast.walk(tree)
|
||||
if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))
|
||||
and n.name == func_name), None)
|
||||
if fn is None:
|
||||
return []
|
||||
out: list[str] = []
|
||||
for stmt in _iter_stmts(fn.body):
|
||||
value = None
|
||||
if isinstance(stmt, ast.Assign) and len(stmt.targets) == 1:
|
||||
value = stmt.value
|
||||
elif isinstance(stmt, ast.Return):
|
||||
value = stmt.value
|
||||
elif (isinstance(stmt, ast.Expr) and isinstance(stmt.value, ast.Call)
|
||||
and isinstance(stmt.value.func, ast.Attribute)
|
||||
and stmt.value.func.attr == "append"
|
||||
and len(stmt.value.args) == 1):
|
||||
value = stmt.value.args[0]
|
||||
if value is None:
|
||||
continue
|
||||
try:
|
||||
v = eval_expr(value, {}, mode="placeholder")
|
||||
except NotExtractable:
|
||||
continue
|
||||
if isinstance(v, str) and v:
|
||||
out.append(v)
|
||||
return out
|
||||
|
||||
|
||||
def find_template(templates: list[str], needle: str, field: str, *,
|
||||
contains: bool = False, required: bool = True) -> str | None:
|
||||
"""指纹定位唯一模板;required=False 时允许缺失(auto 上游无该特性)。"""
|
||||
if contains:
|
||||
hits = [t for t in templates if needle in t]
|
||||
else:
|
||||
hits = [t for t in templates if t.startswith(needle)]
|
||||
if len(hits) == 1:
|
||||
return hits[0]
|
||||
if not required and not hits:
|
||||
return None
|
||||
raise SystemExit(
|
||||
f"提取失败: {field} 锚点应唯一命中,实得 {len(hits)} 处: {needle!r}")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# price_patterns 行内注释保留(tokenize 关联到下一条目)
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def attach_price_comments(src: str, assign: ast.Assign,
|
||||
entries: list[dict]) -> None:
|
||||
"""PRICE_PATTERNS 列表内的 # 注释块挂到紧随其后的条目(comment 键)。
|
||||
|
||||
防误杀断言的设计说明随 pattern 一并数据化,编辑 wall.json 的人能看见。
|
||||
"""
|
||||
comments: list[tuple[int, str]] = []
|
||||
for tok in generate_tokens(StringIO(src).readline):
|
||||
if tok.type == COMMENT and assign.lineno < tok.start[0] < assign.end_lineno:
|
||||
comments.append((tok.start[0], tok.string))
|
||||
if not comments:
|
||||
return
|
||||
first_lines = [elt.lineno for elt in assign.value.elts]
|
||||
blocks: dict[int, list[str]] = {}
|
||||
for row, text in comments:
|
||||
idx = bisect_right(first_lines, row) # 注释属于行号大于它的第一条目
|
||||
if 0 < idx < len(entries):
|
||||
blocks.setdefault(idx, []).append(text)
|
||||
for idx, lines in blocks.items():
|
||||
entries[idx]["comment"] = "\n".join(lines)
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# wall.json 组装
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def _regex_entries(specs: list[dict]) -> list:
|
||||
"""re.compile 求值结果列表 → JSON 条目列表。
|
||||
|
||||
无标志 → 纯字符串(pattern 与源码逐字一致);
|
||||
有标志 → {"pattern": ..., "flags": "i"}(显式承载源码的 re.I/IGNORECASE)。
|
||||
"""
|
||||
return [s["pattern"] if not s["flags"]
|
||||
else {"pattern": s["pattern"], "flags": s["flags"]} for s in specs]
|
||||
|
||||
|
||||
def _lang_pools(constants: dict, base: str, suffix: str = "") -> dict:
|
||||
"""DEFLECTIONS_EN / DEFLECTIONS_EN_CONTACT 形态按在场语言收集
|
||||
(auto 无 ES 池,自然缺 es 键)。"""
|
||||
return {lang: constants[f"{base}_{lang.upper()}{suffix}"]
|
||||
for lang in ("en", "zh", "es")
|
||||
if f"{base}_{lang.upper()}{suffix}" in constants}
|
||||
|
||||
|
||||
def build_wall(project_root: Path, slug: str) -> dict:
|
||||
bf_src = (project_root / "app" / "services" / "boundary_filter.py").read_text(encoding="utf-8")
|
||||
cv_src = (project_root / "app" / "services" / "conversation.py").read_text(encoding="utf-8")
|
||||
bf_tree = ast.parse(bf_src)
|
||||
cv_tree = ast.parse(cv_src)
|
||||
bf, cv = extract_module_constants(bf_tree), extract_module_constants(cv_tree)
|
||||
|
||||
def need(table: dict, name: str):
|
||||
if name not in table:
|
||||
raise SystemExit(f"提取失败: {slug} 源码缺常量 {name}")
|
||||
return table[name]
|
||||
|
||||
# price_patterns: 统一先建 dict 以便挂行内注释,无 flags 且无 comment 再瘦回字符串
|
||||
price_assign = next(n for n in bf_tree.body
|
||||
if isinstance(n, ast.Assign)
|
||||
and getattr(n.targets[0], "id", "") == "PRICE_PATTERNS")
|
||||
price_entries: list[dict] = []
|
||||
for r in need(bf, "PRICE_PATTERNS"):
|
||||
e: dict = {"pattern": r["pattern"]}
|
||||
if r["flags"]:
|
||||
e["flags"] = r["flags"]
|
||||
price_entries.append(e)
|
||||
attach_price_comments(bf_src, price_assign, price_entries)
|
||||
price_entries_json = [e if (e.get("flags") or e.get("comment")) else e["pattern"]
|
||||
for e in price_entries]
|
||||
|
||||
reminders_inline = extract_function_templates(cv_tree, "_build_context_reminder")
|
||||
cc_guide = extract_function_templates(cv_tree, "_cc_confirm_note")
|
||||
|
||||
def fp(needle: str, field: str, **kw) -> str | None:
|
||||
return find_template(reminders_inline, needle, f"{slug} {field}", **kw)
|
||||
|
||||
return {
|
||||
"price_patterns": price_entries_json,
|
||||
"deflections": _lang_pools(bf, "DEFLECTIONS"),
|
||||
"deflections_contact": _lang_pools(bf, "DEFLECTIONS", "_CONTACT"),
|
||||
"pivots": _lang_pools(bf, "PIVOTS"),
|
||||
"pivots_contact": _lang_pools(bf, "PIVOTS", "_CONTACT"),
|
||||
"no_contact": {
|
||||
"web": need(bf, "NO_CONTACT_WEB"),
|
||||
"app": need(bf, "NO_CONTACT_APP"),
|
||||
"already": need(bf, "NO_CONTACT_ALREADY"),
|
||||
},
|
||||
"zero_number": {
|
||||
"long_number": _regex_entries([need(bf, "LONG_NUMBER_RE")])[0],
|
||||
"any_email": _regex_entries([need(bf, "ANY_EMAIL_RE")])[0],
|
||||
},
|
||||
"reask_patterns": _regex_entries(need(bf, "REASK_PATTERNS")),
|
||||
"triggers": {
|
||||
"boundary": _regex_entries([need(cv, "BOUNDARY_TRIGGER_RE")])[0],
|
||||
"budget": _regex_entries([need(cv, "BUDGET_TRIGGER_RE")])[0],
|
||||
"contact_ask": _regex_entries([need(cv, "CONTACT_ASK_TRIGGER_RE")])[0],
|
||||
},
|
||||
"reminders": {
|
||||
"boundary": {
|
||||
"budget": fp("⚠️ 职能边界提醒:客户报预算求推荐", "reminders.boundary.budget"),
|
||||
"normal": fp("⚠️ 职能边界提醒:客户正在询问价格", "reminders.boundary.normal"),
|
||||
},
|
||||
"contact_ask": {
|
||||
"app_already": fp("⚠️ 客户正在索要我方联系方式,且客户已留过",
|
||||
"reminders.contact_ask.app_already"),
|
||||
"app": fp("⚠️ 客户正在索要我方联系方式(最高意向信号)。当前为 App 会话",
|
||||
"reminders.contact_ask.app"),
|
||||
"web": fp("⚠️ 客户正在索要我方联系方式(最高意向信号)。请逐字使用这句指路",
|
||||
"reminders.contact_ask.web"),
|
||||
},
|
||||
# auto 上游无语言跟随特性 → 空键集(主流程以 jiancai 引擎标准回填)
|
||||
"language_follow": {k: v for k, v in {
|
||||
"zh": cv.get("LANG_NOTE_ZH"), "es": cv.get("LANG_NOTE_ES"),
|
||||
}.items() if v is not None},
|
||||
"lead_given": _lead_given(slug, reminders_inline),
|
||||
"cc_guide": {k: v for k, v in {
|
||||
"echo": find_template(cc_guide, "系统已按 IP 推测补全为",
|
||||
f"{slug} reminders.cc_guide.echo",
|
||||
contains=True, required=False),
|
||||
"ask": find_template(cc_guide, "且系统无法按 IP 推测补全",
|
||||
f"{slug} reminders.cc_guide.ask",
|
||||
contains=True, required=False),
|
||||
}.items() if v is not None},
|
||||
"email_light": {k: v for k, v in {
|
||||
"en": cv.get("_EMAIL_NOTE_EN"), "zh": cv.get("_EMAIL_NOTE_ZH"),
|
||||
}.items() if v is not None},
|
||||
},
|
||||
"cc_templates": {group: {lang: cv[f"_CC_{kind}_{lang.upper()}"]
|
||||
for lang in ("en", "zh", "es")
|
||||
if f"_CC_{kind}_{lang.upper()}" in cv}
|
||||
for group, kind in (("ask", "ASK"), ("wall", "WALL"),
|
||||
("note", "NOTE"), ("echo", "ECHO"))},
|
||||
}
|
||||
|
||||
|
||||
def _lead_given(slug: str, templates: list[str]) -> dict:
|
||||
"""已留资禁令四件套。jc 为两条独立 append;auto 是单条合并串(按固定
|
||||
标记"需要回应时可直接逐字使用"切分回两段,值不变)。"""
|
||||
field = f"{slug} reminders.lead_given"
|
||||
combined = find_template(templates, "⚠️ 客户已提供联系方式(",
|
||||
f"{field}.forbidden")
|
||||
marker = "需要回应时可直接逐字使用"
|
||||
if marker in combined:
|
||||
head, tail = combined.split(marker, 1)
|
||||
forbidden, respond = head, marker + tail
|
||||
else:
|
||||
forbidden = combined
|
||||
respond = find_template(templates, marker, f"{field}.respond_script")
|
||||
return {
|
||||
"forbidden": forbidden,
|
||||
"respond_script": respond,
|
||||
"boundary_tail": find_template(
|
||||
templates, "客户已留过联系方式——边界请求直接告知", f"{field}.boundary_tail"),
|
||||
"suspect": find_template(
|
||||
templates, "⚠️ 客户疑似已提供联系方式(", f"{field}.suspect"),
|
||||
}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# brand_values.json 草稿 / prompts 拷贝
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
def build_brand_values(project_root: Path) -> dict:
|
||||
static = project_root / "static"
|
||||
chat = (static / "chat.html").read_text(encoding="utf-8")
|
||||
widget = (static / "widget.js").read_text(encoding="utf-8")
|
||||
chatjs = (static / "js" / "chat.js").read_text(encoding="utf-8")
|
||||
env: dict[str, str] = {}
|
||||
env_path = project_root / ".env"
|
||||
if env_path.exists():
|
||||
for line in env_path.read_text(encoding="utf-8").splitlines():
|
||||
if "=" in line and not line.lstrip().startswith("#"):
|
||||
k, _, v = line.partition("=")
|
||||
env[k.strip()] = v.strip()
|
||||
|
||||
def find1(pattern: str, text: str) -> str | None:
|
||||
m = re.search(pattern, text)
|
||||
return m.group(1) if m else None
|
||||
|
||||
def js_default(attr: str, text: str) -> str | None:
|
||||
# widget.js: getAttribute("data-x") || "…";chat.js 多一层括号: …")) || "…"
|
||||
return find1(r'data-%s"\)+\s*\|\|\s*"([^"]*)"' % attr, text)
|
||||
|
||||
def leaf_values(d):
|
||||
for v in d.values():
|
||||
if isinstance(v, str):
|
||||
yield v
|
||||
elif isinstance(v, list):
|
||||
yield from (x for x in v if isinstance(x, str))
|
||||
elif isinstance(v, dict):
|
||||
yield from leaf_values(v)
|
||||
|
||||
def with_conflict(field: dict) -> dict:
|
||||
vals = {v for v in leaf_values(field) if v}
|
||||
return {**field, "conflict": len(vals) > 1}
|
||||
|
||||
title = {
|
||||
"chat_html_title": find1(r"<title>(.*?)</title>", chat),
|
||||
"chat_html_h1": find1(r"<h1>(.*?)</h1>", chat),
|
||||
"widget_js_default": js_default("title", widget),
|
||||
}
|
||||
whatsapp = {
|
||||
"chat_html_href": find1(r'href="(https://wa\.me/[^"]*)"', chat),
|
||||
"widget_js_default": js_default("whatsapp", widget),
|
||||
"chat_js_default": js_default("whatsapp", chatjs),
|
||||
}
|
||||
email = {
|
||||
"chat_html_href": find1(r'href="(mailto:[^"]*)"', chat),
|
||||
"widget_js_default": js_default("email", widget),
|
||||
"chat_js_default": js_default("email", chatjs),
|
||||
}
|
||||
return {
|
||||
"_doc": ("品牌值机器提取草稿(Task 5 消费): 值取自 chat.html/widget.js/"
|
||||
"chat.js 硬编码与 .env;conflict=true 处需人工归一定稿"),
|
||||
"title": with_conflict(title),
|
||||
"whatsapp": with_conflict(whatsapp),
|
||||
"email": with_conflict(email),
|
||||
"notify_email": {"env": env.get("NOTIFY_EMAIL", "")},
|
||||
"help_description": {
|
||||
"chat_html": html_mod.unescape(
|
||||
find1(r'help-description">(.*?)</div>', chat) or ""),
|
||||
},
|
||||
"popular_topics": {
|
||||
"chat_html": [html_mod.unescape(t)
|
||||
for t in re.findall(r'data-topic="([^"]*)"', chat)],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def copy_prompts(project_root: Path, out_dir: Path) -> list[str]:
|
||||
copied = []
|
||||
for md in sorted((project_root / "app" / "prompts").glob("*.md")):
|
||||
target = out_dir / "prompts" / md.name
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(md, target)
|
||||
copied.append(md.name)
|
||||
return copied
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 主流程
|
||||
# --------------------------------------------------------------------------
|
||||
|
||||
# auto 上游不存在的引擎特性(jc 后期 Task 的引擎能力)——对应字段沿用
|
||||
# jiancai 出厂(引擎标准)话术,以 _meta 显性标注,Task 5+ 校对时可见。
|
||||
FALLBACK_FIELDS = ["reminders.language_follow", "reminders.cc_guide",
|
||||
"reminders.email_light", "cc_templates"]
|
||||
# 租户自有词表(auto 用自己的值整体替换 jiancai 基座)
|
||||
OWN_FIELDS = ["price_patterns", "deflections", "deflections_contact", "pivots",
|
||||
"pivots_contact", "no_contact", "zero_number", "reask_patterns",
|
||||
"triggers"]
|
||||
OWN_REMINDER_CATS = ["boundary", "contact_ask", "lead_given"]
|
||||
|
||||
|
||||
def write_json(path: Path, data: dict) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8", newline="\n")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--jc", default=r"D:\refactor\ai-kefu-jc",
|
||||
help="jiancai 老项目根目录")
|
||||
parser.add_argument("--auto", default=r"D:\refactor\ai-kefu",
|
||||
help="auto 老项目根目录")
|
||||
parser.add_argument("--out", default=str(Path(__file__).resolve().parents[1]),
|
||||
help="seed_profiles 输出根目录(仓库根)")
|
||||
args = parser.parse_args()
|
||||
out_root = Path(args.out)
|
||||
jc_root, auto_root = Path(args.jc), Path(args.auto)
|
||||
for root in (jc_root, auto_root):
|
||||
if not (root / "app" / "services" / "boundary_filter.py").exists():
|
||||
raise SystemExit(f"源项目不存在或结构不符: {root}")
|
||||
|
||||
# jiancai(主线,完整提取;多语言墙 = 引擎出厂默认)
|
||||
jc_wall = build_wall(jc_root, "jiancai")
|
||||
jc_dir = out_root / "seed_profiles" / "jiancai"
|
||||
write_json(jc_dir / "wall.json", jc_wall)
|
||||
write_json(jc_dir / "brand_values.json", build_brand_values(jc_root))
|
||||
jc_prompts = copy_prompts(jc_root, jc_dir)
|
||||
|
||||
# auto: 自有词表覆盖 jiancai 基座;引擎特性字段沿用基座(见 FALLBACK_FIELDS)
|
||||
auto_own = build_wall(auto_root, "auto")
|
||||
auto_wall = json.loads(json.dumps(jc_wall, ensure_ascii=False))
|
||||
for key in OWN_FIELDS:
|
||||
auto_wall[key] = auto_own[key]
|
||||
for cat in OWN_REMINDER_CATS:
|
||||
auto_wall["reminders"][cat] = auto_own["reminders"][cat]
|
||||
auto_wall["_meta"] = {
|
||||
"doc": ("auto 上游无语言跟随/CC 回显确认/邮箱轻补引擎特性,"
|
||||
"以下字段沿用 jiancai 出厂(引擎标准)话术"),
|
||||
"fallback_from": "jiancai",
|
||||
"fields": FALLBACK_FIELDS,
|
||||
}
|
||||
auto_dir = out_root / "seed_profiles" / "auto"
|
||||
write_json(auto_dir / "wall.json", auto_wall)
|
||||
write_json(auto_dir / "brand_values.json", build_brand_values(auto_root))
|
||||
auto_prompts = copy_prompts(auto_root, auto_dir)
|
||||
|
||||
print(f"jiancai: price {len(jc_wall['price_patterns'])} 条 / "
|
||||
f"deflections {'+'.join(sorted(jc_wall['deflections']))} / "
|
||||
f"prompts {jc_prompts}")
|
||||
print(f"auto: price {len(auto_wall['price_patterns'])} 条 / "
|
||||
f"deflections {'+'.join(sorted(auto_wall['deflections']))} / "
|
||||
f"prompts {auto_prompts}")
|
||||
print(f"auto fallback(引擎标准)字段: {', '.join(FALLBACK_FIELDS)}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user