415 lines
18 KiB
Python
415 lines
18 KiB
Python
"""L2 训练营知识库工具 — 按需加载 + 语义搜索
|
||
|
||
本文件包含知识库查询工具的实现。修改主提示词时请勿改动此文件。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
from pathlib import Path
|
||
|
||
from common.knowledge_loader import list_knowledge_files, load_knowledge_file
|
||
|
||
|
||
# ───────────────────────────── Markdown 清理 ─────────────────────────────
|
||
def _strip_markdown(text: str) -> str:
|
||
"""将 Markdown 格式转为纯文本,方便微信等平台显示"""
|
||
# 移除代码块
|
||
text = re.sub(r"```[\s\S]*?```", lambda m: m.group(0).strip("`\n"), text)
|
||
# 移除行内代码反引号
|
||
text = re.sub(r"`([^`]+)`", r"\1", text)
|
||
# 移除加粗/斜体
|
||
text = re.sub(r"\*\*(.+?)\*\*", r"\1", text)
|
||
text = re.sub(r"\*(.+?)\*", r"\1", text)
|
||
text = re.sub(r"__(.+?)__", r"\1", text)
|
||
text = re.sub(r"_(.+?)_", r"\1", text)
|
||
# 移除标题标记,保留文字
|
||
text = re.sub(r"^#{1,6}\s+", "", text, flags=re.MULTILINE)
|
||
# 移除链接,保留文字
|
||
text = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", text)
|
||
# 移除图片
|
||
text = re.sub(r"!\[([^\]]*)\]\([^)]+\)", "", text)
|
||
# 移除水平线
|
||
text = re.sub(r"^---+$", "", text, flags=re.MULTILINE)
|
||
# 移除引用标记
|
||
text = re.sub(r"^>\s?", "", text, flags=re.MULTILINE)
|
||
# 移除列表标记(- 和 * 开头)
|
||
text = re.sub(r"^[\-\*]\s+", "· ", text, flags=re.MULTILINE)
|
||
# 移除编号列表
|
||
text = re.sub(r"^\d+\.\s+", "", text, flags=re.MULTILINE)
|
||
# 清理多余空行(最多保留一个空行)
|
||
text = re.sub(r"\n{3,}", "\n\n", text)
|
||
return text.strip()
|
||
|
||
# ───────────────────────────── 配置 ─────────────────────────────
|
||
_KB_DIR = Path(__file__).parent / "knowledge_base"
|
||
|
||
# 知识库元数据(与文件名一一对应)
|
||
_KB_METADATA: dict[str, dict] = {
|
||
"01_课程层次与问题判断.md": {
|
||
"title": "课程层次与问题判断",
|
||
"description": "判断299、1280七天训练营、大本营之间的层次关系,以及学员当前问题卡在哪一层。",
|
||
"keywords": [
|
||
"课程层次", "299", "1280", "七天训练营", "启蒙课", "训练场",
|
||
"大本营", "层次判断", "知道但做不到", "课程信心", "没体感",
|
||
"后续训练", "回溯", "回溯现象", "未完成事件", "事实法则",
|
||
"流动法则", "现实风险层", "问题判断", "卡在哪一层",
|
||
],
|
||
},
|
||
"02_即时回复表达原则.md": {
|
||
"title": "即时回复表达原则",
|
||
"description": "生成有阅读感、人情味、有分寸、可供辅导老师参考和改写的回复。",
|
||
"keywords": [
|
||
"表达", "回复", "参考回复", "怎么回", "怎么说", "语气",
|
||
"措辞", "风格", "原则", "阅读感", "人情味", "中肯方向",
|
||
"长故事", "短问题", "群内回复", "私聊回复", "AI味",
|
||
"课程词", "慎用术语", "话术表达", "成交表达",
|
||
],
|
||
},
|
||
"03_常见学员问题回应素材.md": {
|
||
"title": "常见学员问题回应素材",
|
||
"description": "妈妈父母、孩子、伴侣、课程失望、知道但做不到、想放弃等常见场景回应素材。",
|
||
"keywords": [
|
||
"常见问题", "学员问题", "回应素材", "妈妈", "父母",
|
||
"原生家庭", "孩子", "不上学", "躺平", "伴侣", "老公",
|
||
"出轨", "离婚", "课程没用", "没体感", "没变化", "失望",
|
||
"知道但做不到", "反应过度", "小事失控", "想放弃",
|
||
"不想写输出", "不想分享", "群内长篇分享", "私聊长故事",
|
||
"攻击课程", "攻击老师", "刷屏", "互相影响", "没救了",
|
||
"别人有反应我没有", "依赖老师",
|
||
],
|
||
},
|
||
"04_功课与练习反馈边界.md": {
|
||
"title": "功课与练习反馈边界",
|
||
"description": "功课、练习、释放、万能释放法、练习反馈和练习后不适的处理边界。",
|
||
"keywords": [
|
||
"功课", "练习", "打卡", "反馈", "边界", "作业",
|
||
"释放", "释放不是宣泄", "万能释放法", "万能释放公式",
|
||
"看见且释放", "没感觉", "不知道释放什么", "做完哭",
|
||
"做完抖", "做完堵", "头晕", "胸闷", "呼吸不适",
|
||
"飘", "空", "回不来", "看到画面", "前世", "小时候画面",
|
||
"旧记忆", "功课回溯", "最小功课", "带练", "暂停练习",
|
||
],
|
||
},
|
||
"05_安全边界与现实风险.md": {
|
||
"title": "安全边界与现实风险",
|
||
"description": "安全、医疗、心理危机、法律、家暴、未成年人、严重身体不适等最高优先级边界。",
|
||
"keywords": [
|
||
"安全", "风险", "危机", "高风险", "自伤", "自杀",
|
||
"想死", "不想活", "伤害自己", "伤害他人", "家暴",
|
||
"人身安全", "未成年人", "孩子安全", "离家出走", "暴力",
|
||
"报警", "上报", "班主任", "督导", "现实支持", "严重身体不适",
|
||
"呼吸困难", "胸闷", "精神混乱", "幻觉", "妄想", "解离",
|
||
"飘", "空", "麻", "回不来", "用药", "停药", "换药",
|
||
"诊断", "治疗", "医疗", "法律", "财产", "版权", "保密",
|
||
"意识许可", "课程资料外传",
|
||
],
|
||
},
|
||
"06_1280核心术语词典.md": {
|
||
"title": "1280核心术语词典",
|
||
"description": "1280核心术语的人话解释、适用场景、禁用场景和老师提醒。",
|
||
"keywords": [
|
||
"术语", "词典", "概念", "定义", "解释", "是什么意思",
|
||
"看见自己", "回到自己", "看见且释放", "万能释放法",
|
||
"万能释放公式", "情绪心钥", "抗拒心钥", "信念心钥",
|
||
"程序", "程序心钥", "人生剧本", "回溯现象", "未完成事件",
|
||
"事实法则", "流动法则", "派生情绪", "原生情绪", "抓取",
|
||
"抗拒", "原生数据", "先天数据", "潜意识数据", "代际",
|
||
"家族数据", "数息法", "身体框架法", "认知透镜",
|
||
"知道但做不到", "自动反应", "自我的七个层面",
|
||
"百慕大能量三角形", "场域支持", "借光而行", "借事修心",
|
||
"生命数据化", "输出", "支点", "格物致知", "知行合一",
|
||
"良知",
|
||
],
|
||
},
|
||
"07_大本营承接与成交话术.md": {
|
||
"title": "大本营承接与成交话术",
|
||
"description": "大本营承接时机、成交判断、课程信心修复、异议处理和老师成交提醒。",
|
||
"keywords": [
|
||
"大本营", "承接", "成交", "异议", "销售", "推课",
|
||
"后续课程", "继续学", "持续训练", "持续场域", "场域支持",
|
||
"借光而行", "课程信心", "课程没用", "没体感", "怕没效果",
|
||
"知道但做不到", "怕结束后回到原样", "没时间", "没钱",
|
||
"价格", "权益", "优惠", "名额", "截止时间", "报名",
|
||
"退款", "家人不同意", "觉得被销售", "不想继续",
|
||
"不适合承接", "轻轻埋种子", "明确承接", "高风险停止成交",
|
||
],
|
||
},
|
||
}
|
||
|
||
_ROUTE_RULES: list[dict] = [
|
||
{
|
||
"filename": "05_安全边界与现实风险.md",
|
||
"boost": 5.0,
|
||
"keywords": [
|
||
"自伤", "自残", "自杀", "想死", "不想活", "活不下去",
|
||
"伤害自己", "伤害他人", "杀了", "家暴", "打我", "被打",
|
||
"报警", "人身安全", "孩子安全", "未成年人", "离家出走",
|
||
"呼吸困难", "胸闷", "喘不过气", "头晕", "严重身体不适",
|
||
"幻觉", "妄想", "精神混乱", "解离", "回不来", "停药",
|
||
"换药", "用药", "诊断", "治疗", "医院", "医生", "法律",
|
||
"财产", "版权", "保密", "课程资料", "外传", "意识许可",
|
||
],
|
||
},
|
||
{
|
||
"filename": "04_功课与练习反馈边界.md",
|
||
"boost": 3.5,
|
||
"keywords": [
|
||
"功课", "作业", "练习", "带练", "打卡", "释放", "万能释放",
|
||
"看见且释放", "不知道释放什么", "没感觉", "做完哭",
|
||
"做完抖", "做完堵", "练完", "头晕", "胸闷", "呼吸不适",
|
||
"飘", "空", "麻", "回不来", "看到画面", "前世", "小时候画面",
|
||
"旧记忆", "暂停练习", "最小功课",
|
||
],
|
||
},
|
||
{
|
||
"filename": "07_大本营承接与成交话术.md",
|
||
"boost": 3.5,
|
||
"keywords": [
|
||
"大本营", "承接", "成交", "推课", "销售", "后续课程",
|
||
"继续学", "继续练", "持续训练", "报名", "价格", "多少钱",
|
||
"权益", "优惠", "名额", "截止", "退款", "没时间", "没钱",
|
||
"怕没效果", "家人不同意", "觉得被销售", "不想继续",
|
||
"怕结束后回到原样", "适不适合推",
|
||
],
|
||
},
|
||
{
|
||
"filename": "06_1280核心术语词典.md",
|
||
"boost": 3.0,
|
||
"keywords": [
|
||
"是什么意思", "怎么解释", "术语", "概念", "定义",
|
||
"看见自己", "回到自己", "情绪心钥", "抗拒心钥",
|
||
"信念心钥", "程序心钥", "人生剧本", "回溯现象",
|
||
"未完成事件", "事实法则", "流动法则", "派生情绪",
|
||
"原生情绪", "原生数据", "先天数据", "潜意识数据",
|
||
"代际", "家族数据", "身体框架法", "认知透镜",
|
||
"自我的七个层面", "百慕大", "借光而行", "借事修心",
|
||
"生命数据化", "格物致知", "知行合一", "良知",
|
||
],
|
||
},
|
||
{
|
||
"filename": "03_常见学员问题回应素材.md",
|
||
"boost": 2.8,
|
||
"keywords": [
|
||
"妈妈", "父母", "原生家庭", "孩子", "不上学", "躺平",
|
||
"老公", "伴侣", "出轨", "离婚", "课程没用", "没体感",
|
||
"没变化", "失望", "知道但做不到", "反应过度",
|
||
"小事失控", "想放弃", "不想写输出", "不想分享",
|
||
"群里刷屏", "攻击课程", "攻击老师", "没救了",
|
||
"别人都有反应", "我没有反应", "依赖老师",
|
||
],
|
||
},
|
||
{
|
||
"filename": "02_即时回复表达原则.md",
|
||
"boost": 2.4,
|
||
"keywords": [
|
||
"怎么回", "怎么说", "如何回复", "参考回复", "话术",
|
||
"语气", "表达", "阅读感", "人情味", "长故事",
|
||
"短问题", "群内", "私聊", "AI味", "不要像AI",
|
||
"课程词", "慎用术语",
|
||
],
|
||
},
|
||
{
|
||
"filename": "01_课程层次与问题判断.md",
|
||
"boost": 2.2,
|
||
"keywords": [
|
||
"课程层次", "299", "1280", "七天训练营", "启蒙课",
|
||
"训练场", "卡在哪", "层次判断", "课程区别", "大本营偏向",
|
||
"回溯", "未完成事件", "事实法则", "流动法则",
|
||
"现实风险层", "后续训练需求",
|
||
],
|
||
},
|
||
]
|
||
|
||
# ───────────────────────────── 工具函数 ─────────────────────────────
|
||
|
||
def _tokenize(text: str) -> set[str]:
|
||
"""中文分词(使用jieba)"""
|
||
try:
|
||
import jieba
|
||
|
||
return set(jieba.cut(text))
|
||
except ImportError:
|
||
# 降级:按字拆分
|
||
return set(text)
|
||
|
||
|
||
def _calc_relevance(query: str, content: str) -> float:
|
||
"""计算查询与内容的相关度(0-1)"""
|
||
query_tokens = _tokenize(query.lower())
|
||
content_tokens = _tokenize(content.lower())
|
||
|
||
if not query_tokens:
|
||
return 0.0
|
||
|
||
overlap = query_tokens & content_tokens
|
||
return len(overlap) / len(query_tokens)
|
||
|
||
|
||
def _normalize_text(text: str) -> str:
|
||
"""用于短语命中的轻量归一化。"""
|
||
return re.sub(r"\s+", "", text.lower())
|
||
|
||
|
||
def _count_phrase_hits(query: str, phrases: list[str]) -> int:
|
||
"""统计查询命中的业务短语数量。"""
|
||
normalized_query = _normalize_text(query)
|
||
hits = 0
|
||
for phrase in phrases:
|
||
normalized_phrase = _normalize_text(phrase)
|
||
if normalized_phrase and normalized_phrase in normalized_query:
|
||
hits += 1
|
||
return hits
|
||
|
||
|
||
def _metadata_text(filename: str) -> str:
|
||
"""拼接知识库元数据,供检索加权。"""
|
||
meta = _KB_METADATA.get(filename, {})
|
||
return " ".join(
|
||
[
|
||
meta.get("title", ""),
|
||
meta.get("description", ""),
|
||
" ".join(meta.get("keywords", [])),
|
||
]
|
||
)
|
||
|
||
|
||
def _calc_metadata_score(query: str, filename: str) -> float:
|
||
"""计算文件元数据与查询的相关度。"""
|
||
meta = _KB_METADATA.get(filename, {})
|
||
keywords = meta.get("keywords", [])
|
||
phrase_hits = _count_phrase_hits(query, keywords)
|
||
relevance = _calc_relevance(query, _metadata_text(filename))
|
||
return relevance + min(phrase_hits * 0.35, 2.0)
|
||
|
||
|
||
def _calc_route_boost(query: str, filename: str) -> float:
|
||
"""根据 V7.1 固定业务场景给目标知识库加权。"""
|
||
boost = 0.0
|
||
for rule in _ROUTE_RULES:
|
||
hits = _count_phrase_hits(query, rule["keywords"])
|
||
if not hits:
|
||
continue
|
||
if rule["filename"] == filename:
|
||
boost += rule["boost"] + min(hits * 0.3, 1.5)
|
||
return boost
|
||
|
||
|
||
def _extract_relevant_sections(content: str, query: str, max_sections: int = 3) -> list[str]:
|
||
"""从知识库内容中提取与查询最相关的章节"""
|
||
# 按二级标题分割
|
||
sections = re.split(r"\n(?=##\s+)", content)
|
||
|
||
scored = []
|
||
for section in sections:
|
||
if not section.strip():
|
||
continue
|
||
score = _calc_relevance(query, section)
|
||
scored.append((score, section))
|
||
|
||
scored.sort(key=lambda x: x[0], reverse=True)
|
||
return [s for _, s in scored[:max_sections]]
|
||
|
||
|
||
def _normalize_top_k(top_k: int) -> int:
|
||
"""限制 top_k,避免模型误传过大值导致输出过长。"""
|
||
try:
|
||
value = int(top_k)
|
||
except (TypeError, ValueError):
|
||
value = 3
|
||
return max(1, min(value, 8))
|
||
|
||
|
||
# ───────────────────────────── 公开工具 ─────────────────────────────
|
||
|
||
def list_kb() -> str:
|
||
"""列出所有可用的知识库文件
|
||
|
||
当你不确定该查哪个知识库文件时,先调用此工具查看列表。
|
||
"""
|
||
lines = ["【可用知识库文件】\n"]
|
||
for fname, meta in _KB_METADATA.items():
|
||
lines.append(f"◆ {meta['title']}")
|
||
lines.append(f" 描述: {meta['description']}")
|
||
lines.append(f" 关键词: {', '.join(meta['keywords'][:5])}...\n")
|
||
|
||
return "\n".join(lines)
|
||
|
||
|
||
def load_kb(filename: str) -> str:
|
||
"""加载指定知识库文件的完整内容
|
||
|
||
Args:
|
||
filename: 知识库文件名(如 "01_课程层次与问题判断.md")
|
||
|
||
当你已确定需要哪个文件的内容时,调用此工具加载完整内容。
|
||
"""
|
||
if filename not in _KB_METADATA:
|
||
available = ", ".join(_KB_METADATA.keys())
|
||
return f"错误: 文件 '{filename}' 不存在。可用文件: {available}"
|
||
|
||
content = load_knowledge_file(_KB_DIR, filename)
|
||
if not content:
|
||
return f"错误: 无法读取文件 '{filename}'"
|
||
|
||
return _strip_markdown(content)
|
||
|
||
|
||
def search_kb(query: str, top_k: int = 3) -> str:
|
||
"""根据关键词搜索所有知识库文件,返回最相关的片段
|
||
|
||
Args:
|
||
query: 搜索关键词(中文)
|
||
top_k: 返回结果数量(默认3条)
|
||
|
||
当你不确定具体文件,但知道要查什么内容时,调用此工具。
|
||
"""
|
||
top_k = _normalize_top_k(top_k)
|
||
all_files = list_knowledge_files(_KB_DIR)
|
||
results: list[tuple[float, str, str]] = []
|
||
|
||
for fname in all_files:
|
||
content = load_knowledge_file(_KB_DIR, fname)
|
||
if not content:
|
||
continue
|
||
|
||
# 计算文件级、元数据级、硬路由级相关度
|
||
file_score = _calc_relevance(query, content)
|
||
metadata_score = _calc_metadata_score(query, fname)
|
||
route_boost = _calc_route_boost(query, fname)
|
||
|
||
# 提取最相关的章节
|
||
sections = _extract_relevant_sections(content, query, max_sections=3)
|
||
|
||
for section in sections:
|
||
section_score = _calc_relevance(query, section)
|
||
section_phrase_hits = _count_phrase_hits(
|
||
query,
|
||
re.findall(r"^#{1,6}\s+(.+)$", section, flags=re.MULTILINE),
|
||
)
|
||
# 综合得分:硬路由 > 元数据 > 章节 > 全文,优先保证关键业务场景命中正确文件。
|
||
final_score = (
|
||
route_boost
|
||
+ metadata_score * 0.7
|
||
+ section_score * 1.2
|
||
+ file_score * 0.3
|
||
+ min(section_phrase_hits * 0.4, 1.2)
|
||
)
|
||
results.append((final_score, fname, section))
|
||
|
||
# 排序并取 top_k
|
||
results.sort(key=lambda x: x[0], reverse=True)
|
||
top_results = results[:top_k]
|
||
|
||
if not top_results:
|
||
return f"未找到与 '{query}' 相关的知识库内容。"
|
||
|
||
lines = [f"【知识库搜索结果: '{query}'】\n"]
|
||
for i, (score, fname, section) in enumerate(top_results, 1):
|
||
meta = _KB_METADATA.get(fname, {})
|
||
title = meta.get("title", fname)
|
||
lines.append(f"结果{i}(相关度: {score:.2f})| 来源: {title}")
|
||
lines.append(f"{_strip_markdown(section[:800])}\n")
|
||
|
||
return "\n".join(lines)
|