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:
479
app/services/llm_service.py
Normal file
479
app/services/llm_service.py
Normal 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
|
||||
Reference in New Issue
Block a user