1
This commit is contained in:
parent
ea9fa6e5ff
commit
c03d5a88e8
@ -1,4 +1,7 @@
|
|||||||
ASTOCK_TUSHARE_TOKEN=0ed6419a00d8923dc19c0b58fc92d94c9a0696949ab91a13aa58a0cc
|
ASTOCK_TUSHARE_TOKEN=0ed6419a00d8923dc19c0b58fc92d94c9a0696949ab91a13aa58a0cc
|
||||||
ASTOCK_DEBUG=true
|
ASTOCK_DEBUG=true
|
||||||
|
|
||||||
ASTOCK_DEEPSEEK_API_KEY=sk-9f6b56f08796435d988cf202e37f6ee3
|
ASTOCK_DEEPSEEK_API_KEY=sk-9f6b56f08796435d988cf202e37f6ee3
|
||||||
|
ASTOCK_ALERT_ENABLED=true
|
||||||
|
ASTOCK_FEISHU_WEBHOOK_URL=https://open.feishu.cn/open-apis/bot/v2/hook/6307668f-10aa-4fc1-8c1e-bad1b6b78d4d
|
||||||
|
ASTOCK_ALERT_ENVIRONMENT=local
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -20,7 +20,7 @@ from datetime import datetime
|
|||||||
from app.data.tushare_client import tushare_client
|
from app.data.tushare_client import tushare_client
|
||||||
from app.data import tencent_client
|
from app.data import tencent_client
|
||||||
from app.data.models import SectorInfo, Recommendation, MarketTemperature, StockQuote
|
from app.data.models import SectorInfo, Recommendation, MarketTemperature, StockQuote
|
||||||
from app.data.eastmoney_client import SECTOR_LIST_URL, SECTOR_HEADERS
|
from app.data.eastmoney_client import SECTOR_LIST_URL, SECTOR_HEADERS, _parse_eastmoney_json
|
||||||
from app.analysis.sector_scanner import scan_hot_sectors
|
from app.analysis.sector_scanner import scan_hot_sectors
|
||||||
from app.analysis.technical import add_all_indicators
|
from app.analysis.technical import add_all_indicators
|
||||||
from app.analysis.signals import generate_signals
|
from app.analysis.signals import generate_signals
|
||||||
@ -68,7 +68,7 @@ async def intraday_market_temperature(prev_temp: MarketTemperature) -> MarketTem
|
|||||||
("m:0+t:6,m:0+t:80,m:0+t:81+s:2048", 9.9), # 主板 10%
|
("m:0+t:6,m:0+t:80,m:0+t:81+s:2048", 9.9), # 主板 10%
|
||||||
("m:1+t:2,m:1+t:23", 19.9), # 创业板/科创板 20%
|
("m:1+t:2,m:1+t:23", 19.9), # 创业板/科创板 20%
|
||||||
]:
|
]:
|
||||||
async with httpx.AsyncClient() as client:
|
async with httpx.AsyncClient(follow_redirects=True) as client:
|
||||||
# 涨停:按涨幅降序取 top 200
|
# 涨停:按涨幅降序取 top 200
|
||||||
params_up = {
|
params_up = {
|
||||||
"pn": "1", "pz": "200", "po": "1", "np": "1",
|
"pn": "1", "pz": "200", "po": "1", "np": "1",
|
||||||
@ -77,7 +77,8 @@ async def intraday_market_temperature(prev_temp: MarketTemperature) -> MarketTem
|
|||||||
"fields": "f3,f12,f14",
|
"fields": "f3,f12,f14",
|
||||||
}
|
}
|
||||||
resp = await client.get(SECTOR_LIST_URL, params=params_up, headers=SECTOR_HEADERS, timeout=10)
|
resp = await client.get(SECTOR_LIST_URL, params=params_up, headers=SECTOR_HEADERS, timeout=10)
|
||||||
items = resp.json().get("data", {}).get("diff", []) if resp.json().get("data") else []
|
data_up = _parse_eastmoney_json(resp, "涨停统计")
|
||||||
|
items = data_up.get("data", {}).get("diff", []) if data_up.get("data") else []
|
||||||
for item in items:
|
for item in items:
|
||||||
pct = item.get("f3")
|
pct = item.get("f3")
|
||||||
if pct == "-" or pct is None:
|
if pct == "-" or pct is None:
|
||||||
@ -93,7 +94,8 @@ async def intraday_market_temperature(prev_temp: MarketTemperature) -> MarketTem
|
|||||||
"fields": "f3,f12,f14",
|
"fields": "f3,f12,f14",
|
||||||
}
|
}
|
||||||
resp_down = await client.get(SECTOR_LIST_URL, params=params_down, headers=SECTOR_HEADERS, timeout=10)
|
resp_down = await client.get(SECTOR_LIST_URL, params=params_down, headers=SECTOR_HEADERS, timeout=10)
|
||||||
items_down = resp_down.json().get("data", {}).get("diff", []) if resp_down.json().get("data") else []
|
data_down = _parse_eastmoney_json(resp_down, "跌停统计")
|
||||||
|
items_down = data_down.get("data", {}).get("diff", []) if data_down.get("data") else []
|
||||||
neg_threshold = -threshold
|
neg_threshold = -threshold
|
||||||
for item in items_down:
|
for item in items_down:
|
||||||
pct = item.get("f3")
|
pct = item.get("f3")
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@ -5,12 +5,14 @@ POST /api/chat/stream - SSE 流式对话
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
|
import traceback
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends
|
from fastapi import APIRouter, Depends
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
from app.core.deps import get_current_user
|
from app.core.deps import get_current_user
|
||||||
|
from app.db.error_logger import log_error
|
||||||
from app.llm.chat_agent import chat_stream
|
from app.llm.chat_agent import chat_stream
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
@ -39,6 +41,12 @@ async def chat_stream_endpoint(req: ChatRequest, current_user: dict = Depends(ge
|
|||||||
yield "data: [DONE]\n\n"
|
yield "data: [DONE]\n\n"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Chat stream error: {e}")
|
logger.error(f"Chat stream error: {e}")
|
||||||
|
await log_error(
|
||||||
|
"chat",
|
||||||
|
f"Chat stream error: {e}",
|
||||||
|
detail=traceback.format_exc(),
|
||||||
|
context={"method": "POST", "path": "/api/chat/stream"},
|
||||||
|
)
|
||||||
error_data = json.dumps(
|
error_data = json.dumps(
|
||||||
{"type": "content", "content": f"出错了: {e}"},
|
{"type": "content", "content": f"出错了: {e}"},
|
||||||
ensure_ascii=False,
|
ensure_ascii=False,
|
||||||
|
|||||||
@ -304,6 +304,7 @@ async def get_diagnose_history(ts_code: str):
|
|||||||
return history
|
return history
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"获取诊断历史失败: {e}")
|
logger.error(f"获取诊断历史失败: {e}")
|
||||||
|
await log_error("stocks", f"获取诊断历史失败: {e}", detail=traceback.format_exc())
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
@ -648,6 +649,12 @@ async def diagnose_stock(ts_code: str, mode: str = Query("entry")):
|
|||||||
except Exception as e:
|
except Exception as e:
|
||||||
error_msg = str(e)
|
error_msg = str(e)
|
||||||
logger.error(f"诊断流式调用失败: {error_msg}")
|
logger.error(f"诊断流式调用失败: {error_msg}")
|
||||||
|
await log_error(
|
||||||
|
"stocks",
|
||||||
|
f"诊断流式调用失败: {error_msg}",
|
||||||
|
detail=traceback.format_exc(),
|
||||||
|
context={"method": "POST", "path": f"/api/stocks/{ts_code}/diagnose"},
|
||||||
|
)
|
||||||
yield f"data: {json.dumps({'error': error_msg}, ensure_ascii=False)}\n\n"
|
yield f"data: {json.dumps({'error': error_msg}, ensure_ascii=False)}\n\n"
|
||||||
yield f"data: {json.dumps({'done': True, 'ts_code': ts_code}, ensure_ascii=False)}\n\n"
|
yield f"data: {json.dumps({'done': True, 'ts_code': ts_code}, ensure_ascii=False)}\n\n"
|
||||||
|
|
||||||
|
|||||||
@ -59,6 +59,14 @@ class Settings(BaseSettings):
|
|||||||
llm_max_tokens: int = 2000
|
llm_max_tokens: int = 2000
|
||||||
llm_temperature: float = 0.3
|
llm_temperature: float = 0.3
|
||||||
|
|
||||||
|
# 告警(Feishu / Lark Incoming Webhook)
|
||||||
|
alert_enabled: bool = False
|
||||||
|
feishu_webhook_url: str = ""
|
||||||
|
alert_dedup_ttl_seconds: int = 300
|
||||||
|
alert_max_detail_chars: int = 1200
|
||||||
|
alert_app_name: str = "AStock Agent"
|
||||||
|
alert_environment: str = "local"
|
||||||
|
|
||||||
# 前端
|
# 前端
|
||||||
frontend_url: str = "http://localhost:3002"
|
frontend_url: str = "http://localhost:3002"
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@ -14,18 +14,19 @@ from datetime import datetime
|
|||||||
|
|
||||||
from app.data.cache import cache
|
from app.data.cache import cache
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
from app.db.error_logger import log_error
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# 东方财富接口
|
# 东方财富接口
|
||||||
EASTMONEY_KLINE_URL = "http://push2his.eastmoney.com/api/qt/stock/kline/get"
|
EASTMONEY_KLINE_URL = "https://push2his.eastmoney.com/api/qt/stock/kline/get"
|
||||||
SECTOR_LIST_URL = "http://push2.eastmoney.com/api/qt/clist/get"
|
SECTOR_LIST_URL = "https://push2.eastmoney.com/api/qt/clist/get"
|
||||||
HEADERS = {
|
HEADERS = {
|
||||||
"Referer": "http://finance.eastmoney.com",
|
"Referer": "https://finance.eastmoney.com",
|
||||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
||||||
}
|
}
|
||||||
SECTOR_HEADERS = {
|
SECTOR_HEADERS = {
|
||||||
"Referer": "http://data.eastmoney.com",
|
"Referer": "https://data.eastmoney.com",
|
||||||
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
"User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36",
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -95,8 +96,9 @@ async def get_sector_realtime_ranking(
|
|||||||
params=params,
|
params=params,
|
||||||
headers=SECTOR_HEADERS,
|
headers=SECTOR_HEADERS,
|
||||||
timeout=10,
|
timeout=10,
|
||||||
|
follow_redirects=True,
|
||||||
)
|
)
|
||||||
data = resp.json()
|
data = _parse_eastmoney_json(resp, "板块实时排名")
|
||||||
|
|
||||||
items = data.get("data", {}).get("diff", [])
|
items = data.get("data", {}).get("diff", [])
|
||||||
if not items:
|
if not items:
|
||||||
@ -130,6 +132,11 @@ async def get_sector_realtime_ranking(
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"东方财富板块实时排名获取失败: {e}")
|
logger.error(f"东方财富板块实时排名获取失败: {e}")
|
||||||
|
await log_error(
|
||||||
|
"eastmoney",
|
||||||
|
f"东方财富板块实时排名获取失败: {e}",
|
||||||
|
detail=f"fs={fs}, sort_by={sort_by}, page_size={page_size}",
|
||||||
|
)
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
|
||||||
@ -174,8 +181,9 @@ async def get_min_kline(
|
|||||||
params=params,
|
params=params,
|
||||||
headers=HEADERS,
|
headers=HEADERS,
|
||||||
timeout=10,
|
timeout=10,
|
||||||
|
follow_redirects=True,
|
||||||
)
|
)
|
||||||
data = resp.json()
|
data = _parse_eastmoney_json(resp, f"分钟K线 {ts_code}")
|
||||||
|
|
||||||
klines = data.get("data", {}).get("klines", [])
|
klines = data.get("data", {}).get("klines", [])
|
||||||
if not klines:
|
if not klines:
|
||||||
@ -209,9 +217,31 @@ async def get_min_kline(
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"东方财富分钟K线获取失败 {ts_code}: {e}")
|
logger.error(f"东方财富分钟K线获取失败 {ts_code}: {e}")
|
||||||
|
await log_error(
|
||||||
|
"eastmoney",
|
||||||
|
f"东方财富分钟K线获取失败 {ts_code}: {e}",
|
||||||
|
detail=f"period={period}, count={count}",
|
||||||
|
)
|
||||||
return pd.DataFrame()
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_eastmoney_json(resp: httpx.Response, label: str) -> dict:
|
||||||
|
"""解析东方财富 JSON 响应,遇到 302/HTML 等非 JSON 情况给出更清晰日志。"""
|
||||||
|
resp.raise_for_status()
|
||||||
|
content_type = resp.headers.get("content-type", "")
|
||||||
|
text_preview = (resp.text or "")[:160].replace("\n", " ").replace("\r", " ")
|
||||||
|
if "json" not in content_type.lower() and not resp.text.strip().startswith("{"):
|
||||||
|
raise ValueError(
|
||||||
|
f"{label} 返回非JSON响应(status={resp.status_code}, content_type={content_type}, body={text_preview})"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
return resp.json()
|
||||||
|
except Exception as e:
|
||||||
|
raise ValueError(
|
||||||
|
f"{label} JSON解析失败(status={resp.status_code}, content_type={content_type}, body={text_preview})"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
|
||||||
def analyze_intraday_volume_distribution(min_df: pd.DataFrame) -> dict:
|
def analyze_intraday_volume_distribution(min_df: pd.DataFrame) -> dict:
|
||||||
"""分析盘中量能分布(基于5分钟K线)
|
"""分析盘中量能分布(基于5分钟K线)
|
||||||
|
|
||||||
@ -300,4 +330,4 @@ def _is_trading_hours() -> bool:
|
|||||||
return True
|
return True
|
||||||
if (hour == 13) or (hour == 14) or (hour == 15 and minute == 0):
|
if (hour == 13) or (hour == 14) or (hour == 15 and minute == 0):
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import httpx
|
|||||||
from app.data.cache import cache
|
from app.data.cache import cache
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.data.models import StockQuote
|
from app.data.models import StockQuote
|
||||||
|
from app.db.error_logger import log_error
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -109,6 +110,7 @@ async def get_realtime_quote(ts_code: str) -> StockQuote | None:
|
|||||||
return quote
|
return quote
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"腾讯行情获取失败 {ts_code}: {e}")
|
logger.error(f"腾讯行情获取失败 {ts_code}: {e}")
|
||||||
|
await log_error("tencent", f"腾讯行情获取失败 {ts_code}: {e}")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@ -172,6 +174,11 @@ async def get_realtime_quotes_batch(ts_codes: list[str]) -> dict[str, StockQuote
|
|||||||
results[ts_code] = quote
|
results[ts_code] = quote
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"腾讯批量行情获取失败: {e}")
|
logger.error(f"腾讯批量行情获取失败: {e}")
|
||||||
|
await log_error(
|
||||||
|
"tencent",
|
||||||
|
f"腾讯批量行情获取失败: {e}",
|
||||||
|
detail=f"batch_size={len(batch)}",
|
||||||
|
)
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|
||||||
@ -217,5 +224,10 @@ async def get_index_realtime(index_codes: list[str] = None) -> dict[str, dict]:
|
|||||||
}
|
}
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"腾讯指数行情获取失败: {e}")
|
logger.error(f"腾讯指数行情获取失败: {e}")
|
||||||
|
await log_error(
|
||||||
|
"tencent",
|
||||||
|
f"腾讯指数行情获取失败: {e}",
|
||||||
|
detail=f"indices={','.join(index_codes)}",
|
||||||
|
)
|
||||||
|
|
||||||
return results
|
return results
|
||||||
|
|||||||
@ -11,6 +11,7 @@ from datetime import datetime, timedelta
|
|||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.data.cache import cache
|
from app.data.cache import cache
|
||||||
|
from app.db.error_logger import log_error_background
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -51,6 +52,10 @@ class TushareClient:
|
|||||||
time.sleep((2 ** attempt) * 1)
|
time.sleep((2 ** attempt) * 1)
|
||||||
else:
|
else:
|
||||||
logger.error(f"Tushare 请求最终失败: {e}")
|
logger.error(f"Tushare 请求最终失败: {e}")
|
||||||
|
log_error_background(
|
||||||
|
"tushare",
|
||||||
|
f"Tushare 请求最终失败: {e}",
|
||||||
|
)
|
||||||
return pd.DataFrame()
|
return pd.DataFrame()
|
||||||
return pd.DataFrame()
|
return pd.DataFrame()
|
||||||
|
|
||||||
|
|||||||
@ -1,13 +1,22 @@
|
|||||||
"""错误日志持久化"""
|
"""错误日志持久化"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
import traceback
|
import traceback
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from app.db.database import get_db
|
from app.db.database import get_db
|
||||||
from app.db import tables
|
from app.db import tables
|
||||||
|
from app.notifications.feishu import send_feishu_alert
|
||||||
|
|
||||||
|
|
||||||
async def log_error(source: str, message: str, detail: str = "", level: str = "error"):
|
async def log_error(
|
||||||
"""将错误写入数据库,失败时静默(不影响主流程)"""
|
source: str,
|
||||||
|
message: str,
|
||||||
|
detail: str = "",
|
||||||
|
level: str = "error",
|
||||||
|
context: dict | None = None,
|
||||||
|
notify: bool = True,
|
||||||
|
):
|
||||||
|
"""将错误写入数据库,并按策略发送告警。"""
|
||||||
try:
|
try:
|
||||||
async with get_db() as db:
|
async with get_db() as db:
|
||||||
stmt = tables.error_logs_table.insert().values(
|
stmt = tables.error_logs_table.insert().values(
|
||||||
@ -20,4 +29,41 @@ async def log_error(source: str, message: str, detail: str = "", level: str = "e
|
|||||||
await db.execute(stmt)
|
await db.execute(stmt)
|
||||||
await db.commit()
|
await db.commit()
|
||||||
except Exception:
|
except Exception:
|
||||||
pass # 写日志失败不应影响主业务
|
pass # 写日志失败不应影响主业务
|
||||||
|
|
||||||
|
if notify and level.lower() in {"error", "critical"}:
|
||||||
|
try:
|
||||||
|
await send_feishu_alert(
|
||||||
|
source=source,
|
||||||
|
message=message,
|
||||||
|
detail=detail,
|
||||||
|
level=level,
|
||||||
|
context=context,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def log_error_background(
|
||||||
|
source: str,
|
||||||
|
message: str,
|
||||||
|
detail: str = "",
|
||||||
|
level: str = "error",
|
||||||
|
context: dict | None = None,
|
||||||
|
notify: bool = True,
|
||||||
|
):
|
||||||
|
"""在存在事件循环时后台投递错误记录。"""
|
||||||
|
try:
|
||||||
|
loop = asyncio.get_running_loop()
|
||||||
|
loop.create_task(
|
||||||
|
log_error(
|
||||||
|
source=source,
|
||||||
|
message=message,
|
||||||
|
detail=detail,
|
||||||
|
level=level,
|
||||||
|
context=context,
|
||||||
|
notify=notify,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
except RuntimeError:
|
||||||
|
pass
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
@ -10,6 +10,7 @@ import logging
|
|||||||
import re
|
import re
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
from app.db.error_logger import log_error
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -62,6 +63,11 @@ async def prefilter_single_stock(candidate: dict, market_summary: str) -> dict:
|
|||||||
return _parse_prefilter_response(content)
|
return _parse_prefilter_response(content)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"LLM 预筛 {candidate.get('ts_code')} 失败: {e}")
|
logger.error(f"LLM 预筛 {candidate.get('ts_code')} 失败: {e}")
|
||||||
|
await log_error(
|
||||||
|
"llm_prefilter",
|
||||||
|
f"LLM 预筛 {candidate.get('ts_code')} 失败: {e}",
|
||||||
|
detail=f"candidate={candidate.get('ts_code')}|{candidate.get('name', '')}",
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"decision": "watch",
|
"decision": "watch",
|
||||||
"confidence": 5,
|
"confidence": 5,
|
||||||
@ -135,6 +141,11 @@ async def analyze_single_stock(candidate: dict, market_summary: str) -> dict:
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"LLM 分析 {candidate.get('ts_code')} 失败: {e}")
|
logger.error(f"LLM 分析 {candidate.get('ts_code')} 失败: {e}")
|
||||||
|
await log_error(
|
||||||
|
"llm_final",
|
||||||
|
f"LLM 分析 {candidate.get('ts_code')} 失败: {e}",
|
||||||
|
detail=f"candidate={candidate.get('ts_code')}|{candidate.get('name', '')}",
|
||||||
|
)
|
||||||
return {
|
return {
|
||||||
"verdict": "watch",
|
"verdict": "watch",
|
||||||
"action_plan": "重点关注",
|
"action_plan": "重点关注",
|
||||||
|
|||||||
@ -6,6 +6,7 @@
|
|||||||
import logging
|
import logging
|
||||||
from openai import AsyncOpenAI
|
from openai import AsyncOpenAI
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
from app.db.error_logger import log_error
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -53,6 +54,11 @@ async def chat_completion(
|
|||||||
return resp.choices[0].message
|
return resp.choices[0].message
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"LLM 调用失败: {e}")
|
logger.error(f"LLM 调用失败: {e}")
|
||||||
|
await log_error(
|
||||||
|
"llm",
|
||||||
|
f"LLM 调用失败: {e}",
|
||||||
|
detail=f"model={settings.deepseek_model}, tools={bool(tools)}",
|
||||||
|
)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@ -86,3 +92,8 @@ async def stream_chat_completion(
|
|||||||
yield chunk.choices[0].delta
|
yield chunk.choices[0].delta
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"LLM 流式调用失败: {e}")
|
logger.error(f"LLM 流式调用失败: {e}")
|
||||||
|
await log_error(
|
||||||
|
"llm",
|
||||||
|
f"LLM 流式调用失败: {e}",
|
||||||
|
detail=f"model={settings.deepseek_model}, tools={bool(tools)}",
|
||||||
|
)
|
||||||
|
|||||||
@ -7,6 +7,8 @@ import json
|
|||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
|
|
||||||
|
from app.db.error_logger import log_error
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_chat_user_context: dict | None = None
|
_chat_user_context: dict | None = None
|
||||||
@ -50,6 +52,11 @@ async def execute_tool(name: str, arguments: dict) -> str:
|
|||||||
return json.dumps({"error": f"未知工具: {name}"}, ensure_ascii=False)
|
return json.dumps({"error": f"未知工具: {name}"}, ensure_ascii=False)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"工具执行失败 {name}: {e}")
|
logger.error(f"工具执行失败 {name}: {e}")
|
||||||
|
await log_error(
|
||||||
|
"llm_tool",
|
||||||
|
f"工具执行失败 {name}: {e}",
|
||||||
|
detail=f"arguments={json.dumps(arguments, ensure_ascii=False, default=str)}",
|
||||||
|
)
|
||||||
return json.dumps({"error": str(e)}, ensure_ascii=False)
|
return json.dumps({"error": str(e)}, ensure_ascii=False)
|
||||||
|
|
||||||
|
|
||||||
@ -272,4 +279,5 @@ async def _get_realtime_indices() -> str:
|
|||||||
}, ensure_ascii=False, default=str)
|
}, ensure_ascii=False, default=str)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"获取实时指数失败: {e}")
|
logger.error(f"获取实时指数失败: {e}")
|
||||||
|
await log_error("llm_tool", f"获取实时指数失败: {e}")
|
||||||
return json.dumps({"error": f"获取指数数据失败: {e}"}, ensure_ascii=False)
|
return json.dumps({"error": f"获取指数数据失败: {e}"}, ensure_ascii=False)
|
||||||
|
|||||||
@ -1,11 +1,14 @@
|
|||||||
"""A 股分析推荐 Agent - FastAPI 入口"""
|
"""A 股分析推荐 Agent - FastAPI 入口"""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
|
import traceback
|
||||||
from contextlib import asynccontextmanager
|
from contextlib import asynccontextmanager
|
||||||
from fastapi import FastAPI
|
from fastapi import FastAPI, Request
|
||||||
|
from fastapi.responses import JSONResponse
|
||||||
from fastapi.middleware.cors import CORSMiddleware
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
|
from app.db.error_logger import log_error
|
||||||
from app.db.database import init_db
|
from app.db.database import init_db
|
||||||
from app.engine.scheduler import start_scheduler, stop_scheduler
|
from app.engine.scheduler import start_scheduler, stop_scheduler
|
||||||
from app.api import market, sectors, recommendations, stocks, watchlists, websocket, chat, auth, debug
|
from app.api import market, sectors, recommendations, stocks, watchlists, websocket, chat, auth, debug
|
||||||
@ -46,15 +49,25 @@ async def ensure_admin_exists():
|
|||||||
async def lifespan(app: FastAPI):
|
async def lifespan(app: FastAPI):
|
||||||
# 启动
|
# 启动
|
||||||
logger.info("A 股分析推荐 Agent 启动中...")
|
logger.info("A 股分析推荐 Agent 启动中...")
|
||||||
await init_db()
|
try:
|
||||||
logger.info("数据库初始化完成")
|
await init_db()
|
||||||
await ensure_admin_exists()
|
logger.info("数据库初始化完成")
|
||||||
start_scheduler()
|
await ensure_admin_exists()
|
||||||
logger.info("调度器已启动")
|
start_scheduler()
|
||||||
yield
|
logger.info("调度器已启动")
|
||||||
# 关闭
|
yield
|
||||||
stop_scheduler()
|
except Exception as e:
|
||||||
logger.info("服务已关闭")
|
logger.exception("应用生命周期异常")
|
||||||
|
await log_error(
|
||||||
|
"lifespan",
|
||||||
|
f"应用生命周期异常: {e}",
|
||||||
|
detail=traceback.format_exc(),
|
||||||
|
level="critical",
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
finally:
|
||||||
|
stop_scheduler()
|
||||||
|
logger.info("服务已关闭")
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
@ -87,6 +100,27 @@ app.include_router(debug.router)
|
|||||||
app.websocket("/ws")(websocket.ws_endpoint)
|
app.websocket("/ws")(websocket.ws_endpoint)
|
||||||
|
|
||||||
|
|
||||||
|
@app.exception_handler(Exception)
|
||||||
|
async def unhandled_exception_handler(request: Request, exc: Exception):
|
||||||
|
logger.exception("未处理的接口异常: %s %s", request.method, request.url.path)
|
||||||
|
query = str(request.url.query or "")
|
||||||
|
await log_error(
|
||||||
|
"asgi",
|
||||||
|
f"未处理的接口异常: {exc}",
|
||||||
|
detail=traceback.format_exc(),
|
||||||
|
level="error",
|
||||||
|
context={
|
||||||
|
"method": request.method,
|
||||||
|
"path": request.url.path,
|
||||||
|
"query": query,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return JSONResponse(
|
||||||
|
status_code=500,
|
||||||
|
content={"detail": "服务器内部错误"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health")
|
@app.get("/api/health")
|
||||||
async def health():
|
async def health():
|
||||||
return {
|
return {
|
||||||
|
|||||||
1
backend/app/notifications/__init__.py
Normal file
1
backend/app/notifications/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
"""通知模块"""
|
||||||
93
backend/app/notifications/feishu.py
Normal file
93
backend/app/notifications/feishu.py
Normal file
@ -0,0 +1,93 @@
|
|||||||
|
"""Feishu/Lark 告警发送"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.data.cache import cache
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def _build_signature(
|
||||||
|
source: str,
|
||||||
|
message: str,
|
||||||
|
level: str,
|
||||||
|
context: dict | None = None,
|
||||||
|
) -> str:
|
||||||
|
context = context or {}
|
||||||
|
basis = "|".join([
|
||||||
|
source,
|
||||||
|
level,
|
||||||
|
message.strip(),
|
||||||
|
str(context.get("method", "")),
|
||||||
|
str(context.get("path", "")),
|
||||||
|
])
|
||||||
|
return hashlib.sha1(basis.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def _truncate(text: str, limit: int) -> str:
|
||||||
|
text = (text or "").strip()
|
||||||
|
if len(text) <= limit:
|
||||||
|
return text
|
||||||
|
return f"{text[:limit]}..."
|
||||||
|
|
||||||
|
|
||||||
|
async def send_feishu_alert(
|
||||||
|
source: str,
|
||||||
|
message: str,
|
||||||
|
detail: str = "",
|
||||||
|
level: str = "error",
|
||||||
|
context: dict | None = None,
|
||||||
|
) -> bool:
|
||||||
|
"""发送 Feishu 告警,内置去重,失败不抛异常。"""
|
||||||
|
if not settings.alert_enabled or not settings.feishu_webhook_url:
|
||||||
|
return False
|
||||||
|
|
||||||
|
signature = _build_signature(source, message, level, context)
|
||||||
|
dedup_key = f"feishu_alert:{signature}"
|
||||||
|
if cache.get(dedup_key):
|
||||||
|
return False
|
||||||
|
|
||||||
|
cache.set(dedup_key, True, settings.alert_dedup_ttl_seconds)
|
||||||
|
now = datetime.now(ZoneInfo("Asia/Shanghai")).strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
context = context or {}
|
||||||
|
detail = _truncate(detail, settings.alert_max_detail_chars)
|
||||||
|
|
||||||
|
lines = [
|
||||||
|
f"[{settings.alert_app_name}] {level.upper()}",
|
||||||
|
f"环境: {settings.alert_environment}",
|
||||||
|
f"时间: {now}",
|
||||||
|
f"来源: {source}",
|
||||||
|
f"摘要: {message}",
|
||||||
|
]
|
||||||
|
if context.get("method") or context.get("path"):
|
||||||
|
lines.append(
|
||||||
|
f"请求: {context.get('method', '')} {context.get('path', '')}".strip()
|
||||||
|
)
|
||||||
|
if context.get("query"):
|
||||||
|
lines.append(f"Query: {context['query']}")
|
||||||
|
if detail:
|
||||||
|
lines.append(f"详情: {detail}")
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"msg_type": "text",
|
||||||
|
"content": {
|
||||||
|
"text": "\n".join(lines),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with httpx.AsyncClient(timeout=8, follow_redirects=True) as client:
|
||||||
|
resp = await client.post(settings.feishu_webhook_url, json=payload)
|
||||||
|
resp.raise_for_status()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("Feishu 告警发送失败: %s", e)
|
||||||
|
return False
|
||||||
Loading…
Reference in New Issue
Block a user