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;

View File

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

View File

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

View File

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

View File

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