feat: 意图识别优化

This commit is contained in:
qinyong@9artedu.com 2026-07-19 17:34:09 +08:00
parent 0bc6b76508
commit 71a169a8d3
12 changed files with 2290 additions and 972 deletions

View File

@ -0,0 +1,43 @@
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import PromptTemplate
from app.llm import llm
general_prompt = """
你是一个专业的文本改写助手请分析输入文本按以下规则处理
**规则**
1. 如果文本中包含明确的时间年限"今天""明天""25年""2025年""28年""2028年"等具体年份或年限请将其改写为模糊化表达去除具体年份保留核心逻辑关系
2. 如果文本中包含具体的姓名或称号如人名昵称网名特定头衔等可识别个人身份的指代请将其改写为模糊化通用化表达根据上下文用"对方""这位老师""相关负责人""业内人士""有前辈"等通用称谓替代保留其在句子中的角色和语义关系
3. 如果文本中**既不包含**明确的时间年限**也不包含**任何具体的姓名或称号**保持原样返回**不做任何修改
4. 改写时保留原文的核心语义毕业时间校招条件限制社招要求AI岗位趋势竞争压力简历包装等关键信息
5. 去除具体时间数字"毕业后X年""校招年限要求""当前"等相对化表达替代去除具体姓名称号用不指向特定个人的通用化称谓替代
6. 保持原文字数大致相当不要过度扩写或删减
**示例一时间模糊化**
输入"如果25年毕业校招机会基本没有了因为25年毕业的学生在28年已经不符合校招条件只能走社招。社招通常要求至少一年工作经验且今年AI校招的美术岗位在大量减少竞争只会更大。学得很好也需要包装简历走社招和社保没有直接关系。"
输出"如果刚毕业不久校招机会基本没有了因为毕业一段时间后已经不符合校招年限条件只能走社招。社招通常要求至少一年工作经验且当前AI校招的美术岗位在大量减少竞争只会更大。学得很好也需要包装简历走社招和社保没有直接关系。"
**示例二时间与姓名称号同时模糊化**
输入"王工之前提醒过25届的同学最迟26年就要准备转社招再拖下去机会更少。"
输出"有前辈之前提醒过,刚毕业的同学最迟毕业一两年内就要准备转社招,再拖下去机会更少。"
**请直接输出处理后的文本不要解释不要添加额外内容**
输入文本{text}
"""
class TextExtract:
async def text_extract(self, text:str)->str:
if not text:
return ""
prompt_template = PromptTemplate(template=general_prompt, input_variables=["text"])
str_output_parser = StrOutputParser()
chain = prompt_template | llm |str_output_parser
answer = await chain.ainvoke({"text": text})
return answer

View File

