From 6c03faf10cedf1993b21593015c44f9b2fd1195b Mon Sep 17 00:00:00 2001 From: Nelson <1475262689@qq.com> Date: Fri, 31 Jul 2026 16:40:13 +0800 Subject: [PATCH] feat: enrich question insights classification --- .../apps/admin-web/src/App.vue | 5 + .../apps/admin-web/src/styles.css | 10 ++ .../apps/admin-web/src/types/api.ts | 7 ++ .../app/services/question_insight_service.py | 94 ++++++++++++++++++- .../backend/tests/test_admin_pagination.py | 4 + .../docs/qianwen_product_todo.md | 4 + 6 files changed, 122 insertions(+), 2 deletions(-) diff --git a/ai_knowledge_base_v2/apps/admin-web/src/App.vue b/ai_knowledge_base_v2/apps/admin-web/src/App.vue index 082f335..8d61ea2 100644 --- a/ai_knowledge_base_v2/apps/admin-web/src/App.vue +++ b/ai_knowledge_base_v2/apps/admin-web/src/App.vue @@ -1584,8 +1584,13 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
+ {{ cluster.categoryLabel }} + 需补知识/查召回 + 无命中 {{ cluster.noHitCount }} + 失败 {{ cluster.failedCount }} {{ term }}
+

{{ cluster.suggestedAction }}

diff --git a/ai_knowledge_base_v2/apps/admin-web/src/styles.css b/ai_knowledge_base_v2/apps/admin-web/src/styles.css index 8c23317..e42fd68 100644 --- a/ai_knowledge_base_v2/apps/admin-web/src/styles.css +++ b/ai_knowledge_base_v2/apps/admin-web/src/styles.css @@ -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; diff --git a/ai_knowledge_base_v2/apps/admin-web/src/types/api.ts b/ai_knowledge_base_v2/apps/admin-web/src/types/api.ts index 46b848c..e91f81c 100644 --- a/ai_knowledge_base_v2/apps/admin-web/src/types/api.ts +++ b/ai_knowledge_base_v2/apps/admin-web/src/types/api.ts @@ -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[]; diff --git a/ai_knowledge_base_v2/apps/backend/app/services/question_insight_service.py b/ai_knowledge_base_v2/apps/backend/app/services/question_insight_service.py index eec0a29..73bcb8a 100644 --- a/ai_knowledge_base_v2/apps/backend/app/services/question_insight_service.py +++ b/ai_knowledge_base_v2/apps/backend/app/services/question_insight_service.py @@ -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 "建议抽样查看相似问法,判断是否需要沉淀成知识库补充项。" diff --git a/ai_knowledge_base_v2/apps/backend/tests/test_admin_pagination.py b/ai_knowledge_base_v2/apps/backend/tests/test_admin_pagination.py index bc75560..49a63a8 100644 --- a/ai_knowledge_base_v2/apps/backend/tests/test_admin_pagination.py +++ b/ai_knowledge_base_v2/apps/backend/tests/test_admin_pagination.py @@ -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 diff --git a/ai_knowledge_base_v2/docs/qianwen_product_todo.md b/ai_knowledge_base_v2/docs/qianwen_product_todo.md index d106fe9..61521c1 100644 --- a/ai_knowledge_base_v2/docs/qianwen_product_todo.md +++ b/ai_knowledge_base_v2/docs/qianwen_product_todo.md @@ -827,6 +827,10 @@ AI 日志增加: - 标记召回失败问题; - 支持人工合并/拆分问题组。 +#### 开发进度 + +- 2026-07-31:二期第一步已在现有统计结果中增加问题分类、关联 AI 请求数、无知识命中次数、请求失败次数、是否需要知识跟进和运营处理建议;后台问题洞察卡片已展示分类标签、无命中/失败标记和建议动作。暂未新增持久化清洗表和人工合并/拆分能力,避免一次性扩大数据模型。 + #### 验收标准 - 能看到高频问题趋势;