Skip to content

Commit c325a14

Browse files
authored
Add web market review trigger (#1242)
* feat: add web market review trigger
1 parent caf50cc commit c325a14

19 files changed

Lines changed: 1914 additions & 173 deletions

api/v1/endpoints/analysis.py

Lines changed: 151 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,10 @@
2121
import logging
2222
import re
2323
from datetime import datetime
24+
from pathlib import Path
2425
from typing import Optional, Union, Dict, Any
2526

26-
from fastapi import APIRouter, HTTPException, Depends, Query
27+
from fastapi import APIRouter, HTTPException, Depends, Query, Body
2728
from fastapi.responses import JSONResponse, StreamingResponse
2829

2930
from api.deps import get_config_dep
@@ -38,6 +39,8 @@
3839
TaskInfo,
3940
TaskListResponse,
4041
DuplicateTaskErrorResponse,
42+
MarketReviewRequest,
43+
MarketReviewAccepted,
4144
)
4245
from api.v1.schemas.common import ErrorResponse
4346
from api.v1.schemas.history import (
@@ -49,6 +52,15 @@
4952
)
5053
from data_provider.base import canonical_stock_code, normalize_stock_code
5154
from src.config import Config
55+
from src.core.market_review_lock import (
56+
MarketReviewExecutionLock as _MarketReviewExecutionLock,
57+
market_review_lock_path,
58+
release_market_review_lock as _release_market_review_lock,
59+
try_acquire_market_review_lock as _try_acquire_market_review_lock,
60+
)
61+
from src.core.market_review_runtime import (
62+
build_market_review_runtime as _runtime_build_market_review_runtime,
63+
)
5264
from src.report_language import get_localized_stock_name, normalize_report_language
5365
from src.services.name_to_code_resolver import resolve_name_to_code
5466
from src.services.stock_code_utils import is_code_like
@@ -71,6 +83,60 @@
7183
_SUPPORTED_FREE_TEXT_RE = re.compile(r"^[A-Za-z0-9.*\-+\u3400-\u9fff\s]+$")
7284

7385

