feat: RAG inline citations, source highlighting, and admin panel

- Increase RAG retrieval from 5 to 50 docs with relevance threshold (0.15)
- LLM inline citations with [来源N] format and reference list
- Clickable citation links scroll to source cards with highlight animation
- Source previews with full chunk text and answer-matched highlighting
- Message-scoped source IDs to fix cross-round citation targeting
- Admin panel pages (knowledge, users, forum, course, settings)
- Add rehype-raw dependency for HTML-in-markdown rendering

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 20:51:28 +08:00
parent 5d42a0573a
commit abcced35b8
23 changed files with 2249 additions and 112 deletions
+4
View File
@@ -185,6 +185,10 @@ from src.api import course_content, forum
app.include_router(course_content.router)
app.include_router(forum.router)
# 导入并注册后台管理API
from src.api import admin
app.include_router(admin.router)
# 静态文件服务
if os.path.exists(settings.upload_dir):
app.mount("/uploads", StaticFiles(directory=settings.upload_dir), name="uploads")
+511
View File
@@ -0,0 +1,511 @@
"""
后台管理API
"""
from typing import List, Optional
from datetime import datetime, timedelta
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlalchemy.orm import Session
from sqlalchemy import func, desc
from ..core.database import get_db
from ..core.security import get_current_user
from ..models.user import User
from ..models.chat import ChatSession, ChatMessage
from ..models.document import Document, DocumentChunk
from ..models.knowledge_base import KnowledgeBase
from ..models.forum import ForumCategory, ForumPost, ForumReply
router = APIRouter(prefix="/admin", tags=["后台管理"])
async def require_admin(
current_user: str = Depends(get_current_user),
db: Session = Depends(get_db)
) -> User:
"""验证当前用户是否为管理员"""
user = db.query(User).filter(User.username == current_user).first()
if not user or not user.is_superuser:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="需要管理员权限"
)
return user
# ===== 数据概览 =====
class AdminDashboardStats(BaseModel):
total_users: int
active_users_7d: int
total_sessions: int
total_messages: int
total_documents: int
total_knowledge_bases: int
total_forum_posts: int
total_forum_replies: int
total_generated_images: int
class AdminTrendItem(BaseModel):
date: str
count: int
@router.get("/dashboard", response_model=AdminDashboardStats)
async def get_dashboard(
admin: User = Depends(require_admin),
db: Session = Depends(get_db)
):
"""获取管理后台仪表盘数据"""
total_users = db.query(func.count(User.id)).scalar() or 0
# 7天内活跃用户(有消息的用户)
week_ago = datetime.utcnow() - timedelta(days=7)
active_users_7d = db.query(func.count(func.distinct(ChatMessage.session_id))).join(
ChatSession
).filter(
ChatMessage.created_at >= week_ago
).scalar() or 0
# 更准确的计算:有消息的独立用户数
active_users_7d = db.query(func.count(func.distinct(ChatSession.user_id))).join(
ChatMessage
).filter(
ChatMessage.created_at >= week_ago
).scalar() or 0
total_sessions = db.query(func.count(ChatSession.id)).scalar() or 0
total_messages = db.query(func.count(ChatMessage.id)).scalar() or 0
total_documents = db.query(func.count(Document.id)).scalar() or 0
total_knowledge_bases = db.query(func.count(KnowledgeBase.id)).scalar() or 0
total_forum_posts = db.query(func.count(ForumPost.id)).scalar() or 0
total_forum_replies = db.query(func.count(ForumReply.id)).scalar() or 0
# 生成图像数(从目录统计)
from pathlib import Path
from ..core.config import get_settings
settings = get_settings()
generated_images_dir = Path(settings.generated_images_dir)
total_generated_images = 0
if generated_images_dir.exists():
image_extensions = {'.png', '.jpg', '.jpeg', '.webp', '.gif'}
total_generated_images = sum(
1 for f in generated_images_dir.iterdir()
if f.is_file() and f.suffix.lower() in image_extensions
)
return AdminDashboardStats(
total_users=total_users,
active_users_7d=active_users_7d,
total_sessions=total_sessions,
total_messages=total_messages,
total_documents=total_documents,
total_knowledge_bases=total_knowledge_bases,
total_forum_posts=total_forum_posts,
total_forum_replies=total_forum_replies,
total_generated_images=total_generated_images,
)
@router.get("/trends/users")
async def get_user_trends(
days: int = 30,
admin: User = Depends(require_admin),
db: Session = Depends(get_db)
):
"""获取用户注册趋势"""
start_date = datetime.utcnow() - timedelta(days=days)
results = db.query(
func.date(User.created_at).label("date"),
func.count(User.id).label("count")
).filter(
User.created_at >= start_date
).group_by(
func.date(User.created_at)
).order_by("date").all()
return [{"date": str(r.date), "count": r.count} for r in results]
@router.get("/trends/messages")
async def get_message_trends(
days: int = 30,
admin: User = Depends(require_admin),
db: Session = Depends(get_db)
):
"""获取消息趋势"""
start_date = datetime.utcnow() - timedelta(days=days)
results = db.query(
func.date(ChatMessage.created_at).label("date"),
func.count(ChatMessage.id).label("count")
).filter(
ChatMessage.created_at >= start_date
).group_by(
func.date(ChatMessage.created_at)
).order_by("date").all()
return [{"date": str(r.date), "count": r.count} for r in results]
# ===== 用户管理 =====
class AdminUserItem(BaseModel):
id: int
username: str
email: str
full_name: Optional[str]
is_active: bool
is_superuser: bool
created_at: Optional[str]
last_login: Optional[str]
session_count: int
message_count: int
class Config:
from_attributes = True
@router.get("/users", response_model=List[AdminUserItem])
async def list_users(
skip: int = 0,
limit: int = 50,
admin: User = Depends(require_admin),
db: Session = Depends(get_db)
):
"""获取用户列表"""
users = db.query(User).order_by(desc(User.id)).offset(skip).limit(limit).all()
result = []
for u in users:
session_count = db.query(func.count(ChatSession.id)).filter(
ChatSession.user_id == u.id
).scalar() or 0
message_count = db.query(func.count(ChatMessage.id)).join(
ChatSession
).filter(ChatSession.user_id == u.id).scalar() or 0
result.append(AdminUserItem(
id=u.id,
username=u.username,
email=u.email,
full_name=u.full_name,
is_active=u.is_active,
is_superuser=u.is_superuser,
created_at=u.created_at.isoformat() if u.created_at else None,
last_login=u.last_login.isoformat() if u.last_login else None,
session_count=session_count,
message_count=message_count,
))
return result
@router.put("/users/{user_id}/toggle-active")
async def toggle_user_active(
user_id: int,
admin: User = Depends(require_admin),
db: Session = Depends(get_db)
):
"""启用/禁用用户"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
if user.id == admin.id:
raise HTTPException(status_code=400, detail="不能禁用自己")
user.is_active = not user.is_active
db.commit()
return {"success": True, "is_active": user.is_active}
@router.put("/users/{user_id}/toggle-admin")
async def toggle_user_admin(
user_id: int,
admin: User = Depends(require_admin),
db: Session = Depends(get_db)
):
"""设为/取消管理员"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
if user.id == admin.id:
raise HTTPException(status_code=400, detail="不能修改自己的管理员状态")
user.is_superuser = not user.is_superuser
db.commit()
return {"success": True, "is_superuser": user.is_superuser}
@router.delete("/users/{user_id}")
async def delete_user(
user_id: int,
admin: User = Depends(require_admin),
db: Session = Depends(get_db)
):
"""删除用户"""
user = db.query(User).filter(User.id == user_id).first()
if not user:
raise HTTPException(status_code=404, detail="用户不存在")
if user.id == admin.id:
raise HTTPException(status_code=400, detail="不能删除自己")
db.delete(user)
db.commit()
return {"success": True}
# ===== 论坛管理 =====
class AdminForumCategory(BaseModel):
id: int
slug: str
name: str
description: Optional[str]
post_count: int
class Config:
from_attributes = True
class AdminForumCategoryCreate(BaseModel):
name: str
slug: str
description: Optional[str] = None
@router.get("/forum/categories", response_model=List[AdminForumCategory])
async def list_forum_categories(
admin: User = Depends(require_admin),
db: Session = Depends(get_db)
):
"""获取论坛分类列表(管理)"""
categories = db.query(ForumCategory).order_by(ForumCategory.id).all()
result = []
for c in categories:
post_count = db.query(func.count(ForumPost.id)).filter(
ForumPost.category_id == c.id
).scalar() or 0
result.append(AdminForumCategory(
id=c.id,
slug=c.slug,
name=c.name,
description=c.description,
post_count=post_count,
))
return result
@router.post("/forum/categories", response_model=AdminForumCategory)
async def create_forum_category(
data: AdminForumCategoryCreate,
admin: User = Depends(require_admin),
db: Session = Depends(get_db)
):
"""创建论坛分类"""
existing = db.query(ForumCategory).filter(ForumCategory.slug == data.slug).first()
if existing:
raise HTTPException(status_code=400, detail="slug 已存在")
category = ForumCategory(
slug=data.slug,
name=data.name,
description=data.description,
)
db.add(category)
db.commit()
db.refresh(category)
return AdminForumCategory(
id=category.id,
slug=category.slug,
name=category.name,
description=category.description,
post_count=0,
)
@router.put("/forum/categories/{category_id}")
async def update_forum_category(
category_id: int,
data: AdminForumCategoryCreate,
admin: User = Depends(require_admin),
db: Session = Depends(get_db)
):
"""更新论坛分类"""
category = db.query(ForumCategory).filter(ForumCategory.id == category_id).first()
if not category:
raise HTTPException(status_code=404, detail="分类不存在")
category.name = data.name
category.slug = data.slug
category.description = data.description
db.commit()
return {"success": True}
@router.delete("/forum/categories/{category_id}")
async def delete_forum_category(
category_id: int,
admin: User = Depends(require_admin),
db: Session = Depends(get_db)
):
"""删除论坛分类"""
category = db.query(ForumCategory).filter(ForumCategory.id == category_id).first()
if not category:
raise HTTPException(status_code=404, detail="分类不存在")
# 删除该分类下的所有帖子和回复
posts = db.query(ForumPost).filter(ForumPost.category_id == category_id).all()
for post in posts:
db.query(ForumReply).filter(ForumReply.post_id == post.id).delete()
db.query(ForumPost).filter(ForumPost.category_id == category_id).delete()
db.delete(category)
db.commit()
return {"success": True}
class AdminForumPost(BaseModel):
id: int
title: str
author_name: str
category_name: str
reply_count: int
created_at: str
@router.get("/forum/posts", response_model=List[AdminForumPost])
async def list_forum_posts(
skip: int = 0,
limit: int = 50,
admin: User = Depends(require_admin),
db: Session = Depends(get_db)
):
"""获取所有帖子列表(管理)"""
posts = db.query(ForumPost).order_by(desc(ForumPost.created_at)).offset(skip).limit(limit).all()
result = []
for p in posts:
author = db.query(User).filter(User.id == p.user_id).first()
category = db.query(ForumCategory).filter(ForumCategory.id == p.category_id).first()
reply_count = db.query(func.count(ForumReply.id)).filter(
ForumReply.post_id == p.id
).scalar() or 0
result.append(AdminForumPost(
id=p.id,
title=p.title,
author_name=author.username if author else "未知",
category_name=category.name if category else "未知",
reply_count=reply_count,
created_at=p.created_at.isoformat() if p.created_at else "",
))
return result
@router.delete("/forum/posts/{post_id}")
async def delete_forum_post(
post_id: int,
admin: User = Depends(require_admin),
db: Session = Depends(get_db)
):
"""删除帖子"""
post = db.query(ForumPost).filter(ForumPost.id == post_id).first()
if not post:
raise HTTPException(status_code=404, detail="帖子不存在")
db.query(ForumReply).filter(ForumReply.post_id == post_id).delete()
db.delete(post)
db.commit()
return {"success": True}
# ===== 知识库管理 =====
class AdminKnowledgeBase(BaseModel):
id: int
name: str
description: Optional[str]
owner_name: str
is_system: bool
document_count: int
chunk_count: int
created_at: str
@router.get("/knowledge-bases", response_model=List[AdminKnowledgeBase])
async def list_knowledge_bases(
admin: User = Depends(require_admin),
db: Session = Depends(get_db)
):
"""获取所有知识库"""
kbs = db.query(KnowledgeBase).order_by(desc(KnowledgeBase.id)).all()
result = []
for kb in kbs:
owner = db.query(User).filter(User.id == kb.user_id).first()
doc_count = db.query(func.count(Document.id)).filter(
Document.knowledge_base_id == kb.id
).scalar() or 0
chunk_count = db.query(func.count(DocumentChunk.id)).join(
Document
).filter(Document.knowledge_base_id == kb.id).scalar() or 0
result.append(AdminKnowledgeBase(
id=kb.id,
name=kb.name,
description=kb.description,
owner_name=owner.username if owner else "系统",
is_system=kb.is_system,
document_count=doc_count,
chunk_count=chunk_count,
created_at=kb.created_at.isoformat() if kb.created_at else "",
))
return result
@router.delete("/knowledge-bases/{kb_id}")
async def delete_knowledge_base(
kb_id: int,
admin: User = Depends(require_admin),
db: Session = Depends(get_db)
):
"""删除知识库"""
kb = db.query(KnowledgeBase).filter(KnowledgeBase.id == kb_id).first()
if not kb:
raise HTTPException(status_code=404, detail="知识库不存在")
# 删除关联文档和chunks
docs = db.query(Document).filter(Document.knowledge_base_id == kb_id).all()
for doc in docs:
db.query(DocumentChunk).filter(DocumentChunk.document_id == doc.id).delete()
db.query(Document).filter(Document.knowledge_base_id == kb_id).delete()
db.delete(kb)
db.commit()
return {"success": True}
# ===== 系统状态 =====
@router.get("/system/status")
async def get_system_status(
admin: User = Depends(require_admin),
db: Session = Depends(get_db)
):
"""获取系统状态"""
from ..core.config import get_settings
settings = get_settings()
# 数据库状态
db_ok = True
try:
db.execute(func.now())
except Exception:
db_ok = False
# 向量库状态
vector_store_ok = True
vector_count = 0
try:
from ..rag.vector_store import VectorStore
vs = VectorStore()
collection = vs.get_or_create_collection("knowledge_base")
vector_count = collection.count()
except Exception:
vector_store_ok = False
return {
"database": {"status": "ok" if db_ok else "error"},
"vector_store": {"status": "ok" if vector_store_ok else "error", "vector_count": vector_count},
"llm_model": settings.siliconflow_model,
"embedding_model": "text2vec-base-chinese",
}
+1 -1
View File
@@ -242,7 +242,7 @@ async def stream_message(
rag_chain = create_rag_chain(
knowledge_base_ids=request.knowledge_base_ids,
search_type="similarity",
k=5,
k=50,
model=request.model
)
+21 -10
View File
@@ -18,8 +18,8 @@ class RAGChain:
self,
knowledge_base_ids: Optional[List[int]] = None,
search_type: str = "similarity",
k: int = 5,
score_threshold: float = 0.1,
k: int = 50,
score_threshold: float = 0.15,
model: Optional[str] = None
):
"""初始化RAG链
@@ -58,8 +58,8 @@ class RAGChain:
"""创建检索链(LangChain 1.0 Runnable API"""
# 使用LangChain 1.0的runnable API构建RAG链
def format_docs(docs):
"""格式化文档"""
return "\n\n".join(doc.page_content for doc in docs)
"""格式化文档(使用带编号的上下文格式)"""
return self._format_docs_for_context(docs)
# 构建RAG链 - 只检索一次,返回完整结果
def rag_with_sources(input_data):
@@ -115,7 +115,7 @@ class RAGChain:
"""流式调用(只流式输出答案)"""
# 先获取文档(这是唯一一次检索)
docs = await self.retriever.ainvoke(question)
context = "\n\n".join(doc.page_content for doc in docs)
context = self._format_docs_for_context(docs)
# 构建prompt
prompt_value = await self.prompt.ainvoke({"context": context, "question": question})
@@ -143,7 +143,7 @@ class RAGChain:
# 发送检索结果(只在有文档时)
if docs:
doc_details = []
for i, doc in enumerate(docs[:5]):
for i, doc in enumerate(docs[:10]):
metadata = doc.metadata if hasattr(doc, 'metadata') else {}
title = metadata.get("title", metadata.get("filename", f"文档 {i+1}"))
preview = doc.page_content[:100].replace('\n', ' ')
@@ -158,7 +158,7 @@ class RAGChain:
"details": doc_details
}
context = "\n\n".join(doc.page_content for doc in docs)
context = self._format_docs_for_context(docs)
# 2. 构建prompt并流式生成
prompt_value = await self.prompt.ainvoke({"context": context, "question": question})
@@ -190,20 +190,31 @@ class RAGChain:
def _format_sources(self, documents: List) -> List[Dict]:
"""格式化来源信息"""
sources = []
for doc in documents:
for i, doc in enumerate(documents, start=1):
metadata = doc.metadata if hasattr(doc, 'metadata') else {}
sources.append({
"id": i,
"title": metadata.get("title", "未知标题"),
"filename": metadata.get("filename", "未知文件"),
"page": metadata.get("chunk_index", 0),
"preview": doc.page_content[:200] + "..." if len(doc.page_content) > 200 else doc.page_content
"score": metadata.get("score"),
"preview": doc.page_content
})
return sources
@staticmethod
def _format_docs_for_context(docs) -> str:
"""将文档列表格式化为带编号的LLM上下文字符串"""
parts = []
for i, doc in enumerate(docs, start=1):
source_title = doc.metadata.get("title", doc.metadata.get("filename", "未知"))
parts.append(f"[来源{i}](来源:{source_title}\n{doc.page_content}")
return "\n\n".join(parts)
def create_rag_chain(
knowledge_base_ids: Optional[List[int]] = None,
search_type: str = "similarity",
k: int = 5,
k: int = 50,
model: Optional[str] = None
) -> RAGChain:
"""创建RAG链实例
+9 -6
View File
@@ -7,15 +7,18 @@ from langchain_core.messages import SystemMessage, HumanMessage
# RAG系统提示词
RAG_SYSTEM_PROMPT = """你是一个专业的国土空间规划知识问答助手。请基于以下上下文信息回答用户的问题。
上下文信息:
上下文信息(每个来源已标注编号,如 [来源1]、[来源2])
{context}
要求:
1. 回答要准确、专业、详细
2. 如果上下文中没有相关信息,请诚实说明
3. 回答要结构清晰,逻辑性强
4. 适当引用相关概念和术语
5. 回答长度控制在500-1000字之间
1. 综合评估上下文信息的相关性,优先采用与问题最相关的内容,不强行引用不相关的来源
2. 回答中必须使用 [来源N] 格式(N为上下文中的来源编号)标注所引用的具体来源,例如"根据[来源3],国土空间规划..."
3. 回答要准确、专业、详细,结构清晰,逻辑性强
4. 如果上下文中没有相关信息,请诚实说明
5. 回答末尾,列出所有实际引用的参考来源,格式为:
**参考来源:**
- [来源N] 文档标题
6. 回答长度控制在500-1000字之间
请基于上述上下文信息回答用户的问题。"""
+1 -1
View File
@@ -14,7 +14,7 @@ class KnowledgeBaseRetriever(BaseRetriever):
knowledge_base_ids: Optional[List[int]] = None
search_type: str = "similarity"
search_kwargs: dict = {"k": 5}
score_threshold: float = 0.1
score_threshold: float = 0.15
def _get_relevant_documents(
self,