@ -0,0 +1,455 @@
import os
import asyncio
import json
import time
from datetime import datetime, timedelta
from typing import List, Dict
from langchain_core.prompts import PromptTemplate
from app.chat_qa.classification.text_intent import TextIntent
from app.chat_qa.classification.general_text_extract import TextExtract
from app.chat_qa.classification.standard_intent import standard_intent
from app.client.embedding_client_manager import EmbeddingClientManager, embedding_client
from app.client.milvus_client_manager import milvus_client
from app.client.mysql_client_manager import db_assistant_mysql_client_manager
from app.dto.summary_dto import SummaryDto
from app.llm import llm
from app.models.mysql import ArchiveMessages
from app.repository.archive_messages_repository import ArchiveMessagesRepository
from app.repository.milvus.intent_classification_repository import IntentClassificationRepository
from app.repository.milvus.summary_repository import SummaryRepository
from app.core.log import logger
from app.reranker_utils import get_reranker_model
import re
intent_prompt = """
你是一个专业的QA提取专家请分析以下聊天记录提取或生成1-2个高质量的QA对
## 核心规则
### 1. QA提取规则
- 优先提取用户明确提出的问题及其对应的回答
- 如果对话中没有明确的问题根据讨论内容生成一个总结性问题
- 答案必须基于聊天记录中的实际内容不能编造
- 如果某个问题在聊天记录中没有明确答案则不生成QA
- 最多生成2个QA对优先选择最有价值的信息
### 2. 单意图选择规则(关键)
**每个QA对只能有一个意图标签不能以顿号逗号或分隔多个意图**
当内容可能对应多个意图时按以下优先级选择唯一意图
| 优先级 | 选择规则 | 说明 |
|-------|---------|------|
| **P0** | **优先匹配已有意图标签** | 必须优先从已有意图体系含扩展示例中选择最匹配的标签禁止新建与原标签语义高度重复的意图**当销售单方面介绍课程内容无用户明确提问直接使用对应的主题标签3D场景建模3D角色建模严禁新建XXX课程咨询类标签** 场景线上课程介绍 使用3D场景建模而非场景线上课程咨询 |
| **P1** | **核心诉求优先** | 选择最能代表用户核心诉求的意图例如报名流程说明 报名咨询核心是报名咨询而非线上直播教学只是提到线上 |
| **P2** | **动作维度优先于主题维度** | 当主题和诉求冲突时优先选择主题维度例如3D建模费用 3D建模主题 |
| **P3** | **直接提及优先于间接关联** | 用户明确说出的意图优先于推断出的意图 |
| **P4** | **去重合并** | 多个子诉求属于同一类别时只输出一个意图标签 |
### 3. 意图分类体系(必须从中选择或基于其扩充)
{intent_list}
**扩充规则** 仅当用户问题涉及的具体方向在已有标签中完全无法覆盖时才可创建更细粒度的标签创建时必须严格对比已有标签确保无重复语义例如原画好就业吗 可扩充为原画就业因为已有标签中无就业原画的结合且不冲突
### 4. 多意图拆分规则(新增)
- 若同一段对话明确涉及多个不重叠的核心意图如同时介绍3D场景建模就业咨询必须拆分为多个QA对每个QA对聚焦一个意图
- 每个QA对的answer仅包含与该意图直接相关的信息不相关的信息不得混杂其中避免信息污染
- 若几个意图高度关联无法拆分如介绍课程时自然提到费用但核心是课程则按单意图规则选择最核心的标签其余信息可融入answer作为补充但intents仍为单一核心意图
## 输出格式要求
- 输出纯JSON不要任何解释不要markdown标记不要注释
- 将中文引号 "" '' 替换为英文引号 "" ''
- 删除所有注释// /* */
- 删除markdown代码块标记```json ```
- 删除JSON前后多余的文本说明
- 修复末尾多余的逗号 }},] }},}}
- 确保所有字符串用双引号包裹
- 确保布尔值是小写的 true/falsenull 是小写的
- 如果键名没有引号给键名加上双引号
## 输出格式
{{
"qa_pairs": [
{{
"intents": "意图标签(只能有一个,不能并列)",
"answer": "回答内容"
}},
{{
"intents": "意图标签(只能有一个,不能并列)",
"answer": "回答内容"
}}
]
}}
# 历史对话
{message_str}
其中INTERNAL表示销售EXTERNAL表示用户
要求
- 一个intents对应的answer必须包含聊天记录中所有可用信息确保信息不遗漏
- 意图标签必须从上述分类中选择或基于其合理扩充
- **intents字段必须是单个字符串不能包含顿号逗号或**
- 优先选择最有价值的1-2个QA对
- 当销售INTERNAL主动进行课程/产品介绍而非回答用户EXTERNAL明确提问时意图标签应选择主题维度3D角色建模而非诉求维度课程咨询
"""
from FlagEmbedding import FlagReranker
class HistoryIntent:
def __init__(self, archive_messages_repository: ArchiveMessagesRepository,
embedding_client: EmbeddingClientManager,
summary_repository: SummaryRepository,
reranker: FlagReranker
):
self.archive_messages_repository = archive_messages_repository
self.embedding_client = embedding_client
self.summary_repository = summary_repository
self.reranker = reranker
async def get_day_ms_timestamp(self, date_str: str):
"""
输入日期字符串 "2025-06-10"
:return: start_ms(当日0点毫秒时间戳), end_ms(当日23:59:59毫秒时间戳)
"""
# 解析日期,生成当日 00:00:00
day_start = datetime.strptime(date_str, "%Y-%m-%d")
# 当日最后一秒 23:59:59
day_end = day_start + timedelta(days=1) - timedelta(seconds=1)
# timestamp() 返回秒,*1000 转毫秒,转整数
start_ms = int(day_start.timestamp() * 1000)
end_ms = int(day_end.timestamp() * 1000)
return start_ms, end_ms
async def get_message_summary(self, date: str = None):
stime = time.time()
intent_repo = IntentClassificationRepository()
if date:
start_ts, end_ts = await self.get_day_ms_timestamp(date)
day_ArchiveMessages = await self.archive_messages_repository.get_message_statistics_by_date(start_date=start_ts,
end_date=end_ts)
logger.info(f"{date}共产生了{len(day_ArchiveMessages)}条会话")
else:
day_ArchiveMessages = await self.archive_messages_repository.get_message_statistics_by_date()
# 获取意图分类
intent_list:list[str] = await intent_repo.get_style_intent_categories()
# 改成map
date_user_message_a_set = set()
summary_list: list[str] = []
for idx, day_message in enumerate(day_ArchiveMessages, 1): #打印计数
logger.info(f"处理第{idx}会话")
day_date = str(day_message.created_at)
user_a = day_message.from_user
# 找出发送人相关的接收人信息
date_user_message_a = await self.archive_messages_repository.get_message_statistics_by_date_user(day_date,
user_a)
# 找出发送人与接收人 相关的所有会话信息
for date_a in date_user_message_a:
date_user_b = date_a.to_user
# if date_user_b != "[\"wmI1AkDQAAdVhy2ENtY6fecaw9-pi4uQ\"]":
# continue
date_user_a = date_a.from_user
date_user_b_clean = date_user_b.replace("[\"", "").replace("\"]", "")
date_user_a_append = "[\"" + date_user_a + "\"]"
# 判断只要存在就跳过
if date_user_a + date_user_b in date_user_message_a_set or date_user_b_clean + date_user_a_append in date_user_message_a_set:
logger.info(f"已存在{date_user_a}{date_user_b}{day_date}对话,跳过")
continue
logger.info(f"查询{date_user_a}{date_user_b}{day_date}对话开始")
list1 = await self.archive_messages_repository.get_message_statistics_by_date_user(day_date,
date_user_a,
date_user_b)
list2 = await self.archive_messages_repository.get_message_statistics_by_date_user(day_date,
date_user_b_clean,
date_user_a_append)
# 查询过的聊天账号,后续不再查询
date_user_message_a_set.add(date_user_a + date_user_b)
date_user_message_a_set.add(date_user_b_clean + date_user_a_append)
coro_list = list1 + list2
coro_list.sort(key=lambda x: x.created_at, reverse=False)
logger.info(f"查询{date_user_a}{date_user_b}对话共:{len(coro_list)}")
# 获取意图分类
summary_res = await self.intent_message(coro_list, intent_list)
summary_list.append(summary_res)
logger.info(f"总共:{summary_list}")
logger.info(f"识别到意图数量:{len(summary_list)}")
#将意图分类结果进行合并
summary_merged :Dict[str, List[str]] = await self.merge_intents_from_content(summary_list)
logger.info(f"合并后的意图数量:{len(summary_merged)},{summary_merged}")
check_text = TextIntent()
text_extract = TextExtract()
new_summary_merged: Dict[str, List[str]]={}
for key, value in summary_merged.items():
if key in standard_intent:
logger.info(f"已存在标准意图:{key}")
continue
# 重排序取最相近的2个
reranked_value:List[str] = await self.rerank_merged_intents(value, key)
# 检查意图, 并返回最匹配的意图
intent_key: str = await check_text.intent_message(str(reranked_value), key, intent_list)
logger.info(f"老的意图:{key},新的意图:{intent_key}, value:{reranked_value}")
if intent_key in standard_intent:
logger.info(f"已存在标准意图:{intent_key}")
continue
key = intent_key
# 跟标准进行重排序
std_pairs = [[key, std] for std in standard_intent]
try:
std_scores = self.reranker.compute_score(std_pairs, normalize=True)
if isinstance(std_scores, list):
max_sim = max(std_scores) if std_scores else 0.0
else:
max_sim = std_scores or 0.0
if max_sim > 0.9:
top_idx = std_scores.index(max_sim) if isinstance(std_scores, list) else 0
logger.info(f"key[{key}]与标准意图[{standard_intent[top_idx]}]相似度{max_sim:.3f}>0.9,跳过")
continue
except Exception as e:
logger.warning(f"标准意图重排序失败: {e},继续处理")
reranked_value_tmp: List[str] = []
for answers in reranked_value:
# 文本改写
answer = await text_extract.text_extract(answers)
reranked_value_tmp.append(answer)
reranked_value = reranked_value_tmp
#从milvus中获取
milvus_content = await intent_repo.get_content_by_intent_category(key)
#合并
merged = list(dict.fromkeys(reranked_value + milvus_content))
if len(merged) > 2:
# 重新排序
merged = await self.rerank_merged_intents(merged, key, top_k=1)
for content in merged:
insert_data = []
batch_embeddings = await self.embedding_client.aembed_documents([content])
if batch_embeddings:
insert_data.append({
"intent_category": key,
"content": content,
"content_vector": batch_embeddings[0]
})
if insert_data:
await intent_repo.upsert_intent_data(insert_data)
logger.info(f"key[{key}]入库完成upsert {len(insert_data)}")
logger.info(f"运行结束:{(time.time() - stime)}")
async def intent_message(self, coro_list: List[ArchiveMessages], intent_list:list[str]) -> str:
lines = [
f"{msg.created_at} {msg.from_role}{msg.content}"
for msg in coro_list
if not (msg.msgtype == "voice" and "[语音]" not in msg.content)
]
message_str = "\n".join(lines)
prompt_template = PromptTemplate(template=intent_prompt, input_variables=["message_str","intent_list"])
chain = prompt_template | llm
result = await chain.ainvoke({"message_str": message_str,"intent_list": str(intent_list)})
res_new = str(result.content) if hasattr(
result, 'content') else str(result)
return res_new
async def merge_intents_from_content(self, summary_list:list[str]) -> Dict[str, List[str]]:
merged: Dict[str, List[str]] = {}
for content in summary_list:
json_candidates = self._extract_jsons(content)
for json_str in json_candidates:
try:
data = json.loads(json_str)
qa_pairs = data.get("qa_pairs", [])
for pair in qa_pairs:
intent = pair.get("intents", "")
answer = pair.get("answer", "")
if intent and answer and len(answer) > 6:
if intent not in merged:
merged[intent] = []
merged[intent].append(answer)
except (json.JSONDecodeError, AttributeError):
continue
#logger.info(f"合并完成,共 {len(merged)} 个intent")
return merged
def _extract_jsons(self, content: str) -> List[str]:
results = []
start = 0
while start < len(content):
brace_start = content.find("{", start)
if brace_start == -1:
break
depth = 0
end = brace_start
for i in range(brace_start, len(content)):
if content[i] == "{":
depth += 1
elif content[i] == "}":
depth -= 1
if depth == 0:
end = i + 1
break
if end > brace_start:
results.append(content[brace_start:end])
start = end
else:
start = brace_start + 1
return results
def _calc_completeness_score(self, answer: str, query: str) -> float:
if not answer:
return 0.0
length_score = min(len(answer) / 50.0, 1.0)
punctuations = "。!?!?:;,"
punct_score = 1.0 if any(p in answer for p in punctuations) else 0.0
modal_particles = ["", "", "", "", "", "", ""]
modal_penalty = 0.3 if any(answer.rstrip().endswith(m) for m in modal_particles) else 0.0
unique_ratio = len(set(answer)) / max(len(answer), 1)
diversity_score = min(unique_ratio / 0.4, 1.0)
query_chars = set(query)
answer_chars = set(answer)
overlap = query_chars & answer_chars
keyword_score = min(len(overlap) / max(len(query_chars), 1), 1.0)
score = (
0.30 * length_score
+ 0.20 * punct_score
+ 0.20 * diversity_score
+ 0.20 * keyword_score
- 0.10 * modal_penalty
)
return max(0.0, min(score, 1.0))
def _filter_invalid_answers(self, answers: List[str]) -> List[str]:
modal_particles = ["", "", "", "", "", "", ""]
filtered = []
for a in answers:
if len(a) < 6:
continue
stripped = a.strip()
if not stripped:
continue
if len(stripped) <= 3 and any(stripped.endswith(m) for m in modal_particles):
continue
if len(set(stripped)) < max(2, len(stripped) // 3):
continue
filtered.append(a)
return filtered
async def rerank_merged_intents(self, intent_value: List[str], query: str, top_k: int = 2, max_rerank: int = 20) -> List[str]:
if not intent_value:
return []
filtered = self._filter_invalid_answers(intent_value)
if not filtered:
logger.warning(f"所有答案均被规则过滤,返回原数据前{top_k}")
return intent_value[:top_k]
sorted_by_len = sorted(filtered, key=lambda x: len(x), reverse=True)
rerank_candidates = sorted_by_len[:max_rerank]
if self.reranker is None:
logger.warning("reranker不可用直接返回前top_k条")
return rerank_candidates[:top_k]
pairs = [[query, answer] for answer in rerank_candidates]
try:
scores = self.reranker.compute_score(pairs, normalize=True)
except Exception as e:
logger.error(f"rerank失败: {e}直接返回前top_k条")
return rerank_candidates[:top_k]
if not isinstance(scores, list):
scores = [scores]
final_scores = []
for i, answer in enumerate(rerank_candidates):
rerank_score = scores[i] if i < len(scores) else 0.0
completeness_score = self._calc_completeness_score(answer, query)
fused = 0.7 * rerank_score + 0.3 * completeness_score
final_scores.append((answer, fused, rerank_score, completeness_score))
final_scores.sort(key=lambda x: x[1], reverse=True)
print(final_scores)
result = [item[0] for item in final_scores[:top_k]]
details = ", ".join(
f"'{a[:20]}...'(融合={f:.3f}, rerank={r:.3f},完整={c:.3f})"
for a, f, r, c in final_scores[:top_k]
)
logger.info(f"rerank完成: 过滤前{len(intent_value)}条→过滤后{len(filtered)}条→rerank{len(rerank_candidates)}条→top{top_k}: {details}")
return result
async def build(day:str):
logger.info(f"执行日期:{day}")
db_assistant_mysql_client_manager.init()
embedding_client.init()
milvus_client.init()
try:
async with db_assistant_mysql_client_manager.session_factory() as db_assistant:
archive_messages_repository = ArchiveMessagesRepository(db_assistant)
summary_repository = SummaryRepository()
reranker = get_reranker_model()
service = HistoryIntent(archive_messages_repository, embedding_client, summary_repository, reranker)
await service.get_message_summary(day)
# intent_value: List[str] = ['公司介绍','课程咨询','3D建模','3D场景建模','3D角色建模','2D原画','二次元风格','写实风格','动作/分镜','游戏动作/分镜','开发','UE引擎开发','3D大师课','GGAC绘殓课程','大学生扶持计划','兴趣/提升班','学习模式','线下面授教学','线上直播教学','寒暑假教学','提供服务','优势/竞争力','校区分布','退费咨询','住宿咨询','学不会怎么办','就业咨询','学历咨询']
# pairs = [["场景建模", answer] for answer in intent_value]
#
# scores = reranker.compute_score(pairs, normalize=True)
# print(scores)
finally:
await embedding_client.close()
await db_assistant_mysql_client_manager.close()
milvus_client.close()
if __name__ == '__main__':
start = datetime(2026, 7, 17)
end = datetime(2026, 7, 17)
result = []
temp = start
while temp <= end:
# 按 年-月-日 无补零格式输出
date_format = f"{temp.year}-{temp.month}-{temp.day}"
result.append(date_format)
temp += timedelta(days=1)
for date in result:
asyncio.run(build(date))

View File

@ -0,0 +1 @@
standard_intent:list[str] = ['公司介绍','课程咨询','3D建模','3D场景建模','3D角色建模','2D原画','二次元风格','写实风格','动作/分镜','游戏动作/分镜','开发','UE引擎开发','3D大师课','GGAC绘殓课程','大学生扶持计划','兴趣/提升班','学习模式','线下面授教学','线上直播教学','寒暑假教学','提供服务','优势/竞争力','校区分布','退费咨询','住宿咨询','学不会怎么办','就业咨询','学历咨询']

View File

@ -0,0 +1,107 @@
import json
from typing import List
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import PromptTemplate
from app.llm import llm
from app.repository.milvus.intent_classification_repository import IntentClassificationRepository
intent_prompt = """
你是一个专业的意图提取专家请分析以下文本内容并结合当前已有的意图标签判断是否符合若不符合则生成一个新意图符合则直接返回原意图
## 输入说明
- **文本内容**`{text}`
- **目前的意图**`{intent}`可能为空
## 核心规则
### 1. 意图提取与对比规则
- 首先从文本中提取1个核心意图提取方法见下方意图提取规则
- 将提取出的意图与给定的`{{intent}}`进行对比
- 若两者**完全相同**字符串完全一致则最终输出意图为原意图`{{intent}}`
- 若两者不同`{{intent}}`为空则最终输出意图为新提取的意图
- 无论采用原意图还是新意图都必须来自或可扩充自意图分类体系
### 2. 意图提取规则(用于从文本中提取核心意图)
- 若文本包含用户明确的问题提取其核心诉求对应的意图
- 若文本为销售介绍/陈述内容**聚焦于其最想传达的核心功能或核心信息**提取总结性意图**不得将补充说明信息如附带提到的地点时间等误判为核心意图**
- 例如文本为线上课程后期可以转到线下我们线下校区在上海核心是线上转线下这一功能而非校区分布因此应扩充为线上转线下而不是使用校区分布
- 意图必须基于实际内容不编造
- 仅提取1个意图标签
### 3. 单意图选择规则(关键)
**只能输出一个意图标签不能以顿号逗号或分隔多个意图**
当内容可能对应多个意图时按以下优先级选择唯一意图
| 优先级 | 选择规则 | 说明 |
|-------|---------|------|
| **P0** | **优先匹配已有意图标签** | 必须优先从已有意图体系含扩展示例中选择最匹配的标签**若已有标签中无准确对应则按扩充规则创建新标签严禁使用仅部分相关或语义模糊的已有标签** 当销售单方面介绍课程内容无用户明确提问直接使用对应的主题标签3D场景建模若主题为已有标签未覆盖的功能/服务线上转线下则应扩充新标签不得强行归类为校区分布 |
| **P1** | **核心诉求优先** | 选择最能代表文本核心诉求的意图**忽略次要的补充信息**例如文本线上课程可转线下校区在上海 核心是线上转线下而非校区分布报名流程说明 报名咨询而非线上直播教学 |
| **P2** | **主题维度优先于动作维度** | 当主题和动作诉求冲突时优先选择主题维度例如3D建模费用 3D建模主题 |
| **P3** | **直接提及优先于间接关联** | 文本明确说出的核心意图优先于推断出的意图 |
| **P4** | **去重合并** | 多个子诉求属于同一类别时只输出一个意图标签 |
### 4. 意图分类体系(必须从中选择或基于其扩充)
{intent_list}
**扩充规则** 当文本核心意图无法被已有标签准确覆盖时必须创建更精准的新标签创建时
- 新标签应简洁体现核心语义线上转线下
- 严格对比已有标签确保无重复语义
- **示例**文本线上课程后期可以转到线下 已有标签无对应扩充为线上转线下文本原画好就业吗 扩充为原画就业
### 5. 多意图处理
若同一段文本明确涉及多个不重叠的核心意图仅选择最能代表核心内容的1个意图
## 输出格式要求
- 输出纯JSON不要任何解释不要markdown标记不要注释
- 确保所有字符串用双引号包裹
- 输出格式固定为
{{
"intent": "最终的意图标签(原意图或新意图,只能有一个)"
}}
"""
from app.core.log import logger
class TextIntent:
async def intent_message(self, text:str, intent:str, intent_list:list[str]) -> str:
prompt_template = PromptTemplate(template=intent_prompt, input_variables=["text","intent","intent_list"])
chain = prompt_template | llm
result = await chain.ainvoke({"text": text,"intent":intent,"intent_list": str(intent_list)})
res_new = str(result.content) if hasattr(
result, 'content') else str(result)
intent:str=""
try:
data = json.loads(res_new)
intent = data.get("intent", "")
except (json.JSONDecodeError, AttributeError):
logger.info(f"无法解析JSON{res_new}")
return intent
def _extract_jsons(self, content: str) -> List[str]:
results = []
start = 0
while start < len(content):
brace_start = content.find("{", start)
if brace_start == -1:
break
depth = 0
end = brace_start
for i in range(brace_start, len(content)):
if content[i] == "{":
depth += 1
elif content[i] == "}":
depth -= 1
if depth == 0:
end = i + 1
break
if end > brace_start:
results.append(content[brace_start:end])
start = end
else:
start = brace_start + 1
return results

View File

@ -45,7 +45,7 @@ if __name__ == '__main__':
archive_messages_mysql_repository = ArchiveMessagesRepository(db_session)
qa_milvus_repository = QARepository()
state: QueryQAState = QueryQAState(original_query="老师现在建模课多少钱呢", history=['2026-07-10T15:42:13 EXTERNAL 老师现在建模课多少钱呢?\n'])
state: QueryQAState = QueryQAState(original_query="发票", history=['2026-07-10T15:42:13 EXTERNAL 我想学角色,我不知道能不能学,好不好学\n'])
context = QueryQAContext(meta_mysql_repository=archive_messages_mysql_repository,
qa_milvus_repository=qa_milvus_repository
)

View File

@ -7,8 +7,9 @@ from langchain_core.prompts import PromptTemplate
from app.chat_qa_query.query_qa_state import QueryQAState
from app.llm import llm
from app.core.log import logger
from app.repository.milvus.intent_classification_repository import IntentClassificationRepository
problem_rewriting_prompt = """
problem_rewriting_prompt1 = """
你是一名智能客服意图分类助手你的任务是根据用户的历史会话和当前问题提取用户的核心诉求并输出标准化的意图标签
## 核心原则(必须严格遵守)
@ -214,7 +215,201 @@ problem_rewriting_prompt = """
历史聊天{history_text}
请直接输出JSON
"""
problem_rewriting_prompt = """
你是一名智能客服意图分类助手你的任务是根据用户的历史会话和当前问题提取用户的核心诉求并输出标准化的意图标签
## 参数说明
本提示词需要动态传入以下参数
- {query}用户当前提问
- {history_text}历史会话记录可为空
- {intent_tags}完整的意图标签列表格式为JSON字符串数组例如["3D建模", "2D原画", "课程咨询", "就业咨询", ...]**所有输出的意图标签必须严格从该列表中选择不得自创或修改**
## 核心原则(必须严格遵守)
### 1. 双维度提取原则
每个用户问题通常包含两个维度的意图必须分别识别
- **课程/服务主题维度**用户想咨询的具体课程类型或业务主题
- **诉求/动作维度**用户想了解的方面费用就业难度等
这两个维度相互独立必须同时提取不得遗漏任何一个最终输出的标签本身不区分维度但模型内部需要判断每个标签的维度以便后续优先级排序见P3规则判断维度的方法若标签名称中包含咨询怎么办分布等特征词通常属于诉求/动作维度否则多属于主题维度请结合语义自行判断
### 2. 并列结构强制拆分
识别以下并列模式并强制拆分
- 顿号/逗号分隔"A、B、C" [A, B, C]
- "和/与/及/还有"连接"A和B" [A, B]
- 疑问词并列"多少钱、怎么报名、有就业吗" [多少钱, 怎么报名, 有就业吗]
**规则**拆分后每个子项必须独立分析不得合并
### 3. 意图识别置信度评分体系
每个意图标签在输出前必须经过置信度评分**只有总分60分的标签才会被输出**
#### 评分维度:
| 评分维度 | 分值 | 触发条件 | 说明 |
|---------|------|---------|------|
| **直接匹配** | **60** | 用户问题中直接出现标签关键词或同义词扩展词见第5节 | 关键词完全匹配或同义词匹配直接给60分超过阈值 |
| **语义关联** | **40** | 用户表达与标签语义高度相关通过语义场推断 | 语义场匹配给40分需配合上下文支持达到阈值 |
| **上下文支持** | **20** | 历史会话或当前上下文支持该意图推断 | 与语义关联配合40+20=60刚好达到阈值 |
| **历史补全** | **10** | 意图完全依赖历史会话补全 | 仅作为辅助加分不单独使用 |
#### 阈值判定规则:
- **总分 60** 进入候选池
- **总分 < 60** 直接过滤不进入候选池
- 无任何意图60 输出 `{{"intents": []}}`
### 4. 强制截断规则(新增)
**无论识别到多少个意图最终输出最多只返回2个意图标签**
当候选池中超过2个意图时按以下优先级排序只保留前2个
| 优先级 | 排序规则 | 说明 |
|-------|---------|------|
| **P1最高** | 当前问题直接明确表达的 > 历史会话补全的 | 用户当前明确说出的诉求优先 |
| **P2** | 总分高的 > 总分低的 | 置信度更高的优先 |
| **P3** | 诉求维度 > 主题维度 | 用户更关心做什么而非是什么 |
| **P4** | 出现顺序靠前的 > 出现顺序靠后的 | 用户先说的一般更关心 |
**截断规则**排序后只保留前2个其余全部丢弃
### 5. 语义同义词扩展匹配
匹配时不仅使用标签字面本身还必须覆盖常见的同义词扩展以下为常见标签的同义词映射表**当且仅当 {intent_tags} 中包含与下列标准标签完全同名的标签时才可使用该扩展**若传入的标签不在该表中则根据标签字面及通用语义进行匹配禁止编造扩展词
- **就业咨询**就业找工作工作推荐就业支持就业服务就业保障就业率包就业毕业安排工作分配推荐工作就业方向职业发展入职岗位推荐能就业吗好找工作吗就业情况就业数据薪资工资待遇收入水平
- **学不会怎么办**学不会跟不上没基础怕学不会能不能学能学会吗学得会吗适合我学吗零基础能学吗没经验能学吗纯小白能学吗好不好学难不难难吗简单吗容易学吗好学吗有难度吗门槛高吗有门槛吗担心自己学不会不知道能不能学会怕跟不上怕太难上手难学不进听不懂理解不了
- **课程咨询**多少钱费用价格学费学什么内容课程安排怎么收费贵不贵收费标准分期付款优惠折扣教什么学什么内容课程体系教学大纲课程表怎么报名报名流程入学流程报名需要什么报名条件上课时间上课周期学多久多长时间几个月课时开班时间什么时候开课有班级吗还能报名吗
- **退费咨询**退费退款退钱退款政策不学了能退吗中途退学退课退费规则退费流程能退多少退款比例退费条件不想学了怎么办
- **住宿咨询**住宿宿舍包住住哪住宿条件有宿舍吗包住宿吗住宿环境住宿费房租住校外宿租房住宿安排住宿费用
- **学历咨询**学历文凭证书学位毕业证大专本科学历认证学历证书学位认证
- **校区分布**校区地址在哪位置分布哪个城市怎么去交通附近
- **优势/竞争力**优势竞争力为什么选你们和其他机构比口碑排名实力师资教学质量
若传入的标签列表包含上述标签以外的标签同义词匹配将仅基于标签文字及通用语义不得臆造扩展
### 6. 信息补全与语义保持
- 如果当前问题缺少主语从历史会话补全
- 不得改变用户原始意图不得引入历史会话中不存在的新信息
- 不得遗漏当前问题中的任何关键诉求
## 输出格式
- 仅输出JSON不要任何解释性文字
- **最多只返回2个意图标签**超出部分强制截断
- 所有标签必须来自传入的 {intent_tags} 列表
- 输出格式示例`{{"intents": ["标签1", "标签2"]}}` 若候选为空则 `{{"intents": []}}`
## 分析流程(必须按顺序执行)
### 步骤1并列拆分
将当前问题拆分为独立的子诉求
### 步骤2双维度扫描 + 置信度评分
对每个子诉求扫描 {intent_tags} 中的标签按照第3节和第5节计算总分每个意图独立评分
### 步骤3阈值过滤
只保留总分60分的意图进入候选池
### 步骤4优先级排序当候选池超过2个时
按P1P2P3P4规则排序
### 步骤5强制截断
只保留排序后的前2个意图标签
### 步骤6完整性自检输出前必须执行
- [ ] 每个子诉求是否都经过了置信度评分
- [ ] 是否有总分60分的意图被遗漏
- [ ] 最终输出是否2个标签
- [ ] 截断时是否按优先级规则执行
- [ ] 所有输出的标签是否都在 {intent_tags}
如果任何一项为重新分析
## 分类示例假设传入标签列表包含3D建模、3D角色建模、课程咨询、就业咨询、住宿咨询、退费咨询、学不会怎么办
### 示例12个意图无需截断
历史会话
当前问题3D建模培训的课程内容有哪些
分析
- 主题维度"3D建模" 直接匹配 = 60 候选池
- 诉求维度"课程内容" 直接匹配"课程咨询" = 60 候选池
- 候选池共2个无需截断
分类结果
{{"intents": ["3D建模", "课程咨询"]}}
### 示例2并列拆分 + 阈值过滤 + 截断)
历史会话
当前问题流程费用和就业支持
分析
- 拆分[流程, 费用, 就业支持]
- "流程" 直接匹配"课程咨询" = 60 候选池
- "费用" 直接匹配"课程咨询" = 60 候选池去重
- "就业支持" 直接匹配"就业咨询" = 60 候选池
- 候选池["课程咨询", "就业咨询"]共2个无需截断
分类结果
{{"intents": ["课程咨询", "就业咨询"]}}
### 示例33个意图强制截断为2个
历史会话
当前问题我想学角色不知道能不能学好不好学
分析
- 主题维度"学角色" 直接匹配"3D角色建模" = 60 候选池
- 诉求维度1"想学" 直接匹配"课程咨询" = 60 候选池
- 诉求维度2"不知道能不能学" 语义关联(40) + 上下文支持(20) = 60 候选池
- 诉求维度3"好不好学" 语义关联(40) + 上下文支持(20) = 60 候选池与上同标签去重
- 候选池["3D角色建模", "课程咨询", "学不会怎么办"]共3个需截断
- 排序课程咨询(P1当前直接) > 学不会怎么办(P1当前直接) > 3D角色建模(P3主题维度)
- 截断后保留前2个
分类结果
{{"intents": ["课程咨询", "学不会怎么办"]}}
### 示例45个意图强制截断为2个
历史会话
当前问题3D建模费用就业住宿退费
分析
- "3D建模" 直接匹配 = 60 候选池
- "费用" 直接匹配"课程咨询" = 60 候选池
- "就业" 直接匹配"就业咨询" = 60 候选池
- "住宿" 直接匹配"住宿咨询" = 60 候选池
- "退费" 直接匹配"退费咨询" = 60 候选池
- 候选池共5个需截断
- 排序课程咨询(P1当前直接,出现第2) > 就业咨询(P1当前直接,出现第3) > 住宿咨询 > 退费咨询 > 3D建模(P3主题维度)
- 截断后保留前2个
分类结果
{{"intents": ["课程咨询", "就业咨询"]}}
### 示例5历史补全被过滤 + 截断)
历史会话
- 用户我想学3D建模
当前问题多少钱就业情况住宿条件
分析
- 主题维度"3D建模"历史补全 = 10 < 60 不进入候选池
- "多少钱" 直接匹配"课程咨询" = 60 候选池
- "就业情况" 直接匹配"就业咨询" = 60 候选池
- "住宿条件" 直接匹配"住宿咨询" = 60 候选池
- 候选池["课程咨询", "就业咨询", "住宿咨询"]共3个需截断
- 排序课程咨询(P1,出现第1) > 就业咨询(P1,出现第2) > 住宿咨询(P1,出现第3)
- 截断后保留前2个
分类结果
{{"intents": ["课程咨询", "就业咨询"]}}
### 示例6语义关联不足60分全部被过滤
历史会话
当前问题这个课有意思吗
分析
- "有意思" 语义关联"课程咨询" = 40无上下文支持 = 0总分40分 < 60 不进入候选池
- 候选池为空
分类结果
{{"intents": []}}
---
## 现在请处理以下输入
当前问题{query}
历史聊天{history_text}
完整标签列表{intent_tags}
请直接输出JSON
"""
async def rewrite_query(state:QueryQAState):
"""
问题改写
@ -224,12 +419,13 @@ async def rewrite_query(state:QueryQAState):
"""
history_text = state.get("history", "")
query = state.get("original_query", "")
prompt = PromptTemplate(template=problem_rewriting_prompt, input_variables=["history_text", "query"])
prompt = PromptTemplate(template=problem_rewriting_prompt, input_variables=["history_text", "query","intent_tags"])
output = JsonOutputParser()
chain = prompt | llm | output
intent_repo = IntentClassificationRepository()
try:
result = await chain.ainvoke({"history_text": history_text, "query": query})
result = await chain.ainvoke({"history_text": history_text, "query": query, "intent_tags":str(await intent_repo.get_style_intent_categories())})
rewritten_query = result.get("intents", "")
logger.info(f"原问题:{query},历史会话:{history_text},改写后问题:{rewritten_query}")
return {"rewritten_query": rewritten_query}

View File

@ -68,6 +68,11 @@ class RedisConfig:
db: int = 0
decode_responses: bool = True
@dataclass
class RerankerConfig:
model_path: str
use_fp16: bool = True
@dataclass
class AppConfig:
logging: LoggingConfig
@ -77,12 +82,17 @@ class AppConfig:
milvus: MilvusConfig
asr: ASRConfig
redis: RedisConfig
reranker: RerankerConfig
config_file = Path(__file__).parents[2] / 'conf' / 'app_config.yaml'
project_root = Path(__file__).parents[2]
context = OmegaConf.load(config_file)
schema = OmegaConf.structured(AppConfig)
app_config: AppConfig = OmegaConf.to_object(OmegaConf.merge(schema, context))
if not Path(app_config.reranker.model_path).is_absolute():
app_config.reranker.model_path = str(project_root / app_config.reranker.model_path)
if __name__ == '__main__':
print(app_config.db_assistant.host)

View File

@ -77,6 +77,49 @@ class IntentClassificationRepository:
logger.error(f"意图分类数据入库失败: {str(e)}", exc_info=True)
return []
async def upsert_intent_data(self, data: List[Dict[str, Any]]) -> List[int]:
try:
created_at = int(datetime.now().timestamp())
all_ids = []
for item in data:
category = item.get("intent_category", "")
content = item.get("content", "")
vector = item.get("content_vector", [])
escaped_content = content.replace("\\", "\\\\").replace('"', '\\"')
expr = f'intent_category == "{category}"'
existing = milvus_client.client.query(
collection_name=self.collection_name,
filter=expr,
output_fields=["id"],
limit=1000
)
if existing:
existing_ids = [e["id"] for e in existing]
milvus_client.client.delete(
collection_name=self.collection_name,
ids=existing_ids
)
logger.info(f"删除旧记录 {len(existing_ids)} 条: category={category}")
insert_result = milvus_client.client.insert(
collection_name=self.collection_name,
data=[{
"intent_category": category,
"content": content,
"content_vector": vector,
"created_at": created_at
}]
)
all_ids.extend(insert_result.get("ids", []))
logger.info(f"意图分类数据upsert完成共处理 {len(data)}")
return all_ids
except Exception as e:
logger.error(f"意图分类数据upsert失败: {str(e)}", exc_info=True)
return []
async def search_by_intent_category(self, intent_category: List[str], top_k: int = 100) -> List[Dict[str, Any]]:
if isinstance(intent_category, str):
intent_category = [intent_category]
@ -126,4 +169,46 @@ class IntentClassificationRepository:
async def get_style_intent_categories(self) -> List[str]:
all_categories = await self.get_all_intent_categories()
return [c for c in all_categories if "风格" in c]
return [c for c in all_categories]
async def get_content_by_intent_category(self, intent_category: str) -> List[str]:
result = milvus_client.client.query(
collection_name=self.collection_name,
filter=f'intent_category == "{intent_category}"',
output_fields=["content"],
limit=1000
)
return [item.get("content", "") for item in result if item.get("content")]
async def delete_today_data(self) -> int:
today_start = int(datetime.now().replace(hour=0, minute=0, second=0, microsecond=0).timestamp())
query_result = milvus_client.client.query(
collection_name=self.collection_name,
filter=f'created_at >= {today_start}',
output_fields=["id"],
limit=10000
)
if not query_result:
logger.info("今日无数据可删除")
return 0
ids_to_delete = [item["id"] for item in query_result]
milvus_client.client.delete(
collection_name=self.collection_name,
ids=ids_to_delete
)
logger.info(f"删除今日数据 {len(ids_to_delete)}")
return len(ids_to_delete)
async def main():
milvus_client.init()
repo = IntentClassificationRepository()
style_categories = await repo.delete_today_data()
print(style_categories)
if __name__ == "__main__":
# today_start = int(datetime.now().replace(hour=0, minute=0, second=0, microsecond=0).timestamp())
# print(today_start)
import asyncio
asyncio.run(main())

24
app/reranker_utils.py Normal file
View File

@ -0,0 +1,24 @@
from FlagEmbedding import FlagReranker
from app.conf.app_config import app_config
_reranker_model = None
_reranker_init_failed = False
def get_reranker_model():
global _reranker_model, _reranker_init_failed
if _reranker_init_failed:
return None
if _reranker_model is None:
try:
_reranker_model = FlagReranker(
model_name_or_path=app_config.reranker.model_path,
device="cuda:0",
use_fp16=app_config.reranker.use_fp16
)
except Exception as e:
_reranker_init_failed = True
print(f"[WARNING] reranker初始化失败将跳过rerank: {e}")
return None
return _reranker_model

View File

@ -45,3 +45,7 @@ redis:
password: ""
db: 0
decode_responses: true
reranker:
model_path: "app/models/BAAI/bge-reranker-large"
use_fp16: true

View File

@ -9,7 +9,8 @@ dependencies = [
"asyncmy>=0.2.11",
"cryptography>=48.0.1",
"fastapi[standard]>=0.136.3",
"huggingface-hub>=1.18.0",
"flagembedding>=1.4.0",
"huggingface-hub<1.0",
"jieba>=0.42.1",
"langchain>=1.3.7",
"langchain-deepseek>=1.1.0",
@ -25,4 +26,5 @@ dependencies = [
"scikit-learn>=1.9.0",
"sentence-transformers>=5.6.0",
"sqlalchemy>=2.0.50",
"transformers>=4.44.2,<4.45",
]

2323
uv.lock generated

File diff suppressed because it is too large Load Diff