From 972c31465654d86c52c59abfdb8414b82808f50f Mon Sep 17 00:00:00 2001 From: Elvis Wang Date: Tue, 1 Sep 2026 19:25:15 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Web/API=20=E6=8C=87=E6=95=B0=E5=85=A5?= =?UTF-8?q?=E5=8F=A3=E4=B8=8E=E5=85=B1=E4=BA=AB=20canonical=20=E5=8E=BB?= =?UTF-8?q?=E9=87=8D=E5=9F=BA=E7=A1=80=20(#2312)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 --- api/v1/endpoints/analysis.py | 198 +++- api/v1/endpoints/history.py | 34 +- api/v1/schemas/__init__.py | 2 + api/v1/schemas/analysis.py | 26 + api/v1/schemas/history.py | 12 + .../src/components/report/ReportOverview.tsx | 2 +- .../report/__tests__/ReportOverview.test.tsx | 53 + .../watchlist/HomeStockWorkspace.tsx | 65 +- .../__tests__/HomeStockWorkspace.test.tsx | 158 +++ .../hooks/__tests__/useTaskStream.test.tsx | 10 +- apps/dsa-web/src/hooks/useTaskStream.ts | 1 + apps/dsa-web/src/i18n/uiText.ts | 4 + apps/dsa-web/src/pages/ChatPage.tsx | 129 ++- apps/dsa-web/src/pages/HomePage.tsx | 132 ++- .../src/pages/__tests__/ChatPage.test.tsx | 706 ++++++++++++- .../src/pages/__tests__/HomePage.test.tsx | 325 ++++++ .../stores/__tests__/stockPoolStore.test.ts | 142 +++ apps/dsa-web/src/stores/stockPoolStore.ts | 34 +- apps/dsa-web/src/types/analysis.ts | 11 + .../src/utils/__tests__/searchStocks.test.ts | 54 + .../src/utils/__tests__/stockCode.test.ts | 97 +- .../utils/__tests__/stockIndexLoader.test.ts | 47 +- .../src/utils/__tests__/validation.test.ts | 34 + apps/dsa-web/src/utils/chatStockCode.ts | 50 +- apps/dsa-web/src/utils/stockCode.ts | 103 ++ apps/dsa-web/src/utils/stockIndexLoader.ts | 23 +- apps/dsa-web/src/utils/validation.ts | 4 +- docs/CHANGELOG.md | 8 + docs/full-guide.md | 19 +- docs/full-guide_EN.md | 19 +- src/agent/stock_scope.py | 125 ++- src/agent/tools/execution.py | 73 +- src/search_service.py | 14 +- src/services/analysis_service.py | 31 + src/services/history_service.py | 101 +- src/services/task_queue.py | 121 ++- tests/test_agent_executor.py | 215 ++++ tests/test_agent_tool_surface.py | 149 +++ tests/test_analysis_api_contract.py | 956 +++++++++++++++++- tests/test_analysis_history.py | 455 +++++++++ tests/test_analysis_integration.py | 3 + tests/test_pipeline_market_phase_context.py | 63 ++ 42 files changed, 4568 insertions(+), 240 deletions(-) diff --git a/api/v1/endpoints/analysis.py b/api/v1/endpoints/analysis.py index 736ddcd2b..3342c785e 100644 --- a/api/v1/endpoints/analysis.py +++ b/api/v1/endpoints/analysis.py @@ -21,6 +21,7 @@ import copy import json import logging import re +import unicodedata import uuid from datetime import datetime from pathlib import Path @@ -38,6 +39,7 @@ from api.v1.schemas.analysis import ( BatchTaskAcceptedResponse, BatchTaskAcceptedItem, BatchDuplicateTaskItem, + RejectedTaskItem, TaskStatus, TaskInfo, TaskListResponse, @@ -75,6 +77,7 @@ from src.market_phase_summary import ( rebuild_market_phase_summary_for_stock_code, ) from src.services.stock_code_utils import is_code_like, resolve_index_stock_code_for_analysis +from src.services.stock_list_parser import ParseStatus, parse_analysis_target from src.report_language import get_localized_stock_name, normalize_report_language from src.schemas.decision_action import build_action_fields from src.services.name_to_code_resolver import resolve_name_to_code @@ -83,6 +86,7 @@ from src.services.task_queue import ( DuplicateTaskError, TaskStatus as TaskStatusEnum, ) +from src.services.analysis_service import asset_type_from_canonical_code from src.services.run_diagnostics import build_run_diagnostic_summary from src.services.run_flow import build_task_run_flow_snapshot from src.services.empty_news import empty_news_disclosure_from_stored @@ -113,6 +117,32 @@ def _get_task_trace_id(task: Any) -> Optional[str]: return None +def _task_asset_type(task: Any) -> Optional[str]: + """Return the task's optional asset type only when it is a real literal. + + The Pydantic literal domain (``stock``/``index``) is the only allowed set: + real in-domain strings pass through verbatim. Legacy mock/proxy tasks whose + ``getattr`` yields a ``MagicMock`` child, and missing values, degrade to + ``None`` so schema defaults keep legacy responses unchanged. Genuine + out-of-domain strings are logged and degraded instead of widening the enum + or silently masking drift. + """ + raw = getattr(task, "asset_type", None) + if not isinstance(raw, str): + return None + if raw in {"stock", "index"}: + return raw + # 任何真实字符串只要不精确等于字面量域(含空串、纯空白、大小写或带 + # 空格形态)都必须记录 warning 后降级,避免静默掩盖漂移;仅非字符串 + # 代理(如 MagicMock 子对象)保持静默 None。 + logger.warning( + "task asset_type 超出字面量域,降级为 None: task_id=%s asset_type=%r", + getattr(task, "task_id", None), + raw, + ) + return None + + def _market_review_lock_path(config: Config) -> Path: return market_review_lock_path(config) @@ -226,32 +256,68 @@ def _is_obviously_invalid_analysis_input(text: str) -> bool: return has_letters and has_digits -def _resolve_and_normalize_input(raw_value: str) -> str: +def _resolve_analysis_input(raw_value: str): """ - Resolve and normalize a stock input for analysis requests. + Resolve one analysis request input into ``(code, analysis_target)``. - Code-like values keep the existing canonical path. - Non-code inputs must resolve to a known stock code. Obvious garbage - input is rejected before expensive resolver and task-queue work. + Code-like tokens go through :func:`parse_analysis_target` (the single + asset-type authority): registered indices keep their structured + ``AnalysisTarget`` (canonical id + index semantics), unsupported targets + (e.g. unregistered ``930956.CSI``) are surfaced with their reason so the + caller can reject them explicitly, and stock tokens keep the legacy + resolution path. Non-code names (e.g. ``贵州茅台``) keep + :func:`resolve_name_to_code` and never enter index classification. """ text = (raw_value or "").strip() if not text: - return "" + return None + + # CSI explicit forms (``csi930955`` / ``930955.CSI`` / ``CSI930955``) are + # explicit code forms but ``is_code_like`` does not recognise the ``.CSI`` + # suffix; route them through parse_analysis_target so registered CSI + # converges to its canonical index identity and unregistered CSI surfaces + # as an explicit unsupported error (never a name-resolution fallback). + normalized_csi = unicodedata.normalize("NFKC", text).strip().casefold() + if re.fullmatch(r"(?:csi\d{6}|\d{6}\.csi)", normalized_csi): + target = parse_analysis_target(text) + if target.asset_type == ParseStatus.INDEX: + return (target.canonical_id, target) + return (text, target) + + # SH/SZ prefixed six-digit forms (``sh000016`` / ``SH000016``) are explicit + # index/stock code shapes, but ``is_code_like`` rejects them when the bare + # digits do not match the exchange's *stock* digit rules (``000016`` is an + # SH index yet classifies as an SZ stock digit shape). Route them through + # parse_analysis_target so registered SH/SZ indices keep their structured + # target; unknown prefixed forms degrade to the stock path as usual. + normalized_sh_sz = re.fullmatch(r"(sh|sz)(\d{6})", normalized_csi) + if normalized_sh_sz is not None: + target = parse_analysis_target(text) + if target.asset_type == ParseStatus.INDEX: + return (target.canonical_id, target) + return (resolve_index_stock_code_for_analysis(text), None) if is_code_like(text): - return resolve_index_stock_code_for_analysis(text) + target = parse_analysis_target(text) + if target.asset_type == ParseStatus.INDEX: + return (target.canonical_id, target) + if target.asset_type == ParseStatus.UNSUPPORTED: + return (text, target) + # Stock target: keep the existing canonical resolution path and do not + # carry a stock target downstream (stock semantics must not change). + return (resolve_index_stock_code_for_analysis(text), None) if text.isdigit() and len(text) == 4: resolved_index_code = resolve_index_stock_code_for_analysis(text) if resolved_index_code != canonical_stock_code(text): - return resolved_index_code + return (resolved_index_code, None) if _is_obviously_invalid_analysis_input(text): raise _invalid_analysis_input_error() resolved = resolve_name_to_code(text) if resolved: - return canonical_stock_code(resolved) + return (canonical_stock_code(resolved), None) raise _invalid_analysis_input_error() @@ -313,29 +379,56 @@ def trigger_analysis( if not stock_codes: raise api_error(400, "validation_error", "必须提供 stock_code 或 stock_codes 参数") + # Limit the number of non-blank raw tokens BEFORE resolution. Rejected and + # duplicate tokens must also count toward the cap, otherwise a request that + # mixes one valid token with many rejected/duplicate tokens could bypass the + # DoS limit via the post-dedup check below. + MAX_BATCH_SIZE = 50 + non_empty_raw_tokens = [c for c in stock_codes if str(c or "").strip()] + if len(non_empty_raw_tokens) > MAX_BATCH_SIZE: + raise api_error(400, "validation_error", f"单次分析请求最多支持 {MAX_BATCH_SIZE} 只股票") + # Normalize and de-duplicate inputs while preserving compatibility. - resolved = [_resolve_and_normalize_input(c) for c in stock_codes] - + # Code-like tokens go through parse_analysis_target (the single asset-type + # authority) so registered indices keep their structured target; non-code + # names keep the legacy stock-name resolution path. + resolved_entries = [_resolve_analysis_input(c) for c in stock_codes] + seen = set() unique_codes = [] - for code in resolved: + unique_targets = [] + rejected_entries = [] + for entry in resolved_entries: + if entry is None: + continue + code, target = entry if not code: continue - # Use normalize_stock_code to ensure '600519' and '600519.SH' are merged - norm = normalize_stock_code(code) + if target is not None and target.asset_type == ParseStatus.UNSUPPORTED: + rejected_entries.append((code, target)) + continue + # 去重键按 asset_type 分支:INDEX 用 canonical_id(指数与同码股票不折叠、 + # CSI alias 收敛),STOCK 保持 normalize_stock_code 既有语义 + # ('600519' 与 '600519.SH' 合并)。 + if target is not None and target.asset_type == ParseStatus.INDEX: + norm = target.canonical_id + else: + norm = normalize_stock_code(code) if norm not in seen: seen.add(norm) unique_codes.append(code) - + unique_targets.append(target) + stock_codes = unique_codes - # Limit the number of stocks in a single request to prevent DoS - MAX_BATCH_SIZE = 50 - if len(stock_codes) > MAX_BATCH_SIZE: - raise api_error(400, "validation_error", f"单次分析请求最多支持 {MAX_BATCH_SIZE} 只股票") - if not stock_codes: - raise api_error(400, "validation_error", "股票代码不能为空或仅包含空白字符") + if not rejected_entries: + raise api_error(400, "validation_error", "股票代码不能为空或仅包含空白字符") + # 全部目标都被拒绝(单请求或批量全拒):无任务被接受时返回 202 会误导 + # 客户端,一律明确 400;部分被拒则走下方 rejected 语义。 + code, target = rejected_entries[0] + reason = target.unsupported_reason or f"不支持的目标: {code}" + raise api_error(400, "validation_error", reason) # Sync mode only supports single-stock analysis. if not request.async_mode: @@ -345,25 +438,45 @@ def trigger_analysis( "validation_error", "同步模式仅支持单只股票分析,请使用 async_mode=true 进行批量分析", ) - return _handle_sync_analysis(stock_codes[0], request) + if rejected_entries: + # 同步模式不支持 rejected 语义:只要存在被拒绝目标(含全部被拒绝) + # 就以第一个未登记目标的明确校验错误返回 4xx。 + code, target = rejected_entries[0] + reason = target.unsupported_reason or f"不支持的目标: {code}" + raise api_error(400, "validation_error", reason) + return _handle_sync_analysis(stock_codes[0], request, analysis_target=unique_targets[0] if unique_targets else None) # Async mode submits one task per stock. - return _handle_async_analysis_batch(stock_codes, request) + return _handle_async_analysis_batch(stock_codes, request, analysis_targets=unique_targets, rejected_entries=rejected_entries) def _handle_async_analysis_batch( stock_codes: list, - request: AnalyzeRequest + request: AnalyzeRequest, + analysis_targets: Optional[list] = None, + rejected_entries: Optional[list] = None, ) -> JSONResponse: """ Handle asynchronous analysis requests, including batch submission. + + Args: + stock_codes: canonical codes to submit + request: the analysis request + analysis_targets: optional per-code structured targets (index targets + flow through to the pipeline) + rejected_entries: optional list of ``(code, target)`` pairs that were + explicitly rejected (e.g. unregistered CSI); returned in the + ``rejected`` field for batch requests only. """ task_queue = get_task_queue() # Preserve metadata for single-stock requests. For batch requests, # only carry through metadata that semantically applies to the whole # batch, such as import/image source tracking. - is_single = len(stock_codes) == 1 + # A single "accepted" code alongside any rejected entries is a batch: + # rejected entries mean the server has not fully disposed of a single-stock + # request, so single-stock metadata/409/single-202 semantics must not apply. + is_single = len(stock_codes) == 1 and not rejected_entries preserve_batch_metadata = request.selection_source in {"import", "image"} stock_name = request.stock_name if is_single else None @@ -388,6 +501,10 @@ def _handle_async_analysis_batch( submit_kwargs["report_language"] = report_language if skills is not None: submit_kwargs["skills"] = skills + # 仅当存在非 None 的结构化 target(如指数)时才传递,保持纯股票请求 + # 的既有 kwargs 契约不变。 + if analysis_targets is not None and any(t is not None for t in analysis_targets): + submit_kwargs["analysis_targets"] = analysis_targets accepted_tasks, duplicate_errors = task_queue.submit_tasks_batch(**submit_kwargs) @@ -399,6 +516,7 @@ def _handle_async_analysis_batch( status="pending", message=f"分析任务已加入队列: {task.stock_code}", analysis_phase=task.analysis_phase, + asset_type=_task_asset_type(task), ) for task in accepted_tasks ] @@ -410,9 +528,16 @@ def _handle_async_analysis_batch( ) for dup in duplicate_errors ] + rejected = [ + RejectedTaskItem( + stock_code=code, + message=(target.unsupported_reason or f"不支持的目标: {code}"), + ) + for code, target in (rejected_entries or []) + ] # 单只股票且被拒绝:保持 409 兼容性 - if len(stock_codes) == 1 and duplicates: + if is_single and duplicates: dup = duplicates[0] error_response = DuplicateTaskErrorResponse( error="duplicate_task", @@ -425,8 +550,8 @@ def _handle_async_analysis_batch( content=error_response.model_dump() ) - # 单只股票成功:保持原有响应格式兼容性 - if len(stock_codes) == 1 and accepted: + # 单只股票成功(且无 rejected):保持原有响应格式兼容性 + if is_single and accepted and not rejected: task_accepted = TaskAccepted( task_id=accepted[0].task_id, trace_id=accepted[0].trace_id, @@ -439,11 +564,17 @@ def _handle_async_analysis_batch( content=task_accepted.model_dump() ) - # 批量:返回汇总结果 + # 批量:返回汇总结果(rejected 仅 async 批量返回) + rejected_count = len(rejected) + if rejected_count: + message = f"已提交 {len(accepted)} 个任务,{len(duplicates)} 个重复跳过,{rejected_count} 个被拒绝" + else: + message = f"已提交 {len(accepted)} 个任务,{len(duplicates)} 个重复跳过" batch_response = BatchTaskAcceptedResponse( accepted=accepted, duplicates=duplicates, - message=f"已提交 {len(accepted)} 个任务,{len(duplicates)} 个重复跳过", + rejected=rejected if rejected else None, + message=message, ) return JSONResponse( status_code=202, @@ -453,7 +584,8 @@ def _handle_async_analysis_batch( def _handle_sync_analysis( stock_code: str, - request: AnalyzeRequest + request: AnalyzeRequest, + analysis_target: Optional[Any] = None, ) -> AnalysisResultResponse: """ 处理同步分析请求 @@ -476,6 +608,7 @@ def _handle_sync_analysis( skills=getattr(request, "skills", None), analysis_phase=request.analysis_phase, report_language=getattr(request, "report_language", None), + analysis_target=analysis_target, ) if result is None: @@ -647,6 +780,7 @@ def get_task_list( analysis_phase=t.analysis_phase, skills=getattr(t, "skills", None), region=t.region, + asset_type=_task_asset_type(t), ) for t in all_tasks ] @@ -1232,6 +1366,7 @@ def get_analysis_status(task_id: str) -> TaskStatus: current_price=current_price, change_pct=change_pct, market_phase_summary=market_phase_summary, + asset_type=asset_type_from_canonical_code(record.code), ), summary=ReportSummary( sentiment_score=record.sentiment_score, @@ -1394,6 +1529,7 @@ def _build_analysis_report( change_pct=change_pct, model_used=normalize_model_used(meta_data.get("model_used")), market_phase_summary=market_phase_summary, + asset_type=asset_type_from_canonical_code(raw_stock_code), ) def _looks_like_raw_result_payload(candidate: Any) -> bool: diff --git a/api/v1/endpoints/history.py b/api/v1/endpoints/history.py index 10c3f2d40..f3150ea5e 100644 --- a/api/v1/endpoints/history.py +++ b/api/v1/endpoints/history.py @@ -44,6 +44,7 @@ from src.report_language import ( normalize_report_language, ) from src.services.history_service import HistoryService, MarkdownReportGenerationError +from src.services.analysis_service import asset_type_from_canonical_code from src.schemas.decision_action import build_action_fields from src.utils.data_processing import ( normalize_model_used, @@ -129,14 +130,30 @@ def _history_share_image_branding(config: object) -> ShareImageBranding: return share_image_branding_from_config(config) -def _normalize_code_for_grouping(code: str) -> str: - """Normalize stock code for deduplication grouping. +def _stock_bar_group_key(record_code: str, display_code: str) -> str: + """Build the stock-bar grouping key from the *persisted* code. - Delegates to data_provider.base.normalize_stock_code which handles - SH600519, 600519.SH, HK00700, 00700.HK, BJ920748, etc. + PR #2312: registered indices are typed by the persisted ``record.code`` + (never guessed from the display code) and group by the parser canonical + (lowercase ``sh000016`` / ``csi930955``), so every explicit index form + (uppercase legacy / dotted alias) converges to one row and never folds + with the bare same-code stock. Stocks keep the legacy display-based + normalization (``SH600519``/``600519.SH`` -> ``600519``, JP/KR legacy bare + ``005930`` merging with ``005930.KS`` etc.), preserving existing semantics. """ from data_provider.base import normalize_stock_code - return normalize_stock_code(code or "") + from src.services.stock_list_parser import ParseStatus, parse_analysis_target + + code = str(record_code or "").strip() + if not code: + return normalize_stock_code(display_code or "") + try: + target = parse_analysis_target(code) + except Exception: + return normalize_stock_code(display_code or "") + if target.asset_type == ParseStatus.INDEX: + return target.canonical_id + return normalize_stock_code(display_code or "") def _raw_result_value(raw_result: Any, key: str) -> Any: @@ -274,6 +291,7 @@ def get_history_list( model_used=item.get("model_used"), created_at=item.get("created_at"), market_phase_summary=item.get("market_phase_summary"), + asset_type=item.get("asset_type"), ) for item in result.get("items", []) ] @@ -431,7 +449,7 @@ def get_stock_bar( seen: dict = {} for record in records: display_code = service._display_stock_code(record.code or "") - norm_code = _normalize_code_for_grouping(display_code) + norm_code = _stock_bar_group_key(record.code or "", display_code) if norm_code not in seen or record.id > seen[norm_code].id: seen[norm_code] = record @@ -482,6 +500,7 @@ def get_stock_bar( record.code, getattr(record, "context_snapshot", None), ), + asset_type=asset_type_from_canonical_code(record.code), ) ) @@ -589,6 +608,9 @@ def get_history_detail( change_pct=change_pct, model_used=normalize_model_used(result.get("model_used")), market_phase_summary=market_phase_summary, + asset_type=asset_type_from_canonical_code( + result.get("storage_stock_code") or result.get("stock_code") + ), ) summary = ReportSummary( diff --git a/api/v1/schemas/__init__.py b/api/v1/schemas/__init__.py index 95fd63fb8..32d194368 100644 --- a/api/v1/schemas/__init__.py +++ b/api/v1/schemas/__init__.py @@ -29,6 +29,7 @@ from api.v1.schemas.analysis import ( AnalysisResultResponse, TaskAccepted, BatchTaskAcceptedResponse, + RejectedTaskItem, TaskStatus, ) from api.v1.schemas.history import ( @@ -168,6 +169,7 @@ __all__ = [ "AnalysisResultResponse", "TaskAccepted", "BatchTaskAcceptedResponse", + "RejectedTaskItem", "TaskStatus", # history "HistoryItem", diff --git a/api/v1/schemas/analysis.py b/api/v1/schemas/analysis.py index b6532c91e..9d6b1634b 100644 --- a/api/v1/schemas/analysis.py +++ b/api/v1/schemas/analysis.py @@ -231,6 +231,10 @@ class BatchTaskAcceptedItem(BaseModel): ) message: Optional[str] = Field(None, description="提示信息") analysis_phase: AnalysisPhase = Field("auto", description="请求的分析阶段") + asset_type: Optional[Literal["stock", "index"]] = Field( + None, + description="parser 来源的可选资产类型(stock/index);由已提交的 analysis_target 透传,旧客户端可缺省", + ) model_config = ConfigDict(json_schema_extra={ "example": { @@ -259,11 +263,29 @@ class BatchDuplicateTaskItem(BaseModel): }) +class RejectedTaskItem(BaseModel): + """批量异步任务中被明确拒绝的单个目标项(如未登记 CSI 指数)。""" + + stock_code: str = Field(..., description="被拒绝的目标代码") + message: str = Field(..., description="拒绝原因") + + model_config = ConfigDict(json_schema_extra={ + "example": { + "stock_code": "930956.CSI", + "message": "unregistered CSI index: '930956.CSI' is not in the index registry", + } + }) + + class BatchTaskAcceptedResponse(BaseModel): """批量异步任务接受响应。""" accepted: List[BatchTaskAcceptedItem] = Field(default_factory=list, description="成功提交的任务列表") duplicates: List[BatchDuplicateTaskItem] = Field(default_factory=list, description="重复而跳过的任务列表") + rejected: Optional[List[RejectedTaskItem]] = Field( + None, + description="批量中被明确拒绝的目标列表(如未登记 CSI 指数),仅在异步批量请求中返回", + ) message: str = Field(..., description="汇总信息") model_config = ConfigDict(json_schema_extra={ @@ -385,6 +407,10 @@ class TaskInfo(BaseModel): None, description="大盘复盘任务实际执行的 canonical 市场范围", ) + asset_type: Optional[Literal["stock", "index"]] = Field( + None, + description="parser 来源的可选资产类型(stock/index);由已提交的 analysis_target 透传,旧客户端可缺省", + ) model_config = ConfigDict(json_schema_extra={ "example": { diff --git a/api/v1/schemas/history.py b/api/v1/schemas/history.py index ee123d3a4..a3eb3d0a8 100644 --- a/api/v1/schemas/history.py +++ b/api/v1/schemas/history.py @@ -52,6 +52,10 @@ class HistoryItem(BaseModel): description="本次分析市场阶段低敏摘要", ) created_at: Optional[str] = Field(None, description="创建时间") + asset_type: Optional[Literal["stock", "index"]] = Field( + None, + description="后端权威资产类型(stock/index);由持久化代码经 parser 生成。旧客户端与 market review 可缺省。", + ) model_config = ConfigDict(json_schema_extra={ "example": { @@ -149,6 +153,10 @@ class ReportMeta(BaseModel): None, description="本次分析市场阶段低敏摘要", ) + asset_type: Optional[Literal["stock", "index"]] = Field( + None, + description="后端权威资产类型(stock/index);指数报告用于隐藏 stock-only 自选操作。market review 与旧客户端可缺省。", + ) class ReportSummary(BaseModel): @@ -371,6 +379,10 @@ class StockBarItem(BaseModel): None, description="最新分析市场阶段低敏摘要", ) + asset_type: Optional[Literal["stock", "index"]] = Field( + None, + description="后端权威资产类型(stock/index);由持久化代码经 parser 生成。旧客户端可缺省。", + ) model_config = ConfigDict(json_schema_extra={ "example": { "id": 1234, diff --git a/apps/dsa-web/src/components/report/ReportOverview.tsx b/apps/dsa-web/src/components/report/ReportOverview.tsx index 96511148d..79adf1dd5 100644 --- a/apps/dsa-web/src/components/report/ReportOverview.tsx +++ b/apps/dsa-web/src/components/report/ReportOverview.tsx @@ -381,7 +381,7 @@ export const ReportOverview: React.FC = ({ {/* 右侧:情绪指标 / 自选操作 */}
- {watchlist && meta.reportType !== 'market_review' && ( + {watchlist && meta.reportType !== 'market_review' && meta.assetType !== 'index' && (
{t('report.watchlist')} diff --git a/apps/dsa-web/src/components/report/__tests__/ReportOverview.test.tsx b/apps/dsa-web/src/components/report/__tests__/ReportOverview.test.tsx index b99dcd4bf..cded40d6d 100644 --- a/apps/dsa-web/src/components/report/__tests__/ReportOverview.test.tsx +++ b/apps/dsa-web/src/components/report/__tests__/ReportOverview.test.tsx @@ -323,4 +323,57 @@ describe('ReportOverview', () => { expect(screen.getByText('领跌')).toBeInTheDocument(); expect(screen.getByText('-2.50%')).toBeInTheDocument(); }); + + it('hides the stock-only watchlist card for index reports', () => { + render( + false, + onToggle: () => {}, + isActioning: false, + actionMessage: null, + }} + />, + ); + + expect(screen.queryByText('自选')).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /自选/ })).not.toBeInTheDocument(); + }); + + it('keeps the stock-only watchlist card for stock reports', () => { + render( + false, + onToggle: () => {}, + isActioning: false, + actionMessage: null, + }} + />, + ); + + expect(screen.getByText('自选')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: '加入自选' })).toBeInTheDocument(); + }); + + it('keeps the stock-only watchlist card when assetType is absent (legacy)', () => { + render( + false, + onToggle: () => {}, + isActioning: false, + actionMessage: null, + }} + />, + ); + + expect(screen.getByText('自选')).toBeInTheDocument(); + }); }); diff --git a/apps/dsa-web/src/components/watchlist/HomeStockWorkspace.tsx b/apps/dsa-web/src/components/watchlist/HomeStockWorkspace.tsx index 73b27b38f..1c83205cc 100644 --- a/apps/dsa-web/src/components/watchlist/HomeStockWorkspace.tsx +++ b/apps/dsa-web/src/components/watchlist/HomeStockWorkspace.tsx @@ -20,7 +20,7 @@ import type { StockBarItem, TaskInfo } from '../../types/analysis'; import { getSentimentColor } from '../../types/analysis'; import { buildDecisionActionLabelMap, getDecisionActionLabel } from '../../utils/decisionAction'; import { formatDateTime } from '../../utils/format'; -import { areStockCodesEquivalent } from '../../utils/stockCode'; +import { areAssetAwareCodesEquivalent, toAssetAwareCodeKey, type AssetAwareAssetType } from '../../utils/stockCode'; import { truncateStockName } from '../../utils/stockName'; import { useUiLanguage } from '../../contexts/UiLanguageContext'; import type { UiTextKey, UiTextParams } from '../../i18n/uiText'; @@ -30,6 +30,15 @@ export type WatchlistAnalyzeMode = 'all' | 'pending'; export interface HomeWatchlistRow { code: string; + assetType?: AssetAwareAssetType; + /** + * Asset-aware identity key already resolved by HomePage from the stock index + * registry (canonical for registered indices, e.g. `sh000016` for a raw + * watchlist string `000016.SH`). Row-selection compares against this key + * FIRST instead of re-parsing the raw alias, so a canonical selected report + * always selects its own alias-form row and never a same-code stock row. + */ + identityKey?: string; latestItem?: StockBarItem; analyzedToday: boolean; isTodayStatusLoading?: boolean; @@ -62,6 +71,7 @@ interface HomeStockWorkspaceProps { historyItems: StockBarItem[]; isLoadingHistory: boolean; selectedStockCode?: string; + selectedAssetType?: AssetAwareAssetType | null; selectedRecordId?: number; onHistoryItemClick: (recordId: number) => void; onDeleteStock?: (stockCode: string) => Promise | void; @@ -77,6 +87,21 @@ function getTaskStatusLabel(task: TaskInfo | undefined, t: (key: UiTextKey, para return task.status; } +function rowHasSelectedIdentity( + row: HomeWatchlistRow, + selectedStockCode: string | null | undefined, + selectedAssetType: AssetAwareAssetType | null | undefined, +): boolean { + // The registry-derived canonical identity (HomePage) wins: an alias-form raw + // watchlist code (e.g. `000016.SH`) whose identityKey is `sh000016` must + // match the canonical selected report without re-parsing the raw alias, and + // must never fold with the bare `000016` stock row. + if (row.identityKey) { + return toAssetAwareCodeKey(selectedStockCode, selectedAssetType) === row.identityKey; + } + return areAssetAwareCodesEquivalent(selectedStockCode, selectedAssetType, row.code, row.assetType); +} + const ScoreBadge: React.FC<{ item?: StockBarItem }> = ({ item }) => { const { t } = useUiLanguage(); const score = typeof item?.sentimentScore === 'number' ? item.sentimentScore : null; @@ -258,6 +283,7 @@ export const HomeStockWorkspace: React.FC = ({ historyItems, isLoadingHistory, selectedStockCode, + selectedAssetType, selectedRecordId, onHistoryItemClick, onDeleteStock, @@ -266,7 +292,12 @@ export const HomeStockWorkspace: React.FC = ({ }) => { const { t } = useUiLanguage(); const [draftCode, setDraftCode] = useState(''); - const [workspaceNoticeCode, setWorkspaceNoticeCode] = useState(null); + // PR #2312: the notice carries the *triggering row's own* identity + // (code + assetType). Reusing the candidate row's assetType for the notice + // code would let an index row and a same-code stock row cross-match (e.g. + // notice code ``000016.SH`` classified with the stock row's asset type folds + // to stock ``000016`` and attaches to the wrong row). + const [workspaceNotice, setWorkspaceNotice] = useState<{ code: string; assetType: AssetAwareAssetType } | null>(null); const pendingWatchlistCount = watchlistRows .filter((row) => !row.analyzedToday && !row.isTodayStatusLoading && !row.isTodayStatusUnknown) .length; @@ -286,8 +317,10 @@ export const HomeStockWorkspace: React.FC = ({ }, [batchStatus]); const visibleWorkspaceNotice = useMemo(() => { - if (!workspaceNoticeCode) return null; - const row = watchlistRows.find((item) => areStockCodesEquivalent(item.code, workspaceNoticeCode)); + if (!workspaceNotice) return null; + const row = watchlistRows.find( + (item) => areAssetAwareCodesEquivalent(item.code, item.assetType, workspaceNotice.code, workspaceNotice.assetType), + ); if (!row) return null; if (row.isTodayStatusLoading) { return { message: t('watchlist.latestDetailLoading') }; @@ -297,28 +330,28 @@ export const HomeStockWorkspace: React.FC = ({ } if (row.latestItem) return null; return { message: t('watchlist.noLatestDetail') }; - }, [t, watchlistRows, workspaceNoticeCode]); + }, [t, watchlistRows, workspaceNotice]); const handleAddSubmit = (event: React.FormEvent) => { event.preventDefault(); const code = draftCode.trim(); if (!code) return; - setWorkspaceNoticeCode(null); + setWorkspaceNotice(null); void onAddToWatchlist(code).then(() => setDraftCode('')); }; const handleWatchlistRowOpen = (row: HomeWatchlistRow) => { if (row.isTodayStatusLoading || row.isTodayStatusUnknown) { - setWorkspaceNoticeCode(row.code); + setWorkspaceNotice({ code: row.code, assetType: row.assetType ?? 'stock' }); return; } const recordId = row.latestItem?.id; if (typeof recordId === 'number') { - setWorkspaceNoticeCode(null); + setWorkspaceNotice(null); onHistoryItemClick(recordId); return; } - setWorkspaceNoticeCode(row.code); + setWorkspaceNotice({ code: row.code, assetType: row.assetType ?? 'stock' }); }; const renderTabs = ( @@ -334,7 +367,7 @@ export const HomeStockWorkspace: React.FC = ({ selected ? 'bg-primary/15 text-primary shadow-inner' : 'text-secondary-text hover:bg-hover hover:text-foreground' }`} onClick={() => { - setWorkspaceNoticeCode(null); + setWorkspaceNotice(null); onTabChange(tab.key); }} > @@ -391,7 +424,7 @@ export const HomeStockWorkspace: React.FC = ({ className="h-7 w-7 px-0" disabled={watchlistLoading} onClick={() => { - setWorkspaceNoticeCode(null); + setWorkspaceNotice(null); void onRefreshWatchlist(); }} aria-label={t('watchlist.refreshAria')} @@ -415,7 +448,7 @@ export const HomeStockWorkspace: React.FC = ({ size="sm" variant="home-action-ai" className="h-8 flex-1 whitespace-nowrap px-2 text-xs sm:flex-none" - disabled={watchlistRows.length === 0 || isBatchAnalyzing} + disabled={watchlistLoading || watchlistRows.length === 0 || isBatchAnalyzing} isLoading={isBatchAnalyzing} loadingText={t('watchlist.submitting')} onClick={() => void onAnalyzeWatchlist('all')} @@ -428,7 +461,7 @@ export const HomeStockWorkspace: React.FC = ({ size="sm" variant="home-action-report" className="h-8 flex-1 whitespace-nowrap px-2 text-xs sm:flex-none" - disabled={pendingWatchlistCount === 0 || isTodayStatusUnavailable || isBatchAnalyzing} + disabled={watchlistLoading || pendingWatchlistCount === 0 || isTodayStatusUnavailable || isBatchAnalyzing} onClick={() => void onAnalyzeWatchlist('pending')} >
)} - {activeStockCode && ( + {activeStockCode && !isRegisteredIndexCanonicalCode(activeStockCode, stockIndex) && (
{activeStockCode}