kangda-robot-backend/ruoyi-fastapi-backend/module_admin/service/search_service.py
2026-02-02 18:49:54 +08:00

427 lines
19 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.

import json
import hashlib
import re
import time
import httpx
from datetime import datetime
from typing import Any, Dict, Optional
from config.env import SearchConfig
from utils.log_util import logger
from fastapi.responses import StreamingResponse
from utils.response_util import ResponseUtil
from module_admin.entity.vo.ragflow_vo import ConverseWithChatAssistantModel
from module_admin.service.ragflow_service import RAGFlowService
def format_sse(data: dict, event: str | None = None) -> str:
payload = json.dumps(data, ensure_ascii=False)
prefix = f'event: {event}\n' if event else ''
return f'{prefix}data: {payload}\n\n'
class SearchServiceError(Exception):
"""搜索服务业务异常"""
class SearchService:
"""搜索服务 - 处理联网搜索请求"""
# 基础搜索关键词 - 用于判断是否应该使用搜索服务
SEARCH_KEYWORDS = (
'搜索', '查找', '查询', '搜一下', '帮我查', '帮我搜', '检索',
'最新', '最近', '今日', '今天', '现在', '当前', '昨日', '昨天',
'天气', '温度', '气温', '降水', '下雨', '下雪', '', '多云', '阴天', '雾霾',
'新闻', '时事', '资讯', '动态', '报道', '消息',
'发生了什么', '怎么回事', '事件', '事故', '活动',
'search', 'find', 'query', 'latest', 'recent', 'today', 'now', 'current',
'weather', 'temperature', 'news', 'events', 'what happened'
)
# 领域特定关键词
DOMAIN_KEYWORDS = {
'科技': ('AI', '人工智能', '区块链', '元宇宙', '量子计算', '5G', '芯片'),
'体育': ('足球', '篮球', '奥运会', '世界杯', 'NBA', '英超', '比分'),
'娱乐': ('电影', '电视剧', '明星', '演唱会', '综艺', '票房'),
'财经': ('股票', '基金', '汇率', '利率', '财经', '股市', '行情'),
'健康': ('疫情', '疫苗', '健康', '医疗', '疾病', '症状'),
'教育': ('考试', '考研', '高考', '录取', '学校', '教育'),
}
# 否定关键词 - 公司内部相关话题不应触发搜索
NEGATIVE_KEYWORDS = (
# 通用否定
'不要搜索', '不搜索', '无需搜索', '不用搜索', 'don\'t search', 'no search',
# 公司内部话题(基于 static_qa.json
'厂区', '公司', '康达', '预约', '参观', '成立', '业务', '荣誉', '愿景',
'生产区', '生产基地', '子公司', '分支机构', '管理总部',
'高分子', '新材料', '胶粘剂', '复合材料',
'军工', '航空航天', '电子信息', '半导体',
'制造业', '制造业企业', '高新技术企业', '博士后科研工作站'
)
@classmethod
def should_handle(cls, question: Optional[str]) -> bool:
"""判断是否应该使用搜索服务处理该问题"""
if not question:
return False
normalized = question.strip().lower()
if any(neg_k in normalized for neg_k in cls.NEGATIVE_KEYWORDS):
return False
if any(k in question for k in cls.SEARCH_KEYWORDS) or any(k in normalized for k in cls.SEARCH_KEYWORDS):
return True
for domain, domain_terms in cls.DOMAIN_KEYWORDS.items():
has_domain_term = any(term in question for term in domain_terms) or any(term.lower() in normalized for term in domain_terms)
has_time_or_action = any(k in question for k in ('最新', '最近', '今日', '今天', '搜索', '查找', '查询')) or any(k in normalized for k in ('latest', 'recent', 'today', 'search', 'find', 'query'))
if has_domain_term and has_time_or_action:
return True
time_patterns = r'(\d{4}年|\d{1,2}月\d{1,2}日|昨天|昨日|今天|今日|最近\d+天|最近\d+周|最近\d+月)'
if re.search(time_patterns, question) and any(k in question for k in ('发生', '新闻', '事件', '动态')):
return True
return False
@staticmethod
def _simplify_text(text: str) -> str:
"""简化文本内容"""
if not text:
return ""
text = re.sub(r'\s+', ' ', text.strip())
text = re.sub(r'[,、;;]+', '', text)
text = re.sub(r'[。.!]+', '', text)
max_length = 150
if len(text) > max_length:
matches = list(re.finditer(r'[。.!?]', text[:max_length]))
if matches:
last_end = matches[-1].end()
text = text[:last_end] + "..."
else:
text = text[:max_length] + "..."
return text
@classmethod
async def get_search_answer(cls, question: str, redis=None) -> Dict[str, Any]:
"""获取搜索结果 (Baidu AppBuilder API)"""
# if not SearchConfig.BAIDU_APPBUILDER_API_KEY:
# raise SearchServiceError('搜索服务未配置 BAIDU_APPBUILDER_API_KEY')
cache_key = None
if redis:
cache_key = cls._build_cache_key(question)
cached = await redis.get(cache_key)
if cached:
try:
logger.info('搜索结果命中缓存: query=%s', question)
return json.loads(cached)
except (TypeError, json.JSONDecodeError):
pass
if not SearchConfig.BAIDU_APPBUILDER_API_KEY:
logger.error("BAIDU_APPBUILDER_API_KEY is missing in configuration")
raise SearchServiceError('搜索服务未配置 BAIDU_APPBUILDER_API_KEY')
try:
# Baidu API Request Logic
headers = {
"X-Appbuilder-Authorization": f"Bearer {SearchConfig.BAIDU_APPBUILDER_API_KEY}",
"Content-Type": "application/json"
}
payload_data = {
"messages": [{"role": "user", "content": question}],
"search_source": "baidu_search_v1", # Can be parameterized if needed
"stream": False,
# Other optional parameters can be added here
}
async with httpx.AsyncClient(timeout=30.0) as client:
response = await client.post(
SearchConfig.SEARCH_API_BASE,
headers=headers,
json=payload_data
)
if response.status_code != 200:
error_msg = f"HTTP {response.status_code}: {response.text}"
logger.error(f"Baidu API error: {error_msg}")
raise SearchServiceError(f"Baidu API error: {error_msg}")
result_json = response.json()
# Handle potential non-200 OK business errors if any specific structure exists, though usually status code covers it or there's an error field.
# Format Response
formatted = cls._format_baidu_response(question, result_json)
# Cache
if redis and cache_key:
try:
await redis.set(
cache_key,
json.dumps(formatted, ensure_ascii=False),
ex=SearchConfig.SEARCH_CACHE_TTL
)
except Exception as ex:
logger.debug('缓存搜索结果失败: %s', ex)
return formatted
except Exception as exc:
logger.exception('Baidu搜索失败')
raise SearchServiceError(str(exc)) from exc
@classmethod
async def handle_search_chat(cls, converse_params: ConverseWithChatAssistantModel, redis=None) -> Any:
"""Handle chat request using Search Service + RAGFlow"""
start_time = time.perf_counter()
try:
# 1. Check Stream
if converse_params.stream:
return StreamingResponse(
cls._stream_baidu_search(converse_params.question),
media_type='text/event-stream'
)
# 2. Non-stream: Get Search Answer directly (Bypass RAGFlow)
search_payload = await cls.get_search_answer(converse_params.question, redis=redis)
# Use the "message" or "answer" from Baidu as the final response
# Format to match RAGFlow response structure if needed, or just return the helpful message
response_data = {
"answer": search_payload.get('message', ''),
"references": search_payload.get('results', [])
}
logger.info('搜索处理耗时 %.3fs', time.perf_counter() - start_time)
return ResponseUtil.success(data=response_data)
except SearchServiceError as exc:
logger.warning('搜索服务失败降级到RAGFlow: %s', exc)
# Fallback to normal flow
# 修复直接调用同步方法不再使用await
result = RAGFlowService.converse_with_chat_assistant_services(converse_params)
if converse_params.stream:
async def stream_response():
try:
async for chunk in result:
payload = chunk.get('data') if isinstance(chunk, dict) else chunk
if not payload:
continue
body = payload if isinstance(payload, dict) else {'data': payload}
yield format_sse(body)
yield format_sse({'status': 'completed'}, event='end')
except Exception as exc:
logger.exception('ragflow流式对话异常: %s', exc)
yield format_sse({'message': str(exc)}, event='error')
finally:
logger.info('ragflow流式对话耗时 %.3fs', time.perf_counter() - start_time)
return StreamingResponse(stream_response(), media_type='text/event-stream')
code = result.get('code', 0)
if code != 0:
msg = result.get('message') or result.get('msg') or '接口异常'
return ResponseUtil.error(msg=msg, data=result.get('data', None))
return ResponseUtil.success(data=result.get('data', None))
@classmethod
async def _stream_baidu_search(cls, question: str):
"""Stream Search + Answer using ZhipuAI (GLM-4)"""
# Note: Method name kept for compatibility, but logic switched to ZhipuAI
# 先回一条提示,避免联网搜索阶段用户感知“卡住”
yield format_sse({'data': '稍等片刻,达达检索调取中'})
if not SearchConfig.ZHIPUAI_API_KEY:
yield format_sse({'message': 'Configuration Error: Missing ZHIPUAI_API_KEY. Please set valid API key.'}, event='error')
return
headers = {
"Authorization": f"Bearer {SearchConfig.ZHIPUAI_API_KEY}",
"Content-Type": "application/json"
}
# ZhipuAI / GLM-4 Payload with Web Search Tool
payload = {
"model": "glm-4-flash", # Use Flash model for faster speed
"messages": [
{"role": "user", "content": f"请回答用户问题:{question}\n要求:\n1. 务必保持简练字数严格控制在100字以内\n2. 直接输出答案。"}
],
"tools": [{
"type": "web_search",
"web_search": {
"enable": True,
"search_query": question,
"search_result": True,
"count": 3 # Limit to 3 results for speed (optional, default is 10)
}
}],
"stream": True
}
try:
logger.info(f"ZhipuAI Search Request: {question}")
async with httpx.AsyncClient(timeout=60.0) as client:
async with client.stream('POST', SearchConfig.ZHIPUAI_API_BASE, headers=headers, json=payload) as response:
if response.status_code != 200:
error_msg = await response.read()
logger.error(f"ZhipuAI Error: {error_msg}")
yield format_sse({'message': f"Search Error: {response.status_code}"}, event='error')
return
async for line in response.aiter_lines():
if not line.strip():
continue
data_str = line[6:].strip() if line.startswith('data: ') else line
if not data_str or data_str == '[DONE]':
continue
try:
chunk = json.loads(data_str)
choices = chunk.get('choices', [])
if choices:
delta = choices[0].get('delta', {})
content = delta.get('content', '')
# Check for tool calls or references if needed, but GLM-4 usually streams content directly after search
if content:
yield format_sse({'data': content})
except json.JSONDecodeError:
pass
yield format_sse({'status': 'completed'}, event='end')
except Exception as exc:
logger.exception("ZhipuAI Streaming Failed")
yield format_sse({'message': str(exc)}, event='error')
@classmethod
async def classify_intent(cls, question: str) -> str:
"""
Classify user intent: 'SEARCH' (Public Internet) vs 'RAG' (Internal KB)
Uses glm-4-flash for <300ms latency.
"""
# 0) 确定性兜底:明确公司内部话题一律走 RAG避免 LLM 误判导致“走搜索”)
normalized = (question or '').strip().lower()
if any(neg_k in normalized for neg_k in cls.NEGATIVE_KEYWORDS) or any(k in normalized for k in ('康达新材', '康达')):
return 'RAG'
if not SearchConfig.ZHIPUAI_API_KEY:
# Fallback to simple keyword check if no key
return 'SEARCH' if cls.should_handle(question) else 'RAG'
headers = {
"Authorization": f"Bearer {SearchConfig.ZHIPUAI_API_KEY}",
"Content-Type": "application/json"
}
prompt = (
f"判断用户问题意图:\n{question}\n\n"
"背景信息:我们是'康达新材'(简称'康达')的内部助手。\n"
"分类规则:\n"
"1. RAG (内部知识): \n"
" - 涉及'康达''康达新材'、公司历史、架构、产品、内部规章的问题。\n"
" - 询问特定的产品手册、错误代码、内部文档。\n"
"2. SEARCH (公网搜索): \n"
" - 询问天气、今日新闻、股票大盘、通用百科知识。\n"
" - 与公司无关的外部实时资讯。\n"
"只输出分类标签 (SEARCH 或 RAG),不要其他内容。"
)
payload = {
"model": "glm-4-flash",
"messages": [{"role": "user", "content": prompt}],
"temperature": 0.1, # Low temp for deterministic output
"max_tokens": 10
}
try:
async with httpx.AsyncClient(timeout=3.0) as client: # Fast timeout
response = await client.post(
SearchConfig.ZHIPUAI_API_BASE,
headers=headers,
json=payload
)
if response.status_code == 200:
data = response.json()
choices = data.get('choices', [])
if choices:
content = choices[0]['message']['content'].strip().upper()
# Simple heuristic cleanup
if 'SEARCH' in content:
return 'SEARCH'
return 'RAG'
except Exception:
pass # Fail safe to RAG (or logic below)
# Fallback to keyword match if LLM fails
return 'SEARCH' if cls.should_handle(question) else 'RAG'
@classmethod
def _format_baidu_response(cls, query: str, payload: Dict[str, Any]) -> Dict[str, Any]:
"""Format Baidu AppBuilder response to match expected structure"""
# The Baidu response structure based on docs example:
# { "choices": [ { "message": { "content": "..." } } ], "references": [ { "content": "...", "title": "...", "url": "..." } ] }
choices = payload.get('choices', [])
message_content = ""
if choices:
message_content = choices[0].get('message', {}).get('content', '')
references = payload.get('references', [])
results = []
for ref in references:
results.append({
'title': ref.get('title', ''),
'link': ref.get('url', ''),
'snippet': ref.get('content', ''), # Use content as snippet
'source': 'baidu'
})
# Build context for LLM (similar to original logic)
context_parts = []
if message_content:
context_parts.append(f"智能总结:{message_content}")
if results:
context_parts.append("\n相关信息:")
for idx, item in enumerate(results[:5], 1): # Limit to 5
title = item['title']
snippet = cls._simplify_text(item['snippet'])
context_parts.append(f"{idx}. {title}\n {snippet}")
context_for_llm = "\n".join(context_parts)
# Message for display (similar to original logic)
message_parts = [f'关于"{query}"的搜索结果:\n']
if message_content:
message_parts.append(f"【智能回答】{message_content}\n")
if results:
message_parts.append('\n【参考来源】')
for idx, item in enumerate(results[:3], 1):
title = item['title']
snippet = cls._simplify_text(item['snippet'])
message_parts.append(f'\n{idx}. {title}')
else:
message_parts.append('\n未找到相关结果。')
return {
'type': 'search',
'query': query,
'results': results, # Normalized results list
'message': '\n'.join(message_parts), # Display friendly message
'context_for_llm': context_for_llm, # Context for RAG extraction
'raw': payload
}
@staticmethod
def _build_cache_key(query: str) -> str:
query_hash = hashlib.md5(query.encode('utf-8')).hexdigest()[:16]
today = datetime.utcnow().strftime('%Y%m%d')
return f'search:baidu:{query_hash}:{today}'