diff --git a/manual_trading_hub/hub_ai/archive_quote.py b/manual_trading_hub/hub_ai/archive_quote.py new file mode 100644 index 0000000..0df8297 --- /dev/null +++ b/manual_trading_hub/hub_ai/archive_quote.py @@ -0,0 +1,116 @@ +"""内照明心复盘语录 → 交易教练点评。""" +from __future__ import annotations + +from typing import Any + +from hub_ai.chat import _is_ai_error_reply +from hub_ai.client import generate_text, model_label +from hub_ai.config import CHAT_MAX_CONTINUATIONS, CHAT_MAX_OUTPUT_TOKENS, CHAT_TEMPERATURE +from hub_ai.prompts import CHAT_SYSTEM, build_archive_quote_review_prompt +from hub_ai.store import CHAT_BOT_TRADING, append_chat_message, create_new_session, list_chat_sessions +from hub_symbol_archive_lib import list_daily_trades + + +def _tag_label(tag: str) -> str: + t = (tag or "").strip().lower() + if t == "sick": + return "犯病" + if t == "emotion": + return "情绪化" + return t or "—" + + +def _fmt_pnl(v: Any) -> str: + try: + n = float(v or 0) + except (TypeError, ValueError): + return "—" + sign = "+" if n > 0 else "" + return f"{sign}{n:.2f}U" + + +def format_archive_trades_for_ai(payload: dict[str, Any]) -> str: + trades = payload.get("trades") or [] + stats = payload.get("stats") or {} + lines = [ + ( + f"统计:开仓 {int(stats.get('open_count') or 0)} 笔," + f"犯病 {int(stats.get('sick_count') or 0)} 笔," + f"盈亏合计 {_fmt_pnl(stats.get('pnl_total'))}," + f"剔除犯病盈亏 {_fmt_pnl(stats.get('pnl_ex_sick'))}" + ) + ] + if not trades: + lines.append("(该日无交易记录)") + return "\n".join(lines) + for i, t in enumerate(trades, 1): + ex = str(t.get("exchange_key") or t.get("account_exchange_key") or "—") + sym = str(t.get("symbol") or "—") + direction = str(t.get("direction") or "—") + opened = str(t.get("opened_at") or "—") + closed = str(t.get("closed_at") or "—") + hold = str(t.get("hold_minutes_text") or t.get("hold_minutes") or "—") + result = str(t.get("result") or "—") + pnl = _fmt_pnl(t.get("pnl_amount")) + entry = str(t.get("entry_type") or t.get("entry_reason") or t.get("monitor_type") or "—") + tag = _tag_label(str(t.get("behavior_tag") or "")) + note = str(t.get("note") or "").strip() + line = ( + f"{i}. {ex} | {sym} | {direction} | 开仓类型 {entry} | " + f"开 {opened} | 平 {closed} | 持仓 {hold} | 结果 {result} | " + f"盈亏 {pnl} | 标签 {tag}" + ) + if note: + line += f" | 备注 {note}" + lines.append(line) + return "\n".join(lines) + + +def send_archive_quote_review( + *, + quote_date: str, + content: str, +) -> dict[str, Any]: + text = (content or "").strip() + if not text: + return {"ok": False, "msg": "语录内容不能为空"} + day = (quote_date or "").strip()[:10] + if not day: + return {"ok": False, "msg": "语录日期无效"} + + session = create_new_session( + trading_day=day, + title=f"复盘 {day}", + bot_mode=CHAT_BOT_TRADING, + ) + sid = session["id"] + + archive_payload = list_daily_trades(trading_day=day, period="today") + archive_trades_text = format_archive_trades_for_ai(archive_payload) + + append_chat_message(sid, "user", text) + + user_prompt = build_archive_quote_review_prompt( + quote_date=day, + archive_trades_text=archive_trades_text, + user_message=text, + ) + reply = generate_text( + system=CHAT_SYSTEM, + user=user_prompt, + temperature=CHAT_TEMPERATURE, + max_tokens=CHAT_MAX_OUTPUT_TOKENS, + max_continuations=CHAT_MAX_CONTINUATIONS, + ) + if _is_ai_error_reply(reply): + return {"ok": False, "msg": reply, "session_id": sid} + + session = append_chat_message(sid, "assistant", reply) + return { + "ok": True, + "trading_day": day, + "session": session, + "sessions": list_chat_sessions(), + "reply": reply, + "model": model_label(), + } diff --git a/manual_trading_hub/hub_ai/prompts.py b/manual_trading_hub/hub_ai/prompts.py index 849a13b..587e907 100644 --- a/manual_trading_hub/hub_ai/prompts.py +++ b/manual_trading_hub/hub_ai/prompts.py @@ -117,3 +117,30 @@ def build_chat_user_prompt( parts.extend(["【用户附件说明】", attachment_note.strip()]) parts.extend(["【用户现在说(优先回应这一条)】", user_message.strip()]) return "\n\n".join(parts) + + +ARCHIVE_QUOTE_REVIEW_INSTRUCTION = """ +【任务】用户从内照明心提交了一条复盘语录,并附上该交易日的档案交易记录(仅供你分析,用户界面不展示明细)。 +请结合语录与交易记录: +1) 帮他核对自述与操作事实是否一致; +2) 指出心态、纪律、执行上的偏差点(若有); +3) 给出可落地的改进建议。 +语气沿用交易教练:体贴、口语、短句,不用说教式清单;不预测涨跌,不保证收益。 +""".strip() + + +def build_archive_quote_review_prompt( + *, + quote_date: str, + archive_trades_text: str, + user_message: str, +) -> str: + parts = [ + f"【复盘交易日】{quote_date}", + ARCHIVE_QUOTE_REVIEW_INSTRUCTION, + "【该日交易记录(内照明心档案,用户不可见此段)】", + (archive_trades_text or "(该日无交易记录)").strip(), + "【用户复盘语录(对话框已展示,请优先回应)】", + user_message.strip(), + ] + return "\n\n".join(parts) diff --git a/manual_trading_hub/hub_ai/routes.py b/manual_trading_hub/hub_ai/routes.py index 94bf83e..7dfc18d 100644 --- a/manual_trading_hub/hub_ai/routes.py +++ b/manual_trading_hub/hub_ai/routes.py @@ -3,9 +3,10 @@ from __future__ import annotations from typing import Callable -from fastapi import APIRouter, File, Form, HTTPException, UploadFile +from fastapi import APIRouter, Body, File, Form, HTTPException, UploadFile from pydantic import BaseModel, Field +from hub_ai.archive_quote import send_archive_quote_review from hub_ai.chat import ( get_chat_state, remove_chat_session, @@ -40,6 +41,11 @@ class ChatSwitchBody(BaseModel): session_id: str = Field(..., min_length=1) +class ArchiveQuoteChatBody(BaseModel): + quote_date: str = "" + content: str = "" + + def create_hub_ai_router(*, load_all_exchanges: Callable[[], list]) -> APIRouter: router = APIRouter(prefix="/api/ai", tags=["hub-ai"]) @@ -118,6 +124,16 @@ def create_hub_ai_router(*, load_all_exchanges: Callable[[], list]) -> APIRouter raise HTTPException(status_code=404, detail="会话不存在") return result + @router.post("/chat/archive-quote") + def api_ai_chat_archive_quote(body: ArchiveQuoteChatBody = Body(...)): + result = send_archive_quote_review( + quote_date=body.quote_date, + content=body.content, + ) + if not result.get("ok"): + raise HTTPException(status_code=502, detail=result.get("msg") or "发送失败") + return result + @router.post("/chat/send") async def api_ai_chat_send( message: str = Form(""), diff --git a/manual_trading_hub/static/app.css b/manual_trading_hub/static/app.css index 224fd9f..128d0be 100644 --- a/manual_trading_hub/static/app.css +++ b/manual_trading_hub/static/app.css @@ -5615,8 +5615,13 @@ body.funds-fullscreen-open { } .archive-quote-actions { display: flex; + flex-wrap: wrap; gap: 8px; } +.archive-quote-ai-btn { + color: var(--accent); + border-color: color-mix(in srgb, var(--accent) 35%, var(--border-soft)); +} .archive-main-panel { display: flex; flex-direction: column; diff --git a/manual_trading_hub/static/app.js b/manual_trading_hub/static/app.js index df08084..a89cc66 100644 --- a/manual_trading_hub/static/app.js +++ b/manual_trading_hub/static/app.js @@ -3497,9 +3497,70 @@ } } + const ARCHIVE_QUOTE_AI_KEY = "hub_archive_quote_ai"; + + async function consumeArchiveQuoteAiPending() { + let raw = ""; + try { + raw = sessionStorage.getItem(ARCHIVE_QUOTE_AI_KEY) || ""; + } catch (_) { + return; + } + if (!raw) return; + sessionStorage.removeItem(ARCHIVE_QUOTE_AI_KEY); + let payload; + try { + payload = JSON.parse(raw); + } catch (_) { + return; + } + const content = String((payload && payload.content) || "").trim(); + const quoteDate = String((payload && payload.quote_date) || "").trim(); + if (!content) return; + + const input = document.getElementById("ai-chat-input"); + if (input) input.value = content; + updateAiBotTabs("trading"); + if (isMobileAiLayout()) { + localStorage.setItem(AI_MOBILE_TAB_KEY, "trading"); + applyAiMobileTab("trading"); + } + + setAiChatBusy(true); + renderAiChatMessages(aiChatSessionCache, { + pendingUser: content, + thinking: true, + }); + try { + const r = await apiFetch("/api/ai/chat/archive-quote", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ quote_date: quoteDate, content }), + }); + const j = await r.json(); + if (!r.ok) throw new Error(j.detail || j.msg || "发送失败"); + aiChatSessionCache = j.session || null; + aiChatSessionsCache = j.sessions || aiChatSessionsCache; + renderAiChatMessages(aiChatSessionCache); + renderAiChatHistory(aiChatSessionsCache); + if (input) input.value = ""; + showToast("复盘语录已发送给交易教练"); + } catch (e) { + showToast(String(e), true); + try { + await loadAiChatSession(); + } catch (_) { + renderAiChatMessages(aiChatSessionCache); + } + } finally { + setAiChatBusy(false); + } + } + async function loadAiPage() { applyAiMobileTab(); await loadAiChatSession(); + await consumeArchiveQuoteAiPending(); const mobTab = normalizeAiMobileTab(localStorage.getItem(AI_MOBILE_TAB_KEY) || "trading"); if (isMobileAiLayout() && AI_MOBILE_CHAT_TABS.has(mobTab)) { const input = document.getElementById("ai-chat-input"); @@ -3665,6 +3726,16 @@ window.addEventListener("popstate", setActiveNav); } + window.hubNavigateTo = function hubNavigateTo(path) { + const href = String(path || "/").split("?")[0] || "/"; + if (href === window.location.pathname) { + setActiveNav(); + return; + } + history.pushState({}, "", href); + setActiveNav(); + }; + window.hubOpenMonitorExpand = function hubOpenMonitorExpand(exId) { const id = String(exId || "").trim(); if (!id) return; diff --git a/manual_trading_hub/static/archive.js b/manual_trading_hub/static/archive.js index a26da76..41874fe 100644 --- a/manual_trading_hub/static/archive.js +++ b/manual_trading_hub/static/archive.js @@ -566,6 +566,9 @@ '' + + '' + "" : "") + "" @@ -591,6 +594,12 @@ void deleteQuote(btn.getAttribute("data-id")); }); }); + elQuotesList.querySelectorAll(".archive-quote-ai-btn").forEach(function (btn) { + btn.addEventListener("click", function (ev) { + ev.stopPropagation(); + startQuoteAiChat(btn.getAttribute("data-id")); + }); + }); } async function loadQuotes() { @@ -645,6 +654,34 @@ setStatus("语录已保存"); } + const ARCHIVE_QUOTE_AI_KEY = "hub_archive_quote_ai"; + + function startQuoteAiChat(quoteId) { + const q = findQuote(quoteId); + const content = q && String(q.content || "").trim(); + if (!q || !content) { + setStatus("语录内容为空,无法发起 AI 对话"); + return; + } + try { + sessionStorage.setItem( + ARCHIVE_QUOTE_AI_KEY, + JSON.stringify({ + quote_date: q.quote_date || "", + content: content, + }) + ); + } catch (_) { + setStatus("无法保存跳转数据"); + return; + } + if (typeof window.hubNavigateTo === "function") { + window.hubNavigateTo("/ai"); + return; + } + location.href = "/ai"; + } + async function deleteQuote(id) { if (!id || !window.confirm("确定删除这条复盘语录?")) return; const r = await apiFetch("/api/archive/quotes/" + id, { method: "DELETE" }); diff --git a/manual_trading_hub/static/index.html b/manual_trading_hub/static/index.html index 9db0162..d706923 100644 --- a/manual_trading_hub/static/index.html +++ b/manual_trading_hub/static/index.html @@ -15,7 +15,7 @@ - + @@ -589,11 +589,11 @@ - + - +