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;
|
||||
|
||||
Reference in New Issue
Block a user