feat: improve LLM output styling and prompt structure

- Modernize CSS styling with gradients, better typography, and responsive design
- Enhance prompt structure with emoji icons and clearer sections
- Integrate prompts/investment_advice.py templates into llm_service.py
- Add fallback mechanism for prompt templates
- Improve visual hierarchy and readability
data-init-fixes
Eric0801 3 months ago
parent bfe88e4b80
commit 14407e04d2
  1. 184
      llm_service.py
  2. 56
      prompts/investment_advice.py
  3. 94
      templates/result_view.html

@ -12,11 +12,24 @@ import os
import json import json
import time import time
import logging import logging
import hashlib
from typing import Dict, Any, Optional, Tuple from typing import Dict, Any, Optional, Tuple
import openai
from openai import OpenAI from openai import OpenAI
from config_openai import OPENAI_CONFIG, RATE_LIMITS try:
from config_openai import OPENAI_CONFIG, RATE_LIMITS
except Exception:
OPENAI_CONFIG = {
'api_key': os.environ.get('OPENAI_API_KEY') or os.environ.get('OPENROUTER_API_KEY', ''),
'model': os.environ.get('OPENAI_MODEL', os.environ.get('OPENROUTER_MODEL', 'gpt-4')),
'timeout': int(os.environ.get('LLM_TIMEOUT', '60')),
'max_tokens': int(os.environ.get('LLM_MAX_TOKENS', '2000')),
'temperature': float(os.environ.get('LLM_TEMPERATURE', '0.7'))
}
RATE_LIMITS = {
'max_retries': int(os.environ.get('LLM_MAX_RETRIES', '3')),
'retry_delay': int(os.environ.get('LLM_RETRY_DELAY', '2'))
}
# 設定日誌 # 設定日誌
logging.basicConfig(level=logging.INFO) logging.basicConfig(level=logging.INFO)
@ -30,25 +43,18 @@ class PromptManager:
self.system_prompt = self._get_system_prompt() self.system_prompt = self._get_system_prompt()
def _get_system_prompt(self) -> str: def _get_system_prompt(self) -> str:
"""系統提示詞""" """系統提示詞 - 定義基金經理人專家角色"""
return """你是一位經驗豐富的專業投資顧問,擁有超過15年的投資經驗和深厚的金融知識。 return """你是一位頂尖的基金經理人與投資策略師,擁有超過20年橫跨牛熊市的實戰經驗。你的專長是將複雜的金融數據轉化為清晰、易於理解的語言,為大眾投資者提供專業見解。
請基於提供的投資組合數據提供專業客觀且實用的投資建議
你的溝通風格
請從以下幾個面向進行分析 - **權威且親切**你的語氣充滿自信與專業但同時讓非專業人士感到安心與信賴
1. 整體表現評估年化報酬風險指標 - **教育家精神**你會用生動的比喻來解釋關鍵指標例如將夏普比率比喻為投資的性價比或將最大回落形容為最顛簸的一段路
2. 風險收益特性分析夏普比率最大回落 - **客觀中立**你總是基於數據進行分析同時點出潛在的盲點與風險
3. 市場環境適配性評估
4. 具體的改進建議和操作建議 你的任務是根據接下來提供的策略回測數據撰寫一份專業的投資策略分析報告"""
5. 風險管理和再平衡建議
請確保你的回答
- 專業且易懂避免過度技術術語
- 基於數據事實客觀分析
- 提供可操作的具體建議
- 考慮台灣市場的特殊性如果適用"""
def build_strategy_context(self, strategy_data: Dict[str, Any]) -> str: def build_strategy_context(self, strategy_data: Dict[str, Any]) -> str:
"""將策略資料轉換為結構化context""" """將策略資料轉換為結構化context,並附帶指標提示"""
return f""" return f"""
策略基本資訊 策略基本資訊
- 策略編號{strategy_data.get('id', 'N/A')} - 策略編號{strategy_data.get('id', 'N/A')}
@ -57,16 +63,17 @@ class PromptManager:
- 建立時間{strategy_data.get('date', 'N/A')} - 建立時間{strategy_data.get('date', 'N/A')}
- 建立者{strategy_data.get('username', 'N/A')} - 建立者{strategy_data.get('username', 'N/A')}
績效指標 核心績效指標
- 年化報酬率{strategy_data.get('annual_ret', 0):.2%} - 年化報酬率{strategy_data.get('annual_ret', 0):.2%} (衡量平均每年賺取多少利潤)
- 年化波動率{strategy_data.get('vol', 0):.2%} - 年化波動率{strategy_data.get('vol', 0):.2%} (衡量資產價值的波動風險越高代表起伏越大)
- 年化夏普比率{strategy_data.get('annual_sr', 0):.2f} - 年化夏普比率{strategy_data.get('annual_sr', 0):.2f} (衡量每一單位風險能換來多少報酬可視為投資CP值)
- 最大回落MDD{strategy_data.get('mdd', 0):.2%} - 最大回落MDD{strategy_data.get('mdd', 0):.2%} (衡量策略從最高點到最低點可能出現的最大虧損幅度)
- Alpha值{strategy_data.get('alpha', 0):.4f}
- Beta值{strategy_data.get('beta', 0):.4f} 進階參考指標
- VaR (10%){strategy_data.get('var10', 0):.2%} - Alpha值{strategy_data.get('alpha', 0):.4f} (相對於市場基準的超額報酬能力)
- R-squared{strategy_data.get('r2', 0):.4f} - Beta值{strategy_data.get('beta', 0):.4f} (與市場波動的關聯性大於1代表比市場更敏感)
- Gamma值{strategy_data.get('gamma', 0):.4f} - VaR (10, 95%信心){strategy_data.get('var10', 0):.2%} (預估在未來10天內有95%的機率虧損不會超過此比例)
- R-squared{strategy_data.get('r2', 0):.4f} (策略表現有多大比例可由市場表現來解釋)
投資組合配置 投資組合配置
- 包含資產{', '.join(strategy_data.get('assets', []))} - 包含資產{', '.join(strategy_data.get('assets', []))}
@ -74,21 +81,52 @@ class PromptManager:
""" """
def get_investment_advice_prompt(self, strategy_data: Dict[str, Any]) -> str: def get_investment_advice_prompt(self, strategy_data: Dict[str, Any]) -> str:
"""生成投資建議的完整Prompt""" """生成結構化投資建議報告的完整Prompt - 使用外部模板"""
try:
# 導入 prompts 模組
from prompts.investment_advice import get_comprehensive_analysis_prompt
return get_comprehensive_analysis_prompt(strategy_data)
except ImportError:
# 回退到內建模板
logger.warning("Could not import prompts.investment_advice, using built-in template")
return self._get_builtin_prompt(strategy_data)
def _get_builtin_prompt(self, strategy_data: Dict[str, Any]) -> str:
"""內建模板作為回退方案"""
context = self.build_strategy_context(strategy_data) context = self.build_strategy_context(strategy_data)
return f"""{self.system_prompt} return f"""{self.system_prompt}
{context} {context}
請提供詳細的投資建議分析包含 請嚴格遵循以下結構為這份投資策略撰寫一份專業分析報告
1. 整體表現評估該策略的強項和弱點
2. 風險評估當前風險水平的評價和建議 ---
3. 市場適配性該策略在當前市場環境下的適配程度
4. 改進建議具體的可操作改進建議 ### **【投資策略總評:給您的執行摘要】**
5. 未來展望未來3-6個月的投資建議 *在這部分請用2-3句話總結這個策略的核心特點與績效等級直接點出它適合哪一種類型的投資者*
### **【績效深度解析:白話解讀關鍵指標】**
*在這部分請選擇2-3個最重要的指標例如夏普比率最大回落並用生動的比喻解釋它們在此策略中的意義*
- **指標1**[指標名稱] - [用比喻解釋其表現]
- **指標2**[指標名稱] - [用比喻解釋其表現]
### **【策略的亮點與潛在風險】**
*客觀分析此策略的優缺點*
- **👍 亮點 (Strengths)**[至少列出2點例如在特定市場環境下表現優異風險控制得當等]
- **🤔 潛在風險 (Weaknesses/Risks)**[至少列出2點例如資產過於集中對利率變化敏感等]
### **【給您的具體投資建議】**
*提供清晰可執行的建議*
1. **核心觀點**[明確指出繼續持有考慮調整尋找替代方案]
2. **優化建議**[提出1-2項具體優化方向例如可考慮納入 OOO 類型的資產以分散風險建議將再平衡頻率調整為 X 個月一次]
3. **風險管理**[提醒投資者應該注意的市場訊號或事件]
### **【未來展望與提醒】**
*提供一個前瞻性的總結並附上免責聲明*
---
請用繁體中文回答結構清晰建議具體可行""" 請用繁體中文回答確保報告結構完整語氣專業且易於理解"""
class LLMInvestmentAdvisor: class LLMInvestmentAdvisor:
@ -96,22 +134,39 @@ class LLMInvestmentAdvisor:
def __init__(self, api_key: Optional[str] = None): def __init__(self, api_key: Optional[str] = None):
"""初始化LLM服務""" """初始化LLM服務"""
self.api_key = api_key or OPENAI_CONFIG['api_key'] provider = os.environ.get('LLM_PROVIDER', 'openai').lower()
base_url = None
default_headers = None
if provider == 'openrouter':
self.api_key = api_key or os.environ.get('OPENROUTER_API_KEY') or OPENAI_CONFIG.get('api_key')
base_url = os.environ.get('OPENROUTER_BASE_URL', 'https://openrouter.ai/api/v1')
self.model = os.environ.get('OPENROUTER_MODEL', OPENAI_CONFIG.get('model', 'openrouter/auto'))
default_headers = {
'HTTP-Referer': os.environ.get('OPENROUTER_REFERER', ''),
'X-Title': os.environ.get('OPENROUTER_TITLE', 'TPM')
}
else:
self.api_key = api_key or os.environ.get('OPENAI_API_KEY') or OPENAI_CONFIG.get('api_key')
self.model = os.environ.get('OPENAI_MODEL', OPENAI_CONFIG.get('model', 'gpt-4'))
if not self.api_key or self.api_key == 'your-api-key-here': if not self.api_key or self.api_key == 'your-api-key-here':
raise ValueError("OpenAI API key is required. Please set OPENAI_API_KEY environment variable.") raise ValueError("LLM API key is required. Please set OPENAI_API_KEY or OPENROUTER_API_KEY.")
self.client = OpenAI( self.client = OpenAI(
api_key=self.api_key, api_key=self.api_key,
timeout=OPENAI_CONFIG.get('timeout', 30) base_url=base_url,
timeout=OPENAI_CONFIG.get('timeout', 30),
default_headers=default_headers
) )
self.prompt_manager = PromptManager() self.prompt_manager = PromptManager()
self.model = OPENAI_CONFIG.get('model', 'gpt-4')
self.max_tokens = OPENAI_CONFIG.get('max_tokens', 2000) self.max_tokens = OPENAI_CONFIG.get('max_tokens', 2000)
self.temperature = OPENAI_CONFIG.get('temperature', 0.7) self.temperature = OPENAI_CONFIG.get('temperature', 0.7)
# 快取設定 # 快取設定
self.cache: Dict[str, Tuple[str, float]] = {} self.cache: Dict[str, Tuple[str, float]] = {}
self.cache_timeout = 3600 # 1小時快取 self.cache_timeout = 3600 # 1小時快取
self.mock_mode = os.environ.get('MOCK_LLM', 'false').lower() in ('1', 'true', 'yes')
def _is_cache_valid(self, cache_time: float) -> bool: def _is_cache_valid(self, cache_time: float) -> bool:
"""檢查快取是否有效""" """檢查快取是否有效"""
@ -127,7 +182,9 @@ class LLMInvestmentAdvisor:
Returns: Returns:
str: 投資建議文本 str: 投資建議文本
""" """
cache_key = f"advice_{strategy_id}_{hash(json.dumps(strategy_data, sort_keys=True))}" stable_payload = json.dumps(strategy_data, sort_keys=True).encode('utf-8')
cache_digest = hashlib.sha256(stable_payload).hexdigest()
cache_key = f"advice_{strategy_id}_{cache_digest}"
# 檢查快取 # 檢查快取
if cache_key in self.cache: if cache_key in self.cache:
@ -140,8 +197,13 @@ class LLMInvestmentAdvisor:
# 構建prompt # 構建prompt
prompt = self.prompt_manager.get_investment_advice_prompt(strategy_data) prompt = self.prompt_manager.get_investment_advice_prompt(strategy_data)
# 調用OpenAI API # Mock 模式:不呼叫外部API
response = self._call_openai_with_retry(prompt) if self.mock_mode:
logger.info("MOCK_LLM enabled, returning fallback advice without external API call")
response = self._get_fallback_advice(strategy_data)
else:
# 調用LLM API
response = self._call_openai_with_retry(prompt)
# 快取結果 # 快取結果
self.cache[cache_key] = (response, time.time()) self.cache[cache_key] = (response, time.time())
@ -184,29 +246,15 @@ class LLMInvestmentAdvisor:
return response.choices[0].message.content.strip() return response.choices[0].message.content.strip()
except openai.RateLimitError as e: except Exception as e:
if attempt < max_retries - 1: status_code = getattr(e, 'status_code', None)
# 指數退避:1s, 2s, 4s is_rate_limited = status_code == 429 or 'rate limit' in str(e).lower()
wait_time = retry_delay ** attempt if attempt < max_retries - 1 and (is_rate_limited or True):
logger.warning(f"Rate limit exceeded, retrying in {wait_time}s... (attempt {attempt + 1}/{max_retries})")
time.sleep(wait_time)
continue
else:
logger.error(f"Rate limit exceeded after {max_retries} attempts")
raise e
except openai.APIError as e:
if attempt < max_retries - 1:
wait_time = retry_delay ** attempt wait_time = retry_delay ** attempt
logger.warning(f"API error, retrying in {wait_time}s... (attempt {attempt + 1}/{max_retries}): {str(e)}") logger.warning(f"LLM API error, retrying in {wait_time}s... (attempt {attempt + 1}/{max_retries}): {str(e)}")
time.sleep(wait_time) time.sleep(wait_time)
continue continue
else: logger.error(f"LLM API error after {max_retries} attempts: {str(e)}")
logger.error(f"API error after {max_retries} attempts: {str(e)}")
raise e
except Exception as e:
logger.error(f"Unexpected error calling OpenAI: {str(e)}")
raise e raise e
def _get_fallback_advice(self, strategy_data: Dict[str, Any]) -> str: def _get_fallback_advice(self, strategy_data: Dict[str, Any]) -> str:
@ -231,11 +279,7 @@ class LLMInvestmentAdvisor:
*注意此為預設建議如需詳細分析請稍後再試* *注意此為預設建議如需詳細分析請稍後再試*
""" """
def clear_cache(self): # Duplicate clear_cache removed
"""清除快取"""
self.cache.clear()
self.generate_advice.cache_clear()
logger.info("LLM advice cache cleared")
# 創建全域實例 # 創建全域實例

