Signed-off-by: TIANHE <TIANHE@GMAIL.COM>
This commit is contained in:
TIANHE
2026-03-01 17:20:37 +08:00
parent d60409f7f8
commit db91fa4580
53 changed files with 1596 additions and 611 deletions
+1 -1
View File
@@ -57,7 +57,7 @@ def _extract_indicator_meta_from_code(code: str) -> Dict[str, str]:
def _row_to_indicator(row: Dict[str, Any], user_id: int) -> Dict[str, Any]:
"""
Map SQLite row -> frontend expected indicator shape.
Map database row -> frontend expected indicator shape.
Frontend uses:
- id, name, description, code
+254 -375
View File
@@ -1,15 +1,15 @@
"""
Polymarket预测市场API路由
提供预测市场数据和分析接口(只读,不涉及交易)
提供按需分析接口(只读,不涉及交易)
"""
from flask import Blueprint, jsonify, request, g
from app.utils.auth import login_required
from app.utils.logger import get_logger
from app.data_sources.polymarket import PolymarketDataSource
from app.utils.db import get_db_connection
from app.data_sources.polymarket import PolymarketDataSource
import re
import json
from datetime import datetime, timedelta
logger = get_logger(__name__)
@@ -19,286 +19,208 @@ polymarket_bp = Blueprint('polymarket', __name__)
polymarket_source = PolymarketDataSource()
@polymarket_bp.route("/markets", methods=["GET"])
@polymarket_bp.route("/analyze", methods=["POST"])
@login_required
def get_markets():
def analyze_polymarket():
"""
获取预测市场列表
分析Polymarket预测市场(用户输入链接或标题)
Query params:
category: crypto/politics/economics/sports (optional)
sort_by: volume_24h/ai_score/probability_change (default: volume_24h)
limit: 数量 (default: 20)
POST /api/polymarket/analyze
Body: {
"input": "https://polymarket.com/event/xxx""市场标题",
"language": "zh-CN" (optional)
}
流程:
1. 从输入中解析market_id或slug
2. 从API获取市场数据
3. 检查计费并扣除积分
4. 调用AI分析
5. 返回分析结果
"""
try:
category = request.args.get("category")
sort_by = request.args.get("sort_by", "volume_24h")
limit = int(request.args.get("limit", 20))
from app.services.billing_service import BillingService
from app.services.polymarket_analyzer import PolymarketAnalyzer
from decimal import Decimal
# 获取市场列表
markets = polymarket_source.get_trending_markets(category, limit * 2)
logger.info(f"Fetched {len(markets)} markets from PolymarketDataSource (category={category}, limit={limit})")
if not markets:
logger.warning(f"No markets returned from PolymarketDataSource. This may indicate API issues or empty cache.")
user_id = getattr(g, 'user_id', None)
if not user_id:
return jsonify({
"code": 1,
"msg": "success",
"data": [],
"warning": "No markets available. API may be unavailable or cache is empty."
})
"code": 0,
"msg": "User not authenticated",
"data": None
}), 401
# 从数据库读取缓存的AI分析结果(由后台任务批量分析生成)
# 只读取30分钟内的分析结果
try:
with get_db_connection() as db:
cur = db.cursor()
market_ids = [m.get('market_id') for m in markets if m.get('market_id')]
if market_ids:
# 查询30分钟内的分析结果
from datetime import datetime, timedelta
cache_cutoff = datetime.now() - timedelta(minutes=30)
placeholders = ','.join(['%s'] * len(market_ids))
cur.execute(f"""
SELECT market_id, ai_predicted_probability, market_probability, divergence,
recommendation, confidence_score, opportunity_score, reasoning, key_factors
FROM qd_polymarket_ai_analysis
WHERE market_id IN ({placeholders})
AND user_id IS NULL
AND created_at > %s
ORDER BY opportunity_score DESC
""", market_ids + [cache_cutoff])
rows = cur.fetchall()
cur.close()
# 构建分析结果映射
analysis_map = {}
for row in rows:
market_id = row.get('market_id')
if market_id:
key_factors_raw = row.get('key_factors')
key_factors = []
if key_factors_raw:
try:
if isinstance(key_factors_raw, str):
key_factors = json.loads(key_factors_raw)
else:
key_factors = key_factors_raw if isinstance(key_factors_raw, list) else []
except:
key_factors = []
analysis_map[market_id] = {
'predicted_probability': float(row.get('ai_predicted_probability') or 0),
'recommendation': row.get('recommendation') or 'HOLD',
'confidence_score': float(row.get('confidence_score') or 0),
'opportunity_score': float(row.get('opportunity_score') or 0),
'divergence': float(row.get('divergence') or 0),
'reasoning': row.get('reasoning') or '',
'key_factors': key_factors
}
# 为每个市场添加分析结果
for market in markets:
market_id = market.get('market_id')
if market_id and market_id in analysis_map:
market['ai_analysis'] = analysis_map[market_id]
else:
market['ai_analysis'] = None
data = request.get_json() or {}
input_text = (data.get('input') or '').strip()
language = data.get('language', 'zh-CN')
if not input_text:
return jsonify({
"code": 0,
"msg": "Input is required (Polymarket URL or market title)",
"data": None
}), 400
# 1. 解析market_id或slug
market_id = None
slug = None
# 尝试从URL中提取
url_patterns = [
r'polymarket\.com/event/([^/?]+)',
r'polymarket\.com/markets/(\d+)',
r'polymarket\.com/market/(\d+)',
]
for pattern in url_patterns:
match = re.search(pattern, input_text)
if match:
extracted = match.group(1)
# 如果是数字,是market_id;否则是slug
if extracted.isdigit():
market_id = extracted
else:
# 如果没有市场ID,设置分析为None
for market in markets:
market['ai_analysis'] = None
except Exception as e:
logger.warning(f"Failed to load cached analysis: {e}")
# 出错时,所有市场都没有分析结果
for market in markets:
market['ai_analysis'] = None
slug = extracted
break
# 筛选:优先返回有交易机会的市场,但也包含其他活跃市场
# 重点:不是简单复制数据,而是找到交易机会,但也要保证有足够的数据展示
opportunity_markets = []
other_markets = []
# 如果没有从URL提取到,尝试搜索市场
if not market_id and not slug:
# 尝试通过标题搜索
logger.info(f"Searching for market by title: {input_text[:100]}")
search_results = polymarket_source.search_markets(input_text, limit=5)
if search_results:
# 使用第一个搜索结果
market_id = search_results[0].get('market_id')
logger.info(f"Found market via search: {market_id}")
for market in markets:
ai_analysis = market.get('ai_analysis')
volume = market.get('volume_24h', 0) or 0
prob = market.get('current_probability', 50.0) or 50.0
if not market_id and not slug:
return jsonify({
"code": 0,
"msg": "Could not parse market ID or slug from input. Please provide a valid Polymarket URL or market title.",
"data": None
}), 400
# 2. 获取市场数据
if market_id:
market = polymarket_source.get_market_details(market_id)
elif slug:
# 通过slug查找市场(需要先搜索)
search_results = polymarket_source.search_markets(slug, limit=10)
market = None
for result in search_results:
if result.get('slug') == slug or slug in (result.get('question') or ''):
market = result
market_id = result.get('market_id')
break
if ai_analysis:
opportunity_score = ai_analysis.get('opportunity_score', 0) or 0
divergence = abs(ai_analysis.get('divergence', 0) or 0)
confidence = ai_analysis.get('confidence_score', 0) or 0
# 筛选条件:机会评分>60 或 (差异>15% 且 置信度>70) 或 (差异>10% 且 置信度>60)
if opportunity_score > 60 or (divergence > 15 and confidence > 70) or (divergence > 10 and confidence > 60):
opportunity_markets.append(market)
elif volume > 5000: # 交易量较大的也作为备选
other_markets.append(market)
else:
# 如果没有AI分析,但交易量大且概率不是50%(说明有明确的市场共识),也包含
if volume > 5000 and abs(prob - 50.0) > 5: # 降低阈值,包含更多市场
other_markets.append(market)
if not market and search_results:
# 使用第一个搜索结果
market = search_results[0]
market_id = market.get('market_id')
# 合并结果:优先显示机会市场,然后补充其他活跃市场
if opportunity_markets:
# 如果有机会市场,优先显示它们,然后补充其他市场直到达到limit
result_markets = opportunity_markets[:limit]
remaining = limit - len(result_markets)
if remaining > 0 and other_markets:
result_markets.extend(other_markets[:remaining])
opportunity_markets = result_markets
elif other_markets:
# 如果没有机会市场,至少返回高交易量的市场
opportunity_markets = other_markets[:limit]
else:
# 如果都没有,返回原始市场列表的前几个
opportunity_markets = markets[:min(limit, len(markets))]
# 排序和筛选
# 辅助函数:安全获取 ai_analysis 数据(处理 None 情况)
def safe_get_ai_analysis(market, key, default=0):
ai_analysis = market.get('ai_analysis')
if ai_analysis is None:
return default
return ai_analysis.get(key, default) or default
if sort_by == "ai_score":
# 按AI机会评分排序(高概率/高回报比优先)
opportunity_markets.sort(
key=lambda x: (
safe_get_ai_analysis(x, 'opportunity_score', 0),
abs(safe_get_ai_analysis(x, 'divergence', 0)), # 差异越大越好
safe_get_ai_analysis(x, 'confidence_score', 0) # 置信度越高越好
),
reverse=True
)
elif sort_by == "high_probability":
# 高概率机会:AI预测概率 > 市场概率 + 10%
opportunity_markets.sort(
key=lambda x: (
safe_get_ai_analysis(x, 'ai_predicted_probability', 0),
safe_get_ai_analysis(x, 'confidence_score', 0)
),
reverse=True
)
elif sort_by == "high_return":
# 高回报比机会:AI与市场差异大且置信度高
opportunity_markets.sort(
key=lambda x: (
abs(safe_get_ai_analysis(x, 'divergence', 0)) *
safe_get_ai_analysis(x, 'confidence_score', 0) / 100,
safe_get_ai_analysis(x, 'opportunity_score', 0)
),
reverse=True
)
elif sort_by == "probability_change":
# 需要历史数据,暂时按volume排序
opportunity_markets.sort(key=lambda x: x.get('volume_24h', 0), reverse=True)
else:
opportunity_markets.sort(key=lambda x: x.get('volume_24h', 0), reverse=True)
return jsonify({
"code": 1,
"msg": "success",
"data": opportunity_markets[:limit],
"total_opportunities": len(opportunity_markets),
"total_markets": len(markets)
})
except Exception as e:
logger.error(f"get_markets failed: {e}", exc_info=True)
return jsonify({
"code": 0,
"msg": str(e),
"data": None
}), 500
@polymarket_bp.route("/markets/<market_id>", methods=["GET"])
@login_required
def get_market_detail(market_id: str):
"""
获取单个市场详情和AI分析
支持通过market ID或slug查询
"""
try:
# 确保market_id是字符串
market_id = str(market_id).strip()
# 获取市场数据
market = polymarket_source.get_market_details(market_id)
if not market:
return jsonify({
"code": 0,
"msg": "Market not found",
"msg": "Market not found. Please check the URL or title.",
"data": None
}), 404
# 从数据库读取缓存的AI分析结果(30分钟内)
analysis = None
try:
with get_db_connection() as db:
cur = db.cursor()
cache_cutoff = datetime.now() - timedelta(minutes=30)
cur.execute("""
SELECT ai_predicted_probability, market_probability, divergence,
recommendation, confidence_score, opportunity_score,
reasoning, key_factors, related_assets, created_at
FROM qd_polymarket_ai_analysis
WHERE market_id = %s AND user_id IS NULL AND created_at > %s
ORDER BY created_at DESC LIMIT 1
""", (market_id, cache_cutoff))
row = cur.fetchone()
cur.close()
if row:
key_factors_raw = row.get('key_factors')
key_factors = []
if key_factors_raw:
try:
if isinstance(key_factors_raw, str):
key_factors = json.loads(key_factors_raw)
else:
key_factors = key_factors_raw if isinstance(key_factors_raw, list) else []
except:
key_factors = []
analysis = {
"ai_predicted_probability": float(row.get('ai_predicted_probability') or 0),
"market_probability": float(row.get('market_probability') or 0),
"divergence": float(row.get('divergence') or 0),
"recommendation": row.get('recommendation') or 'HOLD',
"confidence_score": float(row.get('confidence_score') or 0),
"opportunity_score": float(row.get('opportunity_score') or 0),
"reasoning": row.get('reasoning') or '',
"key_factors": key_factors,
"related_assets": row.get('related_assets') if row.get('related_assets') else []
}
except Exception as e:
logger.warning(f"Failed to load cached analysis for market {market_id}: {e}")
if not market_id:
market_id = market.get('market_id')
# 资产交易机会(暂时返回空,可以后续实现)
asset_opportunities = []
if not market_id:
return jsonify({
"code": 0,
"msg": "Invalid market data",
"data": None
}), 400
# 3. 检查计费
billing = BillingService()
cost = 0
if billing.is_billing_enabled():
cost = billing.get_feature_cost('polymarket_deep_analysis')
if cost > 0:
user_credits = billing.get_user_credits(user_id)
if user_credits < Decimal(str(cost)):
return jsonify({
"code": 0,
"msg": "Insufficient credits",
"data": {
"required": cost,
"current": float(user_credits),
"shortage": float(Decimal(str(cost)) - user_credits)
}
}), 400
# 扣除积分(使用check_and_consume方法,它会自动从配置中获取成本)
success, error_msg = billing.check_and_consume(
user_id=user_id,
feature='polymarket_deep_analysis',
reference_id=f"polymarket_{market_id}"
)
if not success:
# 检查是否是积分不足的错误
if error_msg.startswith('insufficient_credits'):
parts = error_msg.split(':')
if len(parts) >= 3:
current_credits = parts[1]
required_credits = parts[2]
return jsonify({
"code": 0,
"msg": "Insufficient credits",
"data": {
"required": float(required_credits),
"current": float(current_credits),
"shortage": float(Decimal(required_credits) - Decimal(current_credits))
}
}), 400
return jsonify({
"code": 0,
"msg": f"Failed to deduct credits: {error_msg}",
"data": None
}), 500
# 4. 执行AI分析(传递语言和模型参数)
analyzer = PolymarketAnalyzer()
model = request.get_json().get('model') # 可选:从请求中获取模型参数
analysis_result = analyzer.analyze_market(
market_id,
user_id=user_id,
use_cache=False,
language=language,
model=model
)
if analysis_result.get('error'):
return jsonify({
"code": 0,
"msg": analysis_result.get('error', 'Analysis failed'),
"data": None
}), 500
# 5. 获取剩余积分
remaining_credits = 0
if billing.is_billing_enabled():
remaining_credits = float(billing.get_user_credits(user_id))
return jsonify({
"code": 1,
"msg": "success",
"data": {
"market": market,
"ai_analysis": analysis,
"asset_opportunities": asset_opportunities
"analysis": analysis_result,
"credits_charged": cost,
"remaining_credits": remaining_credits
}
})
except Exception as e:
logger.error(f"get_market_detail failed: {e}", exc_info=True)
logger.error(f"Polymarket analyze API failed: {e}", exc_info=True)
return jsonify({
"code": 0,
"msg": str(e),
@@ -306,142 +228,99 @@ def get_market_detail(market_id: str):
}), 500
@polymarket_bp.route("/markets/<market_id>/opportunities", methods=["GET"])
@polymarket_bp.route("/history", methods=["GET"])
@login_required
def get_market_opportunities(market_id: str):
"""获取基于该预测市场的资产交易机会(暂时返回空,后续可扩展)"""
try:
# 暂时返回空列表,可以后续实现基于预测市场的资产推荐
opportunities = []
return jsonify({
"code": 1,
"msg": "success",
"data": opportunities
})
except Exception as e:
logger.error(f"get_market_opportunities failed: {e}", exc_info=True)
return jsonify({
"code": 0,
"msg": str(e),
"data": None
}), 500
@polymarket_bp.route("/recommendations", methods=["GET"])
@login_required
def get_recommendations():
def get_polymarket_history():
"""
获取AI推荐的高价值预测市场
Get user's Polymarket analysis history.
Query params:
limit: 数量 (default: 10)
GET /api/polymarket/history?page=1&page_size=20
"""
try:
limit = int(request.args.get("limit", 10))
user_id = g.user_id
page = request.args.get('page', 1, type=int)
page_size = min(request.args.get('page_size', 20, type=int), 100)
offset = (page - 1) * page_size
# 获取所有活跃市场
all_markets = polymarket_source.get_trending_markets(limit=100)
with get_db_connection() as db:
cur = db.cursor()
# 获取总数
cur.execute("""
SELECT COUNT(*) AS total
FROM qd_analysis_tasks
WHERE user_id = %s AND market = 'Polymarket'
""", (user_id,))
total_row = cur.fetchone()
total = total_row['total'] if total_row else 0
# 获取历史记录
cur.execute("""
SELECT
t.id,
t.symbol AS market_id,
t.model,
t.language,
t.status,
t.created_at,
t.completed_at,
t.result_json
FROM qd_analysis_tasks t
WHERE t.user_id = %s AND t.market = 'Polymarket'
ORDER BY t.created_at DESC
LIMIT %s OFFSET %s
""", (user_id, page_size, offset))
rows = cur.fetchall() or []
cur.close()
# 从数据库读取缓存的AI分析结果(30分钟内,按机会评分排序)
recommendations = []
try:
with get_db_connection() as db:
cur = db.cursor()
cache_cutoff = datetime.now() - timedelta(minutes=30)
market_ids = [m.get('market_id') for m in all_markets if m.get('market_id')]
if market_ids:
placeholders = ','.join(['%s'] * len(market_ids))
cur.execute(f"""
SELECT a.market_id, a.opportunity_score, a.recommendation,
a.confidence_score, a.reasoning, a.key_factors,
m.question, m.current_probability, m.volume_24h, m.category
FROM qd_polymarket_ai_analysis a
JOIN qd_polymarket_markets m ON a.market_id = m.market_id
WHERE a.market_id IN ({placeholders})
AND a.user_id IS NULL
AND a.created_at > %s
AND a.opportunity_score > 70
ORDER BY a.opportunity_score DESC
LIMIT %s
""", market_ids + [cache_cutoff, limit])
rows = cur.fetchall()
cur.close()
for row in rows:
key_factors_raw = row.get('key_factors')
key_factors = []
if key_factors_raw:
try:
if isinstance(key_factors_raw, str):
key_factors = json.loads(key_factors_raw)
else:
key_factors = key_factors_raw if isinstance(key_factors_raw, list) else []
except:
key_factors = []
recommendations.append({
"market_id": row.get('market_id'),
"question": row.get('question'),
"current_probability": float(row.get('current_probability') or 0),
"volume_24h": float(row.get('volume_24h') or 0),
"category": row.get('category'),
"ai_analysis": {
"opportunity_score": float(row.get('opportunity_score') or 0),
"recommendation": row.get('recommendation') or 'HOLD',
"confidence_score": float(row.get('confidence_score') or 0),
"reasoning": row.get('reasoning') or '',
"key_factors": key_factors
}
})
except Exception as e:
logger.warning(f"Failed to load recommendations: {e}")
# 如果没有缓存结果,返回空列表(等待后台任务分析)
# 解析结果
items = []
for row in rows:
result_json = row.get('result_json', '{}')
try:
result_data = json.loads(result_json) if result_json else {}
except:
result_data = {}
market_data = result_data.get('market', {})
analysis_data = result_data.get('analysis', {})
created_at = row.get('created_at')
completed_at = row.get('completed_at')
if created_at and hasattr(created_at, 'isoformat'):
created_at = created_at.isoformat()
if completed_at and hasattr(completed_at, 'isoformat'):
completed_at = completed_at.isoformat()
items.append({
'id': row.get('id'),
'market_id': row.get('market_id'),
'market_title': market_data.get('question') or market_data.get('title') or f"Market {row.get('market_id')}",
'market_url': market_data.get('polymarket_url'),
'ai_predicted_probability': analysis_data.get('ai_predicted_probability'),
'market_probability': analysis_data.get('market_probability'),
'recommendation': analysis_data.get('recommendation'),
'opportunity_score': analysis_data.get('opportunity_score'),
'confidence_score': analysis_data.get('confidence_score'),
'status': row.get('status'),
'created_at': created_at,
'completed_at': completed_at
})
return jsonify({
"code": 1,
"msg": "success",
"data": recommendations[:limit]
"data": {
"items": items,
"total": total,
"page": page,
"page_size": page_size,
"total_pages": (total + page_size - 1) // page_size
}
})
except Exception as e:
logger.error(f"get_recommendations failed: {e}", exc_info=True)
return jsonify({
"code": 0,
"msg": str(e),
"data": None
}), 500
@polymarket_bp.route("/search", methods=["GET"])
@login_required
def search_markets():
"""搜索预测市场"""
try:
keyword = request.args.get("q", "").strip()
if not keyword:
return jsonify({
"code": 0,
"msg": "Keyword required",
"data": None
}), 400
limit = int(request.args.get("limit", 20))
markets = polymarket_source.search_markets(keyword, limit)
return jsonify({
"code": 1,
"msg": "success",
"data": markets
})
except Exception as e:
logger.error(f"search_markets failed: {e}", exc_info=True)
logger.error(f"Get Polymarket history failed: {e}", exc_info=True)
return jsonify({
"code": 0,
"msg": str(e),
+76 -47
View File
@@ -1334,32 +1334,29 @@ def get_admin_ai_stats():
memory_summary = {}
# --- Per-user stats ---
user_conditions = []
# Build WHERE clause for user search (applied after JOIN)
user_where_clause = ""
user_params = []
if search:
user_conditions.append("(u.username ILIKE ? OR u.nickname ILIKE ? OR u.email ILIKE ?)")
like_val = f"%{search}%"
user_params.extend([like_val, like_val, like_val])
user_where_clause = "WHERE (u.username ILIKE ? OR u.nickname ILIKE ? OR u.email ILIKE ?)"
like_val = f"%{search.strip()}%"
user_params = [like_val, like_val, like_val]
user_where = ""
if user_conditions:
user_where = "WHERE " + " AND ".join(user_conditions)
# Count distinct users who have analysis records
cur.execute(
f"""
# Count distinct users who have analysis records (matching search criteria)
count_sql = f"""
SELECT COUNT(DISTINCT t.user_id) AS cnt
FROM qd_analysis_tasks t
LEFT JOIN qd_users u ON u.id = t.user_id
{user_where}
""",
tuple(user_params)
)
user_total = cur.fetchone()['cnt']
{user_where_clause}
"""
cur.execute(count_sql, tuple(user_params))
count_result = cur.fetchone()
user_total = count_result['cnt'] if count_result else 0
# Get per-user aggregated stats
cur.execute(
f"""
# Important: Filter by user search criteria AFTER grouping, but we need to apply it in WHERE
# Since we're grouping by user fields, we need to filter before GROUP BY
stats_sql = f"""
SELECT
t.user_id,
u.username,
@@ -1372,13 +1369,12 @@ def get_admin_ai_stats():
MIN(t.created_at) AS first_analysis_at
FROM qd_analysis_tasks t
LEFT JOIN qd_users u ON u.id = t.user_id
{user_where}
{user_where_clause}
GROUP BY t.user_id, u.username, u.nickname, u.email
ORDER BY analysis_count DESC
LIMIT ? OFFSET ?
""",
tuple(user_params) + (page_size, offset)
)
"""
cur.execute(stats_sql, tuple(user_params) + (page_size, offset))
user_rows = cur.fetchall() or []
# Get per-user analysis_memory stats (correct/helpful counts)
@@ -1417,13 +1413,15 @@ def get_admin_ai_stats():
memory_stats_map = {}
# Get recent analysis records (last 50)
# Ensure we get user info even if user_id is NULL or user doesn't exist
cur.execute(
"""
SELECT
t.id,
t.user_id,
u.username,
u.nickname,
COALESCE(u.username, '') AS username,
COALESCE(u.nickname, '') AS nickname,
COALESCE(u.email, '') AS email,
t.market,
t.symbol,
t.model,
@@ -1432,6 +1430,7 @@ def get_admin_ai_stats():
t.completed_at
FROM qd_analysis_tasks t
LEFT JOIN qd_users u ON u.id = t.user_id
WHERE t.user_id IS NOT NULL
ORDER BY t.created_at DESC
LIMIT 50
"""
@@ -1444,26 +1443,40 @@ def get_admin_ai_stats():
user_items = []
for row in user_rows:
uid = row.get('user_id')
if not uid: # Skip rows with NULL user_id
continue
ms = memory_stats_map.get(uid, {})
last_at = row.get('last_analysis_at')
first_at = row.get('first_analysis_at')
if hasattr(last_at, 'isoformat'):
# Convert datetime to ISO format string if needed
if last_at and hasattr(last_at, 'isoformat'):
last_at = last_at.isoformat()
if hasattr(first_at, 'isoformat'):
elif last_at:
last_at = str(last_at)
else:
last_at = None
if first_at and hasattr(first_at, 'isoformat'):
first_at = first_at.isoformat()
elif first_at:
first_at = str(first_at)
else:
first_at = None
user_items.append({
'user_id': uid,
'username': row.get('username') or '',
'nickname': row.get('nickname') or '',
'email': row.get('email') or '',
'analysis_count': row.get('analysis_count') or 0,
'symbol_count': row.get('symbol_count') or 0,
'market_count': row.get('market_count') or 0,
'correct': ms.get('correct', 0),
'incorrect': ms.get('incorrect', 0),
'helpful': ms.get('helpful', 0),
'not_helpful': ms.get('not_helpful', 0),
'user_id': int(uid),
'username': str(row.get('username') or ''),
'nickname': str(row.get('nickname') or ''),
'email': str(row.get('email') or ''),
'analysis_count': int(row.get('analysis_count') or 0),
'symbol_count': int(row.get('symbol_count') or 0),
'market_count': int(row.get('market_count') or 0),
'correct': int(ms.get('correct', 0)),
'incorrect': int(ms.get('incorrect', 0)),
'helpful': int(ms.get('helpful', 0)),
'not_helpful': int(ms.get('not_helpful', 0)),
'last_analysis_at': last_at,
'first_analysis_at': first_at
})
@@ -1471,22 +1484,38 @@ def get_admin_ai_stats():
# Build recent records
recent_items = []
for row in recent_rows:
user_id = row.get('user_id')
if not user_id: # Skip rows with NULL user_id
continue
created_at = row.get('created_at')
completed_at = row.get('completed_at')
if hasattr(created_at, 'isoformat'):
# Convert datetime to ISO format string if needed
if created_at and hasattr(created_at, 'isoformat'):
created_at = created_at.isoformat()
if hasattr(completed_at, 'isoformat'):
elif created_at:
created_at = str(created_at)
else:
created_at = None
if completed_at and hasattr(completed_at, 'isoformat'):
completed_at = completed_at.isoformat()
elif completed_at:
completed_at = str(completed_at)
else:
completed_at = None
recent_items.append({
'id': row['id'],
'user_id': row.get('user_id'),
'username': row.get('username') or '',
'nickname': row.get('nickname') or '',
'market': row.get('market') or '',
'symbol': row.get('symbol') or '',
'model': row.get('model') or '',
'status': row.get('status') or '',
'id': int(row.get('id') or 0),
'user_id': int(user_id),
'username': str(row.get('username') or ''),
'nickname': str(row.get('nickname') or ''),
'email': str(row.get('email') or ''),
'market': str(row.get('market') or ''),
'symbol': str(row.get('symbol') or ''),
'model': str(row.get('model') or ''),
'status': str(row.get('status') or ''),
'created_at': created_at,
'completed_at': completed_at
})