This commit is contained in:
aaron 2026-05-18 10:47:07 +08:00
parent a53d8dc9f9
commit a1031a8e06
9 changed files with 485 additions and 56 deletions

View File

@ -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",

View File

@ -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 当前数据、技术面、链上、舆情、复盘相关问题,不要访问内部模拟交易数据",
"不要给真实下单指令,不要修改推荐状态,不要承诺收益。",
"回答使用中文,采用两段式:先结论,再证据。",
"输出严格 JSONsummary, answer, evidence[], risk_flags[], related_records[], followups[]。",
"根据 intent 选择 answer_styletechnical/decision/market/news/onchain/review/notice/help/default。",
"输出严格 JSONsummary, 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"],

View File

@ -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)

View File

@ -96,6 +96,17 @@ def build_router(templates, repo_root: Path, stock_report_template: str):
return HTMLResponse(content=f"<meta charset=utf-8><h2>需要管理员权限</h2><p>{exc.detail}</p><a href=/app>返回看板</a>", 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"<meta charset=utf-8><h2>需要管理员权限</h2><p>{exc.detail}</p><a href=/app>返回看板</a>", 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)

View File

@ -190,6 +190,7 @@ a { color: inherit; text-decoration: none; }
<a class="sidebar-link admin-link {% if active_nav == 'llm_insights' %}active{% endif %}" href="/llm-insights" style="display:none"><svg class="link-icon"><use href="#svg-ai"/></svg>AI 记录</a>
<a class="sidebar-link admin-link {% if active_nav == 'strategy' %}active{% endif %}" href="/strategy" style="display:none"><svg class="link-icon"><use href="#svg-target"/></svg>策略</a>
<a class="sidebar-link admin-link {% if active_nav == 'iteration' %}active{% endif %}" href="/iteration" style="display:none"><svg class="link-icon"><use href="#svg-iterate"/></svg>迭代</a>
<a class="sidebar-link admin-link {% if active_nav == 'chat_logs' %}active{% endif %}" href="/chat-logs" style="display:none"><svg class="link-icon"><use href="#svg-chat"/></svg>问答日志</a>
<div class="sidebar-section-label admin-link" style="display:none">系统</div>
<a class="sidebar-link admin-link {% if active_nav == 'config' %}active{% endif %}" href="/config" style="display:none"><svg class="link-icon"><use href="#svg-config"/></svg>配置中心</a>
<a class="sidebar-link admin-link {% if active_nav == 'cron' %}active{% endif %}" href="/cron" style="display:none"><svg class="link-icon"><use href="#svg-cron"/></svg>调度中心</a>

View File

