feat: enrich question insights classification
This commit is contained in:
@@ -1584,8 +1584,13 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
|
||||
</div>
|
||||
</header>
|
||||
<div class="question-insight-tags">
|
||||
<el-tag size="small" effect="plain">{{ cluster.categoryLabel }}</el-tag>
|
||||
<el-tag v-if="cluster.needsKnowledgeFollowUp" size="small" type="warning" effect="plain">需补知识/查召回</el-tag>
|
||||
<el-tag v-if="cluster.noHitCount" size="small" type="danger" effect="plain">无命中 {{ cluster.noHitCount }}</el-tag>
|
||||
<el-tag v-if="cluster.failedCount" size="small" type="danger" effect="dark">失败 {{ cluster.failedCount }}</el-tag>
|
||||
<el-tag v-for="term in cluster.topTerms" :key="term" size="small" type="success" effect="plain">{{ term }}</el-tag>
|
||||
</div>
|
||||
<p class="question-insight-action">{{ cluster.suggestedAction }}</p>
|
||||
<el-collapse>
|
||||
<el-collapse-item title="查看相似问法和原始样例" :name="cluster.normalized">
|
||||
<div class="question-variants">
|
||||
|
||||
@@ -1956,6 +1956,16 @@ textarea {
|
||||
margin: 12px 0;
|
||||
}
|
||||
|
||||
.question-insight-action {
|
||||
margin: 0 0 12px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 10px;
|
||||
background: #f6faf8;
|
||||
color: #50655e;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.question-variants {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
||||
@@ -567,9 +567,16 @@ export interface QuestionInsightCluster {
|
||||
rank: number;
|
||||
title: string;
|
||||
normalized: string;
|
||||
category: string;
|
||||
categoryLabel: string;
|
||||
count: number;
|
||||
userCount: number;
|
||||
sessionCount: number;
|
||||
aiRequestCount: number;
|
||||
noHitCount: number;
|
||||
failedCount: number;
|
||||
needsKnowledgeFollowUp: boolean;
|
||||
suggestedAction: string;
|
||||
firstSeenAt: string;
|
||||
lastSeenAt: string;
|
||||
topTerms: string[];
|
||||
|
||||
@@ -11,6 +11,7 @@ from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.chat import ChatMessage, ChatSession
|
||||
from app.models.logs import AiRequestLog
|
||||
from app.models.user import User
|
||||
|
||||
|
||||
@@ -116,6 +117,7 @@ class QuestionInsightService:
|
||||
messages = _load_user_messages(db, date_from=date_from, date_to=date_to, limit=max_messages)
|
||||
cleaned, filtered_count = _clean_messages(messages)
|
||||
clusters = _cluster_questions(cleaned)
|
||||
ai_logs = _load_ai_logs(db, date_from=date_from, date_to=date_to, limit=max_messages)
|
||||
visible_clusters = [cluster for cluster in clusters if len(cluster.questions) >= min_count]
|
||||
visible_clusters.sort(key=lambda item: (len(item.questions), item.questions[-1].created_at), reverse=True)
|
||||
|
||||
@@ -137,7 +139,7 @@ class QuestionInsightService:
|
||||
"visibleClusterCount": total,
|
||||
"minCount": min_count,
|
||||
},
|
||||
"items": [_cluster_dict(index + offset + 1, cluster) for index, cluster in enumerate(page_clusters)],
|
||||
"items": [_cluster_dict(index + offset + 1, cluster, ai_logs) for index, cluster in enumerate(page_clusters)],
|
||||
"total": total,
|
||||
"page": page,
|
||||
"pageSize": page_size,
|
||||
@@ -168,6 +170,21 @@ def _load_user_messages(
|
||||
)
|
||||
|
||||
|
||||
def _load_ai_logs(
|
||||
db: Session,
|
||||
*,
|
||||
date_from: datetime | None,
|
||||
date_to: datetime | None,
|
||||
limit: int,
|
||||
) -> list[AiRequestLog]:
|
||||
query = select(AiRequestLog)
|
||||
if date_from is not None:
|
||||
query = query.where(AiRequestLog.created_at >= date_from.replace(tzinfo=None))
|
||||
if date_to is not None:
|
||||
query = query.where(AiRequestLog.created_at <= date_to.replace(tzinfo=None))
|
||||
return list(db.scalars(query.order_by(AiRequestLog.created_at.desc(), AiRequestLog.id.desc()).limit(limit)).all())
|
||||
|
||||
|
||||
def _clean_messages(messages: Iterable[tuple[ChatMessage, ChatSession | None, User | None]]) -> tuple[list[CleanedQuestion], int]:
|
||||
cleaned: list[CleanedQuestion] = []
|
||||
filtered_count = 0
|
||||
@@ -317,19 +334,30 @@ def _looks_more_question_like(candidate: str, current: str) -> bool:
|
||||
return any(marker in candidate for marker in markers) and not any(marker in current for marker in markers)
|
||||
|
||||
|
||||
def _cluster_dict(rank: int, cluster: QuestionCluster) -> dict:
|
||||
def _cluster_dict(rank: int, cluster: QuestionCluster, ai_logs: list[AiRequestLog]) -> dict:
|
||||
questions = sorted(cluster.questions, key=lambda item: item.created_at, reverse=True)
|
||||
users = {item.user_id for item in questions}
|
||||
sessions = {item.session_id for item in questions}
|
||||
variants = Counter(item.text for item in questions).most_common(6)
|
||||
terms = _top_terms(questions)
|
||||
category, category_label = _classify_cluster(cluster)
|
||||
related_logs = _related_ai_logs(cluster, ai_logs)
|
||||
no_hit_count = sum(1 for item in related_logs if not item.knowledge_hit)
|
||||
failed_count = sum(1 for item in related_logs if item.status != "SUCCESS")
|
||||
return {
|
||||
"rank": rank,
|
||||
"title": cluster.title,
|
||||
"normalized": cluster.normalized,
|
||||
"category": category,
|
||||
"categoryLabel": category_label,
|
||||
"count": len(questions),
|
||||
"userCount": len(users),
|
||||
"sessionCount": len(sessions),
|
||||
"aiRequestCount": len(related_logs),
|
||||
"noHitCount": no_hit_count,
|
||||
"failedCount": failed_count,
|
||||
"needsKnowledgeFollowUp": _needs_knowledge_follow_up(category, no_hit_count, failed_count),
|
||||
"suggestedAction": _suggested_action(category, no_hit_count, failed_count),
|
||||
"firstSeenAt": min(item.created_at for item in questions),
|
||||
"lastSeenAt": max(item.created_at for item in questions),
|
||||
"topTerms": terms,
|
||||
@@ -357,3 +385,65 @@ def _top_terms(questions: list[CleanedQuestion]) -> list[str]:
|
||||
if len(token) >= 2 and not token.isdigit():
|
||||
counter[token] += 1
|
||||
return [term for term, _count in counter.most_common(8)]
|
||||
|
||||
|
||||
def _classify_cluster(cluster: QuestionCluster) -> tuple[str, str]:
|
||||
text = f"{cluster.title}{cluster.normalized}"
|
||||
rules = (
|
||||
("fixed_info", "固定信息", ("上课安排", "回放", "会议链接", "课程助理", "时间", "链接", "权益", "联系方式", "安排")),
|
||||
("homework", "功课操作", ("功课", "练习", "作业", "怎么做", "步骤", "操作")),
|
||||
("course_knowledge", "课程知识", ("是什么", "区别", "意思", "概念", "课程", "知识")),
|
||||
("emotion", "情绪梳理", ("情绪", "身体", "感受", "害怕", "愤怒", "委屈", "抗拒", "焦虑", "释放")),
|
||||
("service", "服务权益", ("费用", "退款", "续费", "权益", "名额", "有效期", "购买")),
|
||||
)
|
||||
for key, label, keywords in rules:
|
||||
if any(keyword in text for keyword in keywords):
|
||||
return key, label
|
||||
return "other", "其他问题"
|
||||
|
||||
|
||||
def _related_ai_logs(cluster: QuestionCluster, ai_logs: list[AiRequestLog]) -> list[AiRequestLog]:
|
||||
session_ids = {item.session_id for item in cluster.questions}
|
||||
matched: list[AiRequestLog] = []
|
||||
for log in ai_logs:
|
||||
if log.session_id not in session_ids:
|
||||
continue
|
||||
prompt = _normalize_question(_prompt_question_text(log.prompt or ""))
|
||||
if not prompt:
|
||||
continue
|
||||
score = max(SequenceMatcher(None, cluster.normalized, prompt).ratio(), _containment(cluster.normalized, prompt))
|
||||
if score >= 0.42 or cluster.normalized in prompt:
|
||||
matched.append(log)
|
||||
return matched
|
||||
|
||||
|
||||
def _prompt_question_text(prompt: str) -> str:
|
||||
lines = [line.strip() for line in prompt.splitlines() if line.strip()]
|
||||
for line in lines[:12]:
|
||||
if line.startswith("用户问题") or line.startswith("问题") or line.startswith("用户提问"):
|
||||
return line
|
||||
return "\n".join(lines[:4])
|
||||
|
||||
|
||||
def _needs_knowledge_follow_up(category: str, no_hit_count: int, failed_count: int) -> bool:
|
||||
if failed_count > 0:
|
||||
return True
|
||||
if no_hit_count <= 0:
|
||||
return False
|
||||
return category in {"fixed_info", "homework", "course_knowledge", "service"}
|
||||
|
||||
|
||||
def _suggested_action(category: str, no_hit_count: int, failed_count: int) -> str:
|
||||
if failed_count > 0:
|
||||
return "优先查看 AI 请求失败原因,确认模型或工具链是否异常。"
|
||||
if no_hit_count > 0 and category == "fixed_info":
|
||||
return "建议优先补充或更新固定信息类知识库,并确认该库处于开放状态。"
|
||||
if no_hit_count > 0 and category in {"homework", "course_knowledge"}:
|
||||
return "建议检查课程知识库切片和召回结果,必要时补充课程章节或关键词。"
|
||||
if no_hit_count > 0:
|
||||
return "建议抽样查看原始对话,判断是否需要补知识库或优化无命中回答。"
|
||||
if category == "emotion":
|
||||
return "建议抽样检查回答是否回到当下、身体感受和觉察方向,避免建议过深过多。"
|
||||
if category == "service":
|
||||
return "建议确认服务权益和运营说明是否已有固定信息沉淀。"
|
||||
return "建议抽样查看相似问法,判断是否需要沉淀成知识库补充项。"
|
||||
|
||||
@@ -126,6 +126,7 @@ def test_question_insights_clean_and_cluster_similar_user_questions():
|
||||
ChatMessage(id=5, session_id=1, user_id=1, role="user", content="1、回放在哪里看?\n2、上课链接在哪", created_at=now + timedelta(minutes=3)),
|
||||
]
|
||||
)
|
||||
db.add(AiRequestLog(session_id=1, user_id=1, status="SUCCESS", prompt="用户问题:心光有哪些作业?", knowledge_hit=0))
|
||||
db.commit()
|
||||
|
||||
response = question_insights(
|
||||
@@ -147,3 +148,6 @@ def test_question_insights_clean_and_cluster_similar_user_questions():
|
||||
assert data["items"][0]["count"] == 2
|
||||
assert "心光" in data["items"][0]["title"]
|
||||
assert data["items"][0]["userCount"] == 2
|
||||
assert data["items"][0]["category"] == "homework"
|
||||
assert data["items"][0]["noHitCount"] == 1
|
||||
assert data["items"][0]["needsKnowledgeFollowUp"] is True
|
||||
|
||||
Reference in New Issue
Block a user