Initialize crypto_monitor_user (user edition) from monitor codebase.

Retarget git remote, install path, and deploy docs from crypto_monitor to crypto_monitor_user.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-07-17 16:18:13 +08:00
commit 53863559f4
608 changed files with 162764 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""中控 AI 模块:今日总结 + 交易员聊天(与实例 ai_review 分离)."""
+161
View File
@@ -0,0 +1,161 @@
"""内照明心复盘语录 → 交易教练点评."""
from __future__ import annotations
from typing import Any
from hub_ai.client import generate_text, model_label
from hub_ai.rolling_summary import refresh_session_rolling_summary
from hub_ai.text_util import clip_text, is_ai_error_reply
from hub_ai.config import (
CHAT_MAX_CONTINUATIONS,
CHAT_MAX_OUTPUT_TOKENS,
CHAT_TEMPERATURE,
CHAT_USER_MESSAGE_MAX_CHARS,
)
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,
delete_chat_session,
get_active_session,
list_chat_sessions,
)
from lib.hub.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 _fmt_pct(v: Any) -> str:
try:
n = float(v)
except (TypeError, ValueError):
return ""
return f"{n:.1f}%"
def _fmt_rr(v: Any) -> str:
try:
n = float(v)
except (TypeError, ValueError):
return ""
return f"{n:.2f}:1"
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('win_count') or 0)} / 亏损 {int(stats.get('loss_count') or 0)},"
f"平均盈利 {_fmt_pnl(stats.get('avg_win'))},平均亏损 {_fmt_pnl(stats.get('avg_loss'))},"
f"胜率 {_fmt_pct(stats.get('win_rate'))},盈亏比 {_fmt_rr(stats.get('profit_loss_ratio'))},"
f"最大盈利 {_fmt_pnl(stats.get('max_win'))},最大亏损 {_fmt_pnl(stats.get('max_loss'))},"
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)
max_rows = 50
if len(trades) > max_rows:
lines.append(f"(共 {len(trades)} 笔,以下展示最近 {max_rows} 笔)")
for i, t in enumerate(trades[:max_rows], 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 = clip_text(str(t.get("note") or "").strip(), 80)
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)
user_for_prompt = clip_text(text, CHAT_USER_MESSAGE_MAX_CHARS)
user_prompt = build_archive_quote_review_prompt(
quote_date=day,
archive_trades_text=archive_trades_text,
user_message=user_for_prompt,
)
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):
delete_chat_session(sid)
return {"ok": False, "msg": reply}
append_chat_message(sid, "user", text)
session = append_chat_message(sid, "assistant", reply)
refresh_session_rolling_summary(
sid,
prior_summary="",
user_text=text,
assistant_text=reply,
bot_mode=CHAT_BOT_TRADING,
)
session = get_active_session() or session
return {
"ok": True,
"trading_day": day,
"session": session,
"sessions": list_chat_sessions(),
"reply": reply,
"model": model_label(),
}
+101
View File
@@ -0,0 +1,101 @@
"""中控 AI 聊天附件解析."""
from __future__ import annotations
import base64
from typing import Any
from hub_ai.config import (
CHAT_MAX_ATTACHMENTS,
CHAT_MAX_IMAGE_BYTES,
CHAT_MAX_TEXT_FILE_BYTES,
)
IMAGE_MIMES = {
"image/jpeg",
"image/jpg",
"image/png",
"image/webp",
"image/gif",
}
TEXT_MIMES = {
"text/plain",
"text/markdown",
"application/json",
}
def _guess_mime(filename: str, content_type: str) -> str:
ct = (content_type or "").split(";")[0].strip().lower()
if ct:
return ct
name = (filename or "").lower()
if name.endswith(".png"):
return "image/png"
if name.endswith((".jpg", ".jpeg")):
return "image/jpeg"
if name.endswith(".webp"):
return "image/webp"
if name.endswith(".gif"):
return "image/gif"
if name.endswith((".md", ".markdown")):
return "text/markdown"
if name.endswith(".txt"):
return "text/plain"
if name.endswith(".json"):
return "application/json"
return "application/octet-stream"
def parse_chat_attachments(raw_files: list[dict[str, Any]]) -> dict[str, Any]:
"""
raw_files: [{filename, content_type, data: bytes}]
返回 images_b64, attachment_note, attachment_meta, text_append
"""
images_b64: list[str] = []
meta: list[dict] = []
notes: list[str] = []
text_blocks: list[str] = []
errors: list[str] = []
for item in (raw_files or [])[:CHAT_MAX_ATTACHMENTS]:
name = str(item.get("filename") or "file")
data = item.get("data") or b""
if not isinstance(data, (bytes, bytearray)):
errors.append(f"{name}: 无效数据")
continue
mime = _guess_mime(name, str(item.get("content_type") or ""))
size = len(data)
if mime in IMAGE_MIMES:
if size > CHAT_MAX_IMAGE_BYTES:
errors.append(f"{name}: 图片超过 {CHAT_MAX_IMAGE_BYTES // 1024 // 1024}MB")
continue
images_b64.append(base64.b64encode(bytes(data)).decode("ascii"))
meta.append({"name": name, "kind": "image", "mime": mime, "size": size})
notes.append(f"图片 {name}")
continue
if mime in TEXT_MIMES or name.lower().endswith((".txt", ".md", ".markdown", ".json")):
if size > CHAT_MAX_TEXT_FILE_BYTES:
errors.append(f"{name}: 文本超过 {CHAT_MAX_TEXT_FILE_BYTES // 1024}KB")
continue
try:
text = bytes(data).decode("utf-8")
except UnicodeDecodeError:
errors.append(f"{name}: 非 UTF-8 文本")
continue
text_blocks.append(f"--- 附件 {name} ---\n{text.strip()}")
meta.append({"name": name, "kind": "text", "mime": mime, "size": size})
notes.append(f"文档 {name}")
continue
errors.append(f"{name}: 不支持的类型(仅图片或 txt/md/json)")
attachment_note = ";".join(notes) if notes else ""
if errors:
attachment_note = (attachment_note + ";" if attachment_note else "") + ";".join(errors)
text_append = "\n\n".join(text_blocks)
return {
"images_b64": images_b64,
"attachment_note": attachment_note,
"attachment_meta": meta,
"text_append": text_append,
"errors": errors,
}
+275
View File
@@ -0,0 +1,275 @@
"""中控 AI:单会话聊天(直到用户点击新开)."""
from __future__ import annotations
import threading
from typing import Any, Optional
from hub_ai.attachments import parse_chat_attachments
from hub_ai.client import generate_text, model_label
from hub_ai.config import (
CHAT_CONTEXT_MAX_CHARS,
CHAT_FOLLOWUP_CONTEXT_MAX_CHARS,
CHAT_HISTORY_MAX_CHARS_PER_MSG,
CHAT_MAX_CONTINUATIONS,
CHAT_MAX_HISTORY_TURNS,
CHAT_MAX_OUTPUT_TOKENS,
CHAT_PROMPT_MAX_CHARS,
CHAT_SUMMARY_EXCERPT_MAX_CHARS,
CHAT_TEMPERATURE,
CHAT_USER_MESSAGE_MAX_CHARS,
trading_day_reset_hour,
)
from lib.hub.hub_trades_lib import current_trading_day
from hub_ai.context import (
build_chat_context,
format_chat_context_for_chat,
format_chat_position_overview,
)
from hub_ai.prompts import (
CHAT_GENERAL_SYSTEM,
CHAT_SYSTEM,
build_chat_user_prompt,
build_general_chat_user_prompt,
)
from hub_ai.rolling_summary import refresh_session_rolling_summary
from hub_ai.store import (
CHAT_BOT_GENERAL,
CHAT_BOT_TRADING,
append_chat_message,
create_new_session,
delete_chat_session,
ensure_active_session,
get_active_session,
list_chat_sessions,
load_chat_store,
set_active_session,
summary_excerpt_for_chat,
)
from hub_ai.text_util import clip_text, is_ai_error_reply
def _is_ai_error_reply(text: str) -> bool:
return is_ai_error_reply(text)
def _clip_text(text: str, max_chars: int) -> str:
return clip_text(text, max_chars)
def _history_lines(
messages: list[dict],
max_turns: int = CHAT_MAX_HISTORY_TURNS,
*,
max_chars_per_msg: int = CHAT_HISTORY_MAX_CHARS_PER_MSG,
total_max_chars: int | None = None,
) -> str:
rows = [m for m in (messages or []) if m.get("role") in ("user", "assistant")]
rows = rows[-max_turns * 2 :]
lines = []
for m in rows:
role = "用户" if m.get("role") == "user" else "搭档"
content = str(m.get("content") or "").strip()
if m.get("role") == "assistant" and _is_ai_error_reply(content):
continue
att = m.get("attachments") or []
if att:
names = ",".join(str(a.get("name") or "附件") for a in att[:3])
content = f"{content} [附件: {names}]".strip()
content = _clip_text(content, max_chars_per_msg)
if content:
lines.append(f"{role}:{content}")
if total_max_chars and total_max_chars > 0:
while lines and len("\n".join(lines)) > total_max_chars:
lines.pop(0)
return "\n".join(lines)
def _trading_context_bundle(ctx: dict[str, Any], *, prior_count: int) -> tuple[str, str]:
day = str(ctx.get("trading_day") or (ctx.get("totals") or {}).get("trading_day") or "")
if prior_count <= 0:
brief = format_chat_context_for_chat(ctx, max_chars=CHAT_CONTEXT_MAX_CHARS)
excerpt = summary_excerpt_for_chat(day, max_chars=CHAT_SUMMARY_EXCERPT_MAX_CHARS)
return brief, excerpt
totals = ctx.get("totals") or {}
overview = format_chat_position_overview(ctx)
slim = (
f"【续聊快照 {day}】平仓盈亏 {totals.get('total_pnl_u')}U | "
f"笔数 {totals.get('closed_count')} | "
f"持仓 {totals.get('open_position_count', 0)} 仓 | "
f"浮盈亏 {totals.get('float_pnl_u')}U"
)
brief = _clip_text(overview + "\n" + slim, CHAT_FOLLOWUP_CONTEXT_MAX_CHARS)
return brief, ""
def _history_budget(*sizes: int) -> int:
used = sum(int(s or 0) for s in sizes) + 2200
return max(1200, CHAT_PROMPT_MAX_CHARS - used)
def _prompt_memory(session: dict, prior_msgs: list[dict]) -> tuple[str, str]:
"""续聊优先用滚动摘要;旧会话无摘要时仅带最近 1 轮兜底."""
rolling = str(session.get("rolling_summary") or "").strip()
if rolling:
return rolling, ""
prior_count = len([m for m in prior_msgs if m.get("role") in ("user", "assistant")])
if prior_count <= 0:
return "", ""
tail = _history_lines(
prior_msgs,
max_turns=1,
max_chars_per_msg=CHAT_HISTORY_MAX_CHARS_PER_MSG,
)
return "", tail
def get_chat_state() -> dict[str, Any]:
store = load_chat_store()
session = get_active_session()
if session:
session.setdefault("bot_mode", CHAT_BOT_TRADING)
session.setdefault("rolling_summary", "")
return {
"active_session_id": store.get("active_session_id"),
"session": session,
"sessions": list_chat_sessions(),
"model": model_label(),
}
def start_new_chat(*, trading_day: str, bot_mode: str = CHAT_BOT_TRADING) -> dict:
session = create_new_session(trading_day=trading_day, bot_mode=bot_mode)
return {
"ok": True,
"session": session,
"sessions": list_chat_sessions(),
"model": model_label(),
}
def switch_chat_session(session_id: str) -> dict[str, Any]:
session = set_active_session(session_id)
return {
"ok": True,
"session": session,
"sessions": list_chat_sessions(),
"model": model_label(),
}
def remove_chat_session(session_id: str) -> dict[str, Any]:
deleted, new_active = delete_chat_session(session_id)
if not deleted:
return {"ok": False, "msg": "session_not_found"}
session = get_active_session()
return {
"ok": True,
"active_session_id": new_active,
"session": session,
"sessions": list_chat_sessions(),
"model": model_label(),
}
def send_chat_message(
exchanges: list[dict],
message: str,
*,
trading_day: str | None = None,
raw_attachments: Optional[list[dict]] = None,
) -> dict[str, Any]:
text = (message or "").strip()
parsed = parse_chat_attachments(raw_attachments or [])
if parsed.get("errors") and not text and not parsed.get("images_b64"):
return {"ok": False, "msg": ";".join(parsed["errors"])}
if not text and not parsed.get("images_b64") and not parsed.get("text_append"):
return {"ok": False, "msg": "消息不能为空"}
user_visible = text
if parsed.get("text_append"):
user_visible = (user_visible + "\n\n" + parsed["text_append"]).strip()
if not user_visible and parsed.get("attachment_note"):
user_visible = f"(上传了 {parsed['attachment_note']})"
day = (trading_day or "").strip()[:10] or current_trading_day(
reset_hour=trading_day_reset_hour()
)
session = ensure_active_session(trading_day=day)
sid = session["id"]
prior_rolling = str(session.get("rolling_summary") or "")
prior_msgs = session.get("messages") or []
prior_count = len([m for m in prior_msgs if m.get("role") in ("user", "assistant")])
user_for_prompt = _clip_text(text or user_visible, CHAT_USER_MESSAGE_MAX_CHARS)
rolling_summary, history_tail = _prompt_memory(session, prior_msgs)
bot_mode = (session.get("bot_mode") or CHAT_BOT_TRADING).strip().lower()
if bot_mode == CHAT_BOT_GENERAL:
user_prompt = build_general_chat_user_prompt(
rolling_summary=rolling_summary,
history_lines=history_tail,
user_message=user_for_prompt,
attachment_note=str(parsed.get("attachment_note") or ""),
)
if parsed.get("text_append"):
user_prompt += "\n\n【附件正文】\n" + _clip_text(parsed["text_append"], 3000)
system_prompt = CHAT_GENERAL_SYSTEM
else:
ctx = build_chat_context(exchanges, trading_day=day)
day = ctx["trading_day"]
brief_ctx, excerpt = _trading_context_bundle(ctx, prior_count=prior_count)
user_prompt = build_chat_user_prompt(
context_text=brief_ctx,
trading_day=day,
summary_excerpt=excerpt,
rolling_summary=rolling_summary,
history_lines=history_tail,
user_message=user_for_prompt,
attachment_note=str(parsed.get("attachment_note") or ""),
)
if parsed.get("text_append"):
user_prompt += "\n\n【附件正文】\n" + _clip_text(parsed["text_append"], 3000)
system_prompt = CHAT_SYSTEM
reply = generate_text(
system=system_prompt,
user=user_prompt,
temperature=CHAT_TEMPERATURE,
images_b64=parsed.get("images_b64") or None,
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}
append_chat_message(
sid,
"user",
user_visible,
attachments=parsed.get("attachment_meta") or [],
)
session = append_chat_message(sid, "assistant", reply)
summary_kwargs = {
"session_id": sid,
"prior_summary": prior_rolling,
"user_text": user_visible,
"assistant_text": reply,
"bot_mode": bot_mode,
}
def _refresh_summary_bg() -> None:
try:
refresh_session_rolling_summary(**summary_kwargs)
except Exception:
pass
threading.Thread(target=_refresh_summary_bg, daemon=True).start()
session = get_active_session() or session
return {
"ok": True,
"trading_day": day,
"session": session,
"sessions": list_chat_sessions(),
"reply": reply,
"model": model_label(),
"attachment_warnings": parsed.get("errors") or [],
}
+42
View File
@@ -0,0 +1,42 @@
"""中控 AI 模型调用(共用 ai_client 配置,逻辑独立)."""
from __future__ import annotations
import sys
from pathlib import Path
from typing import Optional, Sequence
_REPO_ROOT = Path(__file__).resolve().parents[2]
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
from lib.ai.ai_client import ai_generate, ai_generate_chat, ai_provider_label # noqa: E402
def model_label() -> str:
return ai_provider_label()
def generate_text(
*,
system: str,
user: str,
temperature: float,
images_b64: Optional[Sequence[str]] = None,
max_tokens: int | None = None,
max_continuations: int = 3,
) -> str:
if max_tokens is not None and max_tokens > 0:
return ai_generate_chat(
system=system,
user=user,
temperature=temperature,
images_b64=images_b64,
max_tokens=int(max_tokens),
max_continuations=max_continuations,
)
prompt = f"{system.strip()}\n\n---\n\n{user.strip()}"
return ai_generate(
prompt,
temperature=temperature,
images_b64=images_b64,
)
+57
View File
@@ -0,0 +1,57 @@
"""中控 AI 配置(读 hub .env,与实例同名 AI 变量)."""
from __future__ import annotations
import os
HUB_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
def _int_env(key: str, default: int) -> int:
try:
return int(os.getenv(key, str(default)) or default)
except ValueError:
return default
SUMMARY_TEMPERATURE = 0.15
CHAT_TEMPERATURE = 0.5
CHAT_MAX_HISTORY_TURNS = _int_env("CHAT_MAX_HISTORY_TURNS", 16)
CHAT_MAX_OUTPUT_TOKENS = _int_env("CHAT_MAX_OUTPUT_TOKENS", 8192)
CHAT_MAX_CONTINUATIONS = _int_env("CHAT_MAX_CONTINUATIONS", 4)
CHAT_CONTEXT_MAX_CHARS = _int_env("CHAT_CONTEXT_MAX_CHARS", 12_000)
CHAT_FOLLOWUP_CONTEXT_MAX_CHARS = _int_env("CHAT_FOLLOWUP_CONTEXT_MAX_CHARS", 4500)
CHAT_PROMPT_MAX_CHARS = _int_env("CHAT_PROMPT_MAX_CHARS", 28_000)
CHAT_USER_MESSAGE_MAX_CHARS = _int_env("CHAT_USER_MESSAGE_MAX_CHARS", 3500)
CHAT_SUMMARY_EXCERPT_MAX_CHARS = _int_env("CHAT_SUMMARY_EXCERPT_MAX_CHARS", 1200)
CHAT_HISTORY_MAX_CHARS_PER_MSG = _int_env("CHAT_HISTORY_MAX_CHARS_PER_MSG", 900)
CHAT_ROLLING_SUMMARY_MAX_CHARS = _int_env("CHAT_ROLLING_SUMMARY_MAX_CHARS", 900)
CHAT_ROLLING_SUMMARY_GEN_MAX_TOKENS = _int_env("CHAT_ROLLING_SUMMARY_GEN_MAX_TOKENS", 512)
CHAT_ROLLING_SUMMARY_TEMPERATURE = 0.2
SUMMARY_RETENTION_DAYS = 90
CHAT_SESSION_RETENTION_DAYS = 60
FUND_HISTORY_DAYS = 180
CHAT_MAX_ATTACHMENTS = 3
CHAT_MAX_IMAGE_BYTES = 4 * 1024 * 1024
CHAT_MAX_TEXT_FILE_BYTES = 200 * 1024
CHAT_CONTEXT_CACHE_TTL_SEC = _int_env("CHAT_CONTEXT_CACHE_TTL_SEC", 45)
def trading_day_reset_hour() -> int:
try:
return int(os.getenv("TRADING_DAY_RESET_HOUR", "8") or "8")
except ValueError:
return 8
def hub_flask_timeout() -> float:
try:
return float(os.getenv("HUB_FLASK_TIMEOUT", "10") or "10")
except ValueError:
return 10.0
def hub_agent_timeout() -> float:
try:
return float(os.getenv("HUB_AGENT_TIMEOUT", "8") or "8")
except ValueError:
return 8.0
File diff suppressed because it is too large Load Diff
+18
View File
@@ -0,0 +1,18 @@
"""中控 AI:分户资金快照(委托 hub_fund_history_lib,保留 180 交易日)."""
from __future__ import annotations
from typing import Any, Optional
from lib.hub.hub_fund_history_lib import (
FUND_HISTORY_DAYS,
format_fund_history_text,
get_fund_history,
record_fund_snapshot,
)
__all__ = [
"FUND_HISTORY_DAYS",
"format_fund_history_text",
"get_fund_history",
"record_fund_snapshot",
]
+244
View File
@@ -0,0 +1,244 @@
"""中控 AI 提示词(与实例 ai_review 分离)."""
SUMMARY_SYSTEM = """
你是多账户加密货币合约交易的台账助手.只根据用户提供的结构化数据输出中文 Markdown,语气克制,偏冷,客观,像值班记录.
硬性规则:
- 只能陈述数据中明确出现的数字与事实;禁止编造成交,止损,扛单,行情预测.
- 上下文仅含「今日」一个交易日的平仓,持仓与监控;不得引用昨日,历史走势或数据里未出现的账户名.
- 未监控的账户必须标注「未监控」,不得臆测其盈亏.
- 连接失败或数据缺失的账户如实写明,不要猜测.
- 趋势回调计划,顺势加仓,关键位监控,进行中的下单监控:仅据数据列示,无则写「无」.
- 第1~4节保持客观台账;**第5节操作建议**可基于当日资金账户/交易账户余额,仓位与监控单,给出简短,可执行的资金与仓位安排建议(仍禁止预测涨跌,保证收益).
- 禁止输出 pipe 分隔的 Markdown 表格或「详细数据支持」附录;禁止夸张词(致命,崩溃,灾难等).
输出格式(Markdown,标题必须一致):
**今日交易总结({trading_day})**
**1. 总览**
- **合计盈亏(U)**:今日平仓合计 …
- **平仓笔数**:今日 …(胜 / 负 / 平)
- **当前持仓浮盈亏(U)**:…
- **资金合计**:资金账户 … / 交易账户 …(仅已监控且有数据账户)
**2. 分户明细**
中控页面会自动渲染分户表格,本节不要输出 pipe 分隔行或 Markdown 表格;可写一句「见下表」或直接留空.
**3. 需关注**
仅有依据时列出(亏损,浮亏,监控/趋势/关键位异常,资金缺口等);若无则写「无」.
**4. 数据说明**
列出数据缺口(某户未启用,接口失败等).
**5. 操作建议**
基于各户当日资金账户与交易账户余额,持仓与监控单,给出 2~5 条简短建议(如:是否需要从资金账户补充交易账户,哪户风险敞口偏高等).无依据则写「暂无」.
""".strip()
CHAT_SYSTEM = """
你是和用户一起盯盘的老搭档交易员,熟悉他多个交易所账户的分工.用中文,口语化,短句交流.
语气要求:
- 先理解对方的压力和情绪,再轻轻帮他把事想清楚(安慰,体贴).
- 可以指出执行或心态上的偏差点,但用商量,陪伴的口吻,绝不用教育,训诫,上课,列清单式说教.
- 不要「第1点第2点你应该…」;不要「作为你的教练我必须…」.
- 不预测涨跌,不保证收益,不替用户做决定.
- 只能依据提供的监控与交易数据说话;看不到的就说「我这边看不到,你可以去 xx 实例页确认」.
- **持仓判定**:只有快照里「实盘持仓总览 / 持仓明细 / 交易所实盘」才算已开仓;「空仓 / 0 仓」就是没仓位.浮盈亏 0U 且空仓时,不要说「还有仓」「卡着不动」.
- **监控单 ≠ 持仓**:趋势回调,关键位,顺势加仓,下单监控是本地计划或挂单监控,用户说已平仓时,即使还有这些监控,也不要当成手里还有仓.
- 用户口述与快照冲突时,以快照为准并口语说明「我这边看到是空仓/有N仓」.
- 若附带「今日总结摘要」,那是较早生成的缓存,**实盘持仓以【当前多账户快照】里的「实盘持仓总览」为准**,摘要里若提到持仓可能已过时.
- 若用户上传图片,可结合图中可见信息讨论,看不清的明确说看不清.
- **优先接住【用户现在说】和【对话核心摘要】**:用户聊心态,悔单,某笔操作时,先顺着这个话题回应,不要每句都复述账户资金数字.
- **接续对话**:有【对话核心摘要】时须接着聊,不要重复开场白;整段回复必须写完,以句号/问号/感叹号收尾,不得停在半句话;编号列表每条单独一行.
- **止盈止损**:持仓明细若出现「止损xxx / 止盈xxx」,表示交易所条件单或监控计划里已有价位,勿再暗示用户「没挂止损/没设止盈」.仅当明细写「止损=未检测到」且无对应监控 SL 时,才可讨论补止损.趋势持仓「止盈=程序监控」表示由程序盯止盈,不是没止盈.
- 快照里的盈亏/资金仅在需要核对事实时引用;用户口述与快照冲突时,以快照为准并口语说明.
""".strip()
def build_summary_user_prompt(context_text: str, trading_day: str) -> str:
return f"""
交易日(今日):{trading_day}
以下为中控聚合的多账户数据(仅今日平仓,持仓,趋势回调/顺势加仓/关键位/监控单):
{context_text}
""".strip()
CHAT_GENERAL_SYSTEM = """
你是简洁,友好的中文助手,陪用户闲聊,答疑,整理思路.
规则:
- 口语化,自然,不要列清单式说教,不要「作为 AI 我必须…」.
- 用户未主动聊交易时,不要主动扯合约,仓位,盈亏,盯盘.
- 你没有接入用户的交易账户数据;不要编造持仓,资金或监控状态.若被问到交易事实,说明这边看不到实盘,建议去中控监控区或实例页查看.
- 若用户上传图片或文档,结合可见内容回应;看不清的直说.
- 接续【对话核心摘要】,不要重复开场白;回复须写完整,以句号/问号/感叹号收尾.
""".strip()
ROLLING_SUMMARY_TRADING_SYSTEM = """
你是交易教练的对话记录员.把「此前摘要」与「本轮用户+教练回复」压成一条极短中文摘要.
要求:
- 120~280 字,纯文本一段,不要标题,不要列表,不要寒暄.
- 只保留:用户情绪/困扰,涉及的交易事实,教练核心建议,已达成的共识,待跟进事项.
- 禁止编造未出现的信息;数字与账户名须来自原文.
""".strip()
ROLLING_SUMMARY_GENERAL_SYSTEM = """
你是对话记录员.把「此前摘要」与「本轮用户+助手回复」压成一条极短中文摘要.
要求:
- 100~240 字,纯文本一段,不要标题,不要列表.
- 只保留:话题,用户诉求,助手给出的关键信息,待跟进事项.
""".strip()
def build_rolling_summary_user_prompt(
*,
prior_summary: str,
user_text: str,
assistant_text: str,
) -> str:
parts: list[str] = []
if prior_summary.strip():
parts.extend(["【此前摘要】", prior_summary.strip()])
parts.extend([
"【本轮用户】",
user_text.strip() or "(空)",
"【本轮教练/助手】",
assistant_text.strip() or "(空)",
"请输出更新后的对话核心摘要:",
])
return "\n\n".join(parts)
def build_general_chat_user_prompt(
*,
rolling_summary: str = "",
history_lines: str = "",
user_message: str,
attachment_note: str = "",
) -> str:
parts: list[str] = []
if rolling_summary.strip():
parts.extend(["【对话核心摘要(须接续,勿重复开场)】", rolling_summary.strip()])
elif history_lines.strip():
parts.extend(["【最近对话】", history_lines.strip()])
if attachment_note.strip():
parts.extend(["【用户附件说明】", attachment_note.strip()])
parts.extend(["【用户现在说(优先回应这一条)】", user_message.strip()])
return "\n\n".join(parts)
def build_chat_user_prompt(
*,
context_text: str,
trading_day: str,
summary_excerpt: str,
rolling_summary: str = "",
history_lines: str = "",
user_message: str,
attachment_note: str = "",
) -> str:
parts = [f"【交易日】{trading_day}"]
if rolling_summary.strip():
parts.extend(["【对话核心摘要(须接续,勿重复开场)】", rolling_summary.strip()])
elif history_lines.strip():
parts.extend(["【最近对话】", history_lines.strip()])
parts.extend([
"【当前多账户快照(事实参考;持仓以「实盘持仓总览」为准)】",
context_text.strip() or "(无监控数据)",
])
if summary_excerpt.strip():
parts.extend([
"【今日总结摘要(可能滞后,持仓以快照为准)】",
summary_excerpt.strip(),
])
if attachment_note.strip():
parts.extend(["【用户附件说明】", attachment_note.strip()])
parts.extend(["【用户现在说(优先回应这一条)】", user_message.strip()])
return "\n\n".join(parts)
ARCHIVE_QUOTE_REVIEW_INSTRUCTION = """
【任务】用户从内照明心提交了一条复盘语录,并附上该交易日的档案交易记录(界面「复盘语录」下方也会展示当日已平仓明细).
请结合语录与交易记录:
1) 帮他核对自述与操作事实是否一致;
2) 指出心态,纪律,执行上的偏差点(若有);
3) 给出可落地的改进建议.
语气沿用交易教练:体贴,口语,短句,不用说教式清单;不预测涨跌,不保证收益.
""".strip()
SUPERVISOR_SYSTEM = """
你是交易监管值班员,职责是防止过度交易与频繁手动操作.用中文,短句,克制语气.
规则:
- 只依据提供的结构化事件与账户快照说话;禁止预测涨跌,保证收益.
- **手动平仓,中控平仓,新开仓**:指出频率,间隔,是否偏急;提醒休息,不训斥.
- **程序止盈/程序止损**:肯定按计划执行,鼓励保持纪律,提醒别立刻反手再开.
- 不替用户做决定,不暗示绕过实例冷静期/日冻结.
- 每次 1~3 句,必须写完整;禁止长清单和「第1点第2点」.
- 实例已进入冷静期/日冻结时,明确说明状态,建议暂停手动开平.
""".strip()
def build_supervisor_ai_prompt(
*,
context_text: str,
trading_day: str,
event: dict,
warnings: list[dict],
) -> str:
warn_lines = "\n".join(f"- {w.get('message')}" for w in (warnings or []) if w.get("message"))
parts = [
f"【交易日】{trading_day}",
"【监管事件】",
str(event or {}),
"【当前多账户快照】",
(context_text or "(无)").strip(),
]
if warn_lines.strip():
parts.extend(["【已触发频率警告】", warn_lines.strip()])
parts.append("请给出 1~3 句监管评语:")
return "\n\n".join(parts)
def build_supervisor_chat_prompt(
*,
context_text: str,
trading_day: str,
history_lines: str,
user_message: str,
) -> str:
parts = [f"【交易日】{trading_day}"]
if history_lines.strip():
parts.extend(["【今日监管对话】", history_lines.strip()])
parts.extend([
"【当前多账户快照】",
(context_text or "(无)").strip(),
"【用户现在说】",
user_message.strip(),
])
return "\n\n".join(parts)
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)
@@ -0,0 +1,69 @@
"""聊天滚动摘要:每轮后压缩历史,续聊只带摘要 + 当前消息."""
from __future__ import annotations
from hub_ai.text_util import clip_text, is_ai_error_reply
from hub_ai.client import generate_text
from hub_ai.config import (
CHAT_ROLLING_SUMMARY_GEN_MAX_TOKENS,
CHAT_ROLLING_SUMMARY_MAX_CHARS,
CHAT_ROLLING_SUMMARY_TEMPERATURE,
)
from hub_ai.prompts import (
ROLLING_SUMMARY_GENERAL_SYSTEM,
ROLLING_SUMMARY_TRADING_SYSTEM,
build_rolling_summary_user_prompt,
)
from hub_ai.store import CHAT_BOT_GENERAL, update_session_rolling_summary
def refresh_session_rolling_summary(
session_id: str,
*,
prior_summary: str,
user_text: str,
assistant_text: str,
bot_mode: str,
) -> str:
"""合并旧摘要与本轮对话,生成新的短摘要并写入会话."""
user_clip = clip_text(user_text, 1200)
assistant_clip = clip_text(assistant_text, 1800)
if not user_clip and not assistant_clip:
summary = clip_text(prior_summary, CHAT_ROLLING_SUMMARY_MAX_CHARS)
update_session_rolling_summary(session_id, summary)
return summary
system = (
ROLLING_SUMMARY_GENERAL_SYSTEM
if (bot_mode or "").strip().lower() == CHAT_BOT_GENERAL
else ROLLING_SUMMARY_TRADING_SYSTEM
)
raw = generate_text(
system=system,
user=build_rolling_summary_user_prompt(
prior_summary=prior_summary,
user_text=user_clip,
assistant_text=assistant_clip,
),
temperature=CHAT_ROLLING_SUMMARY_TEMPERATURE,
max_tokens=CHAT_ROLLING_SUMMARY_GEN_MAX_TOKENS,
max_continuations=1,
)
if is_ai_error_reply(raw):
fallback = _fallback_summary(prior_summary, user_clip, assistant_clip)
update_session_rolling_summary(session_id, fallback)
return fallback
summary = clip_text(raw, CHAT_ROLLING_SUMMARY_MAX_CHARS)
update_session_rolling_summary(session_id, summary)
return summary
def _fallback_summary(prior: str, user_text: str, assistant_text: str) -> str:
parts: list[str] = []
if prior.strip():
parts.append(prior.strip())
if user_text.strip():
parts.append(f"用户:{clip_text(user_text, 200)}")
if assistant_text.strip():
parts.append(f"教练:{clip_text(assistant_text, 280)}")
return clip_text("\n".join(parts), CHAT_ROLLING_SUMMARY_MAX_CHARS)
+200
View File
@@ -0,0 +1,200 @@
"""中控 AI FastAPI 路由."""
from __future__ import annotations
import asyncio
from typing import Callable
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,
send_chat_message,
start_new_chat,
switch_chat_session,
)
from hub_ai.client import model_label
from hub_ai.config import trading_day_reset_hour
from hub_ai.context import build_daily_context
from hub_ai.store import get_latest_summary, list_summaries
from hub_ai.supervisor import send_supervisor_chat
from hub_ai.supervisor_store import get_supervisor_session_state
from hub_ai.summary import generate_daily_summary
from lib.hub.hub_trades_lib import current_trading_day
from settings_store import normalize_supervisor_settings
class ChatSendBody(BaseModel):
message: str = ""
trading_day: str = ""
class SummaryGenerateBody(BaseModel):
trading_day: str = ""
force: bool = False
class ChatNewBody(BaseModel):
trading_day: str = ""
bot_mode: str = "trading"
class ChatSwitchBody(BaseModel):
session_id: str = Field(..., min_length=1)
class ArchiveQuoteChatBody(BaseModel):
quote_date: str = ""
content: str = ""
class SupervisorChatBody(BaseModel):
message: str = ""
trading_day: str = ""
def create_hub_ai_router(*, load_all_exchanges: Callable[[], list]) -> APIRouter:
router = APIRouter(prefix="/api/ai", tags=["hub-ai"])
def _day(raw: str = "") -> str:
d = (raw or "").strip()[:10]
return d or current_trading_day(reset_hour=trading_day_reset_hour())
@router.get("/meta")
def api_ai_meta():
return {
"ok": True,
"model": model_label(),
"trading_day_reset_hour": trading_day_reset_hour(),
"trading_day": current_trading_day(reset_hour=trading_day_reset_hour()),
"storage": {
"summaries": "hub_ai_summaries.json",
"chat": "hub_ai_chat.json",
},
}
@router.get("/context")
def api_ai_context(trading_day: str = ""):
exchanges = load_all_exchanges()
ctx = build_daily_context(exchanges, trading_day=_day(trading_day))
return {"ok": True, **ctx}
@router.get("/summary")
def api_ai_summary_list(trading_day: str = ""):
day = _day(trading_day) if trading_day.strip() else ""
items = list_summaries(trading_day=day or None, limit=20)
latest = get_latest_summary(_day(trading_day)) if trading_day.strip() else (
items[0] if items else None
)
return {
"ok": True,
"trading_day": _day(trading_day) if trading_day.strip() else None,
"summaries": items,
"latest": latest,
"model": model_label(),
}
@router.post("/summary/generate")
def api_ai_summary_generate(body: SummaryGenerateBody = SummaryGenerateBody()):
exchanges = load_all_exchanges()
result = generate_daily_summary(
exchanges,
trading_day=_day(body.trading_day) if body.trading_day.strip() else None,
force=bool(body.force),
)
if not result.get("ok"):
raise HTTPException(status_code=502, detail=result.get("msg") or "生成失败")
result.pop("context", None)
return result
@router.get("/chat/session")
def api_ai_chat_session():
state = get_chat_state()
return {"ok": True, **state, "model": model_label()}
@router.post("/chat/new")
def api_ai_chat_new(body: ChatNewBody = ChatNewBody()):
day = _day(body.trading_day)
return start_new_chat(trading_day=day, bot_mode=body.bot_mode or "trading")
@router.post("/chat/switch")
def api_ai_chat_switch(body: ChatSwitchBody):
try:
return switch_chat_session(body.session_id.strip())
except KeyError:
raise HTTPException(status_code=404, detail="会话不存在")
@router.delete("/chat/session/{session_id}")
def api_ai_chat_delete(session_id: str):
result = remove_chat_session(session_id.strip())
if not result.get("ok"):
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(""),
trading_day: str = Form(""),
files: list[UploadFile] = File(default=[]),
):
exchanges = load_all_exchanges()
raw_attachments = []
for f in files or []:
if not f or not f.filename:
continue
data = await f.read()
raw_attachments.append(
{
"filename": f.filename,
"content_type": f.content_type or "",
"data": data,
}
)
result = await asyncio.to_thread(
send_chat_message,
exchanges,
message,
trading_day=_day(trading_day) if trading_day.strip() else None,
raw_attachments=raw_attachments,
)
if not result.get("ok"):
raise HTTPException(status_code=502, detail=result.get("msg") or "发送失败")
return result
@router.get("/supervisor/session")
def api_ai_supervisor_session(trading_day: str = ""):
day = _day(trading_day)
return get_supervisor_session_state(day)
@router.get("/supervisor/rules")
def api_ai_supervisor_rules():
from settings_store import load_settings
cfg = normalize_supervisor_settings(load_settings().get("supervisor"))
return {"ok": True, "supervisor": cfg}
@router.post("/supervisor/chat/send")
def api_ai_supervisor_chat_send(body: SupervisorChatBody = SupervisorChatBody()):
exchanges = load_all_exchanges()
result = send_supervisor_chat(
exchanges,
body.message,
trading_day=_day(body.trading_day) if body.trading_day.strip() else None,
)
if not result.get("ok"):
raise HTTPException(status_code=502, detail=result.get("msg") or "发送失败")
return result
return router
+302
View File
@@ -0,0 +1,302 @@
"""中控 AI:JSON 持久化(与 hub_settings.json 同目录)."""
from __future__ import annotations
import json
import os
import uuid
from datetime import datetime, timedelta
from pathlib import Path
from typing import Any, Optional
from hub_ai.config import CHAT_SESSION_RETENTION_DAYS, SUMMARY_RETENTION_DAYS
HUB_DIR = Path(__file__).resolve().parent.parent
SUMMARIES_PATH = HUB_DIR / "hub_ai_summaries.json"
CHAT_PATH = HUB_DIR / "hub_ai_chat.json"
def _now_str() -> str:
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def _atomic_write(path: Path, data: dict) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
os.replace(tmp, path)
def _load_json(path: Path, default: dict) -> dict:
if not path.is_file():
return dict(default)
try:
loaded = json.loads(path.read_text(encoding="utf-8"))
if isinstance(loaded, dict):
return loaded
except Exception:
pass
return dict(default)
def _prune_summaries(items: list, *, keep_days: int) -> list:
cutoff = (datetime.now() - timedelta(days=max(1, keep_days))).strftime("%Y-%m-%d")
out = [x for x in items if str(x.get("trading_day") or "") >= cutoff]
return out[-500:]
def _prune_chat_sessions(sessions: list, *, keep_days: int) -> list:
cutoff_dt = datetime.now() - timedelta(days=max(1, keep_days))
out = []
for s in sessions:
ts = str(s.get("updated_at") or s.get("created_at") or "")
try:
dt = datetime.strptime(ts[:19], "%Y-%m-%d %H:%M:%S")
except ValueError:
out.append(s)
continue
if dt >= cutoff_dt:
out.append(s)
return out[-50:]
def load_summaries_store() -> dict:
return _load_json(SUMMARIES_PATH, {"version": 1, "summaries": []})
def save_summaries_store(data: dict) -> None:
summaries = _prune_summaries(
list(data.get("summaries") or []),
keep_days=SUMMARY_RETENTION_DAYS,
)
_atomic_write(SUMMARIES_PATH, {"version": 1, "summaries": summaries})
def append_summary(
*,
trading_day: str,
content_md: str,
model: str,
context_hash: str,
stats_snapshot: dict,
) -> dict:
store = load_summaries_store()
row = {
"id": uuid.uuid4().hex,
"trading_day": trading_day,
"generated_at": _now_str(),
"model": model,
"context_hash": context_hash,
"content_md": content_md,
"stats_snapshot": stats_snapshot,
}
store.setdefault("summaries", []).append(row)
save_summaries_store(store)
return row
def list_summaries(*, trading_day: Optional[str] = None, limit: int = 30) -> list[dict]:
store = load_summaries_store()
items = list(store.get("summaries") or [])
if trading_day:
items = [x for x in items if str(x.get("trading_day")) == trading_day]
items.sort(key=lambda x: str(x.get("generated_at") or ""), reverse=True)
return items[: max(1, min(limit, 100))]
def get_latest_summary(trading_day: str) -> Optional[dict]:
rows = list_summaries(trading_day=trading_day, limit=1)
return rows[0] if rows else None
def load_chat_store() -> dict:
default = {"version": 1, "sessions": [], "active_session_id": None}
data = _load_json(CHAT_PATH, default)
data.setdefault("version", 1)
data.setdefault("sessions", [])
return data
def save_chat_store(data: dict) -> None:
sessions = _prune_chat_sessions(
list(data.get("sessions") or []),
keep_days=CHAT_SESSION_RETENTION_DAYS,
)
active = data.get("active_session_id")
ids = {str(s.get("id")) for s in sessions}
if active and str(active) not in ids:
active = sessions[-1]["id"] if sessions else None
_atomic_write(
CHAT_PATH,
{"version": 1, "sessions": sessions, "active_session_id": active},
)
def get_active_session() -> Optional[dict]:
store = load_chat_store()
sid = store.get("active_session_id")
for s in store.get("sessions") or []:
if str(s.get("id")) == str(sid):
return s
return None
CHAT_BOT_TRADING = "trading"
CHAT_BOT_GENERAL = "general"
CHAT_BOT_SUPERVISOR = "supervisor"
CHAT_BOT_MODES = frozenset({CHAT_BOT_TRADING, CHAT_BOT_GENERAL, CHAT_BOT_SUPERVISOR})
def _normalize_bot_mode(raw: Any) -> str:
mode = (raw or CHAT_BOT_TRADING).strip().lower()
return mode if mode in CHAT_BOT_MODES else CHAT_BOT_TRADING
def create_new_session(
*,
trading_day: str,
title: str = "新对话",
bot_mode: str = CHAT_BOT_TRADING,
) -> dict:
store = load_chat_store()
session = {
"id": uuid.uuid4().hex,
"trading_day": trading_day,
"title": title,
"bot_mode": _normalize_bot_mode(bot_mode),
"created_at": _now_str(),
"updated_at": _now_str(),
"messages": [],
"rolling_summary": "",
}
store.setdefault("sessions", []).append(session)
store["active_session_id"] = session["id"]
save_chat_store(store)
return session
def ensure_active_session(*, trading_day: str) -> dict:
active = get_active_session()
if active:
return active
return create_new_session(trading_day=trading_day)
def update_session_rolling_summary(session_id: str, summary: str) -> dict:
store = load_chat_store()
target = None
for s in store.get("sessions") or []:
if str(s.get("id")) == str(session_id):
target = s
break
if not target:
raise KeyError("session_not_found")
target["rolling_summary"] = str(summary or "").strip()
target["updated_at"] = _now_str()
store["active_session_id"] = target["id"]
save_chat_store(store)
return target
def append_chat_message(
session_id: str,
role: str,
content: str,
*,
attachments: Optional[list] = None,
) -> dict:
store = load_chat_store()
sessions = store.get("sessions") or []
target = None
for s in sessions:
if str(s.get("id")) == str(session_id):
target = s
break
if not target:
raise KeyError("session_not_found")
msg = {"role": role, "content": content.strip(), "at": _now_str()}
if attachments:
msg["attachments"] = list(attachments)
target.setdefault("messages", []).append(msg)
target["updated_at"] = _now_str()
if role == "user" and (target.get("title") in (None, "", "新对话")):
title = content.strip().replace("\n", " ")[:24]
if title:
target["title"] = title
store["active_session_id"] = target["id"]
save_chat_store(store)
return target
def _session_list_item(s: dict, *, active_id: Optional[str]) -> dict:
msgs = s.get("messages") or []
preview = ""
for m in reversed(msgs):
if m.get("role") == "user":
preview = str(m.get("content") or "").replace("\n", " ")[:48]
break
if not preview and msgs:
last = msgs[-1]
preview = str(last.get("content") or "").replace("\n", " ")[:48]
sid = str(s.get("id") or "")
return {
"id": sid,
"title": s.get("title") or "新对话",
"bot_mode": _normalize_bot_mode(s.get("bot_mode")),
"trading_day": s.get("trading_day"),
"created_at": s.get("created_at"),
"updated_at": s.get("updated_at"),
"message_count": len(msgs),
"preview": preview,
"is_active": sid and sid == str(active_id or ""),
}
def list_chat_sessions(*, limit: int = 50) -> list[dict]:
store = load_chat_store()
active_id = store.get("active_session_id")
sessions = list(store.get("sessions") or [])
for s in sessions:
s.setdefault("bot_mode", CHAT_BOT_TRADING)
sessions.sort(key=lambda x: str(x.get("updated_at") or ""), reverse=True)
return [_session_list_item(s, active_id=active_id) for s in sessions[: max(1, min(limit, 100))]]
def set_active_session(session_id: str) -> dict:
store = load_chat_store()
target = None
for s in store.get("sessions") or []:
if str(s.get("id")) == str(session_id):
target = s
break
if not target:
raise KeyError("session_not_found")
target.setdefault("bot_mode", CHAT_BOT_TRADING)
store["active_session_id"] = target["id"]
save_chat_store(store)
return target
def delete_chat_session(session_id: str) -> tuple[bool, Optional[str]]:
store = load_chat_store()
sessions = list(store.get("sessions") or [])
new_sessions = [s for s in sessions if str(s.get("id")) != str(session_id)]
if len(new_sessions) == len(sessions):
return False, None
active = store.get("active_session_id")
new_active = active
if str(active) == str(session_id):
new_active = new_sessions[0]["id"] if new_sessions else None
store["sessions"] = new_sessions
store["active_session_id"] = new_active
save_chat_store(store)
return True, new_active
def summary_excerpt_for_chat(trading_day: str, max_chars: int = 600) -> str:
latest = get_latest_summary(trading_day)
if not latest:
return ""
text = str(latest.get("content_md") or "").strip()
if len(text) <= max_chars:
return text
return text[: max_chars - 3].rstrip() + "..."
+95
View File
@@ -0,0 +1,95 @@
"""中控 AI:今日总结生成."""
from __future__ import annotations
from typing import Any
from hub_ai.client import generate_text, model_label
from hub_ai.context import (
build_daily_context,
collect_closed_trades_snapshot,
format_account_remark,
format_summary_context_text,
summary_context_hash,
)
from hub_ai.prompts import SUMMARY_SYSTEM, build_summary_user_prompt
from hub_ai.store import append_summary, get_latest_summary, list_summaries
def _stats_snapshot_from_ctx(ctx: dict) -> dict:
day = ctx.get("trading_day")
accounts = ctx.get("accounts") or []
return {
"totals": ctx.get("totals"),
"closed_trades": collect_closed_trades_snapshot(accounts, today=day),
"by_account": {
str(ac.get("key") or ac.get("id")): {
"key": ac.get("key"),
"name": ac.get("name"),
"status": ac.get("status"),
"funding_usdt": ac.get("funding_usdt"),
"trading_usdt": ac.get("trading_usdt"),
"available_trading_usdt": ac.get("available_trading_usdt"),
"pnl_u": (ac.get("trade_stats") or {}).get("total_pnl_u"),
"closed_count": (ac.get("trade_stats") or {}).get("closed_count"),
"float_pnl_u": ac.get("float_pnl_u"),
"remark": format_account_remark(ac),
"monitor_lines": ac.get("monitor_lines") or {},
"issues": ac.get("issues") or [],
}
for ac in accounts
},
}
def generate_daily_summary(
exchanges: list[dict],
*,
trading_day: str | None = None,
force: bool = False,
) -> dict[str, Any]:
ctx = build_daily_context(exchanges, trading_day=trading_day)
day = ctx["trading_day"]
summary_payload = {
"trading_day": day,
"totals": ctx.get("totals"),
"accounts": ctx.get("accounts"),
}
summary_text = format_summary_context_text(summary_payload)
digest = summary_context_hash(summary_payload)
if not force:
latest = get_latest_summary(day)
if latest and latest.get("context_hash") == digest:
return {
"ok": True,
"cached": True,
"trading_day": day,
"summary": latest,
"model": latest.get("model") or model_label(),
}
system = SUMMARY_SYSTEM.replace("{trading_day}", day)
user = build_summary_user_prompt(summary_text, day)
content = generate_text(system=system, user=user, temperature=0.15)
if content.startswith("AI 调用失败"):
return {"ok": False, "msg": content, "trading_day": day}
stats_snapshot = _stats_snapshot_from_ctx(ctx)
row = append_summary(
trading_day=day,
content_md=content,
model=model_label(),
context_hash=digest,
stats_snapshot=stats_snapshot,
)
return {
"ok": True,
"cached": False,
"trading_day": day,
"summary": row,
"model": model_label(),
"context": ctx,
}
def summary_list(trading_day: str | None = None) -> list[dict]:
return list_summaries(trading_day=trading_day)
+125
View File
@@ -0,0 +1,125 @@
"""交易监管:AI 评语与用户回聊."""
from __future__ import annotations
import sys
from pathlib import Path
from typing import Any, Optional
_REPO_ROOT = Path(__file__).resolve().parents[2]
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
from lib.ai.ai_client import ai_generate # noqa: E402
from hub_ai.client import generate_text, model_label
from hub_ai.config import (
CHAT_MAX_OUTPUT_TOKENS,
CHAT_TEMPERATURE,
trading_day_reset_hour,
)
from hub_ai.context import build_chat_context, format_chat_context_for_chat, format_chat_position_overview
from hub_ai.prompts import SUPERVISOR_SYSTEM, build_supervisor_ai_prompt, build_supervisor_chat_prompt
from hub_ai.supervisor_store import (
append_supervisor_ai_message,
ensure_supervisor_session,
get_supervisor_session_state,
)
from hub_ai.store import append_chat_message
from hub_ai.text_util import is_ai_error_reply
from hub_supervisor_lib import build_supervisor_fallback_reply
from lib.hub.hub_trades_lib import current_trading_day
SUPERVISOR_AI_MAX_TOKENS = 320
def generate_supervisor_ai_reply(
*,
event: dict,
warnings: list[dict],
trading_day: str,
session_id: str,
exchanges: list[dict],
) -> str:
ctx = build_chat_context(exchanges, trading_day=trading_day)
brief = format_chat_position_overview(ctx) + "\n" + format_chat_context_for_chat(
ctx, max_chars=2400
)
user_prompt = build_supervisor_ai_prompt(
context_text=brief,
trading_day=trading_day,
event=event,
warnings=warnings,
)
prompt = f"{SUPERVISOR_SYSTEM.strip()}\n\n---\n\n{user_prompt.strip()}"
text = ai_generate(prompt, temperature=0.35, max_tokens=SUPERVISOR_AI_MAX_TOKENS)
text = str(text or "").strip()
if not text or is_ai_error_reply(text):
return build_supervisor_fallback_reply(event, warnings)
return text
def make_supervisor_ai_reply_fn(exchanges: list[dict]):
def _fn(*, event: dict, warnings: list[dict], trading_day: str, session_id: str) -> str:
return generate_supervisor_ai_reply(
event=event,
warnings=warnings or [],
trading_day=trading_day,
session_id=session_id,
exchanges=exchanges,
)
return _fn
def send_supervisor_chat(
exchanges: list[dict],
message: str,
*,
trading_day: str | None = None,
) -> dict[str, Any]:
text = (message or "").strip()
if not text:
return {"ok": False, "msg": "消息不能为空"}
day = (trading_day or "").strip()[:10] or current_trading_day(
reset_hour=trading_day_reset_hour()
)
session = ensure_supervisor_session(day)
sid = str(session.get("id") or "")
prior = session.get("messages") or []
ctx = build_chat_context(exchanges, trading_day=day)
brief = format_chat_context_for_chat(ctx, max_chars=6000)
recent = []
for m in prior[-8:]:
role = m.get("role")
if role not in ("user", "assistant", "system"):
continue
label = {"user": "用户", "assistant": "监管", "system": "系统"}.get(role, role)
recent.append(f"{label}:{str(m.get('content') or '').strip()}")
user_prompt = build_supervisor_chat_prompt(
context_text=brief,
trading_day=day,
history_lines="\n".join(recent),
user_message=text,
)
reply = generate_text(
system=SUPERVISOR_SYSTEM,
user=user_prompt,
temperature=min(0.4, CHAT_TEMPERATURE),
max_tokens=min(768, CHAT_MAX_OUTPUT_TOKENS),
max_continuations=1,
)
reply = str(reply or "").strip()
if not reply or is_ai_error_reply(reply):
return {"ok": False, "msg": "AI 暂时不可用,请稍后再试", "session_id": sid}
append_chat_message(sid, "user", text)
session = append_supervisor_ai_message(sid, reply)
state = get_supervisor_session_state(day)
return {
"ok": True,
"trading_day": day,
"session": session,
"reply": reply,
"model": model_label(),
"message_count": state.get("message_count"),
"unread_system": state.get("unread_system"),
}
@@ -0,0 +1,101 @@
"""交易监管专用会话(今日长会话,bot_mode=supervisor)."""
from __future__ import annotations
from typing import Any, Optional
from hub_ai.store import (
CHAT_BOT_SUPERVISOR,
append_chat_message,
load_chat_store,
save_chat_store,
)
def _supervisor_title(trading_day: str) -> str:
return f"今日监管 {trading_day}"
def find_supervisor_session(trading_day: str) -> Optional[dict]:
day = (trading_day or "").strip()[:10]
store = load_chat_store()
for s in store.get("sessions") or []:
if str(s.get("bot_mode") or "") != CHAT_BOT_SUPERVISOR:
continue
if str(s.get("trading_day") or "") == day:
return s
return None
def ensure_supervisor_session(trading_day: str) -> dict:
day = (trading_day or "").strip()[:10]
existing = find_supervisor_session(day)
if existing:
return existing
store = load_chat_store()
from datetime import datetime
import uuid
session = {
"id": uuid.uuid4().hex,
"trading_day": day,
"title": _supervisor_title(day),
"bot_mode": CHAT_BOT_SUPERVISOR,
"created_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"updated_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"messages": [],
"rolling_summary": "",
"supervisor_locked": True,
}
store.setdefault("sessions", []).append(session)
save_chat_store(store)
return session
def append_supervisor_system_message(
session_id: str,
content: str,
*,
event_type: str = "",
level: str = "info",
) -> dict:
store = load_chat_store()
target = None
for s in store.get("sessions") or []:
if str(s.get("id")) == str(session_id):
target = s
break
if not target:
raise KeyError("session_not_found")
from datetime import datetime
msg = {
"role": "system",
"content": (content or "").strip(),
"at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
"event_type": event_type,
"level": level,
}
target.setdefault("messages", []).append(msg)
target["updated_at"] = msg["at"]
save_chat_store(store)
return target
def append_supervisor_ai_message(session_id: str, content: str) -> dict:
return append_chat_message(session_id, "assistant", content)
def get_supervisor_session_state(trading_day: str) -> dict[str, Any]:
from hub_ai.client import model_label
session = ensure_supervisor_session(trading_day)
msgs = session.get("messages") or []
unread = sum(1 for m in msgs if m.get("role") == "system" and not m.get("read"))
return {
"ok": True,
"session": session,
"trading_day": trading_day,
"message_count": len(msgs),
"unread_system": unread,
"model": model_label(),
}
+14
View File
@@ -0,0 +1,14 @@
"""中控 AI 文本小工具."""
def is_ai_error_reply(text: str) -> bool:
t = (text or "").strip()
return t.startswith("AI 调用失败") or t.startswith("AI 生成失败")
def clip_text(text: str, max_chars: int) -> str:
s = str(text or "").strip()
limit = max(200, int(max_chars or 0))
if len(s) <= limit:
return s
return s[: limit - 1].rstrip() + ""