feat: add question insight analytics
This commit is contained in:
@@ -20,6 +20,7 @@ import type {
|
||||
RetrievalLogItem,
|
||||
AttentionItem,
|
||||
ModelItem,
|
||||
QuestionInsightSummary,
|
||||
SystemConfigItem,
|
||||
UserImportResult,
|
||||
} from "./types/api";
|
||||
@@ -45,6 +46,7 @@ const configs = ref<SystemConfigItem[]>([]);
|
||||
const chats = ref<ChatRecord[]>([]);
|
||||
const aiLogs = ref<AiLogRecord[]>([]);
|
||||
const operationLogs = ref<Record<string, unknown>[]>([]);
|
||||
const questionInsights = ref<QuestionInsightSummary | null>(null);
|
||||
const retrievalLogs = ref<RetrievalLogItem[]>([]);
|
||||
const attentionRecords = ref<AttentionItem[]>([]);
|
||||
const selectedRetrievalLog = ref<Record<string, unknown> | 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") {
|
||||
</el-table>
|
||||
<AdminPagination :page="pagers.chats.page" :page-size="pagers.chats.pageSize" :total="pagers.chats.total" @change="(page, pageSize) => changeRecordPage('chats', page, pageSize)" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="问题洞察" name="questionInsights">
|
||||
<div class="record-filter question-insight-filter">
|
||||
<div class="record-date-range" aria-label="问题时间范围">
|
||||
<label class="record-date-field">
|
||||
<span>开始时间</span>
|
||||
<input v-model="questionInsightFilters.dateFrom" type="datetime-local" aria-label="问题开始时间" />
|
||||
</label>
|
||||
<span class="record-date-sep">至</span>
|
||||
<label class="record-date-field">
|
||||
<span>结束时间</span>
|
||||
<input v-model="questionInsightFilters.dateTo" type="datetime-local" aria-label="问题结束时间" />
|
||||
</label>
|
||||
</div>
|
||||
<label class="insight-number-field">
|
||||
<span>最低频次</span>
|
||||
<el-input-number v-model="questionInsightFilters.minCount" :min="1" :max="50" controls-position="right" />
|
||||
</label>
|
||||
<label class="insight-number-field">
|
||||
<span>最多扫描</span>
|
||||
<el-input-number v-model="questionInsightFilters.maxMessages" :min="100" :max="20000" :step="500" controls-position="right" />
|
||||
</label>
|
||||
<div class="record-filter-actions">
|
||||
<el-button type="primary" @click="searchQuestionInsights">统计问题</el-button>
|
||||
<el-button @click="resetQuestionInsightFilters">重置</el-button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="question-insight-help">一期先对用户消息做去噪、拆问、同义词归一和相似问法合并;后续可把清洗后的结果交给大模型做更细的主题命名。</p>
|
||||
<section v-if="questionInsights" class="question-insight-summary">
|
||||
<div><span>扫描用户消息</span><strong>{{ questionInsights.summary.scannedMessages }}</strong></div>
|
||||
<div><span>有效问题</span><strong>{{ questionInsights.summary.cleanedQuestions }}</strong></div>
|
||||
<div><span>过滤低价值</span><strong>{{ questionInsights.summary.filteredMessages }}</strong></div>
|
||||
<div><span>高频问题组</span><strong>{{ questionInsights.summary.visibleClusterCount }}</strong></div>
|
||||
</section>
|
||||
<section v-loading="loading" class="question-insight-list">
|
||||
<article v-for="cluster in questionInsights?.items || []" :key="`${cluster.rank}-${cluster.normalized}`" class="question-insight-card">
|
||||
<header>
|
||||
<div>
|
||||
<small>TOP {{ cluster.rank }}</small>
|
||||
<h3>{{ cluster.title }}</h3>
|
||||
</div>
|
||||
<div class="question-insight-metrics">
|
||||
<span>{{ cluster.count }} 次</span>
|
||||
<span>{{ cluster.userCount }} 人</span>
|
||||
<span>{{ cluster.sessionCount }} 个会话</span>
|
||||
</div>
|
||||
</header>
|
||||
<div class="question-insight-tags">
|
||||
<el-tag v-for="term in cluster.topTerms" :key="term" size="small" type="success" effect="plain">{{ term }}</el-tag>
|
||||
</div>
|
||||
<el-collapse>
|
||||
<el-collapse-item title="查看相似问法和原始样例" :name="cluster.normalized">
|
||||
<div class="question-variants">
|
||||
<span v-for="variant in cluster.variants" :key="variant.text">{{ variant.text }} × {{ variant.count }}</span>
|
||||
</div>
|
||||
<div class="question-samples">
|
||||
<article v-for="sample in cluster.samples" :key="sample.messageId">
|
||||
<div>
|
||||
<strong>{{ sample.userName || sample.userPhone || `用户 #${sample.userId}` }}</strong>
|
||||
<span>{{ sample.createdAt }}</span>
|
||||
</div>
|
||||
<p>清洗后:{{ sample.cleaned }}</p>
|
||||
<pre>{{ sample.raw }}</pre>
|
||||
<el-button link type="primary" @click="openChatSession(sample.sessionId)">查看原会话</el-button>
|
||||
</article>
|
||||
</div>
|
||||
</el-collapse-item>
|
||||
</el-collapse>
|
||||
</article>
|
||||
<el-empty v-if="questionInsights && questionInsights.items.length === 0" description="当前条件下暂无达到频次的问题组" :image-size="72" />
|
||||
<el-empty v-else-if="!questionInsights && !loading" description="选择时间范围后点击统计问题" :image-size="72" />
|
||||
</section>
|
||||
<AdminPagination :page="pagers.questionInsights.page" :page-size="pagers.questionInsights.pageSize" :total="pagers.questionInsights.total" @change="(page, pageSize) => changeRecordPage('questionInsights', page, pageSize)" />
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="AI 请求" name="aiLogs">
|
||||
<el-table v-loading="loading" :data="aiLogs" stripe>
|
||||
<el-table-column prop="sessionId" label="会话ID" width="90" />
|
||||
|
||||
@@ -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<PageResult<AiLogRecord>>(`/admin/ai-log/list${queryString(query)}`),
|
||||
aiLogDetail: (id: number) => request<AiLogRecord>(`/admin/ai-log/${id}`),
|
||||
operationLogs: (query: { module?: string; page?: number; pageSize?: number } = {}) => request<PageResult<Record<string, unknown>>>(`/admin/log/list${queryString(query)}`),
|
||||
questionInsights: (query: { dateFrom?: string; dateTo?: string; minCount?: number; maxMessages?: number; page?: number; pageSize?: number } = {}) =>
|
||||
request<QuestionInsightSummary>(`/admin/question-insights/summary${queryString(query)}`),
|
||||
retrievalLogs: (query: { page?: number; pageSize?: number } = {}) => request<PageResult<RetrievalLogItem>>(`/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 }) }),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
@@ -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,
|
||||
|
||||
@@ -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)]
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user