主要功能: - 图片上传时 OCR 内容去重(3个上传端点统一使用公共函数 _check_ocr_duplicate) - 图片管理 Tab:展示所有图片、手动删除、一键去重 - 搜索结果详情弹窗增加删除按钮(带确认弹窗) - 图片管理卡片点击查看详情(复用 showOcrDetailModal) - 搜索限制和 LLM 批量判断数量可通过网站设置 - MiniMax API 调用添加 reasoning_split=True - 企业微信机器人:WebSocket 长连接、图片搜索、配置化搜索数量 - 版本号升级至 1.2.0
480 lines
18 KiB
Python
480 lines
18 KiB
Python
"""
|
||
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
|