feat: enrich question insights classification
This commit is contained in:
@@ -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">
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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[];
|
||||||
|
|||||||
@@ -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 "建议抽样查看相似问法,判断是否需要沉淀成知识库补充项。"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -827,6 +827,10 @@ AI 日志增加:
|
|||||||
- 标记召回失败问题;
|
- 标记召回失败问题;
|
||||||
- 支持人工合并/拆分问题组。
|
- 支持人工合并/拆分问题组。
|
||||||
|
|
||||||
|
#### 开发进度
|
||||||
|
|
||||||
|
- 2026-07-31:二期第一步已在现有统计结果中增加问题分类、关联 AI 请求数、无知识命中次数、请求失败次数、是否需要知识跟进和运营处理建议;后台问题洞察卡片已展示分类标签、无命中/失败标记和建议动作。暂未新增持久化清洗表和人工合并/拆分能力,避免一次性扩大数据模型。
|
||||||
|
|
||||||
#### 验收标准
|
#### 验收标准
|
||||||
|
|
||||||
- 能看到高频问题趋势;
|
- 能看到高频问题趋势;
|
||||||
|
|||||||
Reference in New Issue
Block a user