feat: add question insight analytics

This commit is contained in:
2026-07-31 11:39:59 +08:00
parent e426f12e22
commit ad2161497f
8 changed files with 782 additions and 3 deletions

View File

@@ -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" />

View File

@@ -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 }) }),

View File

@@ -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);

View File

@@ -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;