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)。
-
-