From ad2161497fa26c69a8d07fad9f2d1def4a553e8b Mon Sep 17 00:00:00 2001 From: Nelson <1475262689@qq.com> Date: Fri, 31 Jul 2026 11:39:59 +0800 Subject: [PATCH] feat: add question insight analytics --- .../apps/admin-web/src/App.vue | 112 +++++- .../apps/admin-web/src/services/api.ts | 3 + .../apps/admin-web/src/styles.css | 170 +++++++++ .../apps/admin-web/src/types/api.ts | 45 +++ .../versions/0013_question_insight_indexes.py | 29 ++ .../apps/backend/app/api/admin_records.py | 25 ++ .../app/services/question_insight_service.py | 358 ++++++++++++++++++ .../backend/tests/test_admin_pagination.py | 43 ++- 8 files changed, 782 insertions(+), 3 deletions(-) create mode 100644 ai_knowledge_base_v2/apps/backend/alembic/versions/0013_question_insight_indexes.py create mode 100644 ai_knowledge_base_v2/apps/backend/app/services/question_insight_service.py 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 d01ffe0..fc2ab18 100644 --- a/ai_knowledge_base_v2/apps/admin-web/src/App.vue +++ b/ai_knowledge_base_v2/apps/admin-web/src/App.vue @@ -20,6 +20,7 @@ import type { RetrievalLogItem, AttentionItem, ModelItem, + QuestionInsightSummary, SystemConfigItem, UserImportResult, } from "./types/api"; @@ -45,6 +46,7 @@ const configs = ref([]); const chats = ref([]); const aiLogs = ref([]); const operationLogs = ref[]>([]); +const questionInsights = ref(null); const retrievalLogs = ref([]); const attentionRecords = ref([]); const selectedRetrievalLog = ref | null>(null); @@ -64,6 +66,7 @@ const pagers = reactive({ chats: { page: 1, pageSize: 20, total: 0 }, aiLogs: { page: 1, pageSize: 20, total: 0 }, operationLogs: { page: 1, pageSize: 20, total: 0 }, + questionInsights: { page: 1, pageSize: 20, total: 0 }, retrievals: { page: 1, pageSize: 20, total: 0 }, attention: { page: 1, pageSize: 20, total: 0 }, }); @@ -206,6 +209,13 @@ const chatFilters = reactive({ dateTo: "", }); +const questionInsightFilters = reactive({ + dateFrom: "", + dateTo: "", + minCount: 2, + maxMessages: 5000, +}); + watch(recordTab, async (tab) => { if (activeMenu.value === "records") await loadRecordTab(tab); }); @@ -297,18 +307,33 @@ async function loadRecordTab(tab = recordTab.value) { } else if (tab === "aiLogs") { const result = await api.aiLogs({ page: pagers.aiLogs.page, pageSize: pagers.aiLogs.pageSize }); aiLogs.value = result.items; Object.assign(pagers.aiLogs, { page: result.page, pageSize: result.pageSize, total: result.total }); - } else { + } else if (tab === "operationLogs") { const result = await api.operationLogs({ page: pagers.operationLogs.page, pageSize: pagers.operationLogs.pageSize }); operationLogs.value = result.items; Object.assign(pagers.operationLogs, { page: result.page, pageSize: result.pageSize, total: result.total }); + } else if (tab === "questionInsights") { + await loadQuestionInsights(pagers.questionInsights.page, pagers.questionInsights.pageSize); } } finally { loading.value = false; } } -async function changeRecordPage(tab: "chats" | "aiLogs" | "operationLogs", page: number, pageSize: number) { +async function changeRecordPage(tab: "chats" | "aiLogs" | "operationLogs" | "questionInsights", page: number, pageSize: number) { Object.assign(pagers[tab], { page, pageSize }); await loadRecordTab(tab); } +async function loadQuestionInsights(page = 1, pageSize = pagers.questionInsights.pageSize) { + const result = await api.questionInsights({ + dateFrom: formatRecordDateTime(questionInsightFilters.dateFrom, "start"), + dateTo: formatRecordDateTime(questionInsightFilters.dateTo, "end"), + minCount: questionInsightFilters.minCount, + maxMessages: questionInsightFilters.maxMessages, + page, + pageSize, + }); + questionInsights.value = result; + Object.assign(pagers.questionInsights, { page: result.page, pageSize: result.pageSize, total: result.total }); +} + async function loadRetrievals(page = 1, pageSize = pagers.retrievals.pageSize) { const result = await api.retrievalLogs({ page, pageSize }); retrievalLogs.value = result.items; Object.assign(pagers.retrievals, { page: result.page, pageSize: result.pageSize, total: result.total }); @@ -743,6 +768,16 @@ async function resetChatFilters() { await searchChats(); } +async function searchQuestionInsights() { + pagers.questionInsights.page = 1; + await loadRecordTab("questionInsights"); +} + +async function resetQuestionInsightFilters() { + Object.assign(questionInsightFilters, { dateFrom: "", dateTo: "", minCount: 2, maxMessages: 5000 }); + await searchQuestionInsights(); +} + async function openChatDetail(row: ChatRecord) { await openChatSession(row.id); } @@ -1199,6 +1234,79 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") { + +
+
+ + + +
+ + +
+ 统计问题 + 重置 +
+
+

一期先对用户消息做去噪、拆问、同义词归一和相似问法合并;后续可把清洗后的结果交给大模型做更细的主题命名。

+
+
扫描用户消息{{ questionInsights.summary.scannedMessages }}
+
有效问题{{ questionInsights.summary.cleanedQuestions }}
+
过滤低价值{{ questionInsights.summary.filteredMessages }}
+
高频问题组{{ questionInsights.summary.visibleClusterCount }}
+
+
+
+
+
+ TOP {{ cluster.rank }} +

{{ cluster.title }}

+
+
+ {{ cluster.count }} 次 + {{ cluster.userCount }} 人 + {{ cluster.sessionCount }} 个会话 +
+
+
+ {{ term }} +
+ + +
+ {{ variant.text }} × {{ variant.count }} +
+
+
+
+ {{ sample.userName || sample.userPhone || `用户 #${sample.userId}` }} + {{ sample.createdAt }} +
+

清洗后:{{ sample.cleaned }}

+
{{ sample.raw }}
+ 查看原会话 +
+
+
+
+
+ + +
+ +
diff --git a/ai_knowledge_base_v2/apps/admin-web/src/services/api.ts b/ai_knowledge_base_v2/apps/admin-web/src/services/api.ts index 934a0bf..3616007 100644 --- a/ai_knowledge_base_v2/apps/admin-web/src/services/api.ts +++ b/ai_knowledge_base_v2/apps/admin-web/src/services/api.ts @@ -28,6 +28,7 @@ import type { PageResult, PromptDetail, PromptHistoryItem, + QuestionInsightSummary, } from "../types/api"; const API_BASE = import.meta.env.VITE_API_BASE_URL ?? "/api"; @@ -211,6 +212,8 @@ export const api = { request>(`/admin/ai-log/list${queryString(query)}`), aiLogDetail: (id: number) => request(`/admin/ai-log/${id}`), operationLogs: (query: { module?: string; page?: number; pageSize?: number } = {}) => request>>(`/admin/log/list${queryString(query)}`), + questionInsights: (query: { dateFrom?: string; dateTo?: string; minCount?: number; maxMessages?: number; page?: number; pageSize?: number } = {}) => + request(`/admin/question-insights/summary${queryString(query)}`), retrievalLogs: (query: { page?: number; pageSize?: number } = {}) => request>(`/admin/retrieval-log/list${queryString(query)}`), estimateRetrievalCleanup: (before: string) => request<{ before: string; estimatedCount: number }>("/admin/retrieval-log/cleanup/estimate", { method: "POST", body: JSON.stringify({ before }) }), cleanupRetrievalLogs: (before: string) => request<{ before: string; deleted: number }>("/admin/retrieval-log/cleanup", { method: "POST", body: JSON.stringify({ before }) }), 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 1f42be7..5caed4b 100644 --- a/ai_knowledge_base_v2/apps/admin-web/src/styles.css +++ b/ai_knowledge_base_v2/apps/admin-web/src/styles.css @@ -1738,6 +1738,176 @@ textarea { align-items: center; } +.question-insight-filter { + align-items: center; +} + +.insight-number-field { + height: 40px; + display: flex; + align-items: center; + gap: 8px; + padding-left: 11px; + border: 1px solid #dcdfe6; + border-radius: 4px; + background: #ffffff; +} + +.insight-number-field > span { + color: #909399; + font-size: 13px; + white-space: nowrap; +} + +.insight-number-field .el-input-number { + width: 132px; +} + +.insight-number-field .el-input__wrapper { + border-radius: 0 4px 4px 0; + box-shadow: none; +} + +.question-insight-help { + margin: -2px 0 14px; + color: #70837c; + font-size: 13px; +} + +.question-insight-summary { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 12px; + margin-bottom: 14px; +} + +.question-insight-summary div { + padding: 14px 16px; + border: 1px solid #dfe8e5; + border-radius: 12px; + background: #f8fbfa; +} + +.question-insight-summary span { + display: block; + color: #70837c; + font-size: 13px; +} + +.question-insight-summary strong { + display: block; + margin-top: 4px; + color: #0f735d; + font-size: 24px; +} + +.question-insight-list { + display: grid; + gap: 12px; + min-height: 160px; +} + +.question-insight-card { + padding: 16px 18px; + border: 1px solid #dfe8e5; + border-radius: 14px; + background: #ffffff; +} + +.question-insight-card header { + display: flex; + justify-content: space-between; + gap: 16px; + align-items: flex-start; +} + +.question-insight-card small { + color: #0f735d; + font-weight: 700; +} + +.question-insight-card h3 { + margin: 4px 0 0; + color: #183b33; + font-size: 18px; + line-height: 1.45; +} + +.question-insight-metrics { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; + min-width: 220px; +} + +.question-insight-metrics span, +.question-variants span { + padding: 4px 9px; + border-radius: 999px; + background: #eef6f3; + color: #49675f; + font-size: 12px; + white-space: nowrap; +} + +.question-insight-tags { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin: 12px 0; +} + +.question-variants { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 12px; +} + +.question-samples { + display: grid; + gap: 10px; +} + +.question-samples article { + padding: 12px; + border: 1px solid #e5ece9; + border-radius: 10px; + background: #f8fbfa; +} + +.question-samples article > div { + display: flex; + flex-wrap: wrap; + gap: 10px; + align-items: center; + color: #70837c; + font-size: 12px; +} + +.question-samples strong { + color: #183b33; +} + +.question-samples p { + margin: 8px 0; + color: #1f2d2a; +} + +.question-samples pre { + max-height: 150px; + margin: 0; + padding: 10px; + overflow: auto; + border-radius: 8px; + background: #ffffff; + color: #52665f; + white-space: pre-wrap; + word-break: break-word; + line-height: 1.6; +} + .chat-detail-body { min-height: 320px; max-height: calc(100vh - 96px); 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 16c4c65..167d681 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 @@ -361,6 +361,51 @@ export interface ChatDetail { aiLogs: AiLogRecord[]; } +export interface QuestionInsightSummary { + range: { + dateFrom?: string | null; + dateTo?: string | null; + maxMessages: number; + }; + summary: { + scannedMessages: number; + cleanedQuestions: number; + filteredMessages: number; + clusterCount: number; + visibleClusterCount: number; + minCount: number; + }; + items: QuestionInsightCluster[]; + total: number; + page: number; + pageSize: number; +} + +export interface QuestionInsightCluster { + rank: number; + title: string; + normalized: string; + count: number; + userCount: number; + sessionCount: number; + firstSeenAt: string; + lastSeenAt: string; + topTerms: string[]; + variants: Array<{ text: string; count: number }>; + samples: QuestionInsightSample[]; +} + +export interface QuestionInsightSample { + messageId: number; + sessionId: number; + userId: number; + userName: string; + userPhone: string; + raw: string; + cleaned: string; + createdAt: string; +} + export interface ChatRecordQuery { keyword?: string; userId?: number | null; diff --git a/ai_knowledge_base_v2/apps/backend/alembic/versions/0013_question_insight_indexes.py b/ai_knowledge_base_v2/apps/backend/alembic/versions/0013_question_insight_indexes.py new file mode 100644 index 0000000..a331179 --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/alembic/versions/0013_question_insight_indexes.py @@ -0,0 +1,29 @@ +"""add question insight message index + +Revision ID: 0013_question_insight_indexes +Revises: 0012_knowledge_latest_only +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "0013_question_insight_indexes" +down_revision = "0012_knowledge_latest_only" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + _create_index_if_missing("sys_chat_message", "ix_chat_message_role_created", ["role", "created_at"]) + + +def downgrade() -> None: + indexes = {item["name"] for item in sa.inspect(op.get_bind()).get_indexes("sys_chat_message")} + if "ix_chat_message_role_created" in indexes: + op.drop_index("ix_chat_message_role_created", table_name="sys_chat_message") + + +def _create_index_if_missing(table: str, name: str, columns: list[str]) -> None: + if name not in {item["name"] for item in sa.inspect(op.get_bind()).get_indexes(table)}: + op.create_index(name, table, columns) diff --git a/ai_knowledge_base_v2/apps/backend/app/api/admin_records.py b/ai_knowledge_base_v2/apps/backend/app/api/admin_records.py index 220dad4..3b08915 100644 --- a/ai_knowledge_base_v2/apps/backend/app/api/admin_records.py +++ b/ai_knowledge_base_v2/apps/backend/app/api/admin_records.py @@ -19,6 +19,7 @@ from app.models.chat import ChatMessage, ChatSession from app.models.logs import AiRequestLog, OperationLog from app.models.user import User from app.api.pagination import page_result +from app.services.question_insight_service import QuestionInsightService router = APIRouter() @@ -222,6 +223,30 @@ def ai_log_detail( return api_success(_ai_log_dict(log, include_prompt=True)) +@router.get("/question-insights/summary") +def question_insights( + dateFrom: datetime | None = Query(default=None), + dateTo: datetime | None = Query(default=None), + minCount: int = Query(default=2, ge=1, le=50), + maxMessages: int = Query(default=5000, ge=100, le=20000), + page: int = Query(default=1, ge=1), + pageSize: int = Query(default=20, ge=10, le=50), + db: Session = Depends(get_db), + current_admin: Admin = Depends(get_current_admin), +) -> dict: + return api_success( + QuestionInsightService.summarize( + db, + date_from=dateFrom, + date_to=dateTo, + min_count=minCount, + max_messages=maxMessages, + page=page, + page_size=pageSize, + ) + ) + + def _chat_query( *, keyword: str, 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 new file mode 100644 index 0000000..93f2e48 --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/services/question_insight_service.py @@ -0,0 +1,358 @@ +from __future__ import annotations + +import re +from collections import Counter +from dataclasses import dataclass, field +from datetime import datetime +from difflib import SequenceMatcher +from typing import Iterable + +from sqlalchemy import select +from sqlalchemy.orm import Session + +from app.models.chat import ChatMessage, ChatSession +from app.models.user import User + + +LOW_VALUE_EXACT = { + "你好", + "您好", + "老师好", + "在吗", + "好的", + "好", + "嗯", + "嗯嗯", + "收到", + "谢谢", + "感谢", + "明白", + "可以", + "ok", + "OK", +} + +COURTESY_PREFIXES = ( + "老师你好", + "老师您好", + "老师好", + "你好", + "您好", + "请问一下", + "请问", + "想问一下", + "麻烦问下", + "我想问一下", + "我想问问", +) + +COURTESY_SUFFIXES = ( + "谢谢老师", + "谢谢", + "感谢老师", + "感谢", + "麻烦老师", + "辛苦老师", +) + +SYNONYM_RULES = ( + (re.compile(r"(作业|练习|功课|课后任务|课后练习)"), "功课"), + (re.compile(r"(回放|录播|视频回看|回看)"), "回放"), + (re.compile(r"(会议链接|会议号|直播链接|上课链接|腾讯会议|飞书会议)"), "会议链接"), + (re.compile(r"(助教|助理|班主任|辅导老师)"), "课程助理"), + (re.compile(r"(上课|直播|带练|带领练习)"), "上课安排"), + (re.compile(r"(怎么做|如何做|咋做|具体步骤|操作步骤|怎么操作|具体操作)"), "怎么做"), + (re.compile(r"(是什么|什么意思|啥意思|定义|区别)"), "是什么"), +) + +NOISE_PATTERN = re.compile(r"[\s\u3000,,。!?!?;;::、“”\"'‘’()()\[\]【】<>《》]+") +QUESTION_SPLIT_PATTERN = re.compile( + r"(?:\n+|[??]\s*|(?:^|\n|\s)[0-9一二三四五六七八九十]+[、..]\s*)" +) + + +@dataclass +class CleanedQuestion: + raw: str + text: str + normalized: str + user_id: int + user_name: str + user_phone: str + session_id: int + message_id: int + created_at: datetime + tokens: set[str] + + +@dataclass +class QuestionCluster: + title: str + normalized: str + tokens: set[str] + questions: list[CleanedQuestion] = field(default_factory=list) + + def add(self, question: CleanedQuestion) -> None: + self.questions.append(question) + if len(question.normalized) < len(self.normalized) or _looks_more_question_like(question.text, self.title): + self.title = question.text + self.normalized = question.normalized + self.tokens = _merge_tokens(self.tokens, question.tokens) + + +class QuestionInsightService: + @staticmethod + def summarize( + db: Session, + *, + date_from: datetime | None = None, + date_to: datetime | None = None, + min_count: int = 2, + page: int = 1, + page_size: int = 20, + max_messages: int = 5000, + ) -> dict: + 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) + 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) + + total = len(visible_clusters) + offset = (page - 1) * page_size + page_clusters = visible_clusters[offset : offset + page_size] + + return { + "range": { + "dateFrom": date_from, + "dateTo": date_to, + "maxMessages": max_messages, + }, + "summary": { + "scannedMessages": len(messages), + "cleanedQuestions": len(cleaned), + "filteredMessages": filtered_count, + "clusterCount": len(clusters), + "visibleClusterCount": total, + "minCount": min_count, + }, + "items": [_cluster_dict(index + offset + 1, cluster) for index, cluster in enumerate(page_clusters)], + "total": total, + "page": page, + "pageSize": page_size, + } + + +def _load_user_messages( + db: Session, + *, + date_from: datetime | None, + date_to: datetime | None, + limit: int, +) -> list[tuple[ChatMessage, ChatSession | None, User | None]]: + query = ( + select(ChatMessage, ChatSession, User) + .join(ChatSession, ChatSession.id == ChatMessage.session_id, isouter=True) + .join(User, User.id == ChatMessage.user_id, isouter=True) + .where(ChatMessage.role == "user") + ) + if date_from is not None: + query = query.where(ChatMessage.created_at >= date_from.replace(tzinfo=None)) + if date_to is not None: + query = query.where(ChatMessage.created_at <= date_to.replace(tzinfo=None)) + return list( + db.execute( + query.order_by(ChatMessage.created_at.desc(), ChatMessage.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 + seen_parts: set[tuple[int, str]] = set() + for message, _session, user in messages: + parts = _split_questions(message.content) + accepted = 0 + for part in parts: + text = _clean_text(part) + if _is_low_value(text): + continue + normalized = _normalize_question(text) + if len(normalized) < 3: + continue + dedupe_key = (message.id, normalized) + if dedupe_key in seen_parts: + continue + seen_parts.add(dedupe_key) + accepted += 1 + cleaned.append( + CleanedQuestion( + raw=message.content, + text=text, + normalized=normalized, + user_id=message.user_id, + user_name=user.name if user else "", + user_phone=user.phone if user else "", + session_id=message.session_id, + message_id=message.id, + created_at=message.created_at, + tokens=_tokens(normalized), + ) + ) + if accepted == 0: + filtered_count += 1 + return cleaned, filtered_count + + +def _split_questions(content: str) -> list[str]: + text = (content or "").strip() + if not text: + return [] + rough_parts = [part.strip() for part in QUESTION_SPLIT_PATTERN.split(text) if part.strip()] + if len(rough_parts) <= 1: + return [text] + merged: list[str] = [] + for part in rough_parts: + if len(part) <= 2 and merged: + merged[-1] = f"{merged[-1]} {part}" + else: + merged.append(part) + return merged + + +def _clean_text(text: str) -> str: + value = re.sub(r"\s+", " ", text.strip()) + changed = True + while changed: + changed = False + for prefix in COURTESY_PREFIXES: + if value.startswith(prefix): + value = value[len(prefix) :].lstrip(" ,,。::") + changed = True + for suffix in COURTESY_SUFFIXES: + if value.endswith(suffix): + value = value[: -len(suffix)].rstrip(" ,,。::") + changed = True + return value.strip() + + +def _is_low_value(text: str) -> bool: + compact = NOISE_PATTERN.sub("", text) + if not compact: + return True + if compact in LOW_VALUE_EXACT: + return True + if len(compact) <= 2: + return True + return len(compact) <= 6 and not any(marker in compact for marker in ("吗", "么", "哪", "谁", "怎么", "如何", "什么", "为啥", "为什么")) + + +def _normalize_question(text: str) -> str: + value = text.lower() + for pattern, replacement in SYNONYM_RULES: + value = pattern.sub(replacement, value) + value = re.sub(r"(吗|呢|呀|啊|嘛)+$", "", value) + return NOISE_PATTERN.sub("", value) + + +def _cluster_questions(questions: list[CleanedQuestion]) -> list[QuestionCluster]: + clusters: list[QuestionCluster] = [] + for question in questions: + best_cluster: QuestionCluster | None = None + best_score = 0.0 + for cluster in clusters: + score = _similarity(question, cluster) + if score > best_score: + best_score = score + best_cluster = cluster + if best_cluster is not None and best_score >= 0.62: + best_cluster.add(question) + else: + clusters.append(QuestionCluster(title=question.text, normalized=question.normalized, tokens=set(question.tokens), questions=[question])) + return clusters + + +def _similarity(question: CleanedQuestion, cluster: QuestionCluster) -> float: + if question.normalized == cluster.normalized: + return 1.0 + token_score = _jaccard(question.tokens, cluster.tokens) + sequence_score = SequenceMatcher(None, question.normalized, cluster.normalized).ratio() + containment_score = _containment(question.normalized, cluster.normalized) + return max(token_score, sequence_score * 0.88, containment_score) + + +def _tokens(text: str) -> set[str]: + tokens = {item for item in re.split(r"[^\w\u4e00-\u9fff]+", text) if len(item) >= 2} + compact = NOISE_PATTERN.sub("", text) + for size in (2, 3): + tokens.update(compact[index : index + size] for index in range(max(len(compact) - size + 1, 0))) + return tokens + + +def _jaccard(left: set[str], right: set[str]) -> float: + if not left or not right: + return 0.0 + return len(left & right) / len(left | right) + + +def _containment(left: str, right: str) -> float: + if not left or not right: + return 0.0 + short, long = (left, right) if len(left) <= len(right) else (right, left) + if len(short) < 4: + return 0.0 + return 0.92 if short in long else 0.0 + + +def _merge_tokens(left: set[str], right: set[str]) -> set[str]: + if len(left) > 260: + return set(Counter(left).keys()) + return left | right + + +def _looks_more_question_like(candidate: str, current: str) -> bool: + 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: + 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) + return { + "rank": rank, + "title": cluster.title, + "normalized": cluster.normalized, + "count": len(questions), + "userCount": len(users), + "sessionCount": len(sessions), + "firstSeenAt": min(item.created_at for item in questions), + "lastSeenAt": max(item.created_at for item in questions), + "topTerms": terms, + "variants": [{"text": text, "count": count} for text, count in variants], + "samples": [ + { + "messageId": item.message_id, + "sessionId": item.session_id, + "userId": item.user_id, + "userName": item.user_name, + "userPhone": item.user_phone, + "raw": item.raw, + "cleaned": item.text, + "createdAt": item.created_at, + } + for item in questions[:5] + ], + } + + +def _top_terms(questions: list[CleanedQuestion]) -> list[str]: + counter: Counter[str] = Counter() + for question in questions: + for token in question.tokens: + if len(token) >= 2 and not token.isdigit(): + counter[token] += 1 + return [term for term, _count in counter.most_common(8)] 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 cbbbe4f..bc75560 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 @@ -1,9 +1,10 @@ from sqlalchemy import create_engine +from datetime import datetime, timedelta from sqlalchemy.orm import Session from sqlalchemy.pool import StaticPool from app.api.admin_agent_records import attention_list, retrieval_logs -from app.api.admin_records import ai_logs, chat_detail, chat_messages +from app.api.admin_records import ai_logs, chat_detail, chat_messages, question_insights from app.api.admin_users import list_users from app.models import Base from app.models.chat import ChatMessage, ChatSession @@ -106,3 +107,43 @@ def test_retrieval_and_attention_lists_are_paginated(): attention = attention_list(priority="", statusValue="", page=3, pageSize=10, db=db, current_admin=object())["data"] assert retrieval["total"] == attention["total"] == 21 assert len(retrieval["items"]) == len(attention["items"]) == 1 + + +def test_question_insights_clean_and_cluster_similar_user_questions(): + with _database() as db: + user_a = User(id=1, phone="13800000000", name="学员A", daily_chat_limit=10) + user_b = User(id=2, phone="13800000001", name="学员B", daily_chat_limit=10) + session_a = ChatSession(id=1, user_id=1, title="问题", message_count=3) + session_b = ChatSession(id=2, user_id=2, title="问题", message_count=2) + now = datetime(2026, 7, 31, 10, 0, 0) + db.add_all([user_a, user_b, session_a, session_b]) + db.add_all( + [ + ChatMessage(id=1, session_id=1, user_id=1, role="user", content="老师你好,心光有哪些作业?", created_at=now), + ChatMessage(id=2, session_id=1, user_id=1, role="assistant", content="回答", created_at=now + timedelta(seconds=1)), + ChatMessage(id=3, session_id=2, user_id=2, role="user", content="请问心光都有什么功课", created_at=now + timedelta(minutes=1)), + ChatMessage(id=4, session_id=2, user_id=2, role="user", content="谢谢老师", created_at=now + timedelta(minutes=2)), + ChatMessage(id=5, session_id=1, user_id=1, role="user", content="1、回放在哪里看?\n2、上课链接在哪", created_at=now + timedelta(minutes=3)), + ] + ) + db.commit() + + response = question_insights( + dateFrom=None, + dateTo=None, + minCount=2, + maxMessages=100, + page=1, + pageSize=10, + db=db, + current_admin=object(), + ) + + data = response["data"] + assert data["summary"]["scannedMessages"] == 4 + assert data["summary"]["filteredMessages"] == 1 + assert data["summary"]["cleanedQuestions"] == 4 + assert data["total"] == 1 + assert data["items"][0]["count"] == 2 + assert "心光" in data["items"][0]["title"] + assert data["items"][0]["userCount"] == 2