From 1845018151432d12fe8e1a135920bb15cb16b542 Mon Sep 17 00:00:00 2001 From: dekun Date: Tue, 26 May 2026 10:04:36 +0800 Subject: [PATCH] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=A4=A7=E6=A8=A1=E5=9E=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/llm_service.py | 46 +++++- backend/app/main.py | 35 +++-- web/app.js | 281 +++++++++++++++++++++++++++---------- web/index.html | 18 +-- web/style.css | 75 +++++++++- 5 files changed, 351 insertions(+), 104 deletions(-) diff --git a/backend/app/llm_service.py b/backend/app/llm_service.py index fb95a74..8d797f2 100644 --- a/backend/app/llm_service.py +++ b/backend/app/llm_service.py @@ -9,7 +9,7 @@ import httpx from .chart_image import render_daily_chart_png_async from .config import settings -from .db import get_llm_interpretation, save_llm_interpretation +from .db import save_llm_interpretation from .stats import compute_three_day_stats logger = logging.getLogger(__name__) @@ -29,6 +29,33 @@ 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"): @@ -110,21 +137,21 @@ async def run_interpretation_batch( *, batch_id: str | None = None, ) -> dict: - global _interpret_state - 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 datetime.now().strftime("%Y-%m-%d-%H%M") + 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: @@ -132,10 +159,9 @@ async def run_interpretation_batch( { "running": True, "current_symbol": "", - "done": 0, + "done": _interpret_state.get("done", 0), "total": len(sym_list), "batch_id": bid, - "last_error": "", } ) for i, sym in enumerate(sym_list): @@ -164,11 +190,17 @@ async def run_interpretation_batch( 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) + 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()) diff --git a/backend/app/main.py b/backend/app/main.py index 486f63f..ee61e3c 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -14,7 +14,7 @@ from .exceptions import BinanceRateLimitedError from .period_api import get_period_top30 from .periods import get_daybefore_period, get_today_period, get_yesterday_period from .chart_image import render_daily_chart_png_async -from .llm_service import get_interpret_state, run_interpretation_batch +from .llm_service import get_interpret_state, init_interpret_batch, run_interpretation_batch from .scheduler import job_finalize_yesterday, job_push_wecom, job_refresh_today, start_scheduler, startup_tasks, stop_scheduler from .stats import compute_three_day_stats from .aggregator import aggregate_period @@ -207,19 +207,38 @@ async def api_llm_status(): @app.get("/api/llm/interpretations") -async def api_llm_interpretations(batch_id: str | None = None, limit: int = 50): - return {"items": get_llm_interpretations(batch_id, limit)} +async def api_llm_interpretations(batch_id: str | None = None, limit: int = 100): + """返回解读列表;进行中时优先当前批次(即使尚无记录)。""" + st = get_interpret_state() + bid = batch_id or (st.get("batch_id") if st.get("running") else None) + items = get_llm_interpretations(bid, limit) if bid else get_llm_interpretations(None, limit) + if not bid and items: + bid = items[0].get("batch_id", "") + return { + "items": items, + "batch_id": bid or st.get("batch_id", ""), + "running": st.get("running", False), + "done": st.get("done", 0), + "total": st.get("total", 0), + "current_symbol": st.get("current_symbol", ""), + } @app.post("/api/llm/interpret/run") async def api_llm_interpret_run(background_tasks: BackgroundTasks): if not settings.llm_api_key.strip(): raise HTTPException(400, "LLM_API_KEY 未配置") - state = get_interpret_state() - if state.get("running"): - return {"ok": False, "message": "解读任务进行中", **state} - background_tasks.add_task(run_interpretation_batch) - return {"ok": True, "message": "已启动三日交集解读队列", **get_interpret_state()} + info = init_interpret_batch() + if not info.get("ok"): + return info + bid = info.get("batch_id") + background_tasks.add_task(run_interpretation_batch, batch_id=bid) + return { + "ok": True, + "message": "已启动三日交集解读队列", + "batch_id": bid, + **get_interpret_state(), + } @app.post("/api/chart/{symbol}/daily/refresh") diff --git a/web/app.js b/web/app.js index 8a7c5cd..4fa8f80 100644 --- a/web/app.js +++ b/web/app.js @@ -17,6 +17,16 @@ let statsData = null; let currentView = "today"; let llmPollTimer = null; let llmInterpretMap = {}; +let llmRunState = { + running: false, + batch_id: "", + current_symbol: "", + done: 0, + total: 0, +}; +let llmSymbolOrder = []; +const llmExpandedSymbols = new Set(); +const llmSeenDone = new Set(); const SORT_KEYS = { rank: (r) => Number(r.rank) || 0, @@ -287,15 +297,17 @@ function renderStatsTable() { return; } + llmSymbolOrder = items.map((r) => r.symbol); + wrap.innerHTML = ` - +
- +
合约 今日排名今日涨跌今日成交额 昨日排名昨日涨跌昨日成交额 前日排名前日涨跌前日成交额 三日总成交额AI解读AI解读
`; @@ -309,20 +321,103 @@ function renderStatsTable() { const pct = x.price_change_pct ?? 0; return `${f === "pct" ? x.price_change_pct_fmt || "—" : f === "rank" ? x.rank ?? "—" : x.quote_volume_fmt || "—"}`; }; - const llm = llmInterpretMap[row.symbol]; - const llmCell = llm - ? `
AI解读
${escapeHtml(llm.content)}
${llm.created_at?.slice(0, 19) || ""}
` - : ''; - return ` + return ` ${row.symbol} ${cell("today", "rank")}${cell("today", "pct")}${cell("today", "vol")} ${cell("yesterday", "rank")}${cell("yesterday", "pct")}${cell("yesterday", "vol")} ${cell("daybefore", "rank")}${cell("daybefore", "pct")}${cell("daybefore", "vol")} - ${formatVol(row.total_quote_volume)} - ${llmCell} + ${formatVol(row.total_quote_volume)} + ${buildLlmCellHtml(row.symbol)} `; }) .join(""); + bindLlmFoldHandlers(); +} + +function getLlmCellState(symbol) { + const llm = llmInterpretMap[symbol]; + if (llm?.content) { + const failed = String(llm.content).startsWith("[解读失败]"); + return { kind: failed ? "failed" : "done", llm }; + } + if (!llmRunState.running) return { kind: "idle" }; + if (symbol === llmRunState.current_symbol) return { kind: "running" }; + const idx = llmSymbolOrder.indexOf(symbol); + if (idx >= 0 && idx < llmRunState.done) return { kind: "done", llm: null }; + return { kind: "pending" }; +} + +function buildLlmCellHtml(symbol) { + const st = getLlmCellState(symbol); + if (st.kind === "done" && !st.llm) { + return `
已完成,点「刷新解读」
`; + } + if (st.kind === "done" && st.llm) { + const t = (st.llm.created_at || "").replace("T", " ").slice(0, 19); + const open = llmExpandedSymbols.has(symbol) ? " open" : ""; + return `
+ 查看解读 ${t} +
${escapeHtml(st.llm.content)}
+
`; + } + if (st.kind === "failed" && st.llm) { + return `
+ 解读失败 +
${escapeHtml(st.llm.content)}
+
`; + } + if (st.kind === "running") { + return `
解读中…
`; + } + if (st.kind === "pending") { + return `
排队等待
`; + } + return ``; +} + +function bindLlmFoldHandlers() { + document.querySelectorAll("details.llm-fold").forEach((el) => { + el.ontoggle = () => { + const sym = el.dataset.symbol; + if (!sym) return; + if (el.open) llmExpandedSymbols.add(sym); + else llmExpandedSymbols.delete(sym); + }; + }); +} + +function updateStatsLlmRows() { + const body = document.getElementById("stats-body"); + if (!body) return; + + document.querySelectorAll("tr.stats-row[data-symbol]").forEach((tr) => { + const sym = tr.dataset.symbol; + const td = tr.querySelector("td.llm-col"); + if (!td) return; + const wasOpen = td.querySelector("details.llm-fold")?.open; + td.innerHTML = buildLlmCellHtml(sym); + const det = td.querySelector("details.llm-fold"); + if (det) { + det.ontoggle = () => { + if (det.open) llmExpandedSymbols.add(sym); + else llmExpandedSymbols.delete(sym); + }; + if (wasOpen || llmExpandedSymbols.has(sym)) det.open = true; + } + }); + + for (const sym of Object.keys(llmInterpretMap)) { + if (llmSeenDone.has(sym)) continue; + const llm = llmInterpretMap[sym]; + if (llm?.content && !String(llm.content).startsWith("[解读失败]")) { + llmSeenDone.add(sym); + const det = document.querySelector(`details.llm-fold[data-symbol="${sym}"]`); + if (det) { + det.open = true; + llmExpandedSymbols.add(sym); + } + } + } } function escapeHtml(s) { @@ -339,71 +434,106 @@ function formatVol(v) { return String(Math.round(v)); } -async function loadLlmInterpretations() { - try { - const res = await fetch("/api/llm/interpretations"); - const data = await res.json(); - llmInterpretMap = {}; - for (const item of data.items || []) { - llmInterpretMap[item.symbol] = item; - } - renderLlmList(data.items || []); - if (statsData?.ok) renderStatsTable(); - } catch { - /* ignore */ +function applyLlmPayload(data) { + if (data.batch_id) llmRunState.batch_id = data.batch_id; + if (data.running != null) { + llmRunState.running = !!data.running; + llmRunState.batch_id = data.batch_id || llmRunState.batch_id; + llmRunState.current_symbol = data.current_symbol || ""; + llmRunState.done = data.done ?? llmRunState.done; + llmRunState.total = data.total ?? llmRunState.total; + } + const nextMap = { ...llmInterpretMap }; + for (const item of data.items || []) { + if (item.symbol) nextMap[item.symbol] = item; + } + llmInterpretMap = nextMap; + if (document.getElementById("stats-body")) { + updateStatsLlmRows(); + } else if (statsData?.ok) { + renderStatsTable(); } } -function renderLlmList(items) { - const el = document.getElementById("llm-interpret-list"); - if (!el) return; - if (!items.length) { - el.innerHTML = '

暂无解读记录

'; - return; +async function loadLlmInterpretations() { + const q = llmRunState.batch_id + ? `?batch_id=${encodeURIComponent(llmRunState.batch_id)}` + : ""; + const res = await fetch(`/api/llm/interpretations${q}`); + if (!res.ok) throw new Error("加载解读失败"); + const data = await res.json(); + applyLlmPayload(data); + return data; +} + +function updateLlmStatusText(st) { + const label = document.getElementById("llm-model-label"); + const text = document.getElementById("llm-status-text"); + if (label) label.textContent = st.enabled ? st.model : "未配置"; + if (!text) return; + if (st.running) { + text.textContent = `解读中 ${st.done}/${st.total} · 当前 ${st.current_symbol || "—"}`; + } else { + text.textContent = st.enabled + ? `就绪 · 已完成 ${Object.keys(llmInterpretMap).length} 条 · 批次 ${st.batch_id || dataBatchId(st)}` + : "请在 .env 配置 LLM_API_KEY"; } - el.innerHTML = items - .map( - (it) => ` -
-

${it.symbol} ${it.batch_id}

-
${escapeHtml(it.content)}
- -
` - ) - .join(""); +} + +function dataBatchId(st) { + const keys = Object.keys(llmInterpretMap); + return keys.length ? llmInterpretMap[keys[0]]?.batch_id || "—" : "—"; } async function refreshLlmStatus() { - try { - const res = await fetch("/api/llm/status"); - const st = await res.json(); - const label = document.getElementById("llm-model-label"); - const text = document.getElementById("llm-status-text"); - if (label) label.textContent = st.enabled ? st.model : "未配置"; - if (!text) return; - if (st.running) { - text.textContent = `解读中 ${st.done}/${st.total} · 当前 ${st.current_symbol || "—"} · 批次 ${st.batch_id}`; - if (!llmPollTimer) { - llmPollTimer = setInterval(async () => { - await refreshLlmStatus(); - await loadLlmInterpretations(); - if (!(await fetch("/api/llm/status").then((r) => r.json())).running) { - clearInterval(llmPollTimer); - llmPollTimer = null; - } - }, 5000); - } - } else { - text.textContent = st.enabled - ? `就绪 · 每币 ${st.interval_sec}s · 最近批次 ${st.batch_id || "—"}` - : "请在 .env 配置 LLM_API_KEY"; - if (llmPollTimer) { + const res = await fetch("/api/llm/status"); + if (!res.ok) throw new Error("状态获取失败"); + const st = await res.json(); + llmRunState.running = !!st.running; + llmRunState.batch_id = st.batch_id || llmRunState.batch_id; + llmRunState.current_symbol = st.current_symbol || ""; + llmRunState.done = st.done ?? 0; + llmRunState.total = st.total ?? 0; + updateLlmStatusText(st); + return st; +} + +function startLlmPolling() { + if (llmPollTimer) return; + llmPollTimer = setInterval(async () => { + try { + await refreshLlmStatus(); + await loadLlmInterpretations(); + const st = await fetch("/api/llm/status").then((r) => r.json()); + if (!st.running) { clearInterval(llmPollTimer); llmPollTimer = null; + await loadLlmInterpretations(); } + } catch (e) { + console.warn("LLM poll:", e); } - } catch { - /* ignore */ + }, 3000); +} + +function stopLlmPolling() { + if (llmPollTimer) { + clearInterval(llmPollTimer); + llmPollTimer = null; + } +} + +async function refreshLlmAll() { + const text = document.getElementById("llm-status-text"); + if (text) text.textContent = "刷新中…"; + try { + const st = await refreshLlmStatus(); + await loadLlmInterpretations(); + if (st.running) startLlmPolling(); + else stopLlmPolling(); + } catch (e) { + if (text) text.textContent = "刷新失败"; + console.error(e); } } @@ -413,8 +543,18 @@ async function runLlmInterpret() { try { const res = await fetch("/api/llm/interpret/run", { method: "POST" }); const data = await res.json(); - if (!data.ok) alert(data.message || "启动失败"); + if (!data.ok) { + alert(data.message || "启动失败"); + return; + } + llmInterpretMap = {}; + llmSeenDone.clear(); + if (data.batch_id) llmRunState.batch_id = data.batch_id; + llmRunState.running = true; await refreshLlmStatus(); + await loadLlmInterpretations(); + startLlmPolling(); + if (document.getElementById("stats-body")) updateStatsLlmRows(); } catch (e) { alert(e.message); } finally { @@ -512,9 +652,8 @@ async function loadStats() { try { const res = await fetch("/api/stats/three-day"); statsData = await res.json(); - await loadLlmInterpretations(); renderStatsTable(); - await refreshLlmStatus(); + await refreshLlmAll(); await loadWecomPreview(); } catch (e) { document.getElementById("stats-table-wrap").innerHTML = `

${e.message}

`; @@ -551,10 +690,7 @@ function switchView(view) { if (view === "stats") { if (!statsData) loadStats(); - else { - refreshLlmStatus(); - loadLlmInterpretations(); - } + else refreshLlmAll(); return; } @@ -588,10 +724,7 @@ document.getElementById("btn-refresh").addEventListener("click", async () => { }); document.getElementById("btn-llm-run")?.addEventListener("click", runLlmInterpret); -document.getElementById("btn-llm-refresh")?.addEventListener("click", async () => { - await loadLlmInterpretations(); - await refreshLlmStatus(); -}); +document.getElementById("btn-llm-refresh")?.addEventListener("click", () => refreshLlmAll()); document.getElementById("btn-reload-stats")?.addEventListener("click", () => { statsData = null; diff --git a/web/index.html b/web/index.html index 2ec2ec6..de09a43 100644 --- a/web/index.html +++ b/web/index.html @@ -75,9 +75,15 @@ + +

+

+ 大模型 + · +

-
-
-

大模型解读

- -
- - -
-
-

每日 08:05(北京时间)自动对「三日 Top30 交集」逐币解读,每币约 3 分钟;启动时也会自动跑一轮(需配置 LLM_API_KEY)。

-
-