@ -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}}
</style>
{% 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='<div class="empty">暂无对话</div>';return;}sessionList.innerHTML=state.sessions.map(function(s){var active=s.id===state.sessionId?' active':'';return '<div class="session-row'+active+'" onclick="loadSession('+s.id+')"><b>'+esc(s.title||'新对话')+'</b><span>'+esc(short(s.last_message_text||s.summary||'还没有消息',88))+'</span></div>';}).join('');}
function renderEmpty(){messages.innerHTML='<div class="empty"><h2>问 AlphaX 一个 Crypto 问题</h2><p>直接输入你的问题即可,支持单币技术面、推荐解释、链上异动、舆情影响、复盘和模拟交易</p></div>';}
function renderEmpty(){messages.innerHTML='<div class="empty"><h2>问 AlphaX 一个 Crypto 问题</h2><p>直接输入你的问题即可,支持单币技术面、推荐解释、链上异动、舆情影响和复盘结果</p></div>';}
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 '<div class="msg user"><div class="avatar"></div><div class="bubble"><div class="bubble-text">'+esc(m.text)+'</div></div></div>';}return '<div class="msg"><div class="avatar ai">AI</div><div class="bubble">'+renderAnswer(m)+'</div></div>';}
function renderProgress(lines){if(!lines||!lines.length)return'';return '<div class="progress-box">'+lines.map(function(line,idx){return '<div class="progress-row"><span>'+esc(line)+'</span>'+(idx===0?'<span class="progress-dots"><i></i><i></i><i></i></span>':'')+'</div>';}).join('')+'</div>';}
function renderEvidenceList(items){if(!items||!items.length)return '<div class="ev">暂无明确证据,已降级为空态回答。</div>';return items.slice(0,8).map(function(x){if(typeof x==='string')return '<div class="ev">'+esc(x)+'</div>';if(Array.isArray(x))return '<div class="ev">'+esc(x.map(function(v){return typeof v==='string'?v:JSON.stringify(v);}).join(' · '))+'</div>';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 '<div class="ev">'+esc(text||JSON.stringify(x))+'</div>';}return '<div class="ev">'+esc(String(x))+'</div>';}).join('');}
function renderRecords(items){if(!items||!items.length)return '<span class="record">无直接记录</span>';return items.slice(0,8).map(function(r){if(typeof r==='string')return '<span class="record">'+esc(r)+'</span>';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 '<span class="record">'+esc(text||'记录')+'</span>';}return '<span class="record">'+esc(String(r))+'</span>';}).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 '<div class="tf-grid">'+order.map(function(tf){var x=tfs[tf]||{};if(!x.available)return '<div class="tf"><span>'+tf+'</span><b>无数据</b><small>'+esc(x.reason||'Binance 未返回')+'</small></div>';var trend={uptrend:'上行',rebound:'反弹',weak:'偏弱',downtrend:'下行',sideways:'震荡'}[x.trend]||x.trend||'--';var sub='RSI '+(x.rsi14||'--')+' · 量 '+(x.volume_ratio_20||0)+'x';return '<div class="tf"><span>'+tf+'</span><b>'+esc(trend)+' · $'+fmtNum(x.price)+'</b><small>'+esc(sub)+'</small></div>';}).join('')+'</div>';}
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 '<div class="answer-head"><b>'+esc(c.summary||'研究结论')+'</b><span class="tag">'+esc((m.intent||ctx.intent||'回答'))+'</span></div>'+progressHtml+'<div class="answer-text">'+esc(answer)+'</div>'+tfHtml+'<div class="evidence-grid"><div class="panel"><h3>证据</h3><div class="ev-list">'+renderEvidenceList(evidence)+(risks.length?renderEvidenceList(risks):'')+'</div></div><div class="panel"><h3>相关记录</h3><div class="record-list">'+renderRecords(records)+'</div></div></div>';}
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&&current[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 '<div class="answer-body">'+blocks.map(function(block){if(/^【[^】]+】/.test(block)||/^(结论|证据|风险|建议|判断|复盘|总结|要点|关注点|结论如下)[:]/.test(block)){return '<div class="answer-block is-title">'+esc(block)+'</div>';}return '<div class="answer-block">'+esc(block)+'</div>';}).join('')+'</div>';}
function renderEvidenceSection(title,items,empty){return '<div class="mini-section"><h3>'+esc(title)+'</h3><div class="ev-list">'+(items&&items.length?renderEvidenceList(items):'<div class="ev">'+esc(empty||'暂无明确数据。')+'</div>')+'</div></div>';}
function renderRecordSection(title,items){if(!items||!items.length)return'';return '<div class="mini-section"><h3>'+esc(title)+'</h3><div class="record-list">'+renderRecords(items)+'</div></div>';}
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 '<div class="brief-grid">'+items.map(function(x){return '<div class="brief"><span>'+esc(x[0])+'</span><b>'+esc(x[1])+'</b></div>';}).join('')+'</div>';}
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='<div class="answer-head"><b>'+esc(c.summary||'研究结论')+'</b><span class="tag">'+esc(tag)+'</span></div>';var head=headOnly+renderPlainTextAnswer(answer);if(style==='notice'||style==='help'||m.intent==='error')return headOnly+renderPlainTextAnswer(answer)+'<div class="notice-box">'+esc(answer)+'</div>';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='<div class="empty">聊天助手加载失败</div>';}}
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 '<div class="answer-head"><b>正在读取 AlphaX 数据</b><span class="tag">分析中</span></div><div class="answer-text"><span class="loading-dots"><i></i><i></i><i></i></span></div>';}return _renderAnswer(m);}
renderAnswer=function(m){
if(m.intent==='loading'){
return '<div class="answer-head"><b>正在分析中</b><span class="tag">分析中</span></div>'
+ '<div class="progress-box">'
+ '<div class="progress-row"><span>读取 AlphaX 当前数据</span><span class="progress-dots"><i></i><i></i><i></i></span></div>'
+ '<div class="progress-row"><span>汇总多周期行情与结构</span></div>'
+ '<div class="progress-row"><span>结合推荐、舆情、链上与复盘</span></div>'
+ '</div>'
+ '<div class="notice-box">稍等一下,我正在把不同数据源拼起来,随后会给你结论和依据。</div>';
}
return _renderAnswer(m);
}
init();
</script>
{% endblock %}

