diff --git a/README.md b/README.md index 3b6a4f7..2a2068a 100644 --- a/README.md +++ b/README.md @@ -10,6 +10,8 @@ 智能选股模块包含 45 日全市场因子库、六阶段市场识别、六套内置策略、受控公式 DSL、自然语言策略编译、候选排名和滚动回测。首次使用需在页面点击“同步因子数据”。未配置 LLM 时使用本地策略模板;配置兼容 API 后自动切换为主模型编译,主模型失败时自动使用辅助模型,两者均支持独立连通性测试。 +每次选股结果会自动进入五交易日持续跟踪,展示 T+1 开盘/收盘、T+3、T+5、最大涨幅与最大回撤。提醒中心支持手工日期提醒,并在策略首日反馈和五日跟踪完成时生成账号私有的站内提醒。 + 问师模块会读取当前复盘、近十日市场情绪、涨跌停、昨日反馈、板块轮动、市场阶段、龙虎榜和指定个股数据,再按选中的游资思维 Skill 进行单师对话。对话记录按账号、老师和交易日期保存在服务端;主模型不可用时自动切换辅助模型。 新增问师角色时,在 `游资skills` 下增加一个包含 `SKILL.md` 的独立目录即可。系统会从 Skill 的 frontmatter、一级标题、核心模型和引用语中自动生成角色信息,无需修改注册代码。 @@ -18,6 +20,8 @@ 问天模块使用项目本地的 `lunar-python` 计算历法,并使用 `data/iching_zh.json` 中的固定六十四卦、卦辞和爻辞。第三方授权见 `THIRD_PARTY_NOTICES.md`。 +“我的复盘”包含结构化手工交易日志,可记录方向、价格、数量、仓位、盈亏、逻辑、执行、情绪和标签,不接券商也不自动下单。顶部“复盘助手”以流式方式读取市场统计、策略跟踪、提醒、个人复盘和交易日志;对话按账号保存,只提供分析和条件化计划。 + ## 启动 ```powershell diff --git a/api_access.py b/api_access.py index d53f373..ad27193 100644 --- a/api_access.py +++ b/api_access.py @@ -13,6 +13,7 @@ MEMBER_GET_PATHS = frozenset( "/api/mentors/setup", "/api/mentors/messages", "/api/heaven/setup", + "/api/assistant/messages", } ) @@ -27,6 +28,7 @@ MEMBER_POST_PATHS = frozenset( "/api/heaven/hexagram", "/api/heaven/personal", "/api/heaven/interpret", + "/api/assistant/chat", } ) @@ -54,7 +56,7 @@ def required_role(method: str, path: str) -> AccessRole: if method == "DELETE": if re.fullmatch(r"/api/heaven/sector-phases/.+", path): return "admin" - if path == "/api/mentors/messages" or re.fullmatch( + if path in {"/api/mentors/messages", "/api/assistant/messages"} or re.fullmatch( r"/api/screener/strategies/\d+", path ): return "member" diff --git a/assistant_agent.py b/assistant_agent.py new file mode 100644 index 0000000..d948626 --- /dev/null +++ b/assistant_agent.py @@ -0,0 +1,91 @@ +from __future__ import annotations + +import json +import urllib.error +import urllib.request +from collections.abc import Iterator +from typing import Any + + +class ReviewAssistantError(RuntimeError): + pass + + +def stream_review_assistant( + context: dict[str, Any], + question: str, + history: list[dict[str, str]], + api_key: str, + base_url: str, + model: str, + timeout: int = 120, +) -> Iterator[str]: + if not api_key or not model: + raise ReviewAssistantError("智能解读服务尚未配置。") + messages = [{"role": "system", "content": _system_prompt(context)}] + messages.extend(history[-12:]) + messages.append({"role": "user", "content": question}) + request = urllib.request.Request( + f"{base_url.rstrip('/')}/chat/completions", + data=json.dumps( + {"model": model, "messages": messages, "stream": True}, ensure_ascii=False + ).encode("utf-8"), + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {api_key}", + "User-Agent": "XiaobaiReviewWeb/1.0", + "Accept": "text/event-stream", + }, + method="POST", + ) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + yielded = False + for raw_line in response: + line = raw_line.decode("utf-8", errors="replace").strip() + if not line or line.startswith(":"): + continue + if line.startswith("data:"): + line = line[5:].strip() + if line == "[DONE]": + break + try: + payload = json.loads(line) + except json.JSONDecodeError: + continue + choices = payload.get("choices") or [] + if not choices: + continue + choice = choices[0] or {} + delta = choice.get("delta") or {} + content = delta.get("content") + if content is None: + content = (choice.get("message") or {}).get("content") + if content: + yielded = True + yield str(content) + if not yielded: + raise ReviewAssistantError("智能解读未返回有效内容。") + except urllib.error.HTTPError as exc: + raise ReviewAssistantError(f"智能解读服务暂不可用({exc.code})。") from exc + except (urllib.error.URLError, TimeoutError, OSError) as exc: + raise ReviewAssistantError("智能解读连接中断,请稍后重试。") from exc + + +def _system_prompt(context: dict[str, Any]) -> str: + context_json = json.dumps(context, ensure_ascii=False, separators=(",", ":")) + return f""" +你是“小白复盘”的统一复盘助手。你负责把网页中已经存在的市场统计、策略跟踪、提醒、复盘笔记和手工交易日志连接起来,帮助用户复盘和形成下一步观察计划。 + +最高优先级规则: +1. 只能使用下方“网页复盘数据”,数据缺失就明确说明,不得补造行情、交易或胜率。 +2. 不自动下单,不声称已执行任何操作,不修改策略、提醒、笔记或交易日志。 +3. 不承诺收益,不给无条件买卖指令。建议必须写成条件、失效条件和风险边界。 +4. 区分市场事实、用户记录和你的推断。引用数字时写明数据日期。 +5. 优先结合用户自己的策略跟踪与交易日志寻找可验证的重复模式;样本不足时明确标注。 +6. 使用中文,先直接回答,再给数据依据和下一步观察。避免空泛口号,不展示模型、接口或内部工程信息。 +7. 控制在 800 个中文字符以内,除非用户明确要求展开。 + +网页复盘数据: +{context_json} +""".strip() diff --git a/database.py b/database.py index 9455bc3..b086046 100644 --- a/database.py +++ b/database.py @@ -321,6 +321,19 @@ class ReviewDatabase: CREATE INDEX IF NOT EXISTS idx_trade_entries_user_date ON trade_entries(user_id, trade_date DESC, id DESC); + + CREATE TABLE IF NOT EXISTS assistant_messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL, + context_date TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL, + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE + ); + + CREATE INDEX IF NOT EXISTS idx_assistant_messages_user + ON assistant_messages(user_id, id DESC); """ ) user_columns = { @@ -1663,6 +1676,50 @@ class ReviewDatabase: ) return cursor.rowcount > 0 + def save_assistant_exchange( + self, user_id: int, question: str, answer: str, context_date: str + ) -> None: + now = datetime.now().astimezone().isoformat(timespec="seconds") + with self.connect() as connection: + connection.executemany( + """ + INSERT INTO assistant_messages + (user_id, role, content, context_date, created_at) + VALUES (?, ?, ?, ?, ?) + """, + [ + (int(user_id), "user", question, context_date, now), + (int(user_id), "assistant", answer, context_date, now), + ], + ) + connection.execute( + """ + DELETE FROM assistant_messages WHERE user_id = ? AND id NOT IN ( + SELECT id FROM assistant_messages + WHERE user_id = ? ORDER BY id DESC LIMIT 200 + ) + """, + (int(user_id), int(user_id)), + ) + + def list_assistant_messages(self, user_id: int, limit: int = 100) -> list[dict[str, Any]]: + with self.connect() as connection: + rows = connection.execute( + """ + SELECT role, content, context_date, created_at FROM assistant_messages + WHERE user_id = ? ORDER BY id DESC LIMIT ? + """, + (int(user_id), max(1, min(200, int(limit)))), + ).fetchall() + return [dict(row) for row in reversed(rows)] + + def delete_assistant_messages(self, user_id: int) -> int: + with self.connect() as connection: + cursor = connection.execute( + "DELETE FROM assistant_messages WHERE user_id = ?", (int(user_id),) + ) + return int(cursor.rowcount) + def start_sync(self, trade_date: str, source: str) -> int: started_at = datetime.now().astimezone().isoformat(timespec="seconds") with self.connect() as connection: diff --git a/server.py b/server.py index aa6bbc4..c3f9e41 100644 --- a/server.py +++ b/server.py @@ -8,6 +8,7 @@ import os import re import secrets import threading +import time from datetime import date, datetime, timedelta, timezone from http import HTTPStatus from http.cookies import SimpleCookie @@ -16,6 +17,7 @@ from typing import Any from urllib.parse import parse_qs, unquote, urlparse from alert_service import AlertService +from assistant_agent import ReviewAssistantError, stream_review_assistant from api_access import required_role from app_config import ( DATA_DIR, @@ -1147,6 +1149,111 @@ class DashboardService: deleted = self.database.delete_trade_entry(self.current_user_id, trade_id) return {"deleted": deleted, **self.trade_entries()} + def assistant_messages(self) -> list[dict[str, Any]]: + return self.database.list_assistant_messages(self.current_user_id) + + def clear_assistant_messages(self) -> int: + return self.database.delete_assistant_messages(self.current_user_id) + + def assistant_stream(self, payload: dict[str, Any]): + question = validate_text(payload.get("question"), "问题", 2000, required=True) + trade_date = normalize_date( + str(payload.get("trade_date") or date.today().isoformat()) + ) + context = self._assistant_context(trade_date) + history = [ + {"role": item["role"], "content": str(item["content"])[:4000]} + for item in self.assistant_messages()[-12:] + if item.get("role") in {"user", "assistant"} + ] + source = self.ensure_llm_access("assistant") + profiles = [ + ( + self.llm_primary_api_key, + self.llm_primary_base_url, + self.llm_primary_model, + ) + ] + if self.llm_fallback_configured: + profiles.append( + ( + self.llm_fallback_api_key, + self.llm_fallback_base_url, + self.llm_fallback_model, + ) + ) + + def generate(): + started = time.perf_counter() + last_error: Exception | None = None + for api_key, base_url, model in profiles: + try: + upstream = iter( + stream_review_assistant( + context, question, history, api_key, base_url, model + ) + ) + first = next(upstream) + except (ReviewAssistantError, StopIteration) as exc: + last_error = exc + continue + answer_parts = [first] + yield first + try: + for chunk in upstream: + answer_parts.append(chunk) + yield chunk + except ReviewAssistantError as exc: + self.record_llm_usage("assistant", source, model, "failed") + raise ValueError("智能解读连接中断,请稍后重试。") from exc + answer = "".join(answer_parts).strip() + latency_ms = round((time.perf_counter() - started) * 1000) + self.database.save_assistant_exchange( + self.current_user_id, question, answer, trade_date + ) + self.record_llm_usage("assistant", source, model, "success", latency_ms) + return + self.record_llm_usage("assistant", source, self.llm_primary_model, "failed") + raise ValueError("智能解读服务暂不可用,请稍后重试。") from last_error + + return generate() + + def _assistant_context(self, trade_date: str) -> dict[str, Any]: + dashboard = self.get_dashboard(trade_date) + actual_date = normalize_date( + str((dashboard.get("meta") or {}).get("trade_date") or trade_date) + ) + sentiment = self.sentiment_history(actual_date, 10) + tracking = self.strategy_tracking.list_tracking(self.current_user_id, 5) + alerts = self.alert_service.list_alerts( + self.current_user_id, "all", date.today().isoformat() + ) + trades = self.trade_journal.list_entries( + self.current_user_id, end_date=actual_date + ) + return { + "data_date": actual_date, + "market": { + "overview": dashboard.get("overview") or {}, + "top_sectors": (dashboard.get("sectors") or [])[:8], + "limit_performance": dashboard.get("limit_performance") or {}, + "sentiment_history": (sentiment.get("rows") or [])[-10:], + }, + "personal": { + "watchlist": self.database.list_watchlist(self.current_user_id)[:30], + "review_notes": self.database.list_notes( + self.current_user_id, scope="daily" + )[:10], + "strategy_tracking": { + "summary": tracking.get("summary") or {}, + "batches": (tracking.get("batches") or [])[:5], + }, + "alerts": (alerts.get("items") or [])[:20], + "trade_summary": trades.get("summary") or {}, + "trade_entries": (trades.get("items") or [])[:30], + }, + } + def sync_screener_data(self, trade_date: str, lookback: int = 45) -> dict[str, Any]: if not self.configured: raise ValueError("请先配置 Tushare Token。") @@ -3436,6 +3543,9 @@ class RequestHandler(BaseHTTPRequestHandler): except ValueError as exc: self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) return + if parsed.path == "/api/assistant/messages": + self.send_json({"items": SERVICE.assistant_messages()}) + return if parsed.path == "/api/dashboard": query = parse_qs(parsed.query) trade_date = query.get("trade_date", [date.today().isoformat()])[0] @@ -3660,6 +3770,9 @@ class RequestHandler(BaseHTTPRequestHandler): if parsed.path == "/api/trades": self.save_trade_entry() return + if parsed.path == "/api/assistant/chat": + self.stream_assistant_chat() + return if parsed.path == "/api/admin/settings": self.save_system_settings() return @@ -3729,6 +3842,10 @@ class RequestHandler(BaseHTTPRequestHandler): deleted = SERVICE.database.delete_user_birth_profile(SERVICE.current_user_id) self.send_json({"ok": True, "deleted": deleted}) return + if parsed.path == "/api/assistant/messages": + deleted = SERVICE.clear_assistant_messages() + self.send_json({"ok": True, "deleted": deleted}) + return if parsed.path == "/api/mentors/messages": query = parse_qs(parsed.query) try: @@ -3889,6 +4006,36 @@ class RequestHandler(BaseHTTPRequestHandler): except (ValueError, json.JSONDecodeError) as exc: self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) + def stream_assistant_chat(self) -> None: + try: + body = self.read_json_body() + stream = SERVICE.assistant_stream(body) + except (ValueError, json.JSONDecodeError) as exc: + self.send_json({"error": str(exc)}, HTTPStatus.BAD_REQUEST) + return + self.send_response(HTTPStatus.OK) + self.send_header("Content-Type", "application/x-ndjson; charset=utf-8") + self.send_header("Cache-Control", "no-cache, no-transform") + self.send_header("X-Accel-Buffering", "no") + self.send_header("Connection", "close") + self.end_headers() + try: + for chunk in stream: + self._write_stream_event({"type": "delta", "content": chunk}) + self._write_stream_event({"type": "done"}) + except (ValueError, ReviewAssistantError) as exc: + self._write_stream_event({"type": "error", "error": str(exc)}) + except (BrokenPipeError, ConnectionResetError): + pass + finally: + self.close_connection = True + + def _write_stream_event(self, payload: dict[str, Any]) -> None: + self.wfile.write( + (json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n").encode("utf-8") + ) + self.wfile.flush() + def session_token(self) -> str: cookie = SimpleCookie() try: diff --git a/static/app.js b/static/app.js index d07596f..41007f5 100644 --- a/static/app.js +++ b/static/app.js @@ -50,6 +50,9 @@ const state = { alerts: [], alertFilter: "all", alertUnreadCount: 0, + assistantMessages: [], + assistantLoading: false, + assistantController: null, screenerMobileView: "strategy", sentimentHistory: null, sentimentRange: 20, @@ -93,6 +96,7 @@ const elements = { toast: document.querySelector("#toast"), stockDialog: document.querySelector("#stockDialog"), alertsDialog: document.querySelector("#alertsDialog"), + assistantDialog: document.querySelector("#assistantDialog"), globalSearchDialog: document.querySelector("#globalSearchDialog"), globalSearchInput: document.querySelector("#globalSearchInput"), globalSearchResults: document.querySelector("#globalSearchResults"), @@ -141,6 +145,7 @@ let stockPreviewAnchor = null; let sentimentChartAnimationFrame = null; let heavenResizeTimer = null; let globalSearchTimer = null; +let assistantRenderFrame = 0; const heartSound = { enabled: false, @@ -385,6 +390,14 @@ function bindEvents() { }); document.querySelector("#globalSearchButton").addEventListener("click", openGlobalSearch); document.querySelector("#alertButton").addEventListener("click", openAlerts); + document.querySelector("#assistantButton").addEventListener("click", openReviewAssistant); + document.querySelector("#closeAssistantDialog").addEventListener("click", () => elements.assistantDialog.close()); + document.querySelector("#assistantForm").addEventListener("submit", sendAssistantQuestion); + document.querySelector("#stopAssistant").addEventListener("click", stopAssistantResponse); + document.querySelector("#clearAssistantMessages").addEventListener("click", clearAssistantConversation); + document.querySelectorAll("[data-assistant-prompt]").forEach((button) => { + button.addEventListener("click", () => useAssistantPrompt(button.dataset.assistantPrompt)); + }); document.querySelector("#closeAlertsDialog").addEventListener("click", () => elements.alertsDialog.close()); document.querySelector("#alertForm").addEventListener("submit", saveAlert); document.querySelector("#markAllAlertsRead").addEventListener("click", markAllAlertsRead); @@ -4498,6 +4511,152 @@ function renderAlerts() { refreshIcons(); } +async function openReviewAssistant() { + if (!hasMemberAccess()) { + showToast("复盘助手仅对会员开放"); + openSettings("membership"); + return; + } + toggleHeaderCommandMenu(false); + toggleAccountDropdown(false); + if (!elements.assistantDialog.open) elements.assistantDialog.showModal(); + try { + const payload = await apiRequest("/api/assistant/messages"); + state.assistantMessages = payload.items || []; + renderAssistantMessages(); + } catch (error) { + showToast(error.message || "对话记录加载失败"); + } + document.querySelector("#assistantQuestion").focus(); +} + +function useAssistantPrompt(prompt) { + const input = document.querySelector("#assistantQuestion"); + input.value = prompt; + input.focus(); +} + +async function sendAssistantQuestion(event) { + event.preventDefault(); + if (state.assistantLoading) return; + const input = document.querySelector("#assistantQuestion"); + const question = input.value.trim(); + if (!question) return; + input.value = ""; + state.assistantMessages.push({ role: "user", content: question, context_date: elements.tradeDate.value.replaceAll("-", "") }); + state.assistantMessages.push({ role: "assistant", content: "", streaming: true, context_date: elements.tradeDate.value.replaceAll("-", "") }); + state.assistantLoading = true; + state.assistantController = new AbortController(); + updateAssistantControls(); + renderAssistantMessages(); + try { + await streamAssistantRequest(question, state.assistantController.signal, (chunk) => { + const message = state.assistantMessages.at(-1); + if (message?.role === "assistant") message.content += chunk; + scheduleAssistantRender(); + }); + const message = state.assistantMessages.at(-1); + if (message) message.streaming = false; + setStatus("复盘助手回答完成"); + } catch (error) { + const message = state.assistantMessages.at(-1); + if (message?.role === "assistant") { + message.streaming = false; + message.error = true; + if (!message.content) message.content = error.name === "AbortError" ? "已停止生成。" : error.message || "回答失败,请稍后重试。"; + } + if (error.name !== "AbortError") showToast(error.message || "复盘助手回答失败"); + } finally { + state.assistantLoading = false; + state.assistantController = null; + updateAssistantControls(); + renderAssistantMessages(); + input.focus(); + } +} + +async function streamAssistantRequest(question, signal, onDelta) { + const response = await fetch("/api/assistant/chat", { + method: "POST", + headers: { + "Content-Type": "application/json", + ...(state.csrfToken ? { "X-CSRF-Token": state.csrfToken } : {}), + }, + body: JSON.stringify({ question, trade_date: elements.tradeDate.value }), + signal, + }); + if (!response.ok) { + const payload = await response.json().catch(() => ({})); + throw new Error(payload.error || "复盘助手暂不可用"); + } + if (!response.body) throw new Error("当前浏览器不支持流式回答"); + const reader = response.body.getReader(); + const decoder = new TextDecoder(); + let buffer = ""; + while (true) { + const { value, done } = await reader.read(); + buffer += decoder.decode(value || new Uint8Array(), { stream: !done }); + const lines = buffer.split("\n"); + buffer = lines.pop() || ""; + for (const line of lines) { + if (!line.trim()) continue; + const event = JSON.parse(line); + if (event.type === "delta") onDelta(String(event.content || "")); + if (event.type === "error") throw new Error(event.error || "复盘助手回答失败"); + } + if (done) break; + } + if (buffer.trim()) { + const event = JSON.parse(buffer); + if (event.type === "delta") onDelta(String(event.content || "")); + if (event.type === "error") throw new Error(event.error || "复盘助手回答失败"); + } +} + +function stopAssistantResponse() { + state.assistantController?.abort(); +} + +async function clearAssistantConversation() { + if (state.assistantLoading || !state.assistantMessages.length) return; + if (!window.confirm("确定清空复盘助手的对话记录吗?")) return; + try { + await apiRequest("/api/assistant/messages", "DELETE"); + state.assistantMessages = []; + renderAssistantMessages(); + } catch (error) { + showToast(error.message || "对话记录清空失败"); + } +} + +function scheduleAssistantRender() { + if (assistantRenderFrame) return; + assistantRenderFrame = requestAnimationFrame(() => { + assistantRenderFrame = 0; + renderAssistantMessages(); + }); +} + +function renderAssistantMessages() { + const container = document.querySelector("#assistantMessages"); + container.innerHTML = state.assistantMessages.map((message) => ` + + `).join("") || '