feat(hub): add archive quote AI coach review from inner light page
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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(),
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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(""),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -566,6 +566,9 @@
|
||||
'<button type="button" class="archive-del-btn archive-quote-del-btn" data-id="' +
|
||||
q.id +
|
||||
'">删除</button>' +
|
||||
'<button type="button" class="ghost archive-quote-ai-btn" data-id="' +
|
||||
q.id +
|
||||
'">AI对话</button>' +
|
||||
"</div></div>"
|
||||
: "") +
|
||||
"</div>"
|
||||
@@ -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" });
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" media="print" onload="this.media='all'" />
|
||||
<noscript><link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Orbitron:wght@500;600;700&display=swap" rel="stylesheet" /></noscript>
|
||||
<link rel="stylesheet" href="/assets/app.css?v=20260612-archive-mobile" />
|
||||
<link rel="stylesheet" href="/assets/app.css?v=20260612-archive-ai-chat" />
|
||||
<link rel="stylesheet" href="/assets/dashboard.css?v=20260612-dash-monitor-count" />
|
||||
</head>
|
||||
<body>
|
||||
@@ -589,11 +589,11 @@
|
||||
<script src="https://unpkg.com/lightweight-charts@4.2.0/dist/lightweight-charts.standalone.production.js"></script>
|
||||
<script src="/assets/chart_draw.js?v=20260609-market-day-split"></script>
|
||||
<script src="/assets/chart.js?v=20260609-market-day-split"></script>
|
||||
<script src="/assets/archive.js?v=20260612-archive-quotes-v3"></script>
|
||||
<script src="/assets/archive.js?v=20260612-archive-ai-chat"></script>
|
||||
<script src="/assets/funds.js?v=20260609-hub-funds-fold"></script>
|
||||
<script src="/assets/dashboard.js?v=20260612-dash-monitor-count"></script>
|
||||
<script src="/assets/ai_review_render.js?v=3"></script>
|
||||
<script src="/assets/time_close_ui.js?v=2"></script>
|
||||
<script src="/assets/app.js?v=20260612-time-close-table"></script>
|
||||
<script src="/assets/app.js?v=20260612-archive-ai-chat"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user