diff --git a/app/db/chat_assistant_db.py b/app/db/chat_assistant_db.py index 5cb8924..4508648 100644 --- a/app/db/chat_assistant_db.py +++ b/app/db/chat_assistant_db.py @@ -3,7 +3,7 @@ from __future__ import annotations import json -from datetime import datetime +from datetime import datetime, timedelta from app.db.llm_insights import repair_mojibake_json, repair_mojibake_text from app.db.postgres_connection import ensure_migrations_once @@ -310,6 +310,140 @@ def bootstrap_chat(user_id: int) -> dict: } +def get_chat_admin_overview(hours: int = 24) -> dict: + init_chat_tables() + hours = max(0, int(hours or 24)) + conn = get_conn() + try: + params = [] + where = "1=1" + if hours > 0: + where += " AND m.created_at >= %s" + params.append((datetime.now().replace(microsecond=0) - timedelta(hours=hours)).isoformat()) + totals = conn.execute( + f""" + SELECT + COUNT(*) AS total_messages, + COUNT(*) FILTER (WHERE role='user') AS total_questions, + COUNT(DISTINCT session_id) AS total_sessions, + COUNT(DISTINCT user_id) AS total_users + FROM chat_messages m + WHERE {where} + """, + tuple(params), + ).fetchone() + top_intents = conn.execute( + f""" + SELECT COALESCE(NULLIF(intent, ''), 'unknown') AS intent, COUNT(*) AS n + FROM chat_messages m + WHERE role='user' AND {where} + GROUP BY 1 + ORDER BY n DESC, intent ASC + LIMIT 8 + """, + tuple(params), + ).fetchall() + top_symbols = conn.execute( + f""" + SELECT COALESCE(NULLIF(symbol, ''), 'unknown') AS symbol, COUNT(*) AS n + FROM chat_messages m + WHERE role='user' AND {where} + GROUP BY 1 + ORDER BY n DESC, symbol ASC + LIMIT 10 + """, + tuple(params), + ).fetchall() + recent = conn.execute( + f""" + SELECT m.id, m.session_id, m.user_id, m.role, m.content_text, m.intent, m.symbol, m.model, m.created_at, + s.title AS session_title, u.email AS user_email + FROM chat_messages m + LEFT JOIN chat_sessions s ON s.id=m.session_id + LEFT JOIN app_user u ON u.id=m.user_id + WHERE m.role='user' AND {where} + ORDER BY m.created_at DESC, m.id DESC + LIMIT 50 + """, + tuple(params), + ).fetchall() + finally: + conn.close() + return { + "hours": hours, + "total_messages": int((totals[0] if totals else 0) or 0), + "total_questions": int((totals[1] if totals else 0) or 0), + "total_sessions": int((totals[2] if totals else 0) or 0), + "total_users": int((totals[3] if totals else 0) or 0), + "top_intents": [dict(row) for row in top_intents], + "top_symbols": [dict(row) for row in top_symbols], + "recent_questions": [dict(row) for row in recent], + } + + +def list_chat_admin_questions(hours: int = 24, intent: str = "", search: str = "", offset: int = 0, limit: int = 50) -> dict: + init_chat_tables() + hours = max(0, int(hours or 24)) + offset = max(0, int(offset or 0)) + limit = max(1, min(int(limit or 50), 200)) + intent = str(intent or "").strip() + search = str(search or "").strip() + conn = get_conn() + try: + params = [] + where = "m.role='user'" + if hours > 0: + where += " AND m.created_at >= %s" + params.append((datetime.now().replace(microsecond=0) - timedelta(hours=hours)).isoformat()) + if intent and intent != "all": + where += " AND COALESCE(NULLIF(m.intent, ''), 'unknown')=%s" + params.append(intent) + if search: + where += " AND (m.content_text ILIKE %s OR COALESCE(s.title,'') ILIKE %s OR COALESCE(u.email,'') ILIKE %s)" + like = f"%{search}%" + params.extend([like, like, like]) + total = conn.execute( + f""" + SELECT COUNT(*) + FROM chat_messages m + LEFT JOIN chat_sessions s ON s.id=m.session_id + LEFT JOIN app_user u ON u.id=m.user_id + WHERE {where} + """, + tuple(params), + ).fetchone()[0] + rows = conn.execute( + f""" + SELECT m.id, m.session_id, m.user_id, m.role, m.content_text, m.content_json, m.context_json, + m.intent, m.symbol, m.timeframe, m.model, m.created_at, + s.title AS session_title, u.email AS user_email + FROM chat_messages m + LEFT JOIN chat_sessions s ON s.id=m.session_id + LEFT JOIN app_user u ON u.id=m.user_id + WHERE {where} + ORDER BY m.created_at DESC, m.id DESC + LIMIT %s OFFSET %s + """, + tuple(params + [limit, offset]), + ).fetchall() + finally: + conn.close() + items = [] + for row in rows: + item = dict(row) + item["content"] = _loads(item.pop("content_json", "{}"), {}) + item["context"] = _loads(item.pop("context_json", "{}"), {}) + item["content_text"] = repair_mojibake_text(item.get("content_text", "")) + items.append(item) + return { + "items": items, + "total": int(total or 0), + "limit": limit, + "offset": offset, + "has_more": offset + len(items) < int(total or 0), + } + + __all__ = [ "append_chat_message", "bootstrap_chat", diff --git a/app/services/chat_assistant.py b/app/services/chat_assistant.py index 8ea01a2..e960f93 100644 --- a/app/services/chat_assistant.py +++ b/app/services/chat_assistant.py @@ -21,7 +21,6 @@ from app.db import chat_assistant_db from app.db.analytics import get_pipeline_runs from app.db.llm_insights import compute_input_hash, repair_mojibake_json, repair_mojibake_text from app.db.onchain_db import get_onchain_token_detail, get_onchain_overview -from app.db.paper_trading import get_paper_trading_summary, list_paper_trade_events, list_paper_trades from app.db.schema import get_conn from app.services.llm_insights import get_llm_params from app.services.market_overview import get_crypto_market_overview @@ -32,7 +31,7 @@ exchange = ccxt.binance({"enableRateLimit": True}) CRYPTO_TERMS = { "btc", "eth", "bnb", "sol", "xrp", "doge", "ada", "sui", "link", "qnt", "币", "加密", "crypto", "usdt", "binance", "行情", "链上", "舆情", "推荐", "复盘", - "k线", "k 线", "技术面", "止盈", "止损", "纸面", "模拟交易", "仓位", "山寨", + "k线", "k 线", "技术面", "止盈", "止损", "山寨", } INTENT_LABELS = { @@ -42,7 +41,7 @@ INTENT_LABELS = { "sentiment": "舆情解读", "onchain": "链上异动", "review": "复盘查询", - "paper_trading": "模拟交易", + "restricted": "受限内容", "help": "帮助", "unsupported": "范围外", } @@ -124,12 +123,12 @@ def extract_symbol(message: str, session=None, preferences=None) -> str: def detect_intent(message: str, symbol: str = "") -> str: text = str(message or "").lower() + if any(k in text for k in ("模拟交易", "纸面交易", "paper trading", "paper-trading", "paper")): + return "restricted" if not _is_crypto_question(text, symbol): return "unsupported" if any(k in text for k in ("怎么用", "能做什么", "帮助", "help", "问什么")): return "help" - if any(k in text for k in ("模拟交易", "paper", "开仓", "平仓", "持仓", "收益", "仓位")): - return "paper_trading" if any(k in text for k in ("链上", "onchain", "鲸鱼", "转账", "dex", "流动性", "合约")): return "onchain" if any(k in text for k in ("舆情", "新闻", "消息", "情绪", "热点", "叙事", "ai 舆情")): @@ -399,21 +398,6 @@ def _review_context(symbol: str = "", limit=8): conn.close() return [dict(row) for row in rows] - -def _paper_context(symbol: str = ""): - if symbol: - trades = list_paper_trades(limit=10, status="") - symbol_norm = _normalize_symbol(symbol) - items = [x for x in trades.get("items", []) if x.get("symbol") == symbol_norm][:5] - events = list_paper_trade_events(limit=20, symbol=symbol_norm) - return {"trades": items, "events": events.get("items", [])} - return { - "summary": get_paper_trading_summary(days=30), - "trades": list_paper_trades(limit=5).get("items", []), - "events": list_paper_trade_events(limit=8).get("items", []), - } - - def _market_context(): try: market = get_crypto_market_overview() @@ -444,6 +428,8 @@ def build_context(intent: str, message: str, symbol: str, preferences=None) -> d } if intent == "unsupported": return ctx + if intent == "restricted": + return ctx if intent in ("coin_analysis", "recommendation_explain", "onchain", "sentiment") and symbol: ctx["technicals"] = analyze_symbol_technicals(symbol) ctx["recommendations"] = _latest_recommendations(symbol=symbol, limit=5) @@ -453,20 +439,15 @@ def build_context(intent: str, message: str, symbol: str, preferences=None) -> d except Exception as exc: ctx["onchain"] = {"error": str(exc)[:200]} ctx["reviews"] = _review_context(symbol=symbol, limit=8) - ctx["paper"] = _paper_context(symbol=symbol) - ctx["sources"] = ["binance_klines", "recommendation", "event_news", "onchain", "review_log", "paper_trading"] + ctx["sources"] = ["binance_klines", "recommendation", "event_news", "onchain", "review_log"] elif intent == "market_overview": ctx.update(_market_context()) ctx["pipeline"] = get_pipeline_runs(limit=5, hours=24, offset=0) ctx["sources"] = ["market_overview", "onchain_overview", "llm_sentiment", "pipeline_runs"] - elif intent == "paper_trading": - ctx["paper"] = _paper_context(symbol=symbol) - ctx["sources"] = ["paper_trading"] elif intent == "review": ctx["reviews"] = _review_context(symbol=symbol, limit=12) ctx["recommendations"] = _latest_recommendations(symbol=symbol, limit=8) if symbol else _latest_recommendations(limit=8) - ctx["paper"] = _paper_context(symbol=symbol) - ctx["sources"] = ["review_log", "recommendation", "paper_trading"] + ctx["sources"] = ["review_log", "recommendation"] elif intent == "sentiment": ctx["sentiment_events"] = _sentiment_context(symbol=symbol, limit=20) try: @@ -490,16 +471,24 @@ def build_context(intent: str, message: str, symbol: str, preferences=None) -> d "单币技术面:自动拉取 Binance 15m/1h/4h/1d K 线", "推荐解释:结合当前看板状态、入场窗口、TP/SL、风险理由", "舆情和链上:读取当前系统已采集与 AI 解读的结果", - "复盘和模拟交易:区分信号表现与纸面交易收益", + "复盘:区分信号表现、失效样本和真实推荐结果", ] return ctx def _fallback_answer(intent: str, message: str, context: dict) -> dict: + if intent == "restricted": + return { + "summary": "内部模拟交易数据不可在智能问答中直接访问。", + "answer": "我不能读取或解释内部模拟交易数据。你可以继续问公开行情、单币技术面、推荐解释、链上异动、舆情影响或复盘结果(不含纸面交易明细)。", + "evidence": [], + "related_records": [], + "followups": ["分析 BTC/USDT 的技术面", "解释最新推荐为什么不是可买"], + } if intent == "unsupported": return { "summary": "我只能回答加密货币和 AlphaX 当前数据相关的问题。", - "answer": "这个问题超出了 Crypto 研究助手的范围。你可以问某个币的技术面、看板推荐原因、链上异动、舆情影响、复盘或模拟交易表现。", + "answer": "这个问题超出了 Crypto 研究助手的范围。你可以问某个币的技术面、看板推荐原因、链上异动、舆情影响或复盘表现。", "evidence": [], "related_records": [], "followups": ["分析 BTC/USDT 的技术面", "今天市场适合追强势币吗?"], @@ -510,7 +499,6 @@ def _fallback_answer(intent: str, message: str, context: dict) -> dict: recommendations = context.get("recommendations") or [] sentiment = context.get("sentiment_events") or [] onchain = context.get("onchain") or {} - paper = context.get("paper") or {} evidence = [] if tech_summary: evidence.append(f"技术面:{tech_summary.get('headline', '')}") @@ -521,9 +509,6 @@ def _fallback_answer(intent: str, message: str, context: dict) -> dict: evidence.append(f"舆情:近 72h 有 {len(sentiment)} 条相关事件,最新为「{sentiment[0].get('title', '')[:60]}」。") if isinstance(onchain, dict) and (onchain.get("events") or onchain.get("metrics")): evidence.append(f"链上:近 7 天有 {len(onchain.get('events') or [])} 条映射事件。") - if isinstance(paper, dict) and paper.get("trades"): - latest = paper["trades"][0] - evidence.append(f"模拟交易:最新状态 {latest.get('status')},收益 {latest.get('pnl_pct') or latest.get('realized_pnl_pct') or 0}%。") if not evidence: evidence.append("当前数据库没有足够样本,结论需要降级为观察。") @@ -534,7 +519,7 @@ def _fallback_answer(intent: str, message: str, context: dict) -> dict: answer = state.get("summary") or "我读取了全市场行情,但当前没有足够信息形成强结论。" elif symbol: summary = tech_summary.get("headline") or f"{symbol} 需要继续观察" - answer = "结论:" + summary + " 证据区已汇总技术面、推荐、舆情、链上和模拟交易上下文。" + answer = "结论:" + summary + " 证据区已汇总技术面、推荐、舆情、链上和复盘上下文。" else: summary = "已读取当前系统数据" answer = "我已经按你的问题读取了当前数据库,但没有识别到明确币种;可以继续追问具体币种。" @@ -554,8 +539,6 @@ def _related_records(context: dict) -> list[dict]: records.append({"type": "recommendation", "label": f"推荐 #{item.get('id')}", "symbol": item.get("symbol"), "status": item.get("execution_status")}) for item in (context.get("sentiment_events") or [])[:3]: records.append({"type": "sentiment", "label": item.get("title"), "symbol": item.get("symbol"), "status": item.get("importance")}) - for item in ((context.get("paper") or {}).get("trades") or [])[:3]: - records.append({"type": "paper_trade", "label": f"模拟交易 #{item.get('id')}", "symbol": item.get("symbol"), "status": item.get("status")}) return records @@ -602,6 +585,20 @@ def _followups(intent: str, symbol: str = "") -> list[str]: return ["分析 BTC/USDT 现在的技术面", "解释最新推荐为什么不是可买"] +def _answer_style_for_intent(intent: str) -> str: + return { + "coin_analysis": "technical", + "recommendation_explain": "decision", + "market_overview": "market", + "sentiment": "news", + "onchain": "onchain", + "review": "review", + "restricted": "notice", + "unsupported": "notice", + "help": "help", + }.get(intent or "", "default") + + def _call_chat_llm(message: str, context: dict, history=None) -> dict: cfg = llm_config() params = get_llm_params() @@ -617,10 +614,11 @@ def _call_chat_llm(message: str, context: dict, history=None) -> dict: "context": context, "recent_history": (history or [])[-8:], "rules": [ - "只回答加密货币、AlphaX 当前数据、技术面、链上、舆情、复盘和模拟交易相关问题。", + "只回答加密货币、AlphaX 当前数据、技术面、链上、舆情、复盘相关问题,不要访问内部模拟交易数据。", "不要给真实下单指令,不要修改推荐状态,不要承诺收益。", "回答使用中文,采用两段式:先结论,再证据。", - "输出严格 JSON:summary, answer, evidence[], risk_flags[], related_records[], followups[]。", + "根据 intent 选择 answer_style:technical/decision/market/news/onchain/review/notice/help/default。", + "输出严格 JSON:summary, answer, answer_style, evidence[], risk_flags[], related_records[], followups[]。", ], } system_prompt = "你是 AlphaX Agent 的 Crypto 研究助手。你只能基于提供的结构化数据回答,不能编造数据。" @@ -649,6 +647,7 @@ def _call_chat_llm(message: str, context: dict, history=None) -> dict: if not isinstance(parsed, dict): raise ValueError("llm_output_not_object") parsed.setdefault("summary", parsed.get("answer", "")[:80]) + parsed.setdefault("answer_style", _answer_style_for_intent(context.get("intent"))) parsed.setdefault("evidence", []) parsed.setdefault("risk_flags", []) parsed.setdefault("related_records", _related_records(context)) @@ -680,18 +679,24 @@ def answer_chat(user_id: int, message: str, session_id: int = 0) -> dict: ) history = chat_assistant_db.list_chat_messages(session["id"], user_id, limit=12).get("items", []) context = build_context(intent, message, symbol, preferences=prefs) - llm_result = _call_chat_llm(message, context, history=history) - if llm_result.get("status") == "success": - answer = repair_mojibake_json(llm_result.get("content") or {}) - model = llm_result.get("model") or "" - else: + if intent == "restricted": + llm_result = {"status": "skipped", "error": "restricted_data_scope"} answer = repair_mojibake_json(_fallback_answer(intent, message, context)) - answer["llm_status"] = llm_result.get("status") - answer["llm_error"] = llm_result.get("error", "") - model = llm_result.get("model") or "" + model = "" + else: + llm_result = _call_chat_llm(message, context, history=history) + if llm_result.get("status") == "success": + answer = repair_mojibake_json(llm_result.get("content") or {}) + model = llm_result.get("model") or "" + else: + answer = repair_mojibake_json(_fallback_answer(intent, message, context)) + answer["llm_status"] = llm_result.get("status") + answer["llm_error"] = llm_result.get("error", "") + model = llm_result.get("model") or "" answer.setdefault("related_records", _related_records(context)) answer.setdefault("followups", _followups(intent, symbol)) answer.setdefault("evidence", []) + answer.setdefault("answer_style", _answer_style_for_intent(intent)) assistant_text = repair_mojibake_text(answer.get("answer") or answer.get("summary") or "") assistant_msg = chat_assistant_db.append_chat_message( session["id"], diff --git a/app/web/routes_admin.py b/app/web/routes_admin.py index 7f9b8d7..301b86c 100644 --- a/app/web/routes_admin.py +++ b/app/web/routes_admin.py @@ -3,6 +3,7 @@ from fastapi.responses import HTMLResponse from app.config.system_config import seed_runtime_system_defaults from app.db import auth_db +from app.db import chat_assistant_db from app.db.scheduler_db import ( enqueue_manual_trigger, get_job_config, @@ -84,6 +85,23 @@ def build_router(templates): raise HTTPException(status_code=404, detail="日志不存在") return item + @router.get("/api/admin/chat-logs/overview") + async def api_admin_chat_logs_overview(hours: int = 24, altcoin_session: str = Cookie(default="")): + require_admin(altcoin_session) + return chat_assistant_db.get_chat_admin_overview(hours=hours) + + @router.get("/api/admin/chat-logs") + async def api_admin_chat_logs( + hours: int = 24, + intent: str = "all", + search: str = "", + offset: int = 0, + limit: int = 50, + altcoin_session: str = Cookie(default=""), + ): + require_admin(altcoin_session) + return chat_assistant_db.list_chat_admin_questions(hours=hours, intent=intent, search=search, offset=offset, limit=limit) + @router.get("/api/runtime-config") async def api_runtime_config(kind: str = "all", altcoin_session: str = Cookie(default="")): require_admin(altcoin_session) diff --git a/app/web/routes_pages.py b/app/web/routes_pages.py index 540b84a..4cff7c3 100644 --- a/app/web/routes_pages.py +++ b/app/web/routes_pages.py @@ -96,6 +96,17 @@ def build_router(templates, repo_root: Path, stock_report_template: str): return HTMLResponse(content=f"

需要管理员权限

{exc.detail}

返回看板", status_code=exc.status_code) return render_page("system_logs.html", request, active_nav="system_logs") + @router.get("/chat-logs", response_class=HTMLResponse) + async def chat_logs_page(request: Request): + user, redirect = require_page_user(request) + if redirect: + return redirect + try: + require_admin(request.cookies.get("altcoin_session", "")) + except HTTPException as exc: + return HTMLResponse(content=f"

需要管理员权限

{exc.detail}

返回看板", status_code=exc.status_code) + return render_page("chat_logs.html", request, active_nav="chat_logs") + @router.get("/paper-trading", response_class=HTMLResponse) async def paper_trading_page(request: Request): user, redirect = require_page_user(request) diff --git a/static/base.html b/static/base.html index d590141..4f0af83 100644 --- a/static/base.html +++ b/static/base.html @@ -190,6 +190,7 @@ a { color: inherit; text-decoration: none; } + diff --git a/static/chat.html b/static/chat.html index 7794bd4..f65476d 100644 --- a/static/chat.html +++ b/static/chat.html @@ -30,6 +30,9 @@ .answer-head b{font-size:16px;color:var(--ink);line-height:1.35} .tag{display:inline-flex;align-items:center;border-radius:999px;padding:4px 8px;background:rgba(66,98,255,.08);color:var(--blue);font-size:11px;font-weight:950;white-space:nowrap} .answer-text{font-size:14px;color:var(--slate);line-height:1.75;margin-bottom:12px} +.answer-body{display:grid;gap:10px;margin-bottom:12px} +.answer-block{font-size:14px;color:var(--slate);line-height:1.75;white-space:pre-wrap} +.answer-block.is-title{font-size:13px;font-weight:950;color:var(--ink);background:var(--surface);border:1px solid var(--hairline-soft);border-radius:10px;padding:8px 10px;line-height:1.5} .progress-box{border:1px solid var(--hairline-soft);border-radius:var(--radius-md);background:var(--surface);padding:11px 12px;margin-bottom:12px} .progress-row{display:flex;align-items:center;justify-content:space-between;gap:12px;font-size:12px;color:var(--slate);padding:5px 0} .progress-row strong{color:var(--ink);font-size:12px} @@ -45,6 +48,13 @@ .ev:before{content:"";position:absolute;left:0;top:.65em;width:4px;height:4px;border-radius:50%;background:var(--blue)} .record-list{display:flex;gap:6px;flex-wrap:wrap} .record{border:1px solid var(--hairline);background:#fff;border-radius:999px;padding:5px 8px;font-size:11px;font-weight:850;color:var(--slate)} +.mini-section{border:1px solid var(--hairline-soft);border-radius:var(--radius-md);background:var(--surface);padding:11px;margin-top:10px} +.mini-section h3{font-size:12px;font-weight:950;color:var(--ink);margin-bottom:8px} +.brief-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin-top:10px} +.brief{border:1px solid var(--hairline-soft);border-radius:var(--radius-md);background:#fff;padding:10px;min-width:0} +.brief span{display:block;font-size:10px;color:var(--stone);font-weight:950} +.brief b{display:block;margin-top:4px;font-size:14px;color:var(--ink);white-space:nowrap;overflow:hidden;text-overflow:ellipsis} +.notice-box{border:1px solid rgba(255,183,77,.42);background:rgba(255,183,77,.11);border-radius:var(--radius-md);padding:12px;color:#6d4700;font-size:13px;line-height:1.65;margin-top:10px} .tf-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;margin-top:10px} .tf{border:1px solid var(--hairline-soft);background:#fff;border-radius:var(--radius-md);padding:9px;min-width:0} .tf span{display:block;color:var(--stone);font-size:10px;font-weight:950} @@ -63,7 +73,7 @@ .loading-dots i:nth-child(2){animation-delay:.15s} .loading-dots i:nth-child(3){animation-delay:.3s} @keyframes pulse{0%,80%,100%{opacity:.25;transform:translateY(0)}40%{opacity:1;transform:translateY(-3px)}} -@media(max-width:980px){.chat-shell{grid-template-columns:1fr}.session-pane{display:none}.hero{padding:14px}.messages{padding:14px}.evidence-grid{grid-template-columns:1fr}.tf-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.composer{padding:10px}.chat-main{height:calc(100vh - 48px)}} +@media(max-width:980px){.chat-shell{grid-template-columns:1fr}.session-pane{display:none}.hero{padding:14px}.messages{padding:14px}.evidence-grid{grid-template-columns:1fr}.brief-grid{grid-template-columns:1fr}.tf-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.composer{padding:10px}.chat-main{height:calc(100vh - 48px)}} @media(max-width:520px){.composer-inner{grid-template-columns:1fr}.send{width:100%}.hero{display:block}.status-pill{margin-top:8px}.msg{grid-template-columns:30px minmax(0,1fr)}.avatar{width:30px;height:30px;border-radius:8px}.bubble{padding:12px}} {% endblock %} @@ -104,14 +114,22 @@ function short(v,n){v=String(v||'');return v.length>n?v.slice(0,n)+'...':v;} function fmtNum(v){v=Number(v||0);if(!v)return'--';if(v>=100)return v.toFixed(2);if(v>=1)return v.toFixed(3);if(v>=0.01)return v.toFixed(4);return v.toFixed(8);} function normMessages(items){return (items||[]).map(function(m){m=deepFix(m||{});return {id:m.id,role:m.role,text:m.content_text||'',content:m.content||{},context:m.context||{},created_at:m.created_at,intent:m.intent,symbol:m.symbol};});} function renderSessions(){if(!state.sessions.length){sessionList.innerHTML='
暂无对话
';return;}sessionList.innerHTML=state.sessions.map(function(s){var active=s.id===state.sessionId?' active':'';return '
'+esc(s.title||'新对话')+''+esc(short(s.last_message_text||s.summary||'还没有消息',88))+'
';}).join('');} -function renderEmpty(){messages.innerHTML='

问 AlphaX 一个 Crypto 问题

直接输入你的问题即可,支持单币技术面、推荐解释、链上异动、舆情影响、复盘和模拟交易。

';} +function renderEmpty(){messages.innerHTML='

问 AlphaX 一个 Crypto 问题

直接输入你的问题即可,支持单币技术面、推荐解释、链上异动、舆情影响和复盘结果。

';} function renderMessages(){if(!state.messages.length){renderEmpty();return;}messages.innerHTML=state.messages.map(renderMessage).join('');messages.scrollTop=messages.scrollHeight;} function renderMessage(m){if(m.role==='user'){return '
'+esc(m.text)+'
';}return '
AI
'+renderAnswer(m)+'
';} function renderProgress(lines){if(!lines||!lines.length)return'';return '
'+lines.map(function(line,idx){return '
'+esc(line)+''+(idx===0?'':'')+'
';}).join('')+'
';} function renderEvidenceList(items){if(!items||!items.length)return '
暂无明确证据,已降级为空态回答。
';return items.slice(0,8).map(function(x){if(typeof x==='string')return '
'+esc(x)+'
';if(Array.isArray(x))return '
'+esc(x.map(function(v){return typeof v==='string'?v:JSON.stringify(v);}).join(' · '))+'
';if(x&&typeof x==='object'){var label=x.label||x.name||x.title||x.reason||x.summary||x.key||'';var value=x.value||x.text||x.detail||x.note||x.signal||x.message||'';var extra=x.timeframe||x.symbol||x.period||x.source||'';var text=(label?label:'证据')+(value?(':'+value):'')+(extra?(' · '+extra):'');return '
'+esc(text||JSON.stringify(x))+'
';}return '
'+esc(String(x))+'
';}).join('');} function renderRecords(items){if(!items||!items.length)return '无直接记录';return items.slice(0,8).map(function(r){if(typeof r==='string')return ''+esc(r)+'';if(r&&typeof r==='object'){var parts=[];if(r.type)parts.push(r.type);if(r.label||r.title||r.name)parts.push(r.label||r.title||r.name);if(r.symbol)parts.push(r.symbol);if(r.status)parts.push(r.status);if(r.timeframe)parts.push(r.timeframe);if(r.created_at||r.detected_at)parts.push((r.created_at||r.detected_at).slice(0,16).replace('T',' '));var text=parts.filter(Boolean).join(' · ');return ''+esc(text||'记录')+'';}return ''+esc(String(r))+'';}).join('');} function renderTfGrid(ctx){var tech=(ctx&&ctx.technicals)||{},tfs=tech.timeframes||{},order=['15m','1h','4h','1d'];var has=order.some(function(tf){return tfs[tf]&&tfs[tf].available;});if(!has)return'';return '
'+order.map(function(tf){var x=tfs[tf]||{};if(!x.available)return '
'+tf+'无数据'+esc(x.reason||'Binance 未返回')+'
';var trend={uptrend:'上行',rebound:'反弹',weak:'偏弱',downtrend:'下行',sideways:'震荡'}[x.trend]||x.trend||'--';var sub='RSI '+(x.rsi14||'--')+' · 量 '+(x.volume_ratio_20||0)+'x';return '
'+tf+''+esc(trend)+' · $'+fmtNum(x.price)+''+esc(sub)+'
';}).join('')+'
';} -function renderAnswer(m){var c=m.content||{},ctx=m.context||{},answer=String(c.answer||m.text||c.summary||'--');var evidence=Array.isArray(c.evidence)?c.evidence:[];var risks=Array.isArray(c.risk_flags)?c.risk_flags:[];var records=Array.isArray(c.related_records)?c.related_records:[];var progressHtml=m.intent==='loading'?renderProgress(['正在读取 AlphaX 数据','正在拉取当前币种行情','正在汇总推荐 / 舆情 / 链上 / 复盘上下文']):'';var tfHtml=renderTfGrid(ctx);return '
'+esc(c.summary||'研究结论')+''+esc((m.intent||ctx.intent||'回答'))+'
'+progressHtml+'
'+esc(answer)+'
'+tfHtml+'

证据

'+renderEvidenceList(evidence)+(risks.length?renderEvidenceList(risks):'')+'

相关记录

'+renderRecords(records)+'
';} +function styleFor(m,c,ctx){return c.answer_style||({coin_analysis:'technical',recommendation_explain:'decision',market_overview:'market',sentiment:'news',onchain:'onchain',review:'review',restricted:'notice',unsupported:'notice',help:'help'}[m.intent||ctx.intent]||'default');} +function splitPlainText(text){var raw=String(text||'').replace(/\r\n/g,'\n').trim();if(!raw)return[];var out=[];var current=[];var lines=raw.split('\n');function flush(){var block=current.join(' ').replace(/\s+/g,' ').trim();if(block)out.push(block);current=[];} +lines.forEach(function(line){var t=line.trim();if(!t){flush();return;}if(/^【[^】]+】/.test(t)||/^(结论|证据|风险|建议|判断|复盘|总结|要点|关注点|结论如下)[::]/.test(t)){flush();out.push(t);return;}var numbered=/^(\d+)[\.、]\s*(.+)$/.exec(t);if(numbered){flush();out.push(numbered[0]);return;}if(/^[-•]\s+/.test(t)){flush();out.push(t);return;}if(current.length&¤t[current.length-1].length+t.length>220){flush();current.push(t);flush();return;}current.push(t);}); +flush();if(!out.length)out.push(raw);return out;} +function renderPlainTextAnswer(text){var blocks=splitPlainText(text);return '
'+blocks.map(function(block){if(/^【[^】]+】/.test(block)||/^(结论|证据|风险|建议|判断|复盘|总结|要点|关注点|结论如下)[::]/.test(block)){return '
'+esc(block)+'
';}return '
'+esc(block)+'
';}).join('')+'
';} +function renderEvidenceSection(title,items,empty){return '

'+esc(title)+'

'+(items&&items.length?renderEvidenceList(items):'
'+esc(empty||'暂无明确数据。')+'
')+'
';} +function renderRecordSection(title,items){if(!items||!items.length)return'';return '

'+esc(title)+'

'+renderRecords(items)+'
';} +function renderMarketBrief(ctx){var src=(ctx&&ctx.technical_summary)||{},items=[];if(src.stance)items.push(['状态',src.stance]);if(src.risk_level)items.push(['风险',src.risk_level]);if(src.latest_price)items.push(['价格','$'+fmtNum(src.latest_price)]);if(!items.length)return'';return '
'+items.map(function(x){return '
'+esc(x[0])+''+esc(x[1])+'
';}).join('')+'
';} +function renderAnswer(m){var c=m.content||{},ctx=m.context||{},answer=String(c.answer||m.text||c.summary||'--');var evidence=Array.isArray(c.evidence)?c.evidence:[];var risks=Array.isArray(c.risk_flags)?c.risk_flags:[];var records=Array.isArray(c.related_records)?c.related_records:[];var style=styleFor(m,c,ctx);var tag=m.intent||ctx.intent||style||'回答';var headOnly='
'+esc(c.summary||'研究结论')+''+esc(tag)+'
';var head=headOnly+renderPlainTextAnswer(answer);if(style==='notice'||style==='help'||m.intent==='error')return headOnly+renderPlainTextAnswer(answer)+'
'+esc(answer)+'
';if(style==='technical')return head+renderTfGrid(ctx)+renderEvidenceSection('关键判断',evidence,'暂无明确技术证据。')+(risks.length?renderEvidenceSection('风险提示',risks,''):'')+renderRecordSection('相关记录',records);if(style==='market')return head+renderMarketBrief(ctx)+renderEvidenceSection('市场要点',evidence,'暂无市场要点。');if(style==='decision')return head+renderEvidenceSection('决策依据',evidence,'暂无推荐依据。')+(risks.length?renderEvidenceSection('不成立条件',risks,''):'')+renderRecordSection('推荐记录',records);if(style==='news')return head+renderEvidenceSection('事件解读',evidence,'暂无舆情事件。')+renderRecordSection('新闻记录',records);if(style==='onchain')return head+renderEvidenceSection('链上信号',evidence,'暂无链上映射信号。')+renderRecordSection('相关事件',records);if(style==='review')return head+renderEvidenceSection('复盘发现',evidence,'暂无复盘样本。')+(risks.length?renderEvidenceSection('需要警惕',risks,''):'')+renderRecordSection('复盘记录',records);return head+renderEvidenceSection('要点',evidence,'暂无明确证据。')+renderRecordSection('相关记录',records);} async function init(){try{var d=await (await fetch('/api/chat/bootstrap')).json();state.sessions=(d.sessions&&d.sessions.items)||[];renderSessions();if(state.sessions[0])loadSession(state.sessions[0].id);else renderEmpty();}catch(e){messages.innerHTML='
聊天助手加载失败
';}} async function refreshSessions(){var d=await (await fetch('/api/chat/sessions?limit=20')).json();state.sessions=d.items||[];renderSessions();} async function newSession(){state.sessionId=0;state.messages=[];renderSessions();renderEmpty();messageInput.focus();} @@ -121,7 +139,18 @@ function appendLoading(){state.messages.push({role:'assistant',text:'',content:{ function removeLoading(){state.messages=state.messages.filter(function(m){return m.intent!=='loading';});} async function sendMessage(){var text=messageInput.value.trim();if(!text||state.loading)return;state.loading=true;sendBtn.disabled=true;chatStatus.textContent='分析中';state.messages.push({role:'user',text:text,content:{text:text},created_at:new Date().toISOString()});messageInput.value='';appendLoading();try{var d=deepFix(await (await fetch('/api/chat/send',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({session_id:state.sessionId||0,message:text})})).json());removeLoading();state.sessionId=(d.session&&d.session.id)||state.sessionId;state.messages.push(d.assistant_message);renderMessages();await refreshSessions();}catch(e){removeLoading();state.messages.push({role:'assistant',content:{summary:'回答失败',answer:'这次请求没有完成,请稍后重试。',evidence:[]},context:{},created_at:new Date().toISOString(),intent:'error'});renderMessages();}finally{state.loading=false;sendBtn.disabled=false;chatStatus.textContent='研究参考';}} var _renderAnswer=renderAnswer; -renderAnswer=function(m){if(m.intent==='loading'){return '
正在读取 AlphaX 数据分析中
';}return _renderAnswer(m);} +renderAnswer=function(m){ + if(m.intent==='loading'){ + return '
正在分析中分析中
' + + '
' + + '
读取 AlphaX 当前数据
' + + '
汇总多周期行情与结构
' + + '
结合推荐、舆情、链上与复盘
' + + '
' + + '
稍等一下,我正在把不同数据源拼起来,随后会给你结论和依据。
'; + } + return _renderAnswer(m); +} init(); {% endblock %} diff --git a/static/chat_logs.html b/static/chat_logs.html new file mode 100644 index 0000000..a0673b0 --- /dev/null +++ b/static/chat_logs.html @@ -0,0 +1,179 @@ +{% extends "base.html" %} +{% block title %}问答日志 · AlphaX Agent{% endblock %} + +{% block extra_head_css %} + +{% endblock %} + +{% block content %} +
+
+
+
问答日志
+
查看智能问答里用户在问什么,以及哪些问题最常出现。
+
+
+ +
+
加载中--
+
+ +
+
+
+ + + + +
+
+ + + +
时间用户意图问题币种会话
加载中...
+
+ +
+
+
+
热门意图--
+
+
+
热门问题
+
+
+
热门币种
+
+
+
+
+{% endblock %} + +{% block password_modal %}{% endblock %} + +{% block extra_script %} + +{% endblock %} diff --git a/tests/test_chat_assistant.py b/tests/test_chat_assistant.py index cbabe40..c6ab760 100644 --- a/tests/test_chat_assistant.py +++ b/tests/test_chat_assistant.py @@ -8,7 +8,9 @@ from app.services import chat_assistant from app.web import web_server -def _login_user(email: str = "chat-user@example.com", password: str = "StrongPass123") -> str: +def _login_user(email: str = "chat-user@example.com", password: str = "StrongPass123", monkeypatch=None) -> str: + if monkeypatch is not None: + monkeypatch.setattr(auth_db, "is_smtp_configured", lambda: False) reg = auth_db.register_user(email, password) auth_db.verify_email(email, reg["verification_code"]) user = auth_db.get_user_by_email(email) @@ -47,7 +49,7 @@ def test_chat_page_and_bootstrap_require_subscription(): def test_single_coin_chat_fetches_multi_timeframe_technicals(monkeypatch): - token = _login_user("chat-coin@example.com") + token = _login_user("chat-coin@example.com", monkeypatch=monkeypatch) seen = [] def fake_fetch(symbol, timeframe, limit=160): @@ -64,6 +66,7 @@ def test_single_coin_chat_fetches_multi_timeframe_technicals(monkeypatch): assert resp.status_code == 200 data = resp.json() assert data["intent"] == "coin_analysis" + assert data["assistant_message"]["content"]["answer_style"] == "technical" assert data["symbol"] == "SUI/USDT" assert {tf for _, tf in seen} >= {"15m", "1h", "4h", "1d"} tfs = data["assistant_message"]["context"]["technicals"]["timeframes"] @@ -72,7 +75,7 @@ def test_single_coin_chat_fetches_multi_timeframe_technicals(monkeypatch): def test_non_crypto_question_is_rejected(monkeypatch): - token = _login_user("chat-scope@example.com") + token = _login_user("chat-scope@example.com", monkeypatch=monkeypatch) monkeypatch.setattr(chat_assistant, "_call_chat_llm", lambda message, context, history=None: {"status": "skipped", "error": "disabled"}) client = TestClient(web_server.app) client.cookies.set("altcoin_session", token) @@ -85,8 +88,23 @@ def test_non_crypto_question_is_rejected(monkeypatch): assert "只能回答加密货币" in data["answer"]["summary"] +def test_chat_rejects_paper_trading_questions(monkeypatch): + token = _login_user("chat-restricted@example.com", monkeypatch=monkeypatch) + monkeypatch.setattr(chat_assistant, "_call_chat_llm", lambda message, context, history=None: {"status": "skipped", "error": "disabled"}) + client = TestClient(web_server.app) + client.cookies.set("altcoin_session", token) + + resp = client.post("/api/chat/send", json={"message": "帮我看一下模拟交易开仓和收益"}) + + assert resp.status_code == 200 + data = resp.json() + assert data["intent"] == "restricted" + assert "内部模拟交易数据不可在智能问答中直接访问" in data["answer"]["summary"] + assert data["answer"]["evidence"] == [] + + def test_chat_user_memory_tracks_last_symbol(monkeypatch): - token = _login_user("chat-memory@example.com") + token = _login_user("chat-memory@example.com", monkeypatch=monkeypatch) user = auth_db.get_user_by_email("chat-memory@example.com") monkeypatch.setattr(chat_assistant, "fetch_binance_klines", lambda symbol, timeframe, limit=160: _fake_ohlcv()) monkeypatch.setattr(chat_assistant, "_call_chat_llm", lambda message, context, history=None: {"status": "skipped", "error": "disabled"}) @@ -102,7 +120,7 @@ def test_chat_user_memory_tracks_last_symbol(monkeypatch): def test_chat_repairs_llm_mojibake_output(monkeypatch): - token = _login_user("chat-mojibake@example.com") + token = _login_user("chat-mojibake@example.com", monkeypatch=monkeypatch) monkeypatch.setattr(chat_assistant, "fetch_binance_klines", lambda symbol, timeframe, limit=160: _fake_ohlcv()) monkeypatch.setattr( chat_assistant, diff --git a/tests/test_chat_logs_admin.py b/tests/test_chat_logs_admin.py new file mode 100644 index 0000000..8e57db0 --- /dev/null +++ b/tests/test_chat_logs_admin.py @@ -0,0 +1,34 @@ +from fastapi.testclient import TestClient + +from app.db import auth_db +from app.web import web_server + + +def _login_admin(email: str = "chat-log-admin@example.com", password: str = "StrongPass123") -> str: + reg = auth_db.register_user(email, password) + auth_db.verify_email(email, reg["verification_code"]) + auth_db.set_user_admin(email, True) + return auth_db.login_user(email, password)["token"] + + +def test_chat_logs_page_requires_admin(): + client = TestClient(web_server.app) + resp = client.get("/chat-logs") + assert resp.status_code in (200, 307) + + +def test_chat_logs_admin_page_and_api(): + token = _login_admin() + client = TestClient(web_server.app) + client.cookies.set("altcoin_session", token) + + page = client.get("/chat-logs") + overview = client.get("/api/admin/chat-logs/overview") + listing = client.get("/api/admin/chat-logs") + + assert page.status_code == 200 + assert "问答日志" in page.text + assert overview.status_code == 200 + assert "total_questions" in overview.json() + assert listing.status_code == 200 + assert "items" in listing.json()