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:
EduBrain Dev
2026-04-13 22:25:08 +08:00
commit b17786b57b
56 changed files with 9300 additions and 0 deletions

479
app/services/llm_service.py Normal file
View File

@@ -0,0 +1,479 @@
"""
LLM 服务模块
提供统一的 LLM 调用接口,用于查询扩展、标签提取等任务
支持 MiniMax / DeepSeek / OpenAI均为 OpenAI 兼容格式)
"""
from __future__ import annotations
import httpx
import json
import logging
import re
from typing import List, Optional
from app.config import settings
logger = logging.getLogger(__name__)
class LLMService:
"""
LLM 服务类
根据配置的 LLM_PROVIDER 自动选择对应的 API 端点。
所有支持的提供商均兼容 OpenAI Chat Completions 格式。
"""
_client = None
@classmethod
def _get_client(cls):
"""延迟初始化 OpenAI 兼容客户端"""
if cls._client is not None:
return cls._client
from openai import AsyncOpenAI
provider = settings.LLM_PROVIDER.lower()
if provider == "minimax":
cls._client = AsyncOpenAI(
api_key=settings.MINIMAX_API_KEY or "EMPTY",
base_url=settings.MINIMAX_BASE_URL,
)
cls._model = settings.MINIMAX_CHAT_MODEL
elif provider == "deepseek":
cls._client = AsyncOpenAI(
api_key=settings.DEEPSEEK_API_KEY or "EMPTY",
base_url=settings.DEEPSEEK_BASE_URL,
)
cls._model = settings.DEEPSEEK_OCR_MODEL
elif provider == "openai":
cls._client = AsyncOpenAI(
api_key=settings.OPENAI_API_KEY or "EMPTY",
base_url=settings.OPENAI_BASE_URL,
)
cls._model = settings.EMBEDDING_MODEL
else:
raise ValueError(f"不支持的 LLM 提供商: {provider}")
logger.info("LLM 服务初始化完成: provider=%s, model=%s", provider, cls._model)
return cls._client
@staticmethod
def _clean_reasoning_content(content: str) -> str:
"""清理推理模型的思考过程,只保留最终回答"""
import re
# 方式1: 清理 🧠...💠 标签MiniMax M2.7 reasoning 模式)
content = re.sub(r'🧠[\s\S]*?💠', '', content)
# 方式2: 清理 💭(U+1F4AD) 包裹的思考块
if '💭' in content:
parts = content.split('💭')
answer_parts = [parts[i] for i in range(len(parts)) if i % 2 == 1]
if answer_parts:
content = ''.join(answer_parts)
# 方式3: 清理 </think 标签M2.7 有时使用)
think_end = content.find('</think')
if think_end >= 0:
after = content[think_end + len('</think'):]
after = re.sub(r'^[>\n\s]*', '', after)
content = after
# 方式4: 清理 \x0f 分隔的思考块
if '\x0f' in content:
parts = content.split('\x0f')
content = ''.join(parts[1::2]) if len(parts) > 1 else parts[-1] if parts else content
# 方式5: 清理 0x00 等特殊字符
content = content.replace('\x00', '').strip()
# 方式6: 如果内容仍然很长(说明还有思考残留),取最后一个短段落作为答案
if len(content) > 100 and '\n\n' in content:
segments = [s.strip() for s in content.split('\n\n')]
for seg in reversed(segments):
if seg and len(seg) <= 100:
content = seg
break
return content.strip()
@classmethod
async def chat(cls, messages: List[dict], temperature: float = 0.3, max_tokens: int = 1024) -> str:
"""
发送聊天请求。
Args:
messages: 消息列表,格式 [{"role": "system/user/assistant", "content": "..."}]
temperature: 采样温度
max_tokens: 最大生成 token 数
Returns:
模型回复文本(已清理思考过程)
"""
client = cls._get_client()
# MiniMax 模型默认开启思考模式,使用 reasoning_split 将思考内容
# 分离到 reasoning_details 字段content 只保留最终回答
extra_body = {}
if settings.LLM_PROVIDER.lower() == "minimax":
extra_body["reasoning_split"] = True
response = await client.chat.completions.create(
model=cls._model,
messages=messages,
temperature=temperature,
max_tokens=max_tokens,
**({"extra_body": extra_body} if extra_body else {}),
)
content = response.choices[0].message.content or ""
# 兜底清理:以防 reasoning_split 未生效或使用了其他模型
content = cls._clean_reasoning_content(content)
return content
@classmethod
async def expand_query(cls, query: str) -> List[str]:
"""
查询扩展:将原始查询改写为多个语义相近的表述。
用于混合搜索时提高召回率。
Args:
query: 原始查询文本
Returns:
扩展后的查询列表(包含原始查询)
"""
messages = [
{
"role": "system",
"content": (
"你是一个搜索查询优化助手。用户会给你一个搜索查询,"
"你需要生成 2 个语义相近但表述不同的替代查询。"
"只输出 JSON 数组格式,不要添加任何其他文字。"
'例如:["原始查询", "替代查询1", "替代查询2"]'
),
},
{
"role": "user",
"content": f"请为以下查询生成 2 个替代表述:{query}",
},
]
try:
result = await cls.chat(messages, temperature=0.3, max_tokens=256)
# 尝试解析 JSON
result = result.strip()
if result.startswith("```"):
result = result.split("\n", 1)[-1]
if result.endswith("```"):
result = result[:-3]
result = result.strip()
queries = json.loads(result)
if isinstance(queries, list):
# 去重并限制数量
seen = set()
unique = []
for q in queries:
q = str(q).strip()
if q and q not in seen:
seen.add(q)
unique.append(q)
return unique[:3]
except Exception as exc:
logger.warning("查询扩展失败: %s,使用原始查询", exc)
return [query]
@classmethod
async def detect_entities(cls, text: str) -> List[dict]:
"""
实体检测:从文本中提取知识点、人物、概念等实体。
Args:
text: 待分析的文本
Returns:
实体列表,格式 [{"name": "...", "type": "knowledge_point|person|concept|technique", "description": "..."}]
"""
messages = [
{
"role": "system",
"content": (
"你是一个教育内容分析助手。用户会给你一段直播课的文字稿,"
"你需要从中提取关键实体(知识点、技巧、人物、概念等)。"
"只输出 JSON 数组格式,不要添加任何其他文字。\n"
'格式:[{"name": "实体名称", "type": "knowledge_point|technique|person|concept", "description": "简要描述"}]\n'
"type 说明:\n"
"- knowledge_point: 知识点(如公式、定理、定义)\n"
"- technique: 技巧/方法(如解题技巧、记忆方法)\n"
"- person: 人物\n"
"- concept: 概念/术语"
),
},
{
"role": "user",
"content": f"请从以下文字中提取关键实体:\n\n{text[:3000]}",
},
]
try:
result = await cls.chat(messages, temperature=0.1, max_tokens=2048)
result = result.strip()
if result.startswith("```"):
result = result.split("\n", 1)[-1]
if result.endswith("```"):
result = result[:-3]
result = result.strip()
entities = json.loads(result)
if isinstance(entities, list):
return entities
except Exception as exc:
logger.warning("实体检测失败: %s", exc)
return []
@classmethod
async def extract_tags(cls, text: str) -> List[str]:
"""
从文本中提取语义标签。
用于图片 OCR 内容的标签化和搜索查询的标签化。
Args:
text: 待提取标签的文本
Returns:
标签列表,如 ["教育", "高考数学", "二次方程", "解题技巧"]
"""
messages = [
{
"role": "system",
"content": (
"你是一个内容标签提取助手。用户会给你一段文字内容,"
"你需要从中提取 3-10 个能概括内容主题的标签。\n"
"标签要求:\n"
"1. 用简短的词语2-6个字\n"
"2. 涵盖主题、场景、情感、关键实体等维度\n"
"3. 考虑同义词和近义词(如'不想工作'也打上'躺平'标签)\n"
"4. 只输出 JSON 数组,不要其他文字\n"
'例如:["高考数学", "二次方程", "韦达定理", "解题技巧"]'
),
},
{
"role": "user",
"content": f"请从以下内容中提取标签:\n\n{text[:2000]}",
},
]
try:
result = await cls.chat(messages, temperature=0.3, max_tokens=256)
result = result.strip()
if result.startswith("```"):
result = result.split("\n", 1)[-1]
if result.endswith("```"):
result = result[:-3]
result = result.strip()
tags = json.loads(result)
if isinstance(tags, list):
# 去重、清洗
seen = set()
clean = []
for t in tags:
t = str(t).strip()
if 1 <= len(t) <= 20 and t not in seen:
seen.add(t)
clean.append(t)
return clean[:15]
except Exception as exc:
logger.warning("标签提取失败: %s", exc)
return []
@classmethod
async def extract_search_tags(cls, query: str) -> List[str]:
"""
从用户搜索查询中提取标签,用于匹配数据库中存储的标签。
和 extract_tags 类似,但更侧重于提取搜索意图相关的标签。
"""
messages = [
{
"role": "system",
"content": (
"你是一个搜索意图分析助手。用户会给你一个搜索查询,"
"你需要提取 3-8 个可能匹配的标签词。\n"
"标签要求:\n"
"1. 用简短的词语2-6个字\n"
"2. 包含同义词和近义词\n"
"3. 包含上义词和下义词\n"
"4. 只输出 JSON 数组\n"
'例如:用户搜"孩子躺平",输出 ["躺平", "不上班", "年轻人", "就业", "摆烂", "啃老"]'
),
},
{
"role": "user",
"content": f"请从以下搜索查询中提取匹配标签:{query}",
},
]
try:
result = await cls.chat(messages, temperature=0.3, max_tokens=256)
result = result.strip()
if result.startswith("```"):
result = result.split("\n", 1)[-1]
if result.endswith("```"):
result = result[:-3]
result = result.strip()
tags = json.loads(result)
if isinstance(tags, list):
seen = set()
clean = []
for t in tags:
t = str(t).strip()
if 1 <= len(t) <= 20 and t not in seen:
seen.add(t)
clean.append(t)
return clean[:10]
except Exception as exc:
logger.warning("搜索标签提取失败: %s", exc)
# 降级:直接用原始查询词作为标签
return [query.strip()] if query.strip() else []
@classmethod
async def summarize_story(cls, text: str) -> str:
"""
用 Qwen3-8B 提炼图片 OCR 文本的故事内容。
非思考模式,直接输出。
"""
messages = [
{
"role": "system",
"content": (
"你是一个内容提炼助手。用户会给你一段从图片中识别出的文字内容,"
"你需要提炼出其中讲述的故事或事件。\n"
"要求:\n"
"1. 用简洁的自然语言描述图片内容讲述的故事或事件\n"
"2. 保留关键人物、事件、情感、场景等核心信息\n"
"3. 50-200字\n"
"4. 直接输出提炼内容,不要加前缀"
),
},
{
"role": "user",
"content": f"/no_think\n请提炼以下内容的故事:\n\n{text[:3000]}",
},
]
try:
from openai import AsyncOpenAI
client = AsyncOpenAI(
api_key=settings.OPENAI_API_KEY,
base_url=settings.OPENAI_BASE_URL,
)
response = await client.chat.completions.create(
model="Qwen/Qwen3-8B",
messages=messages,
temperature=0.3,
max_tokens=512,
extra_body={"chat_template_kwargs": {"enable_thinking": False}},
)
content = response.choices[0].message.content or ""
# 清理可能的 /no_think 回显
content = content.replace("/no_think", "").strip()
return content
except Exception as exc:
logger.warning("故事提炼失败: %s", exc)
return ""
@classmethod
async def judge_batch(cls, query: str, articles: List[dict]) -> List[int]:
"""
用 LLM 批量判断哪些文章符合查询。
Args:
query: 用户搜索查询
articles: [{"id": int, "text": str}, ...]最多10个
Returns:
匹配的文章 id 列表(空列表表示都不匹配)
"""
if not articles:
return []
# 构建文章列表
articles_text = ""
for i, article in enumerate(articles, 1):
text = (article.get("text") or "")[:800]
articles_text += f"{i}. {text}\n"
messages = [
{
"role": "system",
"content": (
"你是一个严格的语义搜索匹配引擎。用户会给你一个搜索查询和多篇文章片段,"
"你需要判断哪些文章与查询在**核心语义上高度相关**。\n\n"
"判断原则:\n"
"- 文章的**核心主题**必须与查询直接相关,而不是仅仅提及了查询中的某个词\n"
"- 例如:查询\"爸爸出轨\",文章只提到\"爸爸带孩子玩\"是不相关的,"
"只有文章讨论出轨、婚姻危机、夫妻矛盾等才算相关\n"
"- 例如:查询\"孩子不想上学\",文章提到\"孩子打游戏不去学校\"是相关的,"
"但只提到\"孩子\"\"学校\"其他方面则不相关\n\n"
"不相关的典型情况(必须排除):\n"
"- 仅包含查询中的某个词,但讨论的是完全不同的话题\n"
"- 主题相近但具体内容无关(如查询出轨,文章讲的是正常的夫妻相处)\n"
"- 只有一个宽泛的关联词,没有实质性的语义联系\n\n"
"只输出匹配的文章编号,用逗号分隔。\n"
"如果没有文章与查询相关,只输出 0。\n"
"不要输出任何解释。宁可漏判,不可误判。"
),
},
{
"role": "user",
"content": f"搜索查询: {query}\n\n{articles_text}\n请输出匹配的文章编号:",
},
]
try:
from openai import AsyncOpenAI
client = AsyncOpenAI(
api_key=settings.MINIMAX_API_KEY,
base_url=settings.MINIMAX_BASE_URL,
http_client=httpx.AsyncClient(timeout=60.0),
)
response = await client.chat.completions.create(
model=settings.MINIMAX_CHAT_MODEL,
messages=messages,
temperature=0.0,
max_tokens=512,
stream=False,
extra_body={"reasoning_split": True},
)
content = response.choices[0].message.content or ""
content = cls._clean_reasoning_content(content)
# 解析返回的编号
numbers = re.findall(r'\d+', content)
if not numbers:
return []
matched_ids = []
for num_str in numbers:
num = int(num_str)
if num == 0:
continue # 0 表示都不匹配
if 1 <= num <= len(articles):
matched_ids.append(articles[num - 1]["id"])
return matched_ids
except Exception as exc:
logger.warning("LLM 批量判断失败: %s", exc)
return []
@classmethod
def reset(cls) -> None:
"""重置客户端(用于测试或切换配置)"""
cls._client = None