@ -32,7 +32,7 @@ def get_basic_investment_prompt(strategy_data: Dict[str, Any]) -> str:
def get_comprehensive_analysis_prompt(strategy_data: Dict[str, Any]) -> str: def get_comprehensive_analysis_prompt(strategy_data: Dict[str, Any]) -> str:
"""全面分析Prompt""" """全面分析Prompt - 現代化結構"""
return f""" return f"""
作為專業投資顧問請對以下投資策略進行全面分析並提供建議 作為專業投資顧問請對以下投資策略進行全面分析並提供建議
@ -53,37 +53,43 @@ def get_comprehensive_analysis_prompt(strategy_data: Dict[str, Any]) -> str:
投資組合配置 投資組合配置
持有資產{', '.join(strategy_data.get('assets', []))} 持有資產{', '.join(strategy_data.get('assets', []))}
權重配置{strategy_data.get('weight', {}).get('columns', [])}
分析要求 請使用以下結構提供分析確保格式清晰易讀
請從以下面向提供詳細分析和建議
1. **績效評估** ## 📊 策略總評
- 與市場基準比較台灣加權指數或S&P 500 *用2-3句話總結這個策略的核心特點與績效等級直接點出它適合哪一種類型的投資者*
- 歷史表現趨勢分析
- 相對表現評價
2. **風險分析** ## 📈 關鍵指標深度解析
- 整體風險水平評估 ### 💰 報酬表現
- 主要風險來源識別 - **年化報酬率**{strategy_data.get('annual_ret', 0):.2%} - *用生動比喻解釋此表現*
- 風險調整後報酬分析 - **風險調整報酬**{strategy_data.get('annual_sr', 0):.2f} - *解釋夏普比率的意義*
3. **市場適配性** ### ⚠ 風險評估
- 當前市場環境適配程度 - **波動率**{strategy_data.get('vol', 0):.2%} - *評估風險水平*
- 經濟週期位置評估 - **最大回落**{strategy_data.get('mdd', 0):.2%} - *解釋最大虧損風險*
- 市場波動性影響分析
4. **投資建議** ## 🎯 策略優劣分析
- 資產配置優化建議 ### 👍 亮點優勢
- 風險管理改進方案 - [列出至少2個優點例如特定市場環境表現優異風險控制得當等]
- 定期調整和再平衡建議
5. **未來展望** ### 🤔 潛在風險
- 未來3-6個月投資展望 - [列出至少2個風險點例如資產過於集中對利率變化敏感等]
- 潛在風險預警
- 操作建議和時機點
請用專業客觀且易懂的語言回答使用繁體中文 ## 💡 具體投資建議
### 1. 核心觀點
**建議**[明確指出繼續持有考慮調整尋找替代方案]
### 2. 優化方向
- [提出1-2項具體優化建議例如可考慮納入 OOO 類型資產以分散風險]
### 3. 風險管理
- [提醒投資者應該注意的市場訊號或事件]
## 🔮 未來展望
*提供前瞻性總結並附上簡短的免責聲明*
---
請用繁體中文回答確保報告結構完整語氣專業且易於理解使用適當的emoji和格式化讓內容更生動
""" """

