From a589a25bdc5569021a4422896f8cd9bc2f79e811 Mon Sep 17 00:00:00 2001 From: Nelson <1475262689@qq.com> Date: Fri, 31 Jul 2026 16:50:11 +0800 Subject: [PATCH] feat: persist cleaned question insights --- .../apps/admin-web/src/App.vue | 29 +- .../apps/admin-web/src/services/api.ts | 5 + .../apps/admin-web/src/types/api.ts | 10 + .../0020_question_insight_persistence.py | 104 +++++++ .../apps/backend/app/api/admin_records.py | 25 ++ .../apps/backend/app/models/__init__.py | 2 + .../apps/backend/app/models/insight.py | 55 ++++ .../app/services/question_insight_service.py | 271 +++++++++++++++++- .../backend/tests/test_admin_pagination.py | 23 +- .../docs/qianwen_product_todo.md | 2 + 10 files changed, 505 insertions(+), 21 deletions(-) create mode 100644 ai_knowledge_base_v2/apps/backend/alembic/versions/0020_question_insight_persistence.py create mode 100644 ai_knowledge_base_v2/apps/backend/app/models/insight.py diff --git a/ai_knowledge_base_v2/apps/admin-web/src/App.vue b/ai_knowledge_base_v2/apps/admin-web/src/App.vue index 8d61ea2..8be6539 100644 --- a/ai_knowledge_base_v2/apps/admin-web/src/App.vue +++ b/ai_knowledge_base_v2/apps/admin-web/src/App.vue @@ -927,7 +927,26 @@ async function resetChatFilters() { async function searchQuestionInsights() { pagers.questionInsights.page = 1; - await loadRecordTab("questionInsights"); + loading.value = true; + try { + const refresh = await api.refreshQuestionInsights({ + dateFrom: formatRecordDateTime(questionInsightFilters.dateFrom, "start"), + dateTo: formatRecordDateTime(questionInsightFilters.dateTo, "end"), + maxMessages: questionInsightFilters.maxMessages, + }); + await loadQuestionInsights(1, pagers.questionInsights.pageSize); + if (refresh.processedMessages > 0) { + ElMessage.success( + `新增清洗 ${refresh.processedMessages} 条消息,得到 ${refresh.acceptedQuestions} 个有效问题${refresh.hasMore ? ";仍有历史消息待清洗" : ""}`, + ); + } else { + ElMessage.success("没有新增消息,已直接使用持久化清洗结果统计"); + } + } catch (error) { + ElMessage.error(error instanceof Error ? error.message : "问题洞察刷新失败"); + } finally { + loading.value = false; + } } async function resetQuestionInsightFilters() { @@ -1555,17 +1574,17 @@ function formatRecordDateTime(value: string, boundary: "start" | "end") {
- 统计问题 + 刷新并统计 重置
-

一期先对用户消息做去噪、拆问、同义词归一和相似问法合并;后续可把清洗后的结果交给大模型做更细的主题命名。

+

清洗结果会持久化保存;点击“刷新并统计”只处理尚未清洗的用户消息,再按当前时间范围聚合,翻页不会重新扫描聊天原文。

-
扫描用户消息{{ questionInsights.summary.scannedMessages }}
+
纳入清洗消息{{ questionInsights.summary.scannedMessages }}
有效问题{{ questionInsights.summary.cleanedQuestions }}
过滤低价值{{ questionInsights.summary.filteredMessages }}
高频问题组{{ questionInsights.summary.visibleClusterCount }}
diff --git a/ai_knowledge_base_v2/apps/admin-web/src/services/api.ts b/ai_knowledge_base_v2/apps/admin-web/src/services/api.ts index b2dc62e..a8c6f10 100644 --- a/ai_knowledge_base_v2/apps/admin-web/src/services/api.ts +++ b/ai_knowledge_base_v2/apps/admin-web/src/services/api.ts @@ -32,6 +32,7 @@ import type { PeriodicReportRecord, PromptDetail, PromptHistoryItem, + QuestionInsightRefreshResult, QuestionInsightSummary, } from "../types/api"; @@ -230,6 +231,10 @@ export const api = { operationLogs: (query: { module?: string; page?: number; pageSize?: number } = {}) => request>>(`/admin/log/list${queryString(query)}`), questionInsights: (query: { dateFrom?: string; dateTo?: string; minCount?: number; maxMessages?: number; page?: number; pageSize?: number } = {}) => request(`/admin/question-insights/summary${queryString(query)}`), + refreshQuestionInsights: (query: { dateFrom?: string; dateTo?: string; maxMessages?: number } = {}) => + request(`/admin/question-insights/refresh${queryString(query)}`, { + method: "POST", + }), retrievalLogs: (query: { page?: number; pageSize?: number } = {}) => request>(`/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 }) }), diff --git a/ai_knowledge_base_v2/apps/admin-web/src/types/api.ts b/ai_knowledge_base_v2/apps/admin-web/src/types/api.ts index e91f81c..79f01de 100644 --- a/ai_knowledge_base_v2/apps/admin-web/src/types/api.ts +++ b/ai_knowledge_base_v2/apps/admin-web/src/types/api.ts @@ -556,6 +556,7 @@ export interface QuestionInsightSummary { clusterCount: number; visibleClusterCount: number; minCount: number; + cleanerVersion: string; }; items: QuestionInsightCluster[]; total: number; @@ -563,6 +564,15 @@ export interface QuestionInsightSummary { pageSize: number; } +export interface QuestionInsightRefreshResult { + processedMessages: number; + acceptedQuestions: number; + filteredMessages: number; + concurrentSkips: number; + hasMore: boolean; + cleanerVersion: string; +} + export interface QuestionInsightCluster { rank: number; title: string; diff --git a/ai_knowledge_base_v2/apps/backend/alembic/versions/0020_question_insight_persistence.py b/ai_knowledge_base_v2/apps/backend/alembic/versions/0020_question_insight_persistence.py new file mode 100644 index 0000000..623c420 --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/alembic/versions/0020_question_insight_persistence.py @@ -0,0 +1,104 @@ +"""persist cleaned question insights + +Revision ID: 0020_question_insight_persistence +Revises: 0019_periodic_reports +Create Date: 2026-07-31 00:00:00.000000 +""" + +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "0020_question_insight_persistence" +down_revision = "0019_periodic_reports" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "sys_question_insight_cleaned_question", + sa.Column( + "id", + sa.BigInteger().with_variant(sa.Integer(), "sqlite"), + primary_key=True, + autoincrement=True, + ), + sa.Column("message_id", sa.BigInteger(), nullable=False), + sa.Column("session_id", sa.BigInteger(), nullable=False), + sa.Column("user_id", sa.BigInteger(), nullable=False), + sa.Column("part_index", sa.Integer(), nullable=False), + sa.Column("cleaner_version", sa.String(length=20), nullable=False), + sa.Column("source_hash", sa.String(length=64), nullable=False), + sa.Column("cleaned_text", sa.Text(), nullable=False), + sa.Column("normalized_text", sa.Text(), nullable=False), + sa.Column("category", sa.String(length=50), nullable=False, server_default="other"), + sa.Column("tokens_json", sa.Text(), nullable=False), + sa.Column("accepted", sa.Integer(), nullable=False, server_default="1"), + sa.Column("filtered_reason", sa.String(length=100), nullable=True), + sa.Column("source_created_at", sa.DateTime(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.func.now()), + sa.ForeignKeyConstraint( + ["message_id"], + ["sys_chat_message.id"], + ondelete="CASCADE", + ), + sa.UniqueConstraint( + "message_id", + "cleaner_version", + "part_index", + name="uq_question_insight_message_version_part", + ), + ) + op.create_index( + "ix_sys_question_insight_cleaned_question_session_id", + "sys_question_insight_cleaned_question", + ["session_id"], + ) + op.create_index( + "ix_sys_question_insight_cleaned_question_user_id", + "sys_question_insight_cleaned_question", + ["user_id"], + ) + op.create_index( + "ix_question_insight_version_accepted_created", + "sys_question_insight_cleaned_question", + ["cleaner_version", "accepted", "source_created_at"], + ) + op.create_index( + "ix_question_insight_category_created", + "sys_question_insight_cleaned_question", + ["category", "source_created_at"], + ) + op.create_index( + "ix_question_insight_session_created", + "sys_question_insight_cleaned_question", + ["session_id", "source_created_at"], + ) + + +def downgrade() -> None: + op.drop_index( + "ix_question_insight_session_created", + table_name="sys_question_insight_cleaned_question", + ) + op.drop_index( + "ix_question_insight_category_created", + table_name="sys_question_insight_cleaned_question", + ) + op.drop_index( + "ix_question_insight_version_accepted_created", + table_name="sys_question_insight_cleaned_question", + ) + op.drop_index( + "ix_sys_question_insight_cleaned_question_user_id", + table_name="sys_question_insight_cleaned_question", + ) + op.drop_index( + "ix_sys_question_insight_cleaned_question_session_id", + table_name="sys_question_insight_cleaned_question", + ) + op.drop_table("sys_question_insight_cleaned_question") diff --git a/ai_knowledge_base_v2/apps/backend/app/api/admin_records.py b/ai_knowledge_base_v2/apps/backend/app/api/admin_records.py index fe2605d..fc7bec5 100644 --- a/ai_knowledge_base_v2/apps/backend/app/api/admin_records.py +++ b/ai_knowledge_base_v2/apps/backend/app/api/admin_records.py @@ -20,6 +20,7 @@ from app.models.growth import ShareDraft, TeacherHelpCard, TopicSummary from app.models.logs import AiRequestLog, OperationLog from app.models.user import User from app.api.pagination import page_result +from app.services.admin_service import OperationLogService from app.services.question_insight_service import QuestionInsightService from app.services.growth_profile_service import topic_dict, topic_summary_dict from app.services.help_card_service import help_card_dict @@ -275,6 +276,30 @@ def question_insights( ) +@router.post("/question-insights/refresh") +def refresh_question_insights( + dateFrom: datetime | None = Query(default=None), + dateTo: datetime | None = Query(default=None), + maxMessages: int = Query(default=5000, ge=100, le=20000), + db: Session = Depends(get_db), + current_admin: Admin = Depends(get_current_admin), +) -> dict: + result = QuestionInsightService.refresh( + db, + date_from=dateFrom, + date_to=dateTo, + max_messages=maxMessages, + ) + OperationLogService.write( + db, + admin_id=current_admin.id, + module="question_insight", + action=f"refresh:{result['processedMessages']}", + ) + db.commit() + return api_success(result) + + def _chat_query( *, keyword: str, diff --git a/ai_knowledge_base_v2/apps/backend/app/models/__init__.py b/ai_knowledge_base_v2/apps/backend/app/models/__init__.py index bb2e634..f9841e3 100644 --- a/ai_knowledge_base_v2/apps/backend/app/models/__init__.py +++ b/ai_knowledge_base_v2/apps/backend/app/models/__init__.py @@ -4,6 +4,7 @@ from app.models.base import Base from app.models.chat import ChatMessage, ChatSession, TopicSession from app.models.entitlement import EntitlementPlan, UserEntitlement, UserEntitlementLog from app.models.growth import GrowthProfileRevision, PeriodicReport, ShareDraft, TeacherHelpCard, TopicSummary, UserGrowthProfile +from app.models.insight import QuestionInsightCleanedQuestion from app.models.knowledge import ( HumanAttentionHistory, HumanAttentionRecord, @@ -54,6 +55,7 @@ __all__ = [ "TopicSession", "TopicSummary", "Prompt", + "QuestionInsightCleanedQuestion", "Role", "SystemConfig", "ShareDraft", diff --git a/ai_knowledge_base_v2/apps/backend/app/models/insight.py b/ai_knowledge_base_v2/apps/backend/app/models/insight.py new file mode 100644 index 0000000..d36610d --- /dev/null +++ b/ai_knowledge_base_v2/apps/backend/app/models/insight.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from datetime import datetime + +from sqlalchemy import BigInteger, DateTime, ForeignKey, Integer, String, Text, UniqueConstraint, func +from sqlalchemy.orm import Mapped, mapped_column + +from app.models.base import Base + + +class QuestionInsightCleanedQuestion(Base): + """用户问题清洗后的持久化结果。 + + 每个原始消息至少写入一条记录。没有有效问题的消息会写入 accepted=0 + 的占位记录,使增量清洗无需重复读取已经处理过的聊天原文。 + """ + + __tablename__ = "sys_question_insight_cleaned_question" + __table_args__ = ( + UniqueConstraint( + "message_id", + "cleaner_version", + "part_index", + name="uq_question_insight_message_version_part", + ), + ) + + id: Mapped[int] = mapped_column( + BigInteger().with_variant(Integer, "sqlite"), + primary_key=True, + autoincrement=True, + ) + message_id: Mapped[int] = mapped_column( + ForeignKey("sys_chat_message.id", ondelete="CASCADE"), + nullable=False, + ) + session_id: Mapped[int] = mapped_column(BigInteger, index=True, nullable=False) + user_id: Mapped[int] = mapped_column(BigInteger, index=True, nullable=False) + part_index: Mapped[int] = mapped_column(Integer, nullable=False) + cleaner_version: Mapped[str] = mapped_column(String(20), nullable=False) + source_hash: Mapped[str] = mapped_column(String(64), nullable=False) + cleaned_text: Mapped[str] = mapped_column(Text, nullable=False, default="") + normalized_text: Mapped[str] = mapped_column(Text, nullable=False, default="") + category: Mapped[str] = mapped_column(String(50), nullable=False, default="other") + tokens_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]") + accepted: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + filtered_reason: Mapped[str | None] = mapped_column(String(100), nullable=True) + source_created_at: Mapped[datetime] = mapped_column(DateTime, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now(), nullable=False) + updated_at: Mapped[datetime] = mapped_column( + DateTime, + server_default=func.now(), + onupdate=func.now(), + nullable=False, + ) diff --git a/ai_knowledge_base_v2/apps/backend/app/services/question_insight_service.py b/ai_knowledge_base_v2/apps/backend/app/services/question_insight_service.py index 73bcb8a..9f584a5 100644 --- a/ai_knowledge_base_v2/apps/backend/app/services/question_insight_service.py +++ b/ai_knowledge_base_v2/apps/backend/app/services/question_insight_service.py @@ -1,5 +1,7 @@ from __future__ import annotations +import hashlib +import json import re from collections import Counter from dataclasses import dataclass, field @@ -7,14 +9,18 @@ from datetime import datetime from difflib import SequenceMatcher from typing import Iterable -from sqlalchemy import select +from sqlalchemy import and_, func, select +from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session from app.models.chat import ChatMessage, ChatSession +from app.models.insight import QuestionInsightCleanedQuestion from app.models.logs import AiRequestLog from app.models.user import User +CLEANER_VERSION = "v1" + LOW_VALUE_EXACT = { "你好", "您好", @@ -103,6 +109,60 @@ class QuestionCluster: class QuestionInsightService: + @staticmethod + def refresh( + db: Session, + *, + date_from: datetime | None = None, + date_to: datetime | None = None, + max_messages: int = 5000, + ) -> dict: + """增量清洗尚未处理的用户消息,并把结果写入持久化清洗表。""" + + messages = _load_unprocessed_user_messages( + db, + date_from=date_from, + date_to=date_to, + limit=max_messages, + ) + processed_messages = 0 + accepted_questions = 0 + filtered_messages = 0 + concurrent_skips = 0 + + for row in messages: + message = row[0] + cleaned, filtered_count = _clean_messages([row]) + records = _cleaned_records(message, cleaned, filtered_count) + try: + # 多个管理员同时刷新时,唯一约束负责去重;单条消息冲突不会回滚整批。 + with db.begin_nested(): + db.add_all(records) + db.flush() + except IntegrityError: + concurrent_skips += 1 + continue + processed_messages += 1 + accepted_questions += len(cleaned) + filtered_messages += filtered_count + + has_more = bool( + _load_unprocessed_user_messages( + db, + date_from=date_from, + date_to=date_to, + limit=1, + ) + ) + return { + "processedMessages": processed_messages, + "acceptedQuestions": accepted_questions, + "filteredMessages": filtered_messages, + "concurrentSkips": concurrent_skips, + "hasMore": has_more, + "cleanerVersion": CLEANER_VERSION, + } + @staticmethod def summarize( db: Session, @@ -114,8 +174,16 @@ class QuestionInsightService: 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) + persisted_items = _load_persisted_cleaned_questions( + db, + date_from=date_from, + date_to=date_to, + limit=max_messages, + ) + cleaned = [_question_from_persisted(item) for item in persisted_items if item.accepted] + source_message_ids = {item.message_id for item in persisted_items} + accepted_message_ids = {item.message_id for item in persisted_items if item.accepted} + filtered_count = len(source_message_ids - accepted_message_ids) clusters = _cluster_questions(cleaned) ai_logs = _load_ai_logs(db, date_from=date_from, date_to=date_to, limit=max_messages) visible_clusters = [cluster for cluster in clusters if len(cluster.questions) >= min_count] @@ -124,6 +192,7 @@ class QuestionInsightService: total = len(visible_clusters) offset = (page - 1) * page_size page_clusters = visible_clusters[offset : offset + page_size] + message_contents, users = _load_sample_context(db, page_clusters) return { "range": { @@ -132,21 +201,31 @@ class QuestionInsightService: "maxMessages": max_messages, }, "summary": { - "scannedMessages": len(messages), + "scannedMessages": len(source_message_ids), "cleanedQuestions": len(cleaned), "filteredMessages": filtered_count, "clusterCount": len(clusters), "visibleClusterCount": total, "minCount": min_count, + "cleanerVersion": CLEANER_VERSION, }, - "items": [_cluster_dict(index + offset + 1, cluster, ai_logs) for index, cluster in enumerate(page_clusters)], + "items": [ + _cluster_dict( + index + offset + 1, + cluster, + ai_logs, + message_contents=message_contents, + users=users, + ) + for index, cluster in enumerate(page_clusters) + ], "total": total, "page": page, "pageSize": page_size, } -def _load_user_messages( +def _load_unprocessed_user_messages( db: Session, *, date_from: datetime | None, @@ -155,9 +234,19 @@ def _load_user_messages( ) -> list[tuple[ChatMessage, ChatSession | None, User | None]]: query = ( select(ChatMessage, ChatSession, User) + .outerjoin( + QuestionInsightCleanedQuestion, + and_( + QuestionInsightCleanedQuestion.message_id == ChatMessage.id, + QuestionInsightCleanedQuestion.cleaner_version == CLEANER_VERSION, + ), + ) .join(ChatSession, ChatSession.id == ChatMessage.session_id, isouter=True) .join(User, User.id == ChatMessage.user_id, isouter=True) - .where(ChatMessage.role == "user") + .where( + ChatMessage.role == "user", + QuestionInsightCleanedQuestion.id.is_(None), + ) ) if date_from is not None: query = query.where(ChatMessage.created_at >= date_from.replace(tzinfo=None)) @@ -170,6 +259,50 @@ def _load_user_messages( ) +def _load_persisted_cleaned_questions( + db: Session, + *, + date_from: datetime | None, + date_to: datetime | None, + limit: int, +) -> list[QuestionInsightCleanedQuestion]: + range_filters = [QuestionInsightCleanedQuestion.cleaner_version == CLEANER_VERSION] + if date_from is not None: + range_filters.append(QuestionInsightCleanedQuestion.source_created_at >= date_from.replace(tzinfo=None)) + if date_to is not None: + range_filters.append(QuestionInsightCleanedQuestion.source_created_at <= date_to.replace(tzinfo=None)) + + latest_messages = ( + select( + QuestionInsightCleanedQuestion.message_id.label("message_id"), + func.max(QuestionInsightCleanedQuestion.source_created_at).label("latest_at"), + ) + .where(*range_filters) + .group_by(QuestionInsightCleanedQuestion.message_id) + .order_by( + func.max(QuestionInsightCleanedQuestion.source_created_at).desc(), + QuestionInsightCleanedQuestion.message_id.desc(), + ) + .limit(limit) + .subquery() + ) + return list( + db.scalars( + select(QuestionInsightCleanedQuestion) + .join( + latest_messages, + QuestionInsightCleanedQuestion.message_id == latest_messages.c.message_id, + ) + .where(QuestionInsightCleanedQuestion.cleaner_version == CLEANER_VERSION) + .order_by( + QuestionInsightCleanedQuestion.source_created_at.desc(), + QuestionInsightCleanedQuestion.message_id.desc(), + QuestionInsightCleanedQuestion.part_index.asc(), + ) + ).all() + ) + + def _load_ai_logs( db: Session, *, @@ -223,6 +356,80 @@ def _clean_messages(messages: Iterable[tuple[ChatMessage, ChatSession | None, Us return cleaned, filtered_count +def _cleaned_records( + message: ChatMessage, + cleaned: list[CleanedQuestion], + filtered_count: int, +) -> list[QuestionInsightCleanedQuestion]: + source_hash = hashlib.sha256((message.content or "").encode("utf-8")).hexdigest() + if filtered_count: + return [ + QuestionInsightCleanedQuestion( + message_id=message.id, + session_id=message.session_id, + user_id=message.user_id, + part_index=-1, + cleaner_version=CLEANER_VERSION, + source_hash=source_hash, + cleaned_text="", + normalized_text="", + category="filtered", + tokens_json="[]", + accepted=0, + filtered_reason="低价值或无有效问题", + source_created_at=message.created_at, + ) + ] + + records: list[QuestionInsightCleanedQuestion] = [] + for part_index, question in enumerate(cleaned): + category, _category_label = _classify_text(f"{question.text}{question.normalized}") + records.append( + QuestionInsightCleanedQuestion( + message_id=message.id, + session_id=message.session_id, + user_id=message.user_id, + part_index=part_index, + cleaner_version=CLEANER_VERSION, + source_hash=source_hash, + cleaned_text=question.text, + normalized_text=question.normalized, + category=category, + tokens_json=json.dumps(sorted(question.tokens), ensure_ascii=False), + accepted=1, + filtered_reason=None, + source_created_at=message.created_at, + ) + ) + return records + + +def _question_from_persisted(item: QuestionInsightCleanedQuestion) -> CleanedQuestion: + try: + decoded_tokens = json.loads(item.tokens_json or "[]") + except (TypeError, ValueError, json.JSONDecodeError): + decoded_tokens = [] + tokens = { + str(token) + for token in decoded_tokens + if isinstance(token, str) and token + } + if not tokens: + tokens = _tokens(item.normalized_text) + return CleanedQuestion( + raw="", + text=item.cleaned_text, + normalized=item.normalized_text, + user_id=item.user_id, + user_name="", + user_phone="", + session_id=item.session_id, + message_id=item.message_id, + created_at=item.source_created_at, + tokens=tokens, + ) + + def _split_questions(content: str) -> list[str]: text = (content or "").strip() if not text: @@ -334,16 +541,47 @@ def _looks_more_question_like(candidate: str, current: str) -> bool: 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, ai_logs: list[AiRequestLog]) -> dict: +def _load_sample_context( + db: Session, + clusters: list[QuestionCluster], +) -> tuple[dict[int, str], dict[int, User]]: + sample_questions: list[CleanedQuestion] = [] + for cluster in clusters: + sample_questions.extend( + sorted(cluster.questions, key=lambda item: item.created_at, reverse=True)[:5] + ) + message_ids = {item.message_id for item in sample_questions} + user_ids = {item.user_id for item in sample_questions} + message_contents = { + message_id: content + for message_id, content in db.execute( + select(ChatMessage.id, ChatMessage.content).where(ChatMessage.id.in_(message_ids)) + ).all() + } if message_ids else {} + users = { + user.id: user + for user in db.scalars(select(User).where(User.id.in_(user_ids))).all() + } if user_ids else {} + return message_contents, users + + +def _cluster_dict( + rank: int, + cluster: QuestionCluster, + ai_logs: list[AiRequestLog], + *, + message_contents: dict[int, str], + users: dict[int, User], +) -> dict: questions = sorted(cluster.questions, key=lambda item: item.created_at, reverse=True) - users = {item.user_id for item in questions} + user_ids = {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) category, category_label = _classify_cluster(cluster) related_logs = _related_ai_logs(cluster, ai_logs) no_hit_count = sum(1 for item in related_logs if not item.knowledge_hit) - failed_count = sum(1 for item in related_logs if item.status != "SUCCESS") + failed_count = sum(1 for item in related_logs if (item.status or "").upper() != "SUCCESS") return { "rank": rank, "title": cluster.title, @@ -351,7 +589,7 @@ def _cluster_dict(rank: int, cluster: QuestionCluster, ai_logs: list[AiRequestLo "category": category, "categoryLabel": category_label, "count": len(questions), - "userCount": len(users), + "userCount": len(user_ids), "sessionCount": len(sessions), "aiRequestCount": len(related_logs), "noHitCount": no_hit_count, @@ -367,9 +605,9 @@ def _cluster_dict(rank: int, cluster: QuestionCluster, ai_logs: list[AiRequestLo "messageId": item.message_id, "sessionId": item.session_id, "userId": item.user_id, - "userName": item.user_name, - "userPhone": item.user_phone, - "raw": item.raw, + "userName": users[item.user_id].name if item.user_id in users else "", + "userPhone": users[item.user_id].phone if item.user_id in users else "", + "raw": message_contents.get(item.message_id, ""), "cleaned": item.text, "createdAt": item.created_at, } @@ -388,7 +626,10 @@ def _top_terms(questions: list[CleanedQuestion]) -> list[str]: def _classify_cluster(cluster: QuestionCluster) -> tuple[str, str]: - text = f"{cluster.title}{cluster.normalized}" + return _classify_text(f"{cluster.title}{cluster.normalized}") + + +def _classify_text(text: str) -> tuple[str, str]: rules = ( ("fixed_info", "固定信息", ("上课安排", "回放", "会议链接", "课程助理", "时间", "链接", "权益", "联系方式", "安排")), ("homework", "功课操作", ("功课", "练习", "作业", "怎么做", "步骤", "操作")), diff --git a/ai_knowledge_base_v2/apps/backend/tests/test_admin_pagination.py b/ai_knowledge_base_v2/apps/backend/tests/test_admin_pagination.py index 49a63a8..5ee06e8 100644 --- a/ai_knowledge_base_v2/apps/backend/tests/test_admin_pagination.py +++ b/ai_knowledge_base_v2/apps/backend/tests/test_admin_pagination.py @@ -2,12 +2,14 @@ from sqlalchemy import create_engine from datetime import datetime, timedelta from sqlalchemy.orm import Session from sqlalchemy.pool import StaticPool +from types import SimpleNamespace from app.api.admin_agent_records import attention_list, retrieval_logs -from app.api.admin_records import ai_logs, chat_detail, chat_messages, question_insights +from app.api.admin_records import ai_logs, chat_detail, chat_messages, question_insights, refresh_question_insights from app.api.admin_users import list_users from app.models import Base from app.models.chat import ChatMessage, ChatSession +from app.models.insight import QuestionInsightCleanedQuestion from app.models.knowledge import HumanAttentionRecord, KnowledgeRetrievalLog from app.models.logs import AiRequestLog from app.models.user import User @@ -129,6 +131,20 @@ def test_question_insights_clean_and_cluster_similar_user_questions(): db.add(AiRequestLog(session_id=1, user_id=1, status="SUCCESS", prompt="用户问题:心光有哪些作业?", knowledge_hit=0)) db.commit() + first_refresh = refresh_question_insights( + dateFrom=None, + dateTo=None, + maxMessages=100, + db=db, + current_admin=SimpleNamespace(id=1), + )["data"] + second_refresh = refresh_question_insights( + dateFrom=None, + dateTo=None, + maxMessages=100, + db=db, + current_admin=SimpleNamespace(id=1), + )["data"] response = question_insights( dateFrom=None, dateTo=None, @@ -141,6 +157,11 @@ def test_question_insights_clean_and_cluster_similar_user_questions(): ) data = response["data"] + assert first_refresh["processedMessages"] == 4 + assert first_refresh["acceptedQuestions"] == 4 + assert first_refresh["filteredMessages"] == 1 + assert second_refresh["processedMessages"] == 0 + assert db.query(QuestionInsightCleanedQuestion).count() == 5 assert data["summary"]["scannedMessages"] == 4 assert data["summary"]["filteredMessages"] == 1 assert data["summary"]["cleanedQuestions"] == 4 diff --git a/ai_knowledge_base_v2/docs/qianwen_product_todo.md b/ai_knowledge_base_v2/docs/qianwen_product_todo.md index 61521c1..2edaa2e 100644 --- a/ai_knowledge_base_v2/docs/qianwen_product_todo.md +++ b/ai_knowledge_base_v2/docs/qianwen_product_todo.md @@ -830,6 +830,8 @@ AI 日志增加: #### 开发进度 - 2026-07-31:二期第一步已在现有统计结果中增加问题分类、关联 AI 请求数、无知识命中次数、请求失败次数、是否需要知识跟进和运营处理建议;后台问题洞察卡片已展示分类标签、无命中/失败标记和建议动作。暂未新增持久化清洗表和人工合并/拆分能力,避免一次性扩大数据模型。 +- 2026-07-31:已新增 `sys_question_insight_cleaned_question` 持久化清洗表和清洗版本字段。后台“刷新并统计”只增量处理尚未清洗的用户消息;低价值消息也会写入过滤占位记录,避免后续反复读取聊天原文;统计、筛选和分页均直接读取清洗结果。并发刷新由数据库唯一约束和单消息事务隔离去重,不会重复沉淀同一消息。 +- 待继续:高频问题一键转知识库补充建议、人工合并/拆分问题组,以及清洗规则升级后的版本重建入口。 #### 验收标准