Files
daily_stock_analysis/src/services/analysis_service.py
Elvis Wang 972c314656 feat: Web/API 指数入口与共享 canonical 去重基础 (#2312)
* feat: Web/API 指数入口与共享 canonical 去重基础

为 Issue #2303 Phase 2 PR1 落地 Web/API 指数入口适配:

- API 使用 parse_analysis_target 构造结构化 AnalysisTarget 并贯通到 pipeline
- API 与 TaskQueue 去重按 asset_type 分支(指数用 canonical_id,个股用 legacy code)
- BatchTaskAcceptedResponse 追加可选 rejected 字段,未登记 CSI 单股 400、批量仅该目标失败
- TaskInfo 固化 dedupe_key,避免指数与同码个股折叠及 _analyzing_stocks 残留
- Web 移除 assetType=index 全局过滤,Chat 名称识别保护指数 canonical
- 补齐 Pipeline 指数 DecisionSignal market_override=cn 真实分支测试

* fix: 收敛指数 canonical 身份与批量响应契约

PR #2312 review 修复:报告 meta 补充 asset_type 隐藏指数自选;/analyze 在解析前限制非空原始 token;is_single 统一驱动 metadata/409/单任务 202;HomePage 三元计数继续后续 chunk。

* fix: avoid double space in index news search query

* fix: preserve canonical index identity

* fix: validate legacy task asset type

* fix: preserve canonical index identity in chat

* fix: preserve index identity across chat backends
2026-09-01 19:25:15 +08:00

290 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# -*- coding: utf-8 -*-
"""
===================================
分析服务层
===================================
职责:
1. 封装股票分析逻辑
2. 调用 analyzer 和 pipeline 执行分析
3. 保存分析结果到数据库
"""
import logging
import copy
import uuid
from typing import Optional, Dict, Any, Callable, List
from src.repositories.analysis_repo import AnalysisRepository
from src.report_language import (
get_sentiment_label,
get_localized_stock_name,
localize_operation_advice,
localize_trend_prediction,
normalize_report_language,
)
from src.market_phase_summary import extract_market_phase_summary
from src.schemas.decision_action import build_action_fields
from src.services.run_diagnostics import (
activate_run_diagnostic_context,
build_run_diagnostic_summary,
get_current_diagnostic_context,
reset_run_diagnostic_context,
)
from src.services.empty_news import empty_news_disclosure
logger = logging.getLogger(__name__)
def asset_type_from_canonical_code(code: Any) -> Optional[str]:
"""Derive the authoritative ``asset_type`` for a canonical stock/index code.
Uses :func:`parse_analysis_target` — the single asset-type authority — on
the *canonical* code, never the display code, so ``sh000016`` (index) and
bare ``000016`` (stock) are distinguished by the parser rather than by
display normalization. Returns ``None`` for market review / empty /
unsupported codes, so legacy clients and market reviews simply omit the
optional field.
"""
text = str(code or "").strip()
if not text:
return None
if text.upper() == "MARKET":
return None
from src.services.stock_list_parser import ParseStatus, parse_analysis_target
target = parse_analysis_target(text)
if target.asset_type == ParseStatus.INDEX:
return "index"
if target.asset_type == ParseStatus.STOCK:
return "stock"
return None
class AnalysisService:
"""
分析服务
封装股票分析相关的业务逻辑
"""
def __init__(self):
"""初始化分析服务"""
self.repo = AnalysisRepository()
self.last_error: Optional[str] = None
def analyze_stock(
self,
stock_code: str,
report_type: str = "detailed",
force_refresh: bool = False,
query_id: Optional[str] = None,
trace_id: Optional[str] = None,
send_notification: bool = True,
progress_callback: Optional[Callable[[int, str], None]] = None,
skills: Optional[List[str]] = None,
analysis_phase: str = "auto",
query_source: str = "api",
portfolio_context: Optional[Dict[str, Any]] = None,
report_language: Optional[str] = None,
analysis_target: Optional[Any] = None,
) -> Optional[Dict[str, Any]]:
"""
执行股票分析
Args:
stock_code: 股票代码
report_type: 报告类型 (simple/detailed)
force_refresh: 是否强制刷新
query_id: 查询 ID可选
send_notification: 是否发送通知API 触发默认发送)
analysis_phase: 请求的分析阶段覆盖auto/premarket/intraday/postmarket
analysis_target: 可选的结构化分析目标(指数目标贯穿到 pipeline
否则指数会退化为股票语义)
Returns:
分析结果字典,包含:
- stock_code: 股票代码
- stock_name: 股票名称
- report: 分析报告
"""
try:
self.last_error = None
# 导入分析相关模块
from src.config import get_config
from src.core.pipeline import StockAnalysisPipeline
from src.enums import ReportType
# 生成 query_id
if query_id is None:
query_id = uuid.uuid4().hex
effective_trace_id = trace_id or query_id
diag_token = None
if get_current_diagnostic_context() is None:
diag_token = activate_run_diagnostic_context(
trace_id=effective_trace_id,
query_id=query_id,
stock_code=stock_code,
trigger_source=query_source or "api",
)
# 获取配置
config = get_config()
normalized_report_language = normalize_report_language(report_language, default="")
if normalized_report_language:
config = copy.copy(config)
config.report_language = normalized_report_language
# 创建分析流水线
pipeline = StockAnalysisPipeline(
config=config,
query_id=query_id,
trace_id=effective_trace_id,
query_source=query_source or "api",
progress_callback=progress_callback,
analysis_skills=skills,
analysis_phase=analysis_phase,
portfolio_context=portfolio_context,
)
# 确定报告类型 (API: simple/detailed/full/brief -> ReportType)
rt = ReportType.from_str(report_type)
# 执行分析
result = pipeline.process_single_stock(
code=stock_code,
skip_analysis=False,
single_stock_notify=send_notification,
report_type=rt,
analysis_target=analysis_target,
)
if result is None:
logger.warning(f"分析股票 {stock_code} 返回空结果")
self.last_error = self.last_error or f"分析股票 {stock_code} 返回空结果"
return None
if not getattr(result, "success", True):
self.last_error = getattr(result, "error_message", None) or f"分析股票 {stock_code} 失败"
logger.warning(f"分析股票 {stock_code} 未成功完成: {self.last_error}")
return None
# 构建响应
return self._build_analysis_response(result, query_id, report_type=rt.value)
except Exception as e:
self.last_error = str(e)
logger.error(f"分析股票 {stock_code} 失败: {e}", exc_info=True)
return None
finally:
reset_run_diagnostic_context(locals().get("diag_token"))
def _build_analysis_response(
self,
result: Any,
query_id: str,
report_type: str = "detailed",
) -> Dict[str, Any]:
"""
构建分析响应
Args:
result: AnalysisResult 对象
query_id: 查询 ID
report_type: 归一化后的报告类型
Returns:
格式化的响应字典
"""
# 获取狙击点位
sniper_points = {}
if hasattr(result, 'get_sniper_points'):
sniper_points = result.get_sniper_points() or {}
# 计算情绪标签
report_language = normalize_report_language(getattr(result, "report_language", "zh"))
sentiment_label = get_sentiment_label(result.sentiment_score, report_language)
stock_name = get_localized_stock_name(getattr(result, "name", None), result.code, report_language)
action_fields = build_action_fields(
operation_advice=getattr(result, "operation_advice", None),
explicit_action=getattr(result, "action", None),
report_type=report_type,
report_language=report_language,
sentiment_score=getattr(result, "sentiment_score", None),
guardrail_reason=getattr(result, "guardrail_reason", None),
align_with_score=True,
)
diagnostic_context = get_current_diagnostic_context()
trace_id = diagnostic_context.trace_id if diagnostic_context is not None else query_id
diagnostic_snapshot = diagnostic_context.snapshot() if diagnostic_context is not None else None
diagnostic_context_snapshot = getattr(result, "diagnostic_context_snapshot", None)
market_phase_summary = extract_market_phase_summary(diagnostic_context_snapshot)
if isinstance(diagnostic_context_snapshot, dict):
context_snapshot = dict(diagnostic_context_snapshot)
if diagnostic_snapshot is not None:
context_snapshot["diagnostics"] = diagnostic_snapshot
elif diagnostic_snapshot is not None:
context_snapshot = {"diagnostics": diagnostic_snapshot}
else:
context_snapshot = None
diagnostic_summary = build_run_diagnostic_summary(
context_snapshot=context_snapshot,
raw_result=result.to_dict() if hasattr(result, "to_dict") else None,
report_saved=True,
query_id=query_id,
stock_code=result.code,
)
# 构建报告结构
report = {
"meta": {
"query_id": query_id,
"trace_id": trace_id,
"stock_code": result.code,
"stock_name": stock_name,
"report_type": report_type,
"report_language": report_language,
"current_price": result.current_price,
"change_pct": result.change_pct,
"model_used": getattr(result, "model_used", None),
"market_phase_summary": market_phase_summary,
"asset_type": asset_type_from_canonical_code(result.code),
},
"summary": {
"analysis_summary": result.analysis_summary,
"operation_advice": localize_operation_advice(result.operation_advice, report_language),
"action": action_fields["action"],
"action_label": action_fields["action_label"],
"trend_prediction": localize_trend_prediction(result.trend_prediction, report_language),
"sentiment_score": result.sentiment_score,
"sentiment_label": sentiment_label,
},
"strategy": {
"ideal_buy": sniper_points.get("ideal_buy"),
"secondary_buy": sniper_points.get("secondary_buy"),
"stop_loss": sniper_points.get("stop_loss"),
"take_profit": sniper_points.get("take_profit"),
},
"details": {
"news_summary": result.news_summary,
"empty_news_disclosure": empty_news_disclosure(result, report_language),
"technical_analysis": result.technical_analysis,
"fundamental_analysis": result.fundamental_analysis,
"risk_warning": result.risk_warning,
}
}
if hasattr(result, "to_dict"):
raw_result_payload = result.to_dict()
if isinstance(raw_result_payload, dict):
report["details"]["raw_result"] = raw_result_payload
return {
"query_id": query_id,
"trace_id": trace_id,
"stock_code": result.code,
"stock_name": stock_name,
"report": report,
"diagnostic_summary": diagnostic_summary,
}