86+
def _market_review_lock_path(config: Config) -> Path:
87+
return market_review_lock_path(config)
88+
89+
90+
def _compute_market_review_override_region(config: Config) -> Optional[str]:
91+
if not getattr(config, "trading_day_check_enabled", True):
92+
return None
93+
94+
try:
95+
from src.core.trading_calendar import (
96+
get_open_markets_today,
97+
compute_effective_region,
98+
)
99+
100+
open_markets = get_open_markets_today()
101+
return compute_effective_region(
102+
getattr(config, "market_review_region", "cn") or "cn",
103+
open_markets,
104+
)
105+
except Exception as exc:
106+
logger.warning("大盘复盘交易日过滤失败,按配置继续执行: %s", exc)
107+
return None
108+
109+
110+
def _build_market_review_runtime(config: Config, source_message: Optional[Any] = None) -> tuple[Any, Any, Any]:
111+
return _runtime_build_market_review_runtime(config, source_message)
112+
113+
114+
def _run_market_review_background(
115+
send_notification: bool,
116+
override_region: Optional[str] = None,
117+
lock_token: Optional[_MarketReviewExecutionLock] = None,
118+
config: Optional[Config] = None,
119+
) -> None:
120+
"""Run market review after the API response has been accepted."""
121+
from src.core.market_review import run_market_review
122+
123+
runtime_config = config or get_config_dep()
124+
try:
125+
notifier, analyzer, search_service = _build_market_review_runtime(runtime_config)
126+
report = run_market_review(
127+
notifier=notifier,
128+
analyzer=analyzer,
129+
search_service=search_service,
130+
send_notification=send_notification,
131+
override_region=override_region,
132+
)
133+
if not report:
134+
raise RuntimeError("大盘复盘未返回可持久化报告")
135+
return {"result": report}
136+
finally:
137+
_release_market_review_lock(lock_token)
138+
139+
74140
def _invalid_analysis_input_error() -> HTTPException:
75141
return HTTPException(
76142
status_code=400,
@@ -392,6 +458,71 @@ def _handle_sync_analysis(
392458
)
393459

394460

461+
# ============================================================
462+
# POST /market-review - 触发大盘复盘
463+
# ============================================================
464+
465+
@router.post(
466+
"/market-review",
467+
response_model=MarketReviewAccepted,
468+
status_code=202,
469+
responses={
470+
202: {"description": "大盘复盘任务已接受", "model": MarketReviewAccepted},
471+
409: {"description": "大盘复盘正在执行", "model": ErrorResponse},
472+
500: {"description": "提交失败", "model": ErrorResponse},
473+
},
474+
summary="触发大盘复盘",
475+
description="提交一个后台大盘复盘任务,复用 CLI 的大盘复盘链路并保存报告。接口内部仅提供进程内/单机防重,如多实例(多 Worker/多容器)部署,需结合外部幂等机制避免重复触发。",
476+
)
477+
def trigger_market_review(
478+
request: Optional[MarketReviewRequest] = Body(None),
479+
config: Config = Depends(get_config_dep),
480+
) -> MarketReviewAccepted:
481+
"""Trigger market review from Web/API without blocking the request."""
482+
request = request or MarketReviewRequest()
483+
484+
override_region = _compute_market_review_override_region(config)
485+
if override_region == "":
486+
return MarketReviewAccepted(
487+
status="accepted",
488+
message="今日大盘复盘相关市场均为非交易日,已跳过大盘复盘",
489+
send_notification=request.send_notification,
490+
)
491+
492+
lock_token = _try_acquire_market_review_lock(config)
493+
if lock_token is None:
494+
raise HTTPException(
495+
status_code=409,
496+
detail={
497+
"error": "duplicate_market_review",
498+
"message": "大盘复盘正在执行中,请稍后再试",
499+
},
500+
)
501+
502+
try:
503+
task = get_task_queue().submit_background_task(
504+
lambda: _run_market_review_background(
505+
request.send_notification,
506+
override_region=override_region,
507+
lock_token=lock_token,
508+
config=config,
509+
),
510+
stock_code="market_review",
511+
stock_name="大盘复盘",
512+
message="大盘复盘任务已提交",
513+
)
514+
except Exception:
515+
_release_market_review_lock(lock_token)
516+
raise
517+
518+
return MarketReviewAccepted(
519+
status="accepted",
520+
message="大盘复盘任务已提交,完成后会保存报告并按配置推送通知",
521+
send_notification=request.send_notification,
522+
task_id=task.task_id,
523+
)
524+
525+
395526
# ============================================================
396527
# GET /tasks - 获取任务列表
397528
# ============================================================
@@ -582,11 +713,29 @@ def get_analysis_status(task_id: str) -> TaskStatus:
582713
task = task_queue.get_task(task_id)
583714

584715
if task:
716+
result: Optional[AnalysisResultResponse] = None
717+
market_review_report = None
718+
719+
if task.status == TaskStatusEnum.COMPLETED and isinstance(task.result, dict):
720+
if task.stock_code == "market_review":
721+
report_text = task.result.get("result")
722+
if isinstance(report_text, str) and report_text.strip():
723+
market_review_report = report_text
724+
else:
725+
try:
726+
result = AnalysisResultResponse.model_validate(task.result)
727+
except Exception:
728+
logger.warning(
729+
"解析任务结果失败,回退为空返回: task_id=%s",
730+
task.task_id,
731+
)
732+
585733
return TaskStatus(
586734
task_id=task.task_id,
587735
status=task.status.value,
588736
progress=task.progress,
589-
result=None, # In-progress tasks do not carry a result payload.
737+
result=result,
738+
market_review_report=market_review_report,
590739
error=task.error,
591740
stock_name=task.stock_name,
592741
original_query=task.original_query,

api/v1/schemas/analysis.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,27 @@ class Config:
8787
}
8888

8989

90+
class MarketReviewRequest(BaseModel):
91+
"""Market review trigger parameters."""
92+
93+
send_notification: bool = Field(
94+
True,
95+
description="是否在大盘复盘完成后发送推送通知",
96+
)
97+
98+
99+
class MarketReviewAccepted(BaseModel):
100+
"""Market review background task accepted response."""
101+
102+
status: str = Field("accepted", description="提交状态")
103+
message: str = Field(..., description="提示信息")
104+
send_notification: bool = Field(..., description="是否发送通知")
105+
task_id: Optional[str] = Field(
106+
None,
107+
description="任务 ID(仅当任务实际提交时返回)",
108+
)
109+
110+
90111
class AnalysisResultResponse(BaseModel):
91112
"""分析结果响应模型"""
92113

@@ -223,6 +244,10 @@ class TaskStatus(BaseModel):
223244
None,
224245
description="分析结果(仅在 completed 时存在)"
225246
)
247+
market_review_report: Optional[str] = Field(
248+
None,
249+
description="大盘复盘任务返回的报告文本(仅大盘复盘任务)",
250+
)
226251
error: Optional[str] = Field(
227252
None,
228253
description="错误信息(仅在 failed 时存在)"
@@ -242,6 +267,7 @@ class Config:
242267
"status": "completed",
243268
"progress": 100,
244269
"result": None,
270+
"market_review_report": None,
245271
"error": None,
246272
"stock_name": "贵州茅台",
247273
"original_query": "茅台",

apps/dsa-web/src/api/analysis.ts

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ import type {
66
AnalyzeResponse,
77
AnalyzeAsyncResponse,
88
AnalysisReport,
9+
MarketReviewAccepted,
10+
MarketReviewRequest,
911
TaskStatus,
1012
TaskListResponse,
1113
} from '../types/analysis';
@@ -87,6 +89,31 @@ export const analysisApi = {
8789
return toCamelCase<AnalyzeAsyncResponse>(response.data);
8890
},
8991

92+
/**
93+
* Trigger market review in background mode.
94+
*/
95+
triggerMarketReview: async (data: MarketReviewRequest = {}): Promise<MarketReviewAccepted> => {
96+
const response = await apiClient.post<Record<string, unknown>>(
97+
'/api/v1/analysis/market-review',
98+
{
99+
send_notification: data.sendNotification ?? true,
100+
},
101+
{
102+
validateStatus: (status) => status === 202 || status === 409,
103+
}
104+
);
105+
106+
if (response.status === 409) {
107+
const detail = response.data?.detail;
108+
const message = detail && typeof detail === 'object' && 'message' in detail
109+
? String((detail as { message?: unknown }).message || '')
110+
: String(response.data?.message || '');
111+
throw new Error(message || '大盘复盘正在执行中,请稍后再试');
112+
}
113+
114+
return toCamelCase<MarketReviewAccepted>(response.data);
115+
},
116+
90117
/**
91118
* Get async task status.
92119
* @param taskId Task ID

0 commit comments

Comments
 (0)