diff --git a/ai_knowledge_base_v2/apps/admin-web/src/styles.css b/ai_knowledge_base_v2/apps/admin-web/src/styles.css
index 8c23317..e42fd68 100644
--- a/ai_knowledge_base_v2/apps/admin-web/src/styles.css
+++ b/ai_knowledge_base_v2/apps/admin-web/src/styles.css
@@ -1956,6 +1956,16 @@ textarea {
margin: 12px 0;
}
+.question-insight-action {
+ margin: 0 0 12px;
+ padding: 10px 12px;
+ border-radius: 10px;
+ background: #f6faf8;
+ color: #50655e;
+ font-size: 13px;
+ line-height: 1.6;
+}
+
.question-variants {
display: flex;
flex-wrap: wrap;
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 46b848c..e91f81c 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
@@ -567,9 +567,16 @@ export interface QuestionInsightCluster {
rank: number;
title: string;
normalized: string;
+ category: string;
+ categoryLabel: string;
count: number;
userCount: number;
sessionCount: number;
+ aiRequestCount: number;
+ noHitCount: number;
+ failedCount: number;
+ needsKnowledgeFollowUp: boolean;
+ suggestedAction: string;
firstSeenAt: string;
lastSeenAt: string;
topTerms: string[];
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 eec0a29..73bcb8a 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
@@ -11,6 +11,7 @@ from sqlalchemy import select
from sqlalchemy.orm import Session
from app.models.chat import ChatMessage, ChatSession
+from app.models.logs import AiRequestLog
from app.models.user import User
@@ -116,6 +117,7 @@ class QuestionInsightService:
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)
+ 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]
visible_clusters.sort(key=lambda item: (len(item.questions), item.questions[-1].created_at), reverse=True)
@@ -137,7 +139,7 @@ class QuestionInsightService:
"visibleClusterCount": total,
"minCount": min_count,
},
- "items": [_cluster_dict(index + offset + 1, cluster) for index, cluster in enumerate(page_clusters)],
+ "items": [_cluster_dict(index + offset + 1, cluster, ai_logs) for index, cluster in enumerate(page_clusters)],
"total": total,
"page": page,
"pageSize": page_size,
@@ -168,6 +170,21 @@ def _load_user_messages(
)
+def _load_ai_logs(
+ db: Session,
+ *,
+ date_from: datetime | None,
+ date_to: datetime | None,
+ limit: int,
+) -> list[AiRequestLog]:
+ query = select(AiRequestLog)
+ if date_from is not None:
+ query = query.where(AiRequestLog.created_at >= date_from.replace(tzinfo=None))
+ if date_to is not None:
+ query = query.where(AiRequestLog.created_at <= date_to.replace(tzinfo=None))
+ return list(db.scalars(query.order_by(AiRequestLog.created_at.desc(), AiRequestLog.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
@@ -317,19 +334,30 @@ 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) -> dict:
+def _cluster_dict(rank: int, cluster: QuestionCluster, ai_logs: list[AiRequestLog]) -> 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)
+ 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")
return {
"rank": rank,
"title": cluster.title,
"normalized": cluster.normalized,
+ "category": category,
+ "categoryLabel": category_label,
"count": len(questions),
"userCount": len(users),
"sessionCount": len(sessions),
+ "aiRequestCount": len(related_logs),
+ "noHitCount": no_hit_count,
+ "failedCount": failed_count,
+ "needsKnowledgeFollowUp": _needs_knowledge_follow_up(category, no_hit_count, failed_count),
+ "suggestedAction": _suggested_action(category, no_hit_count, failed_count),
"firstSeenAt": min(item.created_at for item in questions),
"lastSeenAt": max(item.created_at for item in questions),
"topTerms": terms,
@@ -357,3 +385,65 @@ def _top_terms(questions: list[CleanedQuestion]) -> list[str]:
if len(token) >= 2 and not token.isdigit():
counter[token] += 1
return [term for term, _count in counter.most_common(8)]
+
+
+def _classify_cluster(cluster: QuestionCluster) -> tuple[str, str]:
+ text = f"{cluster.title}{cluster.normalized}"
+ rules = (
+ ("fixed_info", "固定信息", ("上课安排", "回放", "会议链接", "课程助理", "时间", "链接", "权益", "联系方式", "安排")),
+ ("homework", "功课操作", ("功课", "练习", "作业", "怎么做", "步骤", "操作")),
+ ("course_knowledge", "课程知识", ("是什么", "区别", "意思", "概念", "课程", "知识")),
+ ("emotion", "情绪梳理", ("情绪", "身体", "感受", "害怕", "愤怒", "委屈", "抗拒", "焦虑", "释放")),
+ ("service", "服务权益", ("费用", "退款", "续费", "权益", "名额", "有效期", "购买")),
+ )
+ for key, label, keywords in rules:
+ if any(keyword in text for keyword in keywords):
+ return key, label
+ return "other", "其他问题"
+
+
+def _related_ai_logs(cluster: QuestionCluster, ai_logs: list[AiRequestLog]) -> list[AiRequestLog]:
+ session_ids = {item.session_id for item in cluster.questions}
+ matched: list[AiRequestLog] = []
+ for log in ai_logs:
+ if log.session_id not in session_ids:
+ continue
+ prompt = _normalize_question(_prompt_question_text(log.prompt or ""))
+ if not prompt:
+ continue
+ score = max(SequenceMatcher(None, cluster.normalized, prompt).ratio(), _containment(cluster.normalized, prompt))
+ if score >= 0.42 or cluster.normalized in prompt:
+ matched.append(log)
+ return matched
+
+
+def _prompt_question_text(prompt: str) -> str:
+ lines = [line.strip() for line in prompt.splitlines() if line.strip()]
+ for line in lines[:12]:
+ if line.startswith("用户问题") or line.startswith("问题") or line.startswith("用户提问"):
+ return line
+ return "\n".join(lines[:4])
+
+
+def _needs_knowledge_follow_up(category: str, no_hit_count: int, failed_count: int) -> bool:
+ if failed_count > 0:
+ return True
+ if no_hit_count <= 0:
+ return False
+ return category in {"fixed_info", "homework", "course_knowledge", "service"}
+
+
+def _suggested_action(category: str, no_hit_count: int, failed_count: int) -> str:
+ if failed_count > 0:
+ return "优先查看 AI 请求失败原因,确认模型或工具链是否异常。"
+ if no_hit_count > 0 and category == "fixed_info":
+ return "建议优先补充或更新固定信息类知识库,并确认该库处于开放状态。"
+ if no_hit_count > 0 and category in {"homework", "course_knowledge"}:
+ return "建议检查课程知识库切片和召回结果,必要时补充课程章节或关键词。"
+ if no_hit_count > 0:
+ return "建议抽样查看原始对话,判断是否需要补知识库或优化无命中回答。"
+ if category == "emotion":
+ return "建议抽样检查回答是否回到当下、身体感受和觉察方向,避免建议过深过多。"
+ if category == "service":
+ return "建议确认服务权益和运营说明是否已有固定信息沉淀。"
+ return "建议抽样查看相似问法,判断是否需要沉淀成知识库补充项。"
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 bc75560..49a63a8 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
@@ -126,6 +126,7 @@ def test_question_insights_clean_and_cluster_similar_user_questions():
ChatMessage(id=5, session_id=1, user_id=1, role="user", content="1、回放在哪里看?\n2、上课链接在哪", created_at=now + timedelta(minutes=3)),
]
)
+ db.add(AiRequestLog(session_id=1, user_id=1, status="SUCCESS", prompt="用户问题:心光有哪些作业?", knowledge_hit=0))
db.commit()
response = question_insights(
@@ -147,3 +148,6 @@ def test_question_insights_clean_and_cluster_similar_user_questions():
assert data["items"][0]["count"] == 2
assert "心光" in data["items"][0]["title"]
assert data["items"][0]["userCount"] == 2
+ assert data["items"][0]["category"] == "homework"
+ assert data["items"][0]["noHitCount"] == 1
+ assert data["items"][0]["needsKnowledgeFollowUp"] is True
diff --git a/ai_knowledge_base_v2/docs/qianwen_product_todo.md b/ai_knowledge_base_v2/docs/qianwen_product_todo.md
index d106fe9..61521c1 100644
--- a/ai_knowledge_base_v2/docs/qianwen_product_todo.md
+++ b/ai_knowledge_base_v2/docs/qianwen_product_todo.md
@@ -827,6 +827,10 @@ AI 日志增加:
- 标记召回失败问题;
- 支持人工合并/拆分问题组。
+#### 开发进度
+
+- 2026-07-31:二期第一步已在现有统计结果中增加问题分类、关联 AI 请求数、无知识命中次数、请求失败次数、是否需要知识跟进和运营处理建议;后台问题洞察卡片已展示分类标签、无命中/失败标记和建议动作。暂未新增持久化清洗表和人工合并/拆分能力,避免一次性扩大数据模型。
+
#### 验收标准
- 能看到高频问题趋势;