feat: AI市场分析功能

- 后端:AI分析服务、工具调用、定时任务
- 前端:报告列表、详情页、Mermaid图表支持
- 支持OpenAI API兼容模型
- 收盘后自动分析生成报告
This commit is contained in:
Sakurasan
2026-09-01 04:21:29 +08:00
parent d7d019c2c4
commit 912a224b6b
17 changed files with 2525 additions and 4 deletions
+127
View File
@@ -0,0 +1,127 @@
"""AI 分析报告路由(/api/ai-analysis)
提供:
- 报告列表
- 报告详情
- 手动触发分析(12小时频率限制)
- 重新生成报告
- 检查某日是否有报告
"""
import json
from datetime import datetime, timezone, timedelta
from fastapi import APIRouter, HTTPException
from fastapi.responses import JSONResponse
from database import get_connection, dict_from_row
_CST = timezone(timedelta(hours=8))
_NO_CACHE_HEADERS = {"Cache-Control": "no-store, no-cache, must-revalidate, max-age=0"}
router = APIRouter()
def _has_ai_report(trade_date: str) -> bool:
conn = get_connection()
try:
row = conn.execute(
"SELECT 1 FROM ai_reports WHERE trade_date = ? AND report_type = 'daily' LIMIT 1",
(trade_date,)
).fetchone()
return row is not None
finally:
conn.close()
def _can_trigger(trade_date: str) -> tuple[bool, str]:
"""检查是否可以触发分析(12小时频率限制)"""
conn = get_connection()
try:
row = conn.execute(
"SELECT created_at FROM ai_reports WHERE trade_date = ? ORDER BY id DESC LIMIT 1",
(trade_date,)
).fetchone()
if row:
last_time = datetime.strptime(row["created_at"], "%Y-%m-%d %H:%M:%S")
now = datetime.now(_CST)
if (now - last_time).total_seconds() < 12 * 3600:
return False, "12小时内已触发过,请稍后再试"
return True, ""
finally:
conn.close()
@router.get("/ai-analysis", summary="AI 分析报告列表")
async def list_reports():
conn = get_connection()
try:
rows = conn.execute(
"SELECT id, trade_date, report_type, title, summary, tools_used, model, tokens_used, created_at "
"FROM ai_reports ORDER BY trade_date DESC, id DESC LIMIT 50"
).fetchall()
items = []
for r in rows:
item = dict_from_row(r)
item["toolsUsed"] = json.loads(item.pop("tools_used") or "[]")
items.append(item)
return JSONResponse({"data": items}, headers=_NO_CACHE_HEADERS)
finally:
conn.close()
@router.get("/ai-analysis/{report_id}", summary="AI 分析报告详情")
async def get_report(report_id: int):
conn = get_connection()
try:
row = conn.execute("SELECT * FROM ai_reports WHERE id = ?", (report_id,)).fetchone()
if not row:
raise HTTPException(status_code=404, detail="报告不存在")
item = dict_from_row(row)
item["toolsUsed"] = json.loads(item.pop("tools_used") or "[]")
return JSONResponse({"data": item}, headers=_NO_CACHE_HEADERS)
finally:
conn.close()
@router.get("/ai-analysis/check/{trade_date}", summary="检查某日是否有 AI 分析报告")
async def check_report(trade_date: str):
has = _has_ai_report(trade_date)
return JSONResponse({"data": {"hasReport": has, "tradeDate": trade_date}})
@router.post("/ai-analysis/trigger", summary="手动触发今日 AI 分析")
async def trigger_analysis():
now = datetime.now(_CST)
trade_date = now.strftime("%Y-%m-%d")
if _has_ai_report(trade_date):
raise HTTPException(status_code=400, detail="今日已有分析报告")
can, msg = _can_trigger(trade_date)
if not can:
raise HTTPException(status_code=429, detail=msg)
from services.ai_service import collect_ai_analysis
result = await collect_ai_analysis(trade_date)
return JSONResponse({"data": result})
@router.post("/ai-analysis/{report_id}/regenerate", summary="重新生成 AI 分析报告")
async def regenerate_report(report_id: int):
conn = get_connection()
try:
row = conn.execute("SELECT trade_date FROM ai_reports WHERE id = ?", (report_id,)).fetchone()
if not row:
raise HTTPException(status_code=404, detail="报告不存在")
trade_date = row["trade_date"]
finally:
conn.close()
can, msg = _can_trigger(trade_date)
if not can:
raise HTTPException(status_code=429, detail=msg)
from services.ai_service import collect_ai_analysis
result = await collect_ai_analysis(trade_date)
return JSONResponse({"data": result})