341 lines
13 KiB
Python
341 lines
13 KiB
Python
"""
|
||
问题分析器 - 使用LLM深度理解用户意图
|
||
"""
|
||
import json
|
||
import asyncio
|
||
from typing import Dict, Any, Optional, List
|
||
from app.services.llm_service import llm_service
|
||
from app.utils.logger import logger
|
||
|
||
|
||
class QuestionAnalyzer:
|
||
"""智能问题分析器 - 使用LLM深度理解用户意图"""
|
||
|
||
def __init__(self):
|
||
"""初始化问题分析器"""
|
||
self.use_llm = llm_service.client is not None
|
||
if not self.use_llm:
|
||
logger.warning("LLM未配置,QuestionAnalyzer将使用降级模式")
|
||
|
||
async def analyze_question(
|
||
self,
|
||
question: str,
|
||
context: List[Dict],
|
||
session_id: str
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
深度分析用户问题
|
||
|
||
Args:
|
||
question: 用户问题
|
||
context: 对话历史上下文
|
||
session_id: 会话ID
|
||
|
||
Returns:
|
||
QuestionIntent: {
|
||
'type': 'stock_analysis' | 'market_overview' | 'knowledge' | 'chat',
|
||
'target': {
|
||
'stock_code': str,
|
||
'stock_name': str,
|
||
'market': 'A股' | '美股'
|
||
},
|
||
'dimensions': {
|
||
'price_trend': bool, # 价格走势
|
||
'technical': bool, # 技术指标
|
||
'fundamental': bool, # 基本面
|
||
'valuation': bool, # 估值
|
||
'money_flow': bool, # 资金流向
|
||
'risk': bool # 风险分析
|
||
},
|
||
'time_scope': {
|
||
'short_term': bool, # 短期(1-2周)
|
||
'medium_term': bool, # 中期(1-3月)
|
||
'long_term': bool # 长期(半年+)
|
||
},
|
||
'analysis_depth': 'quick' | 'standard' | 'deep',
|
||
'specific_concerns': List[str], # 特定关注点
|
||
'context_references': {
|
||
'refers_to_previous': bool,
|
||
'comparison_target': str | None
|
||
},
|
||
'user_style': {
|
||
'tone': 'professional' | 'casual',
|
||
'detail_level': 'brief' | 'detailed'
|
||
}
|
||
}
|
||
"""
|
||
if not self.use_llm:
|
||
# 降级模式:返回基本的意图分析
|
||
return self._fallback_analysis(question)
|
||
|
||
# 构建上下文字符串
|
||
context_str = self._format_context(context)
|
||
|
||
# 构建LLM prompt
|
||
prompt = self._build_analysis_prompt(question, context_str)
|
||
|
||
try:
|
||
# 异步调用LLM
|
||
result = await self._call_llm_async(
|
||
messages=[{"role": "user", "content": prompt}],
|
||
temperature=0.3,
|
||
max_tokens=800
|
||
)
|
||
|
||
if not result:
|
||
logger.warning("LLM返回空结果,使用降级模式")
|
||
return self._fallback_analysis(question)
|
||
|
||
# 清理和解析JSON
|
||
intent = self._parse_llm_response(result)
|
||
|
||
if intent:
|
||
logger.info(f"问题分析成功: type={intent.get('type')}, dimensions={intent.get('dimensions')}")
|
||
return intent
|
||
else:
|
||
logger.warning("JSON解析失败,使用降级模式")
|
||
return self._fallback_analysis(question)
|
||
|
||
except Exception as e:
|
||
logger.error(f"问题分析失败: {e}")
|
||
return self._fallback_analysis(question)
|
||
|
||
async def _call_llm_async(
|
||
self,
|
||
messages: List[Dict[str, str]],
|
||
temperature: float = 0.3,
|
||
max_tokens: int = 800
|
||
) -> Optional[str]:
|
||
"""异步调用LLM"""
|
||
loop = asyncio.get_event_loop()
|
||
return await loop.run_in_executor(
|
||
None,
|
||
lambda: llm_service.chat(messages, temperature, max_tokens)
|
||
)
|
||
|
||
def _format_context(self, context: List[Dict]) -> str:
|
||
"""格式化对话历史上下文"""
|
||
if not context:
|
||
return ""
|
||
|
||
context_str = "\n\n【对话历史】\n"
|
||
# 只取最近4条消息
|
||
for msg in context[-4:]:
|
||
role = "用户" if msg["role"] == "user" else "助手"
|
||
content = msg['content'][:100] # 限制长度
|
||
context_str += f"{role}: {content}\n"
|
||
|
||
return context_str
|
||
|
||
def _build_analysis_prompt(self, question: str, context_str: str) -> str:
|
||
"""构建问题分析的LLM prompt"""
|
||
prompt = f"""你是一个专业的金融问题分析专家。请深度分析用户的问题,提取结构化信息。
|
||
|
||
{context_str}
|
||
|
||
【当前问题】
|
||
用户: {question}
|
||
|
||
请分析以下维度:
|
||
|
||
1. **问题类型**
|
||
- stock_analysis: 针对**特定单只股票**的分析(如"贵州茅台怎么样"、"分析比亚迪"、"AAPL走势"、"阿里巴巴美股")
|
||
**注意**:如果用户问的是"板块"、"行业"、"概念股"等,这不是stock_analysis,而是market_overview
|
||
- market_overview: 市场整体分析、行业板块分析、投资机会(如"最近有什么投资机会"、"商业航天板块怎么样"、"新能源行业走势"、"现在适合买股票吗")
|
||
- knowledge: 金融知识问答(如"什么是MACD"、"如何看K线图")
|
||
- chat: 一般对话(如"你好"、"在吗")
|
||
|
||
**重要**:判断是stock_analysis还是market_overview的关键:
|
||
- 如果提到具体的公司名称或股票代码 → stock_analysis
|
||
- 如果提到"板块"、"行业"、"概念"、"赛道"、"领域" → market_overview
|
||
- 如果问"哪些股票"、"什么机会" → market_overview
|
||
|
||
2. **股票识别**(如果是stock_analysis,这是最重要的部分)
|
||
请识别用户提到的股票,并返回准确的股票代码:
|
||
|
||
**重要**:如果用户提到多只股票(如"TSLA和NVDA"、"特斯拉、英伟达"),请返回所有股票代码的列表。
|
||
|
||
**A股代码格式**:6位数字
|
||
- 上海主板:600xxx、601xxx、603xxx、605xxx
|
||
- 深圳主板:000xxx、001xxx
|
||
- 创业板:300xxx、301xxx
|
||
- 科创板:688xxx
|
||
- 常见示例:贵州茅台→600519,比亚迪→002594,宁德时代→300750
|
||
|
||
**美股代码格式**:1-5位大写字母
|
||
- 常见示例:苹果→AAPL,特斯拉→TSLA,微软→MSFT,谷歌→GOOGL,英伟达→NVDA
|
||
- 中概股美股:阿里巴巴美股→BABA,京东美股→JD,拼多多→PDD,百度美股→BIDU,网易美股→NTES,哔哩哔哩美股→BILI
|
||
|
||
**港股代码格式**:4-5位数字加.HK后缀
|
||
- 常见示例:腾讯→0700.HK,阿里巴巴港股→9988.HK,美团→3690.HK,小米→1810.HK,京东港股→9618.HK,百度港股→9888.HK,网易港股→9999.HK,哔哩哔哩港股→9626.HK
|
||
- 注意:港股代码需要包含.HK后缀
|
||
|
||
**市场判断**:
|
||
- 如果用户明确说"美股"、"纳斯达克"、"纽交所" → 美股
|
||
- 如果用户明确说"港股"、"香港"、"恒生" → 港股
|
||
- 对于同时在多地上市的公司(如阿里巴巴、京东、百度等):
|
||
- 用户说"美股"或没有明确指定 → 返回美股代码(如BABA)
|
||
- 用户说"港股" → 返回港股代码(如9988.HK)
|
||
- 纯港股公司(如腾讯、美团、小米)→ 港股
|
||
- 默认情况下,中国公司优先考虑A股市场
|
||
|
||
3. **用户关注维度**(如果是stock_analysis)
|
||
分析用户想了解哪些方面:
|
||
- price_trend: 价格走势、涨跌情况、最新价格
|
||
- technical: 技术指标(MACD、RSI、均线、KDJ等)
|
||
- fundamental: 基本面(公司业务、行业地位、财务状况)
|
||
- valuation: 估值水平(PE、PB、市值、估值是否合理)
|
||
- money_flow: 资金流向、主力动向、大单流入流出
|
||
- risk: 风险分析、风险提示、投资风险
|
||
|
||
4. **时间范围**
|
||
- short_term: 短期(1-2周)- 如"短期走势"、"近期表现"
|
||
- medium_term: 中期(1-3月)- 如"中期趋势"、"未来一个月"
|
||
- long_term: 长期(半年以上)- 如"长期投资"、"适合长期持有吗"
|
||
|
||
5. **分析深度**
|
||
- quick: 快速查看(只需要基本信息,如"价格多少")
|
||
- standard: 标准分析(常规分析,如"怎么样"、"分析一下")
|
||
- deep: 深度分析(全面详细,如"全面分析"、"深度研究")
|
||
|
||
6. **特定关注点**
|
||
提取用户明确提到的关注点,如:
|
||
- "支撑位在哪"
|
||
- "盈利能力如何"
|
||
- "适合长期持有吗"
|
||
- "有没有金叉"
|
||
|
||
7. **上下文引用**
|
||
- 是否引用了之前的对话("这只股票"、"它"、"那技术面呢")
|
||
- 是否要求对比分析("和上次相比"、"对比一下")
|
||
|
||
8. **用户风格**
|
||
- tone: professional(专业,使用专业术语)/ casual(随意,通俗易懂)
|
||
- detail_level: brief(简洁,简短回答)/ detailed(详细,详细分析)
|
||
|
||
请以JSON格式返回分析结果:
|
||
{{
|
||
"type": "问题类型",
|
||
"target": {{
|
||
"stock_code": "单只股票时为字符串(如'AAPL'),多只股票时为列表(如['TSLA', 'NVDA'])",
|
||
"stock_name": "单只股票时为字符串(如'苹果'),多只股票时为列表(如['特斯拉', '英伟达'])",
|
||
"market": "A股/美股/港股"
|
||
}},
|
||
"dimensions": {{
|
||
"price_trend": true/false,
|
||
"technical": true/false,
|
||
"fundamental": true/false,
|
||
"valuation": true/false,
|
||
"money_flow": true/false,
|
||
"risk": true/false
|
||
}},
|
||
"time_scope": {{
|
||
"short_term": true/false,
|
||
"medium_term": true/false,
|
||
"long_term": true/false
|
||
}},
|
||
"analysis_depth": "quick/standard/deep",
|
||
"specific_concerns": ["关注点1", "关注点2"],
|
||
"context_references": {{
|
||
"refers_to_previous": true/false,
|
||
"comparison_target": "对比目标(如有)"
|
||
}},
|
||
"user_style": {{
|
||
"tone": "professional/casual",
|
||
"detail_level": "brief/detailed"
|
||
}}
|
||
}}
|
||
|
||
只返回JSON,不要有任何其他内容。"""
|
||
|
||
return prompt
|
||
|
||
def _parse_llm_response(self, response: str) -> Optional[Dict[str, Any]]:
|
||
"""解析LLM返回的JSON响应"""
|
||
try:
|
||
# 清理结果,移除可能的markdown代码块标记
|
||
result = response.strip()
|
||
if result.startswith("```json"):
|
||
result = result[7:]
|
||
if result.startswith("```"):
|
||
result = result[3:]
|
||
if result.endswith("```"):
|
||
result = result[:-3]
|
||
result = result.strip()
|
||
|
||
# 检查是否为空
|
||
if not result:
|
||
return None
|
||
|
||
# 解析JSON
|
||
intent = json.loads(result)
|
||
return intent
|
||
|
||
except json.JSONDecodeError as e:
|
||
logger.error(f"JSON解析失败: {e}, 原始响应: {response[:200]}")
|
||
return None
|
||
except Exception as e:
|
||
logger.error(f"解析LLM响应失败: {e}")
|
||
return None
|
||
|
||
def _fallback_analysis(self, question: str) -> Dict[str, Any]:
|
||
"""降级模式:基于规则的简单分析"""
|
||
question_lower = question.lower()
|
||
|
||
# 简单的关键词匹配
|
||
is_stock_query = any(kw in question for kw in [
|
||
"股票", "分析", "怎么样", "如何", "走势", "价格", "涨", "跌"
|
||
])
|
||
|
||
if is_stock_query:
|
||
# 尝试提取股票名称(简单规则)
|
||
return {
|
||
'type': 'stock_analysis',
|
||
'target': {
|
||
'stock_code': '',
|
||
'stock_name': '',
|
||
'market': 'A股'
|
||
},
|
||
'dimensions': {
|
||
'price_trend': True,
|
||
'technical': True,
|
||
'fundamental': True,
|
||
'valuation': False,
|
||
'money_flow': False,
|
||
'risk': False
|
||
},
|
||
'time_scope': {
|
||
'short_term': True,
|
||
'medium_term': True,
|
||
'long_term': False
|
||
},
|
||
'analysis_depth': 'standard',
|
||
'specific_concerns': [],
|
||
'context_references': {
|
||
'refers_to_previous': False,
|
||
'comparison_target': None
|
||
},
|
||
'user_style': {
|
||
'tone': 'casual',
|
||
'detail_level': 'detailed'
|
||
}
|
||
}
|
||
else:
|
||
# 默认为一般对话
|
||
return {
|
||
'type': 'chat',
|
||
'target': {},
|
||
'dimensions': {},
|
||
'time_scope': {},
|
||
'analysis_depth': 'quick',
|
||
'specific_concerns': [],
|
||
'context_references': {
|
||
'refers_to_previous': False,
|
||
'comparison_target': None
|
||
},
|
||
'user_style': {
|
||
'tone': 'casual',
|
||
'detail_level': 'brief'
|
||
}
|
||
}
|