feat: enrich question insights classification

This commit is contained in:
2026-07-31 16:40:13 +08:00
parent 99e23b33ec
commit 6c03faf10c
6 changed files with 122 additions and 2 deletions

View File

@@ -1584,8 +1584,13 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
</div> </div>
</header> </header>
<div class="question-insight-tags"> <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> <el-tag v-for="term in cluster.topTerms" :key="term" size="small" type="success" effect="plain">{{ term }}</el-tag>
</div> </div>
<p class="question-insight-action">{{ cluster.suggestedAction }}</p>
<el-collapse> <el-collapse>
<el-collapse-item title="查看相似问法和原始样例" :name="cluster.normalized"> <el-collapse-item title="查看相似问法和原始样例" :name="cluster.normalized">
<div class="question-variants"> <div class="question-variants">

View File

@@ -1956,6 +1956,16 @@ textarea {
margin: 12px 0; 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 { .question-variants {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;

View File

@@ -567,9 +567,16 @@ export interface QuestionInsightCluster {
rank: number; rank: number;
title: string; title: string;
normalized: string; normalized: string;
category: string;
categoryLabel: string;
count: number; count: number;
userCount: number; userCount: number;
sessionCount: number; sessionCount: number;
aiRequestCount: number;
noHitCount: number;
failedCount: number;
needsKnowledgeFollowUp: boolean;
suggestedAction: string;
firstSeenAt: string; firstSeenAt: string;
lastSeenAt: string; lastSeenAt: string;
topTerms: string[]; topTerms: string[];

View File

@@ -11,6 +11,7 @@ from sqlalchemy import select
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from app.models.chat import ChatMessage, ChatSession from app.models.chat import ChatMessage, ChatSession
from app.models.logs import AiRequestLog
from app.models.user import User 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) messages = _load_user_messages(db, date_from=date_from, date_to=date_to, limit=max_messages)
cleaned, filtered_count = _clean_messages(messages) cleaned, filtered_count = _clean_messages(messages)
clusters = _cluster_questions(cleaned) 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 = [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) visible_clusters.sort(key=lambda item: (len(item.questions), item.questions[-1].created_at), reverse=True)
@@ -137,7 +139,7 @@ class QuestionInsightService:
"visibleClusterCount": total, "visibleClusterCount": total,
"minCount": min_count, "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, "total": total,
"page": page, "page": page,
"pageSize": page_size, "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]: def _clean_messages(messages: Iterable[tuple[ChatMessage, ChatSession | None, User | None]]) -> tuple[list[CleanedQuestion], int]:
cleaned: list[CleanedQuestion] = [] cleaned: list[CleanedQuestion] = []
filtered_count = 0 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) 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) questions = sorted(cluster.questions, key=lambda item: item.created_at, reverse=True)
users = {item.user_id for item in questions} users = {item.user_id for item in questions}
sessions = {item.session_id for item in questions} sessions = {item.session_id for item in questions}
variants = Counter(item.text for item in questions).most_common(6) variants = Counter(item.text for item in questions).most_common(6)
terms = _top_terms(questions) 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 { return {
"rank": rank, "rank": rank,
"title": cluster.title, "title": cluster.title,
"normalized": cluster.normalized, "normalized": cluster.normalized,
"category": category,
"categoryLabel": category_label,
"count": len(questions), "count": len(questions),
"userCount": len(users), "userCount": len(users),
"sessionCount": len(sessions), "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), "firstSeenAt": min(item.created_at for item in questions),
"lastSeenAt": max(item.created_at for item in questions), "lastSeenAt": max(item.created_at for item in questions),
"topTerms": terms, "topTerms": terms,
@@ -357,3 +385,65 @@ def _top_terms(questions: list[CleanedQuestion]) -> list[str]:
if len(token) >= 2 and not token.isdigit(): if len(token) >= 2 and not token.isdigit():
counter[token] += 1 counter[token] += 1
return [term for term, _count in counter.most_common(8)] 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 "建议抽样查看相似问法,判断是否需要沉淀成知识库补充项。"

View File

@@ -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)), 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() db.commit()
response = question_insights( response = question_insights(
@@ -147,3 +148,6 @@ def test_question_insights_clean_and_cluster_similar_user_questions():
assert data["items"][0]["count"] == 2 assert data["items"][0]["count"] == 2
assert "心光" in data["items"][0]["title"] assert "心光" in data["items"][0]["title"]
assert data["items"][0]["userCount"] == 2 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

View File

@@ -827,6 +827,10 @@ AI 日志增加:
- 标记召回失败问题; - 标记召回失败问题;
- 支持人工合并/拆分问题组。 - 支持人工合并/拆分问题组。
#### 开发进度
- 2026-07-31二期第一步已在现有统计结果中增加问题分类、关联 AI 请求数、无知识命中次数、请求失败次数、是否需要知识跟进和运营处理建议;后台问题洞察卡片已展示分类标签、无命中/失败标记和建议动作。暂未新增持久化清洗表和人工合并/拆分能力,避免一次性扩大数据模型。
#### 验收标准 #### 验收标准
- 能看到高频问题趋势; - 能看到高频问题趋势;