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

@@ -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 "建议抽样查看相似问法,判断是否需要沉淀成知识库补充项。"

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