feat: 意图识别优化
This commit is contained in:
parent
0bc6b76508
commit
71a169a8d3
43
app/chat_qa/classification/general_text_extract.py
Normal file
43
app/chat_qa/classification/general_text_extract.py
Normal 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
|
||||||
455
app/chat_qa/classification/history_intent.py
Normal file
455
app/chat_qa/classification/history_intent.py
Normal 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/false,null 是小写的
|
||||||
|
- 如果键名没有引号,给键名加上双引号
|
||||||
|
|
||||||
|
## 输出格式
|
||||||
|
{{
|
||||||
|
"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))
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
1
app/chat_qa/classification/standard_intent.py
Normal file
1
app/chat_qa/classification/standard_intent.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
standard_intent:list[str] = ['公司介绍','课程咨询','3D建模','3D场景建模','3D角色建模','2D原画','二次元风格','写实风格','动作/分镜','游戏动作/分镜','开发','UE引擎开发','3D大师课','GGAC绘殓课程','大学生扶持计划','兴趣/提升班','学习模式','线下面授教学','线上直播教学','寒暑假教学','提供服务','优势/竞争力','校区分布','退费咨询','住宿咨询','学不会怎么办','就业咨询','学历咨询']
|
||||||
107
app/chat_qa/classification/text_intent.py
Normal file
107
app/chat_qa/classification/text_intent.py
Normal 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
|
||||||
@ -45,7 +45,7 @@ if __name__ == '__main__':
|
|||||||
archive_messages_mysql_repository = ArchiveMessagesRepository(db_session)
|
archive_messages_mysql_repository = ArchiveMessagesRepository(db_session)
|
||||||
qa_milvus_repository = QARepository()
|
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,
|
context = QueryQAContext(meta_mysql_repository=archive_messages_mysql_repository,
|
||||||
qa_milvus_repository=qa_milvus_repository
|
qa_milvus_repository=qa_milvus_repository
|
||||||
)
|
)
|
||||||
|
|||||||
@ -7,8 +7,9 @@ from langchain_core.prompts import PromptTemplate
|
|||||||
from app.chat_qa_query.query_qa_state import QueryQAState
|
from app.chat_qa_query.query_qa_state import QueryQAState
|
||||||
from app.llm import llm
|
from app.llm import llm
|
||||||
from app.core.log import logger
|
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}
|
历史聊天:{history_text}
|
||||||
请直接输出JSON:
|
请直接输出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个时)
|
||||||
|
按P1→P2→P3→P4规则排序。
|
||||||
|
|
||||||
|
### 步骤5:强制截断
|
||||||
|
只保留排序后的前2个意图标签。
|
||||||
|
|
||||||
|
### 步骤6:完整性自检(输出前必须执行)
|
||||||
|
- [ ] 每个子诉求是否都经过了置信度评分?
|
||||||
|
- [ ] 是否有总分≥60分的意图被遗漏?
|
||||||
|
- [ ] 最终输出是否≤2个标签?
|
||||||
|
- [ ] 截断时是否按优先级规则执行?
|
||||||
|
- [ ] 所有输出的标签是否都在 {intent_tags} 中?
|
||||||
|
|
||||||
|
如果任何一项为“否”,重新分析。
|
||||||
|
|
||||||
|
## 分类示例(假设传入标签列表包含:3D建模、3D角色建模、课程咨询、就业咨询、住宿咨询、退费咨询、学不会怎么办)
|
||||||
|
|
||||||
|
### 示例1(2个意图,无需截断)
|
||||||
|
历史会话:(空)
|
||||||
|
当前问题:3D建模培训的课程内容有哪些?
|
||||||
|
分析:
|
||||||
|
- 主题维度:"3D建模" → 直接匹配 = 60分 → 候选池
|
||||||
|
- 诉求维度:"课程内容" → 直接匹配"课程咨询" = 60分 → 候选池
|
||||||
|
- 候选池共2个,无需截断
|
||||||
|
分类结果:
|
||||||
|
{{"intents": ["3D建模", "课程咨询"]}}
|
||||||
|
|
||||||
|
### 示例2(并列拆分 + 阈值过滤 + 截断)
|
||||||
|
历史会话:(空)
|
||||||
|
当前问题:流程、费用和就业支持
|
||||||
|
分析:
|
||||||
|
- 拆分:[流程, 费用, 就业支持]
|
||||||
|
- "流程" → 直接匹配"课程咨询" = 60分 → 候选池
|
||||||
|
- "费用" → 直接匹配"课程咨询" = 60分 → 候选池(去重)
|
||||||
|
- "就业支持" → 直接匹配"就业咨询" = 60分 → 候选池
|
||||||
|
- 候选池:["课程咨询", "就业咨询"],共2个,无需截断
|
||||||
|
分类结果:
|
||||||
|
{{"intents": ["课程咨询", "就业咨询"]}}
|
||||||
|
|
||||||
|
### 示例3(3个意图,强制截断为2个)
|
||||||
|
历史会话:(空)
|
||||||
|
当前问题:我想学角色,不知道能不能学,好不好学
|
||||||
|
分析:
|
||||||
|
- 主题维度:"学角色" → 直接匹配"3D角色建模" = 60分 → 候选池
|
||||||
|
- 诉求维度1:"想学" → 直接匹配"课程咨询" = 60分 → 候选池
|
||||||
|
- 诉求维度2:"不知道能不能学" → 语义关联(40) + 上下文支持(20) = 60分 → 候选池
|
||||||
|
- 诉求维度3:"好不好学" → 语义关联(40) + 上下文支持(20) = 60分 → 候选池(与上同标签,去重)
|
||||||
|
- 候选池:["3D角色建模", "课程咨询", "学不会怎么办"],共3个,需截断
|
||||||
|
- 排序:课程咨询(P1当前直接) > 学不会怎么办(P1当前直接) > 3D角色建模(P3主题维度)
|
||||||
|
- 截断后保留前2个
|
||||||
|
分类结果:
|
||||||
|
{{"intents": ["课程咨询", "学不会怎么办"]}}
|
||||||
|
|
||||||
|
### 示例4(5个意图,强制截断为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):
|
async def rewrite_query(state:QueryQAState):
|
||||||
"""
|
"""
|
||||||
问题改写
|
问题改写
|
||||||
@ -224,12 +419,13 @@ async def rewrite_query(state:QueryQAState):
|
|||||||
"""
|
"""
|
||||||
history_text = state.get("history", "")
|
history_text = state.get("history", "")
|
||||||
query = state.get("original_query", "")
|
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()
|
output = JsonOutputParser()
|
||||||
chain = prompt | llm | output
|
chain = prompt | llm | output
|
||||||
|
intent_repo = IntentClassificationRepository()
|
||||||
|
|
||||||
try:
|
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", "")
|
rewritten_query = result.get("intents", "")
|
||||||
logger.info(f"原问题:{query},历史会话:{history_text},改写后问题:{rewritten_query}")
|
logger.info(f"原问题:{query},历史会话:{history_text},改写后问题:{rewritten_query}")
|
||||||
return {"rewritten_query": rewritten_query}
|
return {"rewritten_query": rewritten_query}
|
||||||
|
|||||||
@ -68,6 +68,11 @@ class RedisConfig:
|
|||||||
db: int = 0
|
db: int = 0
|
||||||
decode_responses: bool = True
|
decode_responses: bool = True
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RerankerConfig:
|
||||||
|
model_path: str
|
||||||
|
use_fp16: bool = True
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class AppConfig:
|
class AppConfig:
|
||||||
logging: LoggingConfig
|
logging: LoggingConfig
|
||||||
@ -77,12 +82,17 @@ class AppConfig:
|
|||||||
milvus: MilvusConfig
|
milvus: MilvusConfig
|
||||||
asr: ASRConfig
|
asr: ASRConfig
|
||||||
redis: RedisConfig
|
redis: RedisConfig
|
||||||
|
reranker: RerankerConfig
|
||||||
|
|
||||||
|
|
||||||
config_file = Path(__file__).parents[2] / 'conf' / 'app_config.yaml'
|
config_file = Path(__file__).parents[2] / 'conf' / 'app_config.yaml'
|
||||||
|
project_root = Path(__file__).parents[2]
|
||||||
context = OmegaConf.load(config_file)
|
context = OmegaConf.load(config_file)
|
||||||
schema = OmegaConf.structured(AppConfig)
|
schema = OmegaConf.structured(AppConfig)
|
||||||
app_config: AppConfig = OmegaConf.to_object(OmegaConf.merge(schema, context))
|
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__':
|
if __name__ == '__main__':
|
||||||
print(app_config.db_assistant.host)
|
print(app_config.db_assistant.host)
|
||||||
|
|||||||
@ -77,6 +77,49 @@ class IntentClassificationRepository:
|
|||||||
logger.error(f"意图分类数据入库失败: {str(e)}", exc_info=True)
|
logger.error(f"意图分类数据入库失败: {str(e)}", exc_info=True)
|
||||||
return []
|
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]]:
|
async def search_by_intent_category(self, intent_category: List[str], top_k: int = 100) -> List[Dict[str, Any]]:
|
||||||
if isinstance(intent_category, str):
|
if isinstance(intent_category, str):
|
||||||
intent_category = [intent_category]
|
intent_category = [intent_category]
|
||||||
@ -126,4 +169,46 @@ class IntentClassificationRepository:
|
|||||||
|
|
||||||
async def get_style_intent_categories(self) -> List[str]:
|
async def get_style_intent_categories(self) -> List[str]:
|
||||||
all_categories = await self.get_all_intent_categories()
|
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
24
app/reranker_utils.py
Normal 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
|
||||||
@ -45,3 +45,7 @@ redis:
|
|||||||
password: ""
|
password: ""
|
||||||
db: 0
|
db: 0
|
||||||
decode_responses: true
|
decode_responses: true
|
||||||
|
|
||||||
|
reranker:
|
||||||
|
model_path: "app/models/BAAI/bge-reranker-large"
|
||||||
|
use_fp16: true
|
||||||
|
|||||||
@ -9,7 +9,8 @@ dependencies = [
|
|||||||
"asyncmy>=0.2.11",
|
"asyncmy>=0.2.11",
|
||||||
"cryptography>=48.0.1",
|
"cryptography>=48.0.1",
|
||||||
"fastapi[standard]>=0.136.3",
|
"fastapi[standard]>=0.136.3",
|
||||||
"huggingface-hub>=1.18.0",
|
"flagembedding>=1.4.0",
|
||||||
|
"huggingface-hub<1.0",
|
||||||
"jieba>=0.42.1",
|
"jieba>=0.42.1",
|
||||||
"langchain>=1.3.7",
|
"langchain>=1.3.7",
|
||||||
"langchain-deepseek>=1.1.0",
|
"langchain-deepseek>=1.1.0",
|
||||||
@ -25,4 +26,5 @@ dependencies = [
|
|||||||
"scikit-learn>=1.9.0",
|
"scikit-learn>=1.9.0",
|
||||||
"sentence-transformers>=5.6.0",
|
"sentence-transformers>=5.6.0",
|
||||||
"sqlalchemy>=2.0.50",
|
"sqlalchemy>=2.0.50",
|
||||||
|
"transformers>=4.44.2,<4.45",
|
||||||
]
|
]
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user