修复web前端,增加openai
This commit is contained in:
+177
@@ -0,0 +1,177 @@
|
||||
"""大模型调用:OpenAI 兼容接口(默认)或本机 Ollama 二选一。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import os
|
||||
from typing import List, Optional, Sequence
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import requests
|
||||
|
||||
AI_TIMEOUT_SECONDS = int(os.getenv("AI_TIMEOUT_SECONDS", "120"))
|
||||
AI_PROVIDER = (os.getenv("AI_PROVIDER", "openai") or "openai").strip().lower()
|
||||
|
||||
OPENAI_API_BASE = (os.getenv("OPENAI_API_BASE", "https://op.bz121.com/v1") or "").strip().rstrip("/")
|
||||
OPENAI_API_KEY = (os.getenv("OPENAI_API_KEY") or os.getenv("OPENAI_API_KEY") or "").strip()
|
||||
OPENAI_MODEL = (os.getenv("OPENAI_MODEL", "gemma4:e4b") or "gemma4:e4b").strip()
|
||||
|
||||
OLLAMA_API = os.getenv("OLLAMA_API", "http://127.0.0.1:11434/api/generate")
|
||||
AI_MODEL = os.getenv("AI_MODEL", "huihui_ai/deepseek-r1-abliterated:latest")
|
||||
|
||||
|
||||
def _use_openai() -> bool:
|
||||
return AI_PROVIDER in ("openai", "openai_compatible", "gateway")
|
||||
|
||||
|
||||
def _read_image_base64(image_path: str) -> Optional[str]:
|
||||
try:
|
||||
with open(image_path, "rb") as f:
|
||||
return base64.b64encode(f.read()).decode("utf-8")
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _collect_images(
|
||||
image_paths: Optional[Sequence[str]] = None,
|
||||
images_b64: Optional[Sequence[str]] = None,
|
||||
) -> List[str]:
|
||||
out: List[str] = []
|
||||
for p in image_paths or []:
|
||||
b = _read_image_base64(p)
|
||||
if b:
|
||||
out.append(b)
|
||||
for b in images_b64 or []:
|
||||
if b:
|
||||
out.append(str(b))
|
||||
return out
|
||||
|
||||
|
||||
def _openai_chat_url() -> str:
|
||||
base = OPENAI_API_BASE or "https://op.bz121.com/v1"
|
||||
if base.endswith("/chat/completions"):
|
||||
return base
|
||||
return f"{base}/chat/completions"
|
||||
|
||||
|
||||
def _generate_openai(prompt: str, images: List[str], temperature: float) -> str:
|
||||
if not OPENAI_API_KEY:
|
||||
return "AI 调用失败:未配置 OPENAI_API_KEY"
|
||||
headers = {
|
||||
"Authorization": f"Bearer {OPENAI_API_KEY}",
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
if images:
|
||||
content: List[dict] = [{"type": "text", "text": prompt}]
|
||||
for b64 in images:
|
||||
content.append(
|
||||
{
|
||||
"type": "image_url",
|
||||
"image_url": {"url": f"data:image/jpeg;base64,{b64}"},
|
||||
}
|
||||
)
|
||||
messages = [{"role": "user", "content": content}]
|
||||
else:
|
||||
messages = [{"role": "user", "content": prompt}]
|
||||
body = {
|
||||
"model": OPENAI_MODEL,
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
"stream": False,
|
||||
}
|
||||
r = requests.post(
|
||||
_openai_chat_url(),
|
||||
headers=headers,
|
||||
json=body,
|
||||
timeout=AI_TIMEOUT_SECONDS,
|
||||
)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
choices = data.get("choices") or []
|
||||
if not choices:
|
||||
return "AI 生成失败:响应无 choices"
|
||||
msg = choices[0].get("message") or {}
|
||||
return (msg.get("content") or "").strip() or "AI 生成失败:空内容"
|
||||
|
||||
|
||||
def _generate_ollama(prompt: str, images: List[str], temperature: float) -> str:
|
||||
payload = {
|
||||
"model": AI_MODEL,
|
||||
"prompt": prompt,
|
||||
"stream": False,
|
||||
"options": {"temperature": temperature},
|
||||
}
|
||||
if images:
|
||||
payload["images"] = images
|
||||
r = requests.post(OLLAMA_API, json=payload, timeout=AI_TIMEOUT_SECONDS)
|
||||
r.raise_for_status()
|
||||
return (r.json().get("response") or "").strip() or "AI 生成失败"
|
||||
|
||||
|
||||
def ai_generate(
|
||||
prompt: str,
|
||||
*,
|
||||
image_paths: Optional[Sequence[str]] = None,
|
||||
images_b64: Optional[Sequence[str]] = None,
|
||||
temperature: float = 0.2,
|
||||
) -> str:
|
||||
"""统一文本生成;失败时返回以「AI 调用失败」开头的说明。"""
|
||||
images = _collect_images(image_paths, images_b64)
|
||||
try:
|
||||
if _use_openai():
|
||||
return _generate_openai(prompt, images, temperature)
|
||||
return _generate_ollama(prompt, images, temperature)
|
||||
except requests.HTTPError as e:
|
||||
detail = ""
|
||||
try:
|
||||
detail = (e.response.text or "")[:500]
|
||||
except Exception:
|
||||
pass
|
||||
prov = "OpenAI" if _use_openai() else "Ollama"
|
||||
return f"AI 调用失败({prov} HTTP {e.response.status_code if e.response else '?'}):{detail or str(e)}"
|
||||
except Exception as e:
|
||||
prov = "OpenAI" if _use_openai() else "Ollama"
|
||||
return f"AI 调用失败({prov}):{str(e)}"
|
||||
|
||||
|
||||
def ai_review(trades_text: str, period_title: str, image_paths=None) -> str:
|
||||
prompt = f"""
|
||||
你是一位专业交易教练。下面是用户的{period_title}交易记录,请做简洁、可执行的复盘(中文)。
|
||||
|
||||
【硬性规则 — 必须遵守】
|
||||
- 你只能根据「交易记录」里**明确出现的字段**陈述事实;禁止编造:是否触发止损、是否扛单、亏损是否扩大、图上具体结构/进出场点位等记录里**没有**的信息。
|
||||
- 「平仓/离场」只是交易员自述摘要,不是客观成交明细;若记录未写明代币是否打到止损价、是否软件平仓等,不要断言执行路径,可用「在记录有限前提下,一种可能是……」或简短写「执行路径记录不足,无法判断」。
|
||||
- 「提前离场」类结论必须优先依据记录中的「提前离场记录」字段;若该段全为「无」或未出现有效内容,不得写道「明显扛单」「拒不止损」「未执行硬止损」等。
|
||||
- 实际RR为负只说明结果相对于预期RR不利,不等同于「风控失灵」或「止损纪律崩溃」,除非记录里另有依据。
|
||||
- 禁止用语:人身攻击、夸张定性(如「致命伤」「灾难」);语气克制、对事不对人。
|
||||
- 若有截图且你能辨认,再结合图讨论;看不清或无明确定位则明确说「无法从图确认」,不得虚构 K 线故事。
|
||||
|
||||
【输出结构】
|
||||
1. 总体盈亏结构(紧扣笔数、盈亏数字与 RR,少形容词)
|
||||
2. 心态与执行(每笔 1–10 分 + 一句依据;依据必须对应记录字段)
|
||||
3. 行为标签(提前离场 / 乱开仓 / 扛单等):仅在有字段或自述支撑时点名;否则写「记录未勾选或未描述,不作强加」
|
||||
4. 改进建议(最多 3 条,每条具体可执行)
|
||||
5. 图表(若有且可读):结合价格行为简述;否则一两句说明无法看图分析
|
||||
|
||||
交易记录:
|
||||
{trades_text}
|
||||
""".strip()
|
||||
return ai_generate(prompt, image_paths=image_paths, temperature=0.2)
|
||||
|
||||
|
||||
def ai_short_advice(prompt_text: str) -> str:
|
||||
prompt = f"""
|
||||
你是交易风控助理。请用中文给出**最多 3 条**提醒,要求:
|
||||
- 每条不超过 25 个字
|
||||
- 语气克制、具体、可执行
|
||||
- 不要输出 Markdown,不要编号前缀以外的废话
|
||||
|
||||
场景:
|
||||
{prompt_text}
|
||||
""".strip()
|
||||
return ai_generate(prompt, temperature=0.2)
|
||||
|
||||
|
||||
def ai_provider_label() -> str:
|
||||
if _use_openai():
|
||||
return f"OpenAI 兼容 · {OPENAI_MODEL} @ {OPENAI_API_BASE}"
|
||||
return f"Ollama · {AI_MODEL}"
|
||||
Reference in New Issue
Block a user