Files
HuiBrain/app/services/llm_service.py
EduBrain Dev b17786b57b feat: v1.2.0 - 图片去重与管理、微信机器人优化、搜索设置可配置
主要功能:
- 图片上传时 OCR 内容去重(3个上传端点统一使用公共函数 _check_ocr_duplicate)
- 图片管理 Tab:展示所有图片、手动删除、一键去重
- 搜索结果详情弹窗增加删除按钮(带确认弹窗)
- 图片管理卡片点击查看详情(复用 showOcrDetailModal)
- 搜索限制和 LLM 批量判断数量可通过网站设置
- MiniMax API 调用添加 reasoning_split=True
- 企业微信机器人:WebSocket 长连接、图片搜索、配置化搜索数量
- 版本号升级至 1.2.0
2026-04-13 22:25:08 +08:00

480 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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