Files
Binance_Altcoin_Monitor/backend/app/llm_service.py
T
2026-05-26 10:04:36 +08:00

207 lines
7.1 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.
"""大模型解读(OpenAI 兼容接口 + 图表图片)。"""
import asyncio
import base64
import logging
from datetime import datetime
import httpx
from .chart_image import render_daily_chart_png_async
from .config import settings
from .db import save_llm_interpretation
from .stats import compute_three_day_stats
logger = logging.getLogger(__name__)
_interpret_lock = asyncio.Lock()
_interpret_state: dict = {
"running": False,
"current_symbol": "",
"done": 0,
"total": 0,
"batch_id": "",
"last_error": "",
}
def get_interpret_state() -> dict:
return dict(_interpret_state)
def init_interpret_batch() -> dict:
"""同步初始化批次(API 立即返回 batch_id,避免前端刷新拉错旧批次)。"""
if _interpret_lock.locked() or _interpret_state.get("running"):
return {"ok": False, "message": "解读任务进行中", **get_interpret_state()}
stats = compute_three_day_stats()
if not stats.get("ok"):
return {"ok": False, "message": stats.get("message", "统计数据未就绪")}
sym_list = stats.get("symbols") or [x["symbol"] for x in stats.get("items", [])]
if not sym_list:
return {"ok": False, "message": "三日交集为空"}
bid = datetime.now().strftime("%Y-%m-%d-%H%M")
_interpret_state.update(
{
"running": True,
"current_symbol": "",
"done": 0,
"total": len(sym_list),
"batch_id": bid,
"last_error": "",
}
)
return {"ok": True, "batch_id": bid, "total": len(sym_list), **get_interpret_state()}
def _api_url() -> str:
base = settings.llm_base_url.rstrip("/")
if base.endswith("/v1"):
return f"{base}/chat/completions"
return f"{base}/v1/chat/completions"
def _build_prompt(symbol: str, stats_row: dict | None) -> str:
lines = [
f"你是加密货币合约分析师。请根据附图({symbol} 近300日K+成交量)及数据给出中文简析。",
"关注:趋势、关键支撑阻力、成交量变化、资金费率含义、未来1-3日可能节奏。",
"控制在 200-350 字,条理清晰,不要废话。",
]
if stats_row:
t, y, b = stats_row.get("today", {}), stats_row.get("yesterday", {}), stats_row.get("daybefore", {})
lines.append(
f"\n三日均为成交额Top30交集:"
f"\n今日 排名{t.get('rank')} 涨跌{t.get('price_change_pct_fmt')}{t.get('quote_volume_fmt')}"
f"\n昨日 排名{y.get('rank')} 涨跌{y.get('price_change_pct_fmt')}{y.get('quote_volume_fmt')}"
f"\n前日 排名{b.get('rank')} 涨跌{b.get('price_change_pct_fmt')}{b.get('quote_volume_fmt')}"
f"\n资金费率(当前){t.get('funding_rate_fmt', '')}"
)
return "\n".join(lines)
async def interpret_symbol(
symbol: str,
stats_row: dict | None = None,
batch_id: str | None = None,
) -> str:
if not settings.llm_api_key.strip():
raise RuntimeError("LLM_API_KEY 未配置")
png = await render_daily_chart_png_async(symbol, settings.chart_kline_limit)
b64 = base64.standard_b64encode(png).decode("ascii")
prompt = _build_prompt(symbol, stats_row)
payload = {
"model": settings.llm_model,
"messages": [
{
"role": "user",
"content": [
{"type": "text", "text": prompt},
{
"type": "image_url",
"image_url": {"url": f"data:image/png;base64,{b64}"},
},
],
}
],
"max_tokens": 800,
"temperature": 0.4,
}
headers = {
"Authorization": f"Bearer {settings.llm_api_key}",
"Content-Type": "application/json",
}
async with httpx.AsyncClient(timeout=120.0) as client:
resp = await client.post(_api_url(), json=payload, headers=headers)
if resp.status_code >= 400:
# 部分模型不支持 vision,降级纯文本
logger.warning("LLM vision failed %s, fallback text", resp.status_code)
payload["messages"] = [{"role": "user", "content": prompt + "\n(附图日K+成交量未能传入,请基于数据简析)"}]
resp = await client.post(_api_url(), json=payload, headers=headers)
resp.raise_for_status()
data = resp.json()
content = data["choices"][0]["message"]["content"]
bid = batch_id or datetime.now().strftime("%Y-%m-%d-%H%M")
save_llm_interpretation(symbol, bid, content)
return content
async def run_interpretation_batch(
symbols: list[str] | None = None,
*,
batch_id: str | None = None,
) -> dict:
if _interpret_lock.locked():
return {"ok": False, "message": "解读任务进行中"}
stats = compute_three_day_stats()
if not stats.get("ok"):
_interpret_state["running"] = False
return {"ok": False, "message": stats.get("message", "统计数据未就绪")}
sym_list = symbols or stats.get("symbols") or [x["symbol"] for x in stats.get("items", [])]
if not sym_list:
_interpret_state["running"] = False
return {"ok": False, "message": "三日交集为空"}
stats_map = {x["symbol"]: x for x in stats.get("items", [])}
bid = batch_id or _interpret_state.get("batch_id") or datetime.now().strftime("%Y-%m-%d-%H%M")
interval = settings.llm_symbol_interval_sec
async with _interpret_lock:
_interpret_state.update(
{
"running": True,
"current_symbol": "",
"done": _interpret_state.get("done", 0),
"total": len(sym_list),
"batch_id": bid,
}
)
for i, sym in enumerate(sym_list):
_interpret_state["current_symbol"] = sym
try:
await interpret_symbol(sym, stats_map.get(sym), bid)
logger.info("LLM interpreted %s (%d/%d)", sym, i + 1, len(sym_list))
except Exception as e:
_interpret_state["last_error"] = str(e)
logger.error("LLM %s failed: %s", sym, e)
save_llm_interpretation(sym, bid, f"[解读失败] {e}")
_interpret_state["done"] = i + 1
if i < len(sym_list) - 1:
await asyncio.sleep(interval)
_interpret_state["running"] = False
_interpret_state["current_symbol"] = ""
return {
"ok": True,
"batch_id": bid,
"count": len(sym_list),
"interval_sec": interval,
}
def schedule_interpret_background(symbols: list[str] | None = None) -> None:
"""后台启动解读,不阻塞请求。"""
info = init_interpret_batch()
if not info.get("ok"):
logger.info("Startup LLM skip: %s", info.get("message"))
return
bid = info.get("batch_id")
async def _run():
try:
await run_interpretation_batch(symbols, batch_id=bid)
except Exception as e:
logger.error("Background LLM batch failed: %s", e)
_interpret_state["running"] = False
asyncio.create_task(_run())