update
This commit is contained in:
parent
a53d8dc9f9
commit
a1031a8e06
@ -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",
|
||||
|
||||
@ -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"],
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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&¤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 '<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
179
static/chat_logs.html
Normal 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{'&':'&','<':'<','>':'>','"':'"'}[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 %}
|
||||
@ -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,
|
||||
|
||||
34
tests/test_chat_logs_admin.py
Normal file
34
tests/test_chat_logs_admin.py
Normal 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()
|
||||
Loading…
Reference in New Issue
Block a user