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