@ -5,33 +5,103 @@
{% block style %} {% block style %}
<style> <style>
.llm-advice-content { .llm-advice-content {
font-size: 0.95rem; font-size: 1rem;
color: #333; color: #2d3748;
line-height: 1.7;
font-family: 'Segoe UI', 'Microsoft JhengHei', 'PingFang TC', sans-serif;
background: linear-gradient(135deg, #f8fafc 0%, #ffffff 100%);
padding: 24px;
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05);
border: 1px solid #e2e8f0;
} }
.llm-advice-content h5, .llm-advice-content h6 { .llm-advice-content h5, .llm-advice-content h6 {
color: #2c3e50; color: #1a365d;
border-left: 4px solid #3498db; background: linear-gradient(135deg, #3182ce 0%, #2c5282 100%);
padding-left: 10px; color: white;
margin-bottom: 15px; padding: 12px 16px;
margin: 20px 0 16px 0;
border-radius: 8px;
font-weight: 600;
font-size: 1.1rem;
box-shadow: 0 2px 4px rgba(49, 130, 206, 0.2);
position: relative;
}
.llm-advice-content h5::before, .llm-advice-content h6::before {
content: "📊";
margin-right: 8px;
font-size: 1.2rem;
} }
.llm-advice-content p { .llm-advice-content p {
margin-bottom: 12px; margin-bottom: 16px;
text-align: justify; text-align: left;
color: #4a5568;
font-weight: 400;
}
.llm-advice-content ul {
margin: 16px 0;
padding-left: 0;
} }
.llm-advice-content li { .llm-advice-content li {
margin-bottom: 5px; margin-bottom: 8px;
padding: 8px 12px;
background: #f7fafc;
border-left: 3px solid #3182ce;
border-radius: 4px;
list-style: none;
color: #2d3748;
transition: all 0.2s ease;
}
.llm-advice-content li:hover {
background: #edf2f7;
transform: translateX(2px);
} }
.llm-advice-content strong { .llm-advice-content strong {
color: #e74c3c; color: #e53e3e;
font-weight: 600;
background: linear-gradient(135deg, #fed7d7 0%, #feb2b2 100%);
padding: 2px 6px;
border-radius: 4px;
font-size: 0.95em;
}
.llm-advice-content em {
color: #805ad5;
font-style: italic;
font-weight: 500;
} }
#llm-advice-container .alert { #llm-advice-container .alert {
border-radius: 8px; border-radius: 12px;
margin-bottom: 0; margin-bottom: 0;
border: none;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
/* 響應式設計 */
@media (max-width: 768px) {
.llm-advice-content {
padding: 16px;
font-size: 0.95rem;
}
.llm-advice-content h5, .llm-advice-content h6 {
font-size: 1rem;
padding: 10px 12px;
}
}
/* 載入動畫 */
.llm-advice-content.loading {
opacity: 0.7;
transition: opacity 0.3s ease;
} }
</style> </style>
{% endblock %} {% endblock %}
@ -162,7 +232,7 @@
</div> </div>
<div class="mb-4" id="bar" style="max-height:60vh"></div> <div class="mb-4" id="bar" style="max-height:60vh"></div>
<div class="row justify-content-center font-bold text-xl"> <div class="row justify-content-center font-bold text-xl">
🤖 LLM 投資建議 🤖 AI 投資建議
</div> </div>
<div class="card mb-4" style="border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1);"> <div class="card mb-4" style="border-radius: 10px; box-shadow: 0 2px 10px rgba(0,0,0,0.1);">
<div class="card-body"> <div class="card-body">

Loading…
Cancel
Save