feat: 事件影响力分析——快讯内容深度获取 + LLM 影响推断 + 个股推荐卡(购入区间/预计收益/推荐指数)+ WS 推送
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
LLM 客户端(OpenAI 兼容 /chat/completions)。
|
||||
- base_url / model / key 由环境变量配置(默认智谱 GLM)
|
||||
- 出站安全:仅 http/https、显式拒绝 localhost、解析 IP 拒绝环回/私有/保留段、
|
||||
禁用重定向(防 DNS rebinding 绕过),值全部走 JSON 序列化,密钥不落日志
|
||||
"""
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import requests
|
||||
|
||||
|
||||
class LlmError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def _validated_url(base_url: str) -> str:
|
||||
u = urlparse(base_url)
|
||||
if u.scheme not in ('http', 'https'):
|
||||
raise LlmError('LLM base_url 仅允许 http/https')
|
||||
host = u.hostname or ''
|
||||
if not host or host.lower() in ('localhost', 'localhost.localdomain'):
|
||||
raise LlmError('LLM base_url 拒绝 localhost')
|
||||
port = u.port or (443 if u.scheme == 'https' else 80)
|
||||
try:
|
||||
infos = socket.getaddrinfo(host, port)
|
||||
except socket.gaierror as e:
|
||||
raise LlmError('LLM base_url 域名解析失败: {}'.format(host))
|
||||
for info in infos:
|
||||
ip = ipaddress.ip_address(info[4][0])
|
||||
if (ip.is_loopback or ip.is_private or ip.is_link_local or ip.is_reserved
|
||||
or ip.is_multicast or ip.is_unspecified):
|
||||
raise LlmError('LLM base_url 拒绝非公网地址: {}'.format(ip))
|
||||
return '{}://{}{}'.format(u.scheme, u.netloc, u.path)
|
||||
|
||||
|
||||
class LlmClient:
|
||||
|
||||
def __init__(self):
|
||||
self.api_key = os.environ.get('JQUANT_LLM_API_KEY', '').strip()
|
||||
self.base_url = (os.environ.get('JQUANT_LLM_BASE_URL', '').strip()
|
||||
or 'https://open.bigmodel.cn/api/paas/v4')
|
||||
self.model = os.environ.get('JQUANT_LLM_MODEL', '').strip() or 'glm-4-flash'
|
||||
self.temperature = float(os.environ.get('JQUANT_LLM_TEMPERATURE', '0.2'))
|
||||
|
||||
@property
|
||||
def enabled(self) -> bool:
|
||||
return bool(self.api_key)
|
||||
|
||||
def chat(self, system_prompt: str, user_prompt: str) -> str:
|
||||
if not self.enabled:
|
||||
raise LlmError('未配置 LLM API Key(JQUANT_LLM_API_KEY),请在服务环境变量中设置')
|
||||
url = _validated_url(self.base_url.rstrip('/')) + '/chat/completions'
|
||||
body = {
|
||||
'model': self.model,
|
||||
'temperature': self.temperature,
|
||||
'messages': [
|
||||
{'role': 'system', 'content': system_prompt},
|
||||
{'role': 'user', 'content': user_prompt},
|
||||
],
|
||||
}
|
||||
# 校验与请求紧邻;禁重定向防 DNS rebinding 绕过 IP 校验
|
||||
r = requests.post(url, json=body, timeout=90, allow_redirects=False,
|
||||
headers={'Authorization': 'Bearer ' + self.api_key,
|
||||
'Content-Type': 'application/json'})
|
||||
if r.status_code in (301, 302, 303, 307, 308):
|
||||
raise LlmError('LLM 端点发生重定向,已拒绝(防 SSRF 绕过)')
|
||||
if r.status_code != 200:
|
||||
raise LlmError('LLM HTTP {}: {}'.format(r.status_code, r.text[:200]))
|
||||
content = r.json().get('choices', [{}])[0].get('message', {}).get('content')
|
||||
if not content:
|
||||
raise LlmError('LLM 响应缺少 content')
|
||||
return content
|
||||
Reference in New Issue
Block a user