feat: v1.2.0 - 图片去重与管理、微信机器人优化、搜索设置可配置
主要功能: - 图片上传时 OCR 内容去重(3个上传端点统一使用公共函数 _check_ocr_duplicate) - 图片管理 Tab:展示所有图片、手动删除、一键去重 - 搜索结果详情弹窗增加删除按钮(带确认弹窗) - 图片管理卡片点击查看详情(复用 showOcrDetailModal) - 搜索限制和 LLM 批量判断数量可通过网站设置 - MiniMax API 调用添加 reasoning_split=True - 企业微信机器人:WebSocket 长连接、图片搜索、配置化搜索数量 - 版本号升级至 1.2.0
This commit is contained in:
297
app/mcp/server.py
Normal file
297
app/mcp/server.py
Normal file
@@ -0,0 +1,297 @@
|
||||
"""
|
||||
MCP Server 实现
|
||||
基于 MCP SDK 提供知识库查询工具,供 AI Agent 调用
|
||||
|
||||
工具列表:
|
||||
1. search_knowledge - 语义搜索知识库
|
||||
2. get_page - 获取知识页面详情
|
||||
3. list_courses - 列出所有课程
|
||||
4. get_page_chunks - 获取页面的分块内容
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
from mcp.server.fastmcp import FastMCP
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# ──────────────────────────── 创建 MCP Server ────────────────────────────
|
||||
|
||||
mcp = FastMCP(
|
||||
name="EduBrain-Knowledge",
|
||||
instructions=(
|
||||
"中文直播教育知识库助手。你可以使用以下工具来搜索和查询知识库中的内容:\n"
|
||||
"- search_knowledge: 语义搜索知识库,支持按课程、讲师、日期过滤\n"
|
||||
"- get_page: 获取指定知识页面的完整内容\n"
|
||||
"- list_courses: 列出知识库中所有课程\n"
|
||||
"- get_page_chunks: 获取页面的分块内容(用于精确定位)\n"
|
||||
"- search_images: 根据关键字搜索已 OCR 识别的聊天截图,返回图片路径"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────── 工具定义 ────────────────────────────
|
||||
|
||||
@mcp.tool()
|
||||
async def search_knowledge(
|
||||
query: str,
|
||||
top_k: int = 5,
|
||||
course_name: str = "",
|
||||
teacher_name: str = "",
|
||||
threshold: float = 0.5,
|
||||
) -> str:
|
||||
"""
|
||||
语义搜索知识库。
|
||||
|
||||
Args:
|
||||
query: 搜索查询文本(自然语言描述你要查找的知识点)
|
||||
top_k: 返回结果数量,默认 5
|
||||
course_name: 按课程名称过滤(可选)
|
||||
teacher_name: 按讲师名称过滤(可选)
|
||||
threshold: 相似度阈值(0-1),默认 0.5
|
||||
|
||||
Returns:
|
||||
搜索结果列表,包含标题、内容摘要和相似度分数
|
||||
"""
|
||||
from app.database import async_session_factory
|
||||
from app.schemas.search import SearchRequest
|
||||
from app.services.search_service import SearchService
|
||||
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
request = SearchRequest(
|
||||
query=query,
|
||||
top_k=top_k,
|
||||
course_name=course_name or None,
|
||||
teacher_name=teacher_name or None,
|
||||
threshold=threshold,
|
||||
use_fulltext=True,
|
||||
)
|
||||
service = SearchService(session)
|
||||
response = await service.search(request)
|
||||
|
||||
if not response.results:
|
||||
return f"未找到与「{query}」相关的知识内容。"
|
||||
|
||||
# 格式化搜索结果
|
||||
output_parts = [
|
||||
f"找到 {response.total} 条相关结果(耗时 {response.elapsed_ms:.0f}ms):\n"
|
||||
]
|
||||
for i, result in enumerate(response.results, 1):
|
||||
output_parts.append(
|
||||
f"【{i}】{result.page_title} "
|
||||
f"(相似度: {result.score:.2%})"
|
||||
)
|
||||
if result.course_name:
|
||||
output_parts.append(f" 课程: {result.course_name}")
|
||||
if result.teacher_name:
|
||||
output_parts.append(f" 讲师: {result.teacher_name}")
|
||||
# 截断内容避免过长
|
||||
content = result.content[:200]
|
||||
if len(result.content) > 200:
|
||||
content += "..."
|
||||
output_parts.append(f" 内容: {content}")
|
||||
output_parts.append("")
|
||||
|
||||
return "\n".join(output_parts)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("MCP 搜索失败: %s", e, exc_info=True)
|
||||
return f"搜索出错: {str(e)}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def get_page(page_id: int) -> str:
|
||||
"""
|
||||
获取指定知识页面的完整内容。
|
||||
|
||||
Args:
|
||||
page_id: 知识页面 ID
|
||||
|
||||
Returns:
|
||||
页面的完整内容,包括标题、正文、元数据
|
||||
"""
|
||||
from app.database import async_session_factory
|
||||
from app.services.page_service import PageService
|
||||
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
service = PageService(session)
|
||||
page = await service.get_page(page_id)
|
||||
|
||||
if page is None:
|
||||
return f"未找到 ID 为 {page_id} 的知识页面。"
|
||||
|
||||
parts = [
|
||||
f"标题: {page.title}",
|
||||
]
|
||||
if page.course_name:
|
||||
parts.append(f"课程: {page.course_name}")
|
||||
if page.teacher_name:
|
||||
parts.append(f"讲师: {page.teacher_name}")
|
||||
if page.live_date:
|
||||
parts.append(f"直播日期: {page.live_date}")
|
||||
parts.append(f"\n{page.content}")
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("MCP 获取页面失败: %s", e, exc_info=True)
|
||||
return f"获取页面出错: {str(e)}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def list_courses() -> str:
|
||||
"""
|
||||
列出知识库中所有课程。
|
||||
|
||||
Returns:
|
||||
课程列表,包含课程名称、讲师、页面数量等信息
|
||||
"""
|
||||
from sqlalchemy import text
|
||||
from app.database import async_session_factory
|
||||
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
result = await session.execute(text("""
|
||||
SELECT
|
||||
course_name,
|
||||
teacher_name,
|
||||
COUNT(*) as page_count,
|
||||
MIN(live_date) as earliest_date,
|
||||
MAX(live_date) as latest_date
|
||||
FROM knowledge_pages
|
||||
WHERE course_name IS NOT NULL
|
||||
GROUP BY course_name, teacher_name
|
||||
ORDER BY page_count DESC
|
||||
"""))
|
||||
rows = result.fetchall()
|
||||
|
||||
if not rows:
|
||||
return "知识库中暂无课程数据。"
|
||||
|
||||
parts = [f"共 {len(rows)} 个课程:\n"]
|
||||
for i, row in enumerate(rows, 1):
|
||||
parts.append(
|
||||
f"{i}. {row.course_name}"
|
||||
f"(讲师: {row.teacher_name or '未知'},"
|
||||
f"页面: {row.page_count},"
|
||||
f"日期: {row.earliest_date or '未知'} ~ {row.latest_date or '未知'})"
|
||||
)
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("MCP 列出课程失败: %s", e, exc_info=True)
|
||||
return f"列出课程出错: {str(e)}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def search_images(
|
||||
keyword: str,
|
||||
top_k: int = 10,
|
||||
) -> str:
|
||||
"""
|
||||
根据关键字搜索已 OCR 识别的聊天截图。
|
||||
|
||||
Args:
|
||||
keyword: 搜索关键字(在 OCR 识别文本中查找)
|
||||
top_k: 返回结果数量,默认 10
|
||||
|
||||
Returns:
|
||||
匹配的图片列表,包含图片路径和 OCR 文本预览
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
from app.database import async_session_factory
|
||||
from app.models.base import OCRImage
|
||||
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
like_pattern = f"%{keyword}%"
|
||||
result = await session.execute(
|
||||
select(OCRImage)
|
||||
.where(
|
||||
OCRImage.status == "completed",
|
||||
OCRImage.ocr_text.ilike(like_pattern),
|
||||
)
|
||||
.order_by(OCRImage.created_at.desc())
|
||||
.limit(top_k)
|
||||
)
|
||||
images = result.scalars().all()
|
||||
|
||||
if not images:
|
||||
return f"未找到包含「{keyword}」的图片。"
|
||||
|
||||
parts = [f"找到 {len(images)} 张包含「{keyword}」的图片:\n"]
|
||||
for i, img in enumerate(images, 1):
|
||||
preview = (img.ocr_text[:100] + "...") if img.ocr_text and len(img.ocr_text) > 100 else (img.ocr_text or "")
|
||||
parts.append(
|
||||
f"【{i}】图片路径: {img.file_path}\n"
|
||||
f" OCR 预览: {preview}\n"
|
||||
f" 置信度: {img.confidence:.0%} | 识别方式: {img.provider}\n"
|
||||
)
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("MCP 图片搜索失败: %s", e, exc_info=True)
|
||||
return f"图片搜索出错: {str(e)}"
|
||||
|
||||
|
||||
@mcp.tool()
|
||||
async def get_page_chunks(page_id: int) -> str:
|
||||
"""
|
||||
获取知识页面的分块内容(用于精确定位知识点)。
|
||||
|
||||
Args:
|
||||
page_id: 知识页面 ID
|
||||
|
||||
Returns:
|
||||
该页面的所有分块内容列表
|
||||
"""
|
||||
from app.database import async_session_factory
|
||||
from app.services.page_service import PageService
|
||||
|
||||
async with async_session_factory() as session:
|
||||
try:
|
||||
service = PageService(session)
|
||||
chunks = await service.get_page_chunks(page_id)
|
||||
|
||||
if not chunks:
|
||||
return f"页面 {page_id} 没有分块数据。"
|
||||
|
||||
parts = [f"页面 {page_id} 共 {len(chunks)} 个分块:\n"]
|
||||
for i, chunk in enumerate(chunks, 1):
|
||||
content = chunk.content[:300]
|
||||
if len(chunk.content) > 300:
|
||||
content += "..."
|
||||
parts.append(f"【分块 {i}】(ID: {chunk.id})\n{content}\n")
|
||||
|
||||
return "\n".join(parts)
|
||||
|
||||
except Exception as e:
|
||||
logger.error("MCP 获取分块失败: %s", e, exc_info=True)
|
||||
return f"获取分块出错: {str(e)}"
|
||||
|
||||
|
||||
# ──────────────────────────── 启动入口 ────────────────────────────
|
||||
|
||||
def run_mcp_server(transport: str = "stdio", port: int = 8001):
|
||||
"""
|
||||
启动 MCP Server。
|
||||
|
||||
Args:
|
||||
transport: 传输协议,支持 "stdio"(默认)或 "sse"
|
||||
port: SSE 模式下的端口号
|
||||
"""
|
||||
if transport == "sse":
|
||||
mcp.run(transport="sse", port=port)
|
||||
else:
|
||||
mcp.run(transport="stdio")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run_mcp_server()
|
||||
Reference in New Issue
Block a user