@@ -558,15 +558,11 @@ REVIEW_JOB_ID = "scheduled_review"
|
||||
|
||||
|
||||
async def _run_scheduled_review(repo) -> None:
|
||||
"""定时复盘 job: 流式生成复盘 → 实时推 SSE(开着页面可见) → 落盘归档 → 推飞书。
|
||||
"""定时复盘 job: 流式生成复盘 → 落盘归档 → 推飞书。
|
||||
|
||||
与手动「生成复盘」体验一致: 流式事件经 quote_service.push_review_event →
|
||||
/api/intraday/stream 的 review_progress 事件 → 前端 reviewStore, 用户开着复盘页
|
||||
即可看到报告边生成边显示, 切走再回来也能看到生成中/已生成。
|
||||
LLM 偶发断流(peer closed connection)时自动重试最多 2 次。
|
||||
与手动「生成复盘」体验一致。LLM 偶发断流(peer closed connection)时自动重试最多 2 次。
|
||||
任何异常都吞掉只记日志, 绝不影响调度器主循环。
|
||||
"""
|
||||
import json
|
||||
|
||||
try:
|
||||
from app.services import market_recap_reports
|
||||
@@ -577,18 +573,9 @@ async def _run_scheduled_review(repo) -> None:
|
||||
logger.info("scheduled review skipped: AI key not configured")
|
||||
return
|
||||
|
||||
app_state = _get_app_state()
|
||||
quote_service = getattr(app_state, "quote_service", None) if app_state else None
|
||||
depth_service = getattr(app_state, "depth_service", None) if app_state else None
|
||||
|
||||
content, meta = await _stream_review_with_retry(repo, quote_service, depth_service)
|
||||
content, meta = await _stream_review_with_retry(repo)
|
||||
if not content:
|
||||
logger.warning("scheduled review produced no content (meta=%s)", meta)
|
||||
# 通知前端进入 error 态(若有页面在听)
|
||||
if quote_service:
|
||||
quote_service.push_review_event(json.dumps(
|
||||
{"type": "error", "message": "复盘生成失败,请稍后手动重试"},
|
||||
ensure_ascii=False))
|
||||
return
|
||||
|
||||
# 落盘: 与手动生成完全相同的归档格式
|
||||
@@ -602,34 +589,17 @@ async def _run_scheduled_review(repo) -> None:
|
||||
})
|
||||
logger.info("scheduled review saved: as_of=%s", meta.get("as_of"))
|
||||
|
||||
# 通知前端: 生成完成且已归档(archived=true 让前端只刷新列表, 不重复归档)
|
||||
if quote_service:
|
||||
quote_service.push_review_event(json.dumps(
|
||||
{"type": "done", "archived": True}, ensure_ascii=False))
|
||||
|
||||
# 推送到飞书(可选): 运行时读取配置, 用户改设置下次触发即生效。
|
||||
# 失败静默降级, 不影响已归档的报告。
|
||||
_maybe_push_review(content, meta)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.exception("scheduled review failed: %s", e)
|
||||
# 兜底: 异常时通知前端停止「生成中」状态, 避免页面卡在 streaming
|
||||
try:
|
||||
app_state = _get_app_state()
|
||||
qs = getattr(app_state, "quote_service", None) if app_state else None
|
||||
if qs:
|
||||
import json as _json
|
||||
qs.push_review_event(_json.dumps(
|
||||
{"type": "error", "message": "复盘生成异常,请稍后手动重试"},
|
||||
ensure_ascii=False))
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
|
||||
async def _stream_review_with_retry(repo, quote_service, depth_service) -> tuple[str, dict]:
|
||||
"""流式生成复盘, 每个事件推 SSE + 累积内容。LLM 断流时最多重试 2 次。
|
||||
async def _stream_review_with_retry(repo) -> tuple[str, dict]:
|
||||
"""流式生成复盘, 累积内容。LLM 断流时最多重试 2 次。
|
||||
|
||||
返回 (content, meta)。重试时推一个 retry 事件让前端清空已累积内容重新开始。
|
||||
成功(收到 done/无 error)或耗尽重试后返回。
|
||||
返回 (content, meta)。成功(收到 done/无 error)或耗尽重试后返回。
|
||||
"""
|
||||
import asyncio
|
||||
import json
|
||||
@@ -643,14 +613,10 @@ async def _stream_review_with_retry(repo, quote_service, depth_service) -> tuple
|
||||
content_parts = [] # 每次重试重新累积
|
||||
failed = False
|
||||
try:
|
||||
async for evt_json in recap_market_stream(repo, quote_service, depth_service):
|
||||
async for evt_json in recap_market_stream(repo):
|
||||
evt = json.loads(evt_json)
|
||||
t = evt.get("type")
|
||||
|
||||
# 推给前端(让开着页面的用户实时看到, 与手动一致)
|
||||
if quote_service:
|
||||
quote_service.push_review_event(evt_json)
|
||||
|
||||
if t == "meta":
|
||||
last_meta = evt
|
||||
elif t == "delta" and evt.get("content"):
|
||||
@@ -675,10 +641,6 @@ async def _stream_review_with_retry(repo, quote_service, depth_service) -> tuple
|
||||
# 失败: 决定是否重试
|
||||
if attempt < max_attempts:
|
||||
logger.info("scheduled review retrying in 3s (attempt %d → %d)", attempt, attempt + 1)
|
||||
# 通知前端: 即将重试, 清空已累积内容重新开始
|
||||
if quote_service:
|
||||
quote_service.push_review_event(json.dumps(
|
||||
{"type": "retry", "attempt": attempt + 1}, ensure_ascii=False))
|
||||
await asyncio.sleep(3)
|
||||
|
||||
# 耗尽重试, 返回已累积内容(可能为空)和最后 meta
|
||||
@@ -790,24 +752,6 @@ def start_scheduler(repo: KlineRepository, capset: CapabilitySet) -> AsyncIOSche
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
# 盘后: 五档盘口 sealed 定版(时间由偏好决定, 默认15:02, 范围15:01~18:00)
|
||||
depth_sched = preferences.get_depth_finalize_time()
|
||||
|
||||
def _depth_finalize():
|
||||
depth_svc = getattr(_get_app_state(), "depth_service", None) if _get_app_state() else None
|
||||
if depth_svc:
|
||||
depth_svc.finalize()
|
||||
|
||||
scheduler.add_job(
|
||||
_depth_finalize,
|
||||
trigger=CronTrigger(day_of_week="mon-fri",
|
||||
hour=depth_sched["hour"], minute=depth_sched["minute"],
|
||||
timezone="Asia/Shanghai"),
|
||||
id="depth_finalize",
|
||||
misfire_grace_time=3600,
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
# 定时复盘 (AI 大盘复盘报告): 工作日到点自动生成并归档。
|
||||
# 默认关闭 —— 仅当用户在复盘页开启时才注册 job。
|
||||
# 复用 recap_market_once(非流式) + market_recap_reports.save_report(落盘)。
|
||||
@@ -819,9 +763,8 @@ def start_scheduler(repo: KlineRepository, capset: CapabilitySet) -> AsyncIOSche
|
||||
review_sched["hour"], review_sched["minute"])
|
||||
|
||||
scheduler.start()
|
||||
logger.info("scheduler started; instruments@%02d:%02d, pipeline@%02d:%02d, depth@%02d:%02d mon-fri",
|
||||
inst_sched["hour"], inst_sched["minute"], sched["hour"], sched["minute"],
|
||||
depth_sched["hour"], depth_sched["minute"])
|
||||
logger.info("scheduler started; instruments@%02d:%02d, pipeline@%02d:%02d mon-fri",
|
||||
inst_sched["hour"], inst_sched["minute"], sched["hour"], sched["minute"])
|
||||
return scheduler
|
||||
|
||||
|
||||
@@ -830,7 +773,7 @@ _app_state_ref = None
|
||||
|
||||
|
||||
def set_app_state(app_state) -> None:
|
||||
"""lifespan 注册 app.state 引用, 供 scheduled job 访问 depth_service 等单例。"""
|
||||
"""lifespan 注册 app.state 引用, 供 scheduled job 访问单例。"""
|
||||
global _app_state_ref
|
||||
_app_state_ref = app_state
|
||||
|
||||
|
||||
Reference in New Issue
Block a user