sales-assistant-py-new/app/chat_qa/classification/history_intent.py

456 lines
21 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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))