179
static/chat_logs.html Normal file
View File

@ -0,0 +1,179 @@
{% extends "base.html" %}
{% block title %}问答日志 · AlphaX Agent{% endblock %}
{% block extra_head_css %}
<style>
main{max-width:1320px;margin:0 auto;width:100%;padding:24px;display:flex;flex-direction:column;gap:16px}
.page-head{display:flex;align-items:flex-end;justify-content:space-between;gap:14px;flex-wrap:wrap}
.page-title{font-size:24px;font-weight:900;color:var(--ink);letter-spacing:-.4px}
.page-sub{margin-top:4px;font-size:13px;color:var(--stone)}
.summary-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px}
.summary-card{padding:14px 15px;border:1px solid var(--hairline-soft);border-radius:var(--radius-md);background:var(--canvas);min-width:0}
.summary-card span{display:block;color:var(--stone);font-size:11px;font-weight:900}
.summary-card b{display:block;margin-top:6px;color:var(--ink);font-size:24px;line-height:1;font-weight:900;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.layout{display:grid;grid-template-columns:minmax(0,1.1fr) minmax(0,.9fr);gap:14px;align-items:start}
.card{background:var(--canvas);border:1px solid var(--hairline-soft);border-radius:var(--radius-md);overflow:hidden}
.toolbar{display:flex;gap:8px;flex-wrap:wrap;padding:14px;border-bottom:1px solid var(--hairline-soft)}
.toolbar input,.toolbar select{min-height:38px;padding:8px 12px;background:var(--surface);border:1px solid var(--hairline);border-radius:var(--radius-md);color:var(--ink);font-size:13px;outline:none}
.toolbar input{flex:1;min-width:220px}
.toolbar button{padding:8px 16px;border:none;border-radius:var(--radius-md);background:var(--primary);color:var(--on-primary);font-size:13px;font-weight:800;cursor:pointer}
.table-wrap{overflow-x:auto}
table{width:100%;border-collapse:collapse;min-width:900px;font-size:13px}
th{text-align:left;padding:10px 12px;color:var(--stone);font-weight:900;border-bottom:1px solid var(--hairline-soft);font-size:11px;text-transform:uppercase;letter-spacing:.4px;background:var(--surface)}
td{padding:11px 12px;border-bottom:1px solid var(--hairline-soft);color:var(--ink);vertical-align:top}
tr:hover td{background:var(--surface)}
.badge{display:inline-flex;align-items:center;height:22px;padding:0 8px;border-radius:var(--radius-full);font-size:11px;font-weight:900;border:1px solid var(--hairline-soft);background:var(--surface);color:var(--stone);white-space:nowrap}
.badge-blue{background:rgba(66,98,255,.10);color:var(--blue);border-color:rgba(66,98,255,.18)}
.badge-yellow{background:rgba(255,208,47,.14);color:var(--yellow-dark);border-color:rgba(255,208,47,.25)}
.badge-green{background:rgba(0,180,115,.10);color:var(--green);border-color:rgba(0,180,115,.18)}
.badge-red{background:rgba(229,62,62,.10);color:var(--red);border-color:rgba(229,62,62,.18)}
.pagination{display:flex;justify-content:center;align-items:center;gap:12px;padding:14px;font-size:13px;color:var(--stone)}
.pagination button{padding:6px 14px;background:var(--surface);border:1px solid var(--hairline);border-radius:var(--radius-md);color:var(--ink);font-size:13px;cursor:pointer}
.pagination button:disabled{opacity:.4;cursor:default}
.panel{padding:16px}
.mini-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:10px}
.mini{border:1px solid var(--hairline-soft);border-radius:var(--radius-md);background:var(--surface);padding:12px}
.mini span{display:block;color:var(--stone);font-size:11px;font-weight:900}
.mini b{display:block;margin-top:6px;color:var(--ink);font-size:18px;font-weight:900;line-height:1.2}
.topic-list{display:grid;gap:8px}
.topic{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:10px 12px;border:1px solid var(--hairline-soft);border-radius:var(--radius-md);background:var(--surface)}
.topic b{font-size:13px;color:var(--ink)}
.topic span{font-size:12px;color:var(--stone);white-space:nowrap}
.question-list{display:grid;gap:8px}
.question{padding:11px 12px;border:1px solid var(--hairline-soft);border-radius:var(--radius-md);background:var(--surface)}
.question b{display:block;font-size:13px;color:var(--ink);line-height:1.45}
.question .meta{display:flex;gap:8px;flex-wrap:wrap;margin-top:6px;font-size:11px;color:var(--stone)}
.empty{text-align:center;padding:34px 14px;color:var(--stone);font-size:13px}
@media(max-width:960px){main{padding:18px}.layout{grid-template-columns:1fr}.summary-grid{grid-template-columns:repeat(2,minmax(0,1fr))}}
@media(max-width:560px){.summary-grid{grid-template-columns:1fr}.page-title{font-size:21px}}
</style>
{% endblock %}
{% block content %}
<main>
<div class="page-head">
<div>
<div class="page-title">问答日志</div>
<div class="page-sub">查看智能问答里用户在问什么,以及哪些问题最常出现。</div>
</div>
</div>
<div class="summary-grid" id="summaryGrid">
<div class="summary-card"><span>加载中</span><b>--</b></div>
</div>
<div class="layout">
<div class="card">
<div class="toolbar">
<input type="text" id="search" placeholder="搜索问题、会话、用户..." onkeydown="if(event.key==='Enter')loadQuestions(0)">
<select id="intent" onchange="loadQuestions(0)">
<option value="all">全部意图</option>
<option value="coin_analysis">单币分析</option>
<option value="market_overview">市场问答</option>
<option value="recommendation_explain">推荐解释</option>
<option value="sentiment">舆情解读</option>
<option value="onchain">链上异动</option>
<option value="review">复盘查询</option>
<option value="restricted">受限内容</option>
</select>
<select id="hours" onchange="loadQuestions(0)">
<option value="24">近 24h</option>
<option value="168" selected>近 7 天</option>
<option value="720">近 30 天</option>
<option value="0">全部</option>
</select>
<button onclick="loadQuestions(0)">查询</button>
</div>
<div class="table-wrap">
<table>
<thead><tr><th>时间</th><th>用户</th><th>意图</th><th>问题</th><th>币种</th><th>会话</th></tr></thead>
<tbody id="table"><tr><td colspan="6" class="empty">加载中...</td></tr></tbody>
</table>
</div>
<div class="pagination" id="pagination"></div>
</div>
<div class="card panel">
<div class="mini-grid" id="sideStats">
<div class="mini"><span>热门意图</span><b>--</b></div>
</div>
<div style="height:14px"></div>
<div class="section-title" style="font-size:12px;font-weight:900;color:var(--stone);text-transform:uppercase;letter-spacing:.4px;margin-bottom:8px">热门问题</div>
<div id="topQuestions" class="question-list"></div>
<div style="height:14px"></div>
<div class="section-title" style="font-size:12px;font-weight:900;color:var(--stone);text-transform:uppercase;letter-spacing:.4px;margin-bottom:8px">热门币种</div>
<div id="topSymbols" class="topic-list"></div>
</div>
</div>
</main>
{% endblock %}
{% block password_modal %}{% endblock %}
{% block extra_script %}
<script>
var API='',PAGE_SIZE=50,OFFSET=0,TOTAL=0;
async function init(){
try{var chk=await fetch(API+'/api/admin/check');if(!chk.ok){window.location.href='/subscription';return}
var info=await chk.json();if(!info.is_admin){window.location.href='/subscription';return}}catch(e){window.location.href='/subscription';return}
loadOverview();loadQuestions(0);
}
async function loadOverview(){
var hours=document.getElementById('hours').value||168;
try{
var r=await fetch(API+'/api/admin/chat-logs/overview?hours='+encodeURIComponent(hours));
if(!r.ok)throw new Error(r.status);
var d=await r.json();
document.getElementById('summaryGrid').innerHTML=[
['提问总数',d.total_questions||0,'近 '+d.hours+'h 用户提问'],
['会话数',d.total_sessions||0,'涉及 '+(d.total_users||0)+' 位用户'],
['消息总数',d.total_messages||0,'包含用户与助手消息'],
['热门意图',((d.top_intents&&d.top_intents[0]&&d.top_intents[0].intent)||'--'),'当前最常见']
].map(function(x){return '<div class="summary-card"><span>'+esc(x[0])+'</span><b>'+esc(x[1])+'</b><div class="sub">'+esc(x[2])+'</div></div>';}).join('');
document.getElementById('sideStats').innerHTML=(d.top_intents||[]).slice(0,4).map(function(x){return '<div class="mini"><span>'+esc(x.intent)+'</span><b>'+esc(x.n)+'</b></div>';}).join('')||'<div class="mini"><span>统计</span><b>暂无数据</b></div>';
document.getElementById('topQuestions').innerHTML=(d.recent_questions||[]).slice(0,8).map(function(x){return '<div class="question"><b>'+esc(shortText(x.content_text||'--',140))+'</b><div class="meta"><span>'+esc(fmtDate(x.created_at))+'</span><span>'+esc(x.user_email||'--')+'</span><span>'+esc(x.intent||'unknown')+'</span></div></div>';}).join('')||'<div class="empty">暂无问题</div>';
document.getElementById('topSymbols').innerHTML=(d.top_symbols||[]).map(function(x){return '<div class="topic"><b>'+esc(x.symbol)+'</b><span>'+esc(x.n)+' 次</span></div>';}).join('')||'<div class="empty">暂无币种</div>';
}catch(e){
document.getElementById('summaryGrid').innerHTML='<div class="summary-card"><span>统计</span><b>加载失败</b></div>';
}
}
async function loadQuestions(offset){
OFFSET=offset;
var q=document.getElementById('search').value.trim();
var intent=document.getElementById('intent').value;
var hours=document.getElementById('hours').value;
document.getElementById('table').innerHTML='<tr><td colspan="6" class="empty">加载中...</td></tr>';
try{
var url=API+'/api/admin/chat-logs?search='+encodeURIComponent(q)+'&intent='+encodeURIComponent(intent)+'&hours='+encodeURIComponent(hours)+'&offset='+offset+'&limit='+PAGE_SIZE;
var r=await fetch(url);if(!r.ok)throw new Error(r.status);
var d=await r.json();TOTAL=d.total||0;renderRows(d.items||[]);renderPagination();
loadOverview();
}catch(e){
document.getElementById('table').innerHTML='<tr><td colspan="6" class="empty" style="color:var(--red)">加载失败</td></tr>';
}
}
function renderRows(items){
var tb=document.getElementById('table');
if(!items.length){tb.innerHTML='<tr><td colspan="6" class="empty">暂无提问</td></tr>';return}
tb.innerHTML=items.map(function(x){
return '<tr>'+
'<td style="color:var(--stone);font-size:12px">'+fmtDate(x.created_at)+'</td>'+
'<td>'+esc(x.user_email||'--')+'</td>'+
'<td><span class="badge badge-blue">'+esc(x.intent||'unknown')+'</span></td>'+
'<td style="line-height:1.5;max-width:480px;white-space:normal">'+esc(shortText(x.content_text||'--',180))+'</td>'+
'<td>'+esc(x.symbol||'--')+'</td>'+
'<td style="color:var(--stone);font-size:12px">'+esc(shortText(x.session_title||('会话 #'+x.session_id),28))+'</td>'+
'</tr>';
}).join('');
}
function renderPagination(){
var pg=document.getElementById('pagination'),totalPages=Math.ceil(TOTAL/PAGE_SIZE),cur=Math.floor(OFFSET/PAGE_SIZE)+1;
pg.innerHTML='<button '+(OFFSET===0?'disabled':'')+' onclick="loadQuestions('+(OFFSET-PAGE_SIZE)+')">上一页</button>'+
'<span>第 '+cur+' / '+Math.max(1,totalPages)+' 页 · 共 '+TOTAL+' 条</span>'+
'<button '+((OFFSET+PAGE_SIZE>=TOTAL)?'disabled':'')+' onclick="loadQuestions('+(OFFSET+PAGE_SIZE)+')">下一页</button>';
}
function esc(s){return String(s||'').replace(/[&<>"]/g,function(c){return{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]})}
function shortText(s,n){s=String(s||'');return s.length>n?s.slice(0,n)+'…':s}
function fmtDate(ts){if(!ts)return'--';var d=new Date(ts);if(isNaN(d.getTime()))return String(ts).slice(0,19).replace('T',' ');return (d.getMonth()+1)+'/'+d.getDate()+' '+('0'+d.getHours()).slice(-2)+':'+('0'+d.getMinutes()).slice(-2)}
init();
</script>
{% endblock %}

View File

@ -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,

View File

@ -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()