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
This commit is contained in:
Elvis Wang
2026-09-01 19:25:15 +08:00
committed by GitHub
parent 3d890a0731
commit 972c314656
42 changed files with 4568 additions and 240 deletions

View File

@@ -21,6 +21,7 @@ import copy
import json import json
import logging import logging
import re import re
import unicodedata
import uuid import uuid
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@@ -38,6 +39,7 @@ from api.v1.schemas.analysis import (
BatchTaskAcceptedResponse, BatchTaskAcceptedResponse,
BatchTaskAcceptedItem, BatchTaskAcceptedItem,
BatchDuplicateTaskItem, BatchDuplicateTaskItem,
RejectedTaskItem,
TaskStatus, TaskStatus,
TaskInfo, TaskInfo,
TaskListResponse, TaskListResponse,
@@ -75,6 +77,7 @@ from src.market_phase_summary import (
rebuild_market_phase_summary_for_stock_code, 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_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.report_language import get_localized_stock_name, normalize_report_language
from src.schemas.decision_action import build_action_fields from src.schemas.decision_action import build_action_fields
from src.services.name_to_code_resolver import resolve_name_to_code from src.services.name_to_code_resolver import resolve_name_to_code
@@ -83,6 +86,7 @@ from src.services.task_queue import (
DuplicateTaskError, DuplicateTaskError,
TaskStatus as TaskStatusEnum, 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_diagnostics import build_run_diagnostic_summary
from src.services.run_flow import build_task_run_flow_snapshot from src.services.run_flow import build_task_run_flow_snapshot
from src.services.empty_news import empty_news_disclosure_from_stored 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 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: def _market_review_lock_path(config: Config) -> Path:
return market_review_lock_path(config) 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 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. Code-like tokens go through :func:`parse_analysis_target` (the single
Non-code inputs must resolve to a known stock code. Obvious garbage asset-type authority): registered indices keep their structured
input is rejected before expensive resolver and task-queue work. ``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() text = (raw_value or "").strip()
if not text: 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): 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: if text.isdigit() and len(text) == 4:
resolved_index_code = resolve_index_stock_code_for_analysis(text) resolved_index_code = resolve_index_stock_code_for_analysis(text)
if resolved_index_code != canonical_stock_code(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): if _is_obviously_invalid_analysis_input(text):
raise _invalid_analysis_input_error() raise _invalid_analysis_input_error()
resolved = resolve_name_to_code(text) resolved = resolve_name_to_code(text)
if resolved: if resolved:
return canonical_stock_code(resolved) return (canonical_stock_code(resolved), None)
raise _invalid_analysis_input_error() raise _invalid_analysis_input_error()
@@ -313,29 +379,56 @@ def trigger_analysis(
if not stock_codes: if not stock_codes:
raise api_error(400, "validation_error", "必须提供 stock_code 或 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. # 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() seen = set()
unique_codes = [] 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: if not code:
continue continue
# Use normalize_stock_code to ensure '600519' and '600519.SH' are merged if target is not None and target.asset_type == ParseStatus.UNSUPPORTED:
norm = normalize_stock_code(code) 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: if norm not in seen:
seen.add(norm) seen.add(norm)
unique_codes.append(code) unique_codes.append(code)
unique_targets.append(target)
stock_codes = unique_codes 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: 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. # Sync mode only supports single-stock analysis.
if not request.async_mode: if not request.async_mode:
@@ -345,25 +438,45 @@ def trigger_analysis(
"validation_error", "validation_error",
"同步模式仅支持单只股票分析,请使用 async_mode=true 进行批量分析", "同步模式仅支持单只股票分析,请使用 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. # 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( def _handle_async_analysis_batch(
stock_codes: list, stock_codes: list,
request: AnalyzeRequest request: AnalyzeRequest,
analysis_targets: Optional[list] = None,
rejected_entries: Optional[list] = None,
) -> JSONResponse: ) -> JSONResponse:
""" """
Handle asynchronous analysis requests, including batch submission. 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() task_queue = get_task_queue()
# Preserve metadata for single-stock requests. For batch requests, # Preserve metadata for single-stock requests. For batch requests,
# only carry through metadata that semantically applies to the whole # only carry through metadata that semantically applies to the whole
# batch, such as import/image source tracking. # 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"} preserve_batch_metadata = request.selection_source in {"import", "image"}
stock_name = request.stock_name if is_single else None 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 submit_kwargs["report_language"] = report_language
if skills is not None: if skills is not None:
submit_kwargs["skills"] = skills 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) accepted_tasks, duplicate_errors = task_queue.submit_tasks_batch(**submit_kwargs)
@@ -399,6 +516,7 @@ def _handle_async_analysis_batch(
status="pending", status="pending",
message=f"分析任务已加入队列: {task.stock_code}", message=f"分析任务已加入队列: {task.stock_code}",
analysis_phase=task.analysis_phase, analysis_phase=task.analysis_phase,
asset_type=_task_asset_type(task),
) )
for task in accepted_tasks for task in accepted_tasks
] ]
@@ -410,9 +528,16 @@ def _handle_async_analysis_batch(
) )
for dup in duplicate_errors 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 兼容性 # 单只股票且被拒绝:保持 409 兼容性
if len(stock_codes) == 1 and duplicates: if is_single and duplicates:
dup = duplicates[0] dup = duplicates[0]
error_response = DuplicateTaskErrorResponse( error_response = DuplicateTaskErrorResponse(
error="duplicate_task", error="duplicate_task",
@@ -425,8 +550,8 @@ def _handle_async_analysis_batch(
content=error_response.model_dump() content=error_response.model_dump()
) )
# 单只股票成功:保持原有响应格式兼容性 # 单只股票成功(且无 rejected:保持原有响应格式兼容性
if len(stock_codes) == 1 and accepted: if is_single and accepted and not rejected:
task_accepted = TaskAccepted( task_accepted = TaskAccepted(
task_id=accepted[0].task_id, task_id=accepted[0].task_id,
trace_id=accepted[0].trace_id, trace_id=accepted[0].trace_id,
@@ -439,11 +564,17 @@ def _handle_async_analysis_batch(
content=task_accepted.model_dump() 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( batch_response = BatchTaskAcceptedResponse(
accepted=accepted, accepted=accepted,
duplicates=duplicates, duplicates=duplicates,
message=f"已提交 {len(accepted)} 个任务,{len(duplicates)} 个重复跳过", rejected=rejected if rejected else None,
message=message,
) )
return JSONResponse( return JSONResponse(
status_code=202, status_code=202,
@@ -453,7 +584,8 @@ def _handle_async_analysis_batch(
def _handle_sync_analysis( def _handle_sync_analysis(
stock_code: str, stock_code: str,
request: AnalyzeRequest request: AnalyzeRequest,
analysis_target: Optional[Any] = None,
) -> AnalysisResultResponse: ) -> AnalysisResultResponse:
""" """
处理同步分析请求 处理同步分析请求
@@ -476,6 +608,7 @@ def _handle_sync_analysis(
skills=getattr(request, "skills", None), skills=getattr(request, "skills", None),
analysis_phase=request.analysis_phase, analysis_phase=request.analysis_phase,
report_language=getattr(request, "report_language", None), report_language=getattr(request, "report_language", None),
analysis_target=analysis_target,
) )
if result is None: if result is None:
@@ -647,6 +780,7 @@ def get_task_list(
analysis_phase=t.analysis_phase, analysis_phase=t.analysis_phase,
skills=getattr(t, "skills", None), skills=getattr(t, "skills", None),
region=t.region, region=t.region,
asset_type=_task_asset_type(t),
) )
for t in all_tasks for t in all_tasks
] ]
@@ -1232,6 +1366,7 @@ def get_analysis_status(task_id: str) -> TaskStatus:
current_price=current_price, current_price=current_price,
change_pct=change_pct, change_pct=change_pct,
market_phase_summary=market_phase_summary, market_phase_summary=market_phase_summary,
asset_type=asset_type_from_canonical_code(record.code),
), ),
summary=ReportSummary( summary=ReportSummary(
sentiment_score=record.sentiment_score, sentiment_score=record.sentiment_score,
@@ -1394,6 +1529,7 @@ def _build_analysis_report(
change_pct=change_pct, change_pct=change_pct,
model_used=normalize_model_used(meta_data.get("model_used")), model_used=normalize_model_used(meta_data.get("model_used")),
market_phase_summary=market_phase_summary, 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: def _looks_like_raw_result_payload(candidate: Any) -> bool:

View File

@@ -44,6 +44,7 @@ from src.report_language import (
normalize_report_language, normalize_report_language,
) )
from src.services.history_service import HistoryService, MarkdownReportGenerationError 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.schemas.decision_action import build_action_fields
from src.utils.data_processing import ( from src.utils.data_processing import (
normalize_model_used, normalize_model_used,
@@ -129,14 +130,30 @@ def _history_share_image_branding(config: object) -> ShareImageBranding:
return share_image_branding_from_config(config) return share_image_branding_from_config(config)
def _normalize_code_for_grouping(code: str) -> str: def _stock_bar_group_key(record_code: str, display_code: str) -> str:
"""Normalize stock code for deduplication grouping. """Build the stock-bar grouping key from the *persisted* code.
Delegates to data_provider.base.normalize_stock_code which handles PR #2312: registered indices are typed by the persisted ``record.code``
SH600519, 600519.SH, HK00700, 00700.HK, BJ920748, etc. (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 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: def _raw_result_value(raw_result: Any, key: str) -> Any:
@@ -274,6 +291,7 @@ def get_history_list(
model_used=item.get("model_used"), model_used=item.get("model_used"),
created_at=item.get("created_at"), created_at=item.get("created_at"),
market_phase_summary=item.get("market_phase_summary"), market_phase_summary=item.get("market_phase_summary"),
asset_type=item.get("asset_type"),
) )
for item in result.get("items", []) for item in result.get("items", [])
] ]
@@ -431,7 +449,7 @@ def get_stock_bar(
seen: dict = {} seen: dict = {}
for record in records: for record in records:
display_code = service._display_stock_code(record.code or "") 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: if norm_code not in seen or record.id > seen[norm_code].id:
seen[norm_code] = record seen[norm_code] = record
@@ -482,6 +500,7 @@ def get_stock_bar(
record.code, record.code,
getattr(record, "context_snapshot", None), 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, change_pct=change_pct,
model_used=normalize_model_used(result.get("model_used")), model_used=normalize_model_used(result.get("model_used")),
market_phase_summary=market_phase_summary, 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( summary = ReportSummary(

View File

@@ -29,6 +29,7 @@ from api.v1.schemas.analysis import (
AnalysisResultResponse, AnalysisResultResponse,
TaskAccepted, TaskAccepted,
BatchTaskAcceptedResponse, BatchTaskAcceptedResponse,
RejectedTaskItem,
TaskStatus, TaskStatus,
) )
from api.v1.schemas.history import ( from api.v1.schemas.history import (
@@ -168,6 +169,7 @@ __all__ = [
"AnalysisResultResponse", "AnalysisResultResponse",
"TaskAccepted", "TaskAccepted",
"BatchTaskAcceptedResponse", "BatchTaskAcceptedResponse",
"RejectedTaskItem",
"TaskStatus", "TaskStatus",
# history # history
"HistoryItem", "HistoryItem",

View File

@@ -231,6 +231,10 @@ class BatchTaskAcceptedItem(BaseModel):
) )
message: Optional[str] = Field(None, description="提示信息") message: Optional[str] = Field(None, description="提示信息")
analysis_phase: AnalysisPhase = Field("auto", 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={ model_config = ConfigDict(json_schema_extra={
"example": { "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): class BatchTaskAcceptedResponse(BaseModel):
"""批量异步任务接受响应。""" """批量异步任务接受响应。"""
accepted: List[BatchTaskAcceptedItem] = Field(default_factory=list, description="成功提交的任务列表") accepted: List[BatchTaskAcceptedItem] = Field(default_factory=list, description="成功提交的任务列表")
duplicates: List[BatchDuplicateTaskItem] = 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="汇总信息") message: str = Field(..., description="汇总信息")
model_config = ConfigDict(json_schema_extra={ model_config = ConfigDict(json_schema_extra={
@@ -385,6 +407,10 @@ class TaskInfo(BaseModel):
None, None,
description="大盘复盘任务实际执行的 canonical 市场范围", description="大盘复盘任务实际执行的 canonical 市场范围",
) )
asset_type: Optional[Literal["stock", "index"]] = Field(
None,
description="parser 来源的可选资产类型stock/index由已提交的 analysis_target 透传,旧客户端可缺省",
)
model_config = ConfigDict(json_schema_extra={ model_config = ConfigDict(json_schema_extra={
"example": { "example": {

View File

@@ -52,6 +52,10 @@ class HistoryItem(BaseModel):
description="本次分析市场阶段低敏摘要", description="本次分析市场阶段低敏摘要",
) )
created_at: Optional[str] = Field(None, 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={ model_config = ConfigDict(json_schema_extra={
"example": { "example": {
@@ -149,6 +153,10 @@ class ReportMeta(BaseModel):
None, None,
description="本次分析市场阶段低敏摘要", description="本次分析市场阶段低敏摘要",
) )
asset_type: Optional[Literal["stock", "index"]] = Field(
None,
description="后端权威资产类型stock/index指数报告用于隐藏 stock-only 自选操作。market review 与旧客户端可缺省。",
)
class ReportSummary(BaseModel): class ReportSummary(BaseModel):
@@ -371,6 +379,10 @@ class StockBarItem(BaseModel):
None, None,
description="最新分析市场阶段低敏摘要", description="最新分析市场阶段低敏摘要",
) )
asset_type: Optional[Literal["stock", "index"]] = Field(
None,
description="后端权威资产类型stock/index由持久化代码经 parser 生成。旧客户端可缺省。",
)
model_config = ConfigDict(json_schema_extra={ model_config = ConfigDict(json_schema_extra={
"example": { "example": {
"id": 1234, "id": 1234,

View File

@@ -381,7 +381,7 @@ export const ReportOverview: React.FC<ReportOverviewProps> = ({
{/* 右侧:情绪指标 / 自选操作 */} {/* 右侧:情绪指标 / 自选操作 */}
<div className="flex flex-col space-y-4"> <div className="flex flex-col space-y-4">
{watchlist && meta.reportType !== 'market_review' && ( {watchlist && meta.reportType !== 'market_review' && meta.assetType !== 'index' && (
<Card variant="bordered" padding="sm" className="home-panel-card"> <Card variant="bordered" padding="sm" className="home-panel-card">
<div className="text-center space-y-3"> <div className="text-center space-y-3">
<span className="label-uppercase">{t('report.watchlist')}</span> <span className="label-uppercase">{t('report.watchlist')}</span>

View File

@@ -323,4 +323,57 @@ describe('ReportOverview', () => {
expect(screen.getByText('领跌')).toBeInTheDocument(); expect(screen.getByText('领跌')).toBeInTheDocument();
expect(screen.getByText('-2.50%')).toBeInTheDocument(); expect(screen.getByText('-2.50%')).toBeInTheDocument();
}); });
it('hides the stock-only watchlist card for index reports', () => {
render(
<ReportOverview
meta={{ ...baseMeta, stockCode: 'sh000016', assetType: 'index' }}
summary={baseSummary}
watchlist={{
isInWatchlist: () => 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(
<ReportOverview
meta={{ ...baseMeta, stockCode: '600519', assetType: 'stock' }}
summary={baseSummary}
watchlist={{
isInWatchlist: () => 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(
<ReportOverview
meta={{ ...baseMeta, stockCode: '600519' }}
summary={baseSummary}
watchlist={{
isInWatchlist: () => false,
onToggle: () => {},
isActioning: false,
actionMessage: null,
}}
/>,
);
expect(screen.getByText('自选')).toBeInTheDocument();
});
}); });

View File

@@ -20,7 +20,7 @@ import type { StockBarItem, TaskInfo } from '../../types/analysis';
import { getSentimentColor } from '../../types/analysis'; import { getSentimentColor } from '../../types/analysis';
import { buildDecisionActionLabelMap, getDecisionActionLabel } from '../../utils/decisionAction'; import { buildDecisionActionLabelMap, getDecisionActionLabel } from '../../utils/decisionAction';
import { formatDateTime } from '../../utils/format'; import { formatDateTime } from '../../utils/format';
import { areStockCodesEquivalent } from '../../utils/stockCode'; import { areAssetAwareCodesEquivalent, toAssetAwareCodeKey, type AssetAwareAssetType } from '../../utils/stockCode';
import { truncateStockName } from '../../utils/stockName'; import { truncateStockName } from '../../utils/stockName';
import { useUiLanguage } from '../../contexts/UiLanguageContext'; import { useUiLanguage } from '../../contexts/UiLanguageContext';
import type { UiTextKey, UiTextParams } from '../../i18n/uiText'; import type { UiTextKey, UiTextParams } from '../../i18n/uiText';
@@ -30,6 +30,15 @@ export type WatchlistAnalyzeMode = 'all' | 'pending';
export interface HomeWatchlistRow { export interface HomeWatchlistRow {
code: string; 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; latestItem?: StockBarItem;
analyzedToday: boolean; analyzedToday: boolean;
isTodayStatusLoading?: boolean; isTodayStatusLoading?: boolean;
@@ -62,6 +71,7 @@ interface HomeStockWorkspaceProps {
historyItems: StockBarItem[]; historyItems: StockBarItem[];
isLoadingHistory: boolean; isLoadingHistory: boolean;
selectedStockCode?: string; selectedStockCode?: string;
selectedAssetType?: AssetAwareAssetType | null;
selectedRecordId?: number; selectedRecordId?: number;
onHistoryItemClick: (recordId: number) => void; onHistoryItemClick: (recordId: number) => void;
onDeleteStock?: (stockCode: string) => Promise<void> | void; onDeleteStock?: (stockCode: string) => Promise<void> | void;
@@ -77,6 +87,21 @@ function getTaskStatusLabel(task: TaskInfo | undefined, t: (key: UiTextKey, para
return task.status; 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 ScoreBadge: React.FC<{ item?: StockBarItem }> = ({ item }) => {
const { t } = useUiLanguage(); const { t } = useUiLanguage();
const score = typeof item?.sentimentScore === 'number' ? item.sentimentScore : null; const score = typeof item?.sentimentScore === 'number' ? item.sentimentScore : null;
@@ -258,6 +283,7 @@ export const HomeStockWorkspace: React.FC<HomeStockWorkspaceProps> = ({
historyItems, historyItems,
isLoadingHistory, isLoadingHistory,
selectedStockCode, selectedStockCode,
selectedAssetType,
selectedRecordId, selectedRecordId,
onHistoryItemClick, onHistoryItemClick,
onDeleteStock, onDeleteStock,
@@ -266,7 +292,12 @@ export const HomeStockWorkspace: React.FC<HomeStockWorkspaceProps> = ({
}) => { }) => {
const { t } = useUiLanguage(); const { t } = useUiLanguage();
const [draftCode, setDraftCode] = useState(''); const [draftCode, setDraftCode] = useState('');
const [workspaceNoticeCode, setWorkspaceNoticeCode] = useState<string | null>(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 const pendingWatchlistCount = watchlistRows
.filter((row) => !row.analyzedToday && !row.isTodayStatusLoading && !row.isTodayStatusUnknown) .filter((row) => !row.analyzedToday && !row.isTodayStatusLoading && !row.isTodayStatusUnknown)
.length; .length;
@@ -286,8 +317,10 @@ export const HomeStockWorkspace: React.FC<HomeStockWorkspaceProps> = ({
}, [batchStatus]); }, [batchStatus]);
const visibleWorkspaceNotice = useMemo(() => { const visibleWorkspaceNotice = useMemo(() => {
if (!workspaceNoticeCode) return null; if (!workspaceNotice) return null;
const row = watchlistRows.find((item) => areStockCodesEquivalent(item.code, workspaceNoticeCode)); const row = watchlistRows.find(
(item) => areAssetAwareCodesEquivalent(item.code, item.assetType, workspaceNotice.code, workspaceNotice.assetType),
);
if (!row) return null; if (!row) return null;
if (row.isTodayStatusLoading) { if (row.isTodayStatusLoading) {
return { message: t('watchlist.latestDetailLoading') }; return { message: t('watchlist.latestDetailLoading') };
@@ -297,28 +330,28 @@ export const HomeStockWorkspace: React.FC<HomeStockWorkspaceProps> = ({
} }
if (row.latestItem) return null; if (row.latestItem) return null;
return { message: t('watchlist.noLatestDetail') }; return { message: t('watchlist.noLatestDetail') };
}, [t, watchlistRows, workspaceNoticeCode]); }, [t, watchlistRows, workspaceNotice]);
const handleAddSubmit = (event: React.FormEvent) => { const handleAddSubmit = (event: React.FormEvent) => {
event.preventDefault(); event.preventDefault();
const code = draftCode.trim(); const code = draftCode.trim();
if (!code) return; if (!code) return;
setWorkspaceNoticeCode(null); setWorkspaceNotice(null);
void onAddToWatchlist(code).then(() => setDraftCode('')); void onAddToWatchlist(code).then(() => setDraftCode(''));
}; };
const handleWatchlistRowOpen = (row: HomeWatchlistRow) => { const handleWatchlistRowOpen = (row: HomeWatchlistRow) => {
if (row.isTodayStatusLoading || row.isTodayStatusUnknown) { if (row.isTodayStatusLoading || row.isTodayStatusUnknown) {
setWorkspaceNoticeCode(row.code); setWorkspaceNotice({ code: row.code, assetType: row.assetType ?? 'stock' });
return; return;
} }
const recordId = row.latestItem?.id; const recordId = row.latestItem?.id;
if (typeof recordId === 'number') { if (typeof recordId === 'number') {
setWorkspaceNoticeCode(null); setWorkspaceNotice(null);
onHistoryItemClick(recordId); onHistoryItemClick(recordId);
return; return;
} }
setWorkspaceNoticeCode(row.code); setWorkspaceNotice({ code: row.code, assetType: row.assetType ?? 'stock' });
}; };
const renderTabs = ( const renderTabs = (
@@ -334,7 +367,7 @@ export const HomeStockWorkspace: React.FC<HomeStockWorkspaceProps> = ({
selected ? 'bg-primary/15 text-primary shadow-inner' : 'text-secondary-text hover:bg-hover hover:text-foreground' selected ? 'bg-primary/15 text-primary shadow-inner' : 'text-secondary-text hover:bg-hover hover:text-foreground'
}`} }`}
onClick={() => { onClick={() => {
setWorkspaceNoticeCode(null); setWorkspaceNotice(null);
onTabChange(tab.key); onTabChange(tab.key);
}} }}
> >
@@ -391,7 +424,7 @@ export const HomeStockWorkspace: React.FC<HomeStockWorkspaceProps> = ({
className="h-7 w-7 px-0" className="h-7 w-7 px-0"
disabled={watchlistLoading} disabled={watchlistLoading}
onClick={() => { onClick={() => {
setWorkspaceNoticeCode(null); setWorkspaceNotice(null);
void onRefreshWatchlist(); void onRefreshWatchlist();
}} }}
aria-label={t('watchlist.refreshAria')} aria-label={t('watchlist.refreshAria')}
@@ -415,7 +448,7 @@ export const HomeStockWorkspace: React.FC<HomeStockWorkspaceProps> = ({
size="sm" size="sm"
variant="home-action-ai" variant="home-action-ai"
className="h-8 flex-1 whitespace-nowrap px-2 text-xs sm:flex-none" 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} isLoading={isBatchAnalyzing}
loadingText={t('watchlist.submitting')} loadingText={t('watchlist.submitting')}
onClick={() => void onAnalyzeWatchlist('all')} onClick={() => void onAnalyzeWatchlist('all')}
@@ -428,7 +461,7 @@ export const HomeStockWorkspace: React.FC<HomeStockWorkspaceProps> = ({
size="sm" size="sm"
variant="home-action-report" variant="home-action-report"
className="h-8 flex-1 whitespace-nowrap px-2 text-xs sm:flex-none" 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')} onClick={() => void onAnalyzeWatchlist('pending')}
> >
<CheckCircle2 className="h-4 w-4" aria-hidden="true" /> <CheckCircle2 className="h-4 w-4" aria-hidden="true" />
@@ -520,7 +553,7 @@ export const HomeStockWorkspace: React.FC<HomeStockWorkspaceProps> = ({
key={row.code} key={row.code}
row={row} row={row}
onRemove={async (code) => { onRemove={async (code) => {
setWorkspaceNoticeCode(null); setWorkspaceNotice(null);
await onRemoveFromWatchlist(code); await onRemoveFromWatchlist(code);
}} }}
onOpenDetail={handleWatchlistRowOpen} onOpenDetail={handleWatchlistRowOpen}
@@ -530,8 +563,8 @@ export const HomeStockWorkspace: React.FC<HomeStockWorkspaceProps> = ({
|| ( || (
Boolean(selectedStockCode) Boolean(selectedStockCode)
&& ( && (
areStockCodesEquivalent(selectedStockCode ?? '', row.code) rowHasSelectedIdentity(row, selectedStockCode, selectedAssetType)
|| areStockCodesEquivalent(selectedStockCode ?? '', row.latestItem?.stockCode ?? '') || areAssetAwareCodesEquivalent(selectedStockCode ?? '', selectedAssetType, row.latestItem?.stockCode ?? '', row.latestItem?.assetType)
) )
) )
} }

View File

@@ -9,11 +9,13 @@ function renderWorkspace({
watchlistRows, watchlistRows,
selectedRecordId, selectedRecordId,
selectedStockCode, selectedStockCode,
selectedAssetType,
activeTab = 'watchlist', activeTab = 'watchlist',
}: { }: {
watchlistRows: HomeWatchlistRow[]; watchlistRows: HomeWatchlistRow[];
selectedRecordId?: number; selectedRecordId?: number;
selectedStockCode?: string; selectedStockCode?: string;
selectedAssetType?: 'stock' | 'index' | null;
activeTab?: HomeWorkspaceTab; activeTab?: HomeWorkspaceTab;
}) { }) {
const onHistoryItemClick = vi.fn(); const onHistoryItemClick = vi.fn();
@@ -42,6 +44,7 @@ function renderWorkspace({
historyItems={[]} historyItems={[]}
isLoadingHistory={false} isLoadingHistory={false}
selectedStockCode={selectedStockCode} selectedStockCode={selectedStockCode}
selectedAssetType={selectedAssetType ?? null}
selectedRecordId={selectedRecordId} selectedRecordId={selectedRecordId}
onHistoryItemClick={onHistoryItemClick} onHistoryItemClick={onHistoryItemClick}
/> />
@@ -324,4 +327,159 @@ describe('HomeStockWorkspace', () => {
expect(screen.getByRole('button', { name: '打开 HK700 最新分析详情' })).toHaveAttribute('aria-pressed', 'true'); expect(screen.getByRole('button', { name: '打开 HK700 最新分析详情' })).toHaveAttribute('aria-pressed', 'true');
}); });
it('keeps a same-code stock row selected without selecting the index row', () => {
renderWorkspace({
watchlistRows: [
{
code: 'sh000016',
assetType: 'index',
analyzedToday: true,
latestItem: {
id: 31,
stockCode: 'sh000016',
stockName: '上证50',
sentimentScore: 66,
operationAdvice: '观望',
analysisCount: 1,
lastAnalysisTime: '2026-03-19T09:00:00+08:00',
assetType: 'index',
},
},
{
code: '000016',
assetType: 'stock',
analyzedToday: true,
latestItem: {
id: 32,
stockCode: '000016',
stockName: '深康佳A',
sentimentScore: 71,
operationAdvice: '买入',
analysisCount: 1,
lastAnalysisTime: '2026-03-19T09:00:00+08:00',
assetType: 'stock',
},
},
],
selectedStockCode: '000016',
selectedAssetType: 'stock',
selectedRecordId: 32,
});
const stockRowButton = screen.getByTestId('watchlist-row-000016').querySelector('button[aria-pressed]');
const indexRowButton = screen.getByTestId('watchlist-row-sh000016').querySelector('button[aria-pressed]');
expect(stockRowButton).toHaveAttribute('aria-pressed', 'true');
expect(indexRowButton).toHaveAttribute('aria-pressed', 'false');
});
it('selects the index row when the index report is active', () => {
renderWorkspace({
watchlistRows: [
{
code: 'sh000016',
assetType: 'index',
analyzedToday: true,
latestItem: {
id: 41,
stockCode: 'SH000016',
stockName: '上证50',
sentimentScore: 66,
operationAdvice: '观望',
analysisCount: 2,
lastAnalysisTime: '2026-03-19T09:00:00+08:00',
assetType: 'index',
},
},
{
code: '000016',
assetType: 'stock',
analyzedToday: true,
latestItem: {
id: 42,
stockCode: '000016',
stockName: '深康佳A',
sentimentScore: 71,
operationAdvice: '买入',
analysisCount: 1,
lastAnalysisTime: '2026-03-19T09:00:00+08:00',
assetType: 'stock',
},
},
],
selectedStockCode: 'sh000016',
selectedAssetType: 'index',
selectedRecordId: 41,
});
const indexRowButton = screen.getByTestId('watchlist-row-sh000016').querySelector('button[aria-pressed]');
const stockRowButton = screen.getByTestId('watchlist-row-000016').querySelector('button[aria-pressed]');
expect(indexRowButton).toHaveAttribute('aria-pressed', 'true');
expect(stockRowButton).toHaveAttribute('aria-pressed', 'false');
});
it('selects an alias-form index row via its registry identity even without a latest detail (PR #2312)', () => {
renderWorkspace({
watchlistRows: [
{
code: '000016.SH',
assetType: 'index',
identityKey: 'sh000016',
analyzedToday: true,
},
{
code: '000016',
assetType: 'stock',
analyzedToday: true,
},
],
selectedStockCode: 'sh000016',
selectedAssetType: 'index',
});
// No `selectedRecordId` and no latest detail on either row, so selection
// must come from the identity comparison: the canonical report pairs with
// the alias row through `identityKey` (HomePage's registry resolution) and
// never with the bare `000016` stock row.
const aliasRowButton = screen.getByTestId('watchlist-row-000016.SH').querySelector('button[aria-pressed]');
const stockRowButton = screen.getByTestId('watchlist-row-000016').querySelector('button[aria-pressed]');
expect(aliasRowButton).toHaveAttribute('aria-pressed', 'true');
expect(stockRowButton).toHaveAttribute('aria-pressed', 'false');
});
it('keeps the no-detail notice on the triggering index row instead of reusing the same-code stock row detail (PR #2312)', async () => {
const { onHistoryItemClick } = renderWorkspace({
watchlistRows: [
{
code: '000016',
assetType: 'stock',
analyzedToday: true,
latestItem: {
id: 99,
stockCode: '000016',
stockName: '深康佳A',
sentimentScore: 70,
operationAdvice: '买入',
analysisCount: 1,
lastAnalysisTime: '2026-03-19T09:00:00+08:00',
assetType: 'stock',
},
},
{
code: '000016.SH',
assetType: 'index',
analyzedToday: false,
},
],
});
fireEvent.click(screen.getByTestId('watchlist-row-000016.SH').querySelector('button[aria-pressed]') as HTMLButtonElement);
// The index row has no detail yet. The notice carries the triggering row's
// own code+assetType (000016.SH / index); without that, the shared stock
// normalization would fold 000016.SH to 000016 and the notice would be
// swallowed by the stock row (which HAS a detail).
expect(await screen.findByRole('alert')).toHaveTextContent('暂无分析详情,可先分析。');
expect(onHistoryItemClick).not.toHaveBeenCalled();
});
}); });

View File

@@ -95,8 +95,9 @@ describe('useTaskStream', () => {
data: JSON.stringify({ data: JSON.stringify({
task_id: 'task-1', task_id: 'task-1',
trace_id: 'trace-task-1', trace_id: 'trace-task-1',
stock_code: '600519', stock_code: 'sh000016',
stock_name: '贵州茅台', stock_name: '上证50',
asset_type: 'index',
status: 'processing', status: 'processing',
progress: 72, progress: 72,
message: 'LLM 正在生成分析结果', message: 'LLM 正在生成分析结果',
@@ -129,8 +130,8 @@ describe('useTaskStream', () => {
expect(onTaskProgress).toHaveBeenCalledWith({ expect(onTaskProgress).toHaveBeenCalledWith({
taskId: 'task-1', taskId: 'task-1',
traceId: 'trace-task-1', traceId: 'trace-task-1',
stockCode: '600519', stockCode: 'sh000016',
stockName: '贵州茅台', stockName: '上证50',
status: 'processing', status: 'processing',
progress: 72, progress: 72,
message: 'LLM 正在生成分析结果', message: 'LLM 正在生成分析结果',
@@ -144,6 +145,7 @@ describe('useTaskStream', () => {
analysisPhase: 'intraday', analysisPhase: 'intraday',
skills: ['growth_quality'], skills: ['growth_quality'],
region: 'jp,kr', region: 'jp,kr',
assetType: 'index',
}); });
expect(onTaskFlowEvent).toHaveBeenCalledWith( expect(onTaskFlowEvent).toHaveBeenCalledWith(
expect.objectContaining({ taskId: 'task-1' }), expect.objectContaining({ taskId: 'task-1' }),

View File

@@ -114,6 +114,7 @@ const toTaskInfo = (data: Record<string, unknown>): TaskInfo => {
selectionSource: data.selection_source as string | undefined, selectionSource: data.selection_source as string | undefined,
analysisPhase: data.analysis_phase as TaskInfo['analysisPhase'], analysisPhase: data.analysis_phase as TaskInfo['analysisPhase'],
skills: Array.isArray(data.skills) ? data.skills.map(String) : undefined, skills: Array.isArray(data.skills) ? data.skills.map(String) : undefined,
assetType: data.asset_type as TaskInfo['assetType'] | undefined,
}; };
if (typeof data.trace_id === 'string' && data.trace_id.trim()) { if (typeof data.trace_id === 'string' && data.trace_id.trim()) {

View File

@@ -448,7 +448,9 @@ const zh = {
'watchlist.batchFailed': '自选股批量分析提交失败', 'watchlist.batchFailed': '自选股批量分析提交失败',
'watchlist.batchIncompleteResponse': '批量接口本组请求 {requested} 只,仅确认 {confirmed} 只,返回结果不完整', 'watchlist.batchIncompleteResponse': '批量接口本组请求 {requested} 只,仅确认 {confirmed} 只,返回结果不完整',
'watchlist.batchPartiallySubmitted': '已确认提交 {accepted} 个任务,{duplicates} 个正在运行;另有 {unconfirmed} 只未确认,已停止后续提交并刷新任务列表。原因:{error}', 'watchlist.batchPartiallySubmitted': '已确认提交 {accepted} 个任务,{duplicates} 个正在运行;另有 {unconfirmed} 只未确认,已停止后续提交并刷新任务列表。原因:{error}',
'watchlist.batchPartiallySubmittedWithRejected': '已确认提交 {accepted} 个任务,{duplicates} 个正在运行,{rejected} 个被拒绝;另有 {unconfirmed} 只未确认,已停止后续提交并刷新任务列表。原因:{error}',
'watchlist.batchSubmitted': '已提交 {accepted} 个任务,{duplicates} 个正在运行', 'watchlist.batchSubmitted': '已提交 {accepted} 个任务,{duplicates} 个正在运行',
'watchlist.batchSubmittedWithRejected': '已提交 {accepted} 个任务,{duplicates} 个正在运行,{rejected} 个被拒绝。首个拒绝原因:{reason}',
'watchlist.emptyDescription': '在报告详情或这里添加股票后,可一键运行整组自选股。', 'watchlist.emptyDescription': '在报告详情或这里添加股票后,可一键运行整组自选股。',
'watchlist.emptyTitle': '暂无自选股', 'watchlist.emptyTitle': '暂无自选股',
'watchlist.listHint': '按自选顺序展示,今日状态实时标记', 'watchlist.listHint': '按自选顺序展示,今日状态实时标记',
@@ -1405,7 +1407,9 @@ const en: Record<UiTextKey, string> = {
'watchlist.batchFailed': 'Watchlist batch submit failed', 'watchlist.batchFailed': 'Watchlist batch submit failed',
'watchlist.batchIncompleteResponse': 'The batch API received {requested} stock(s) in this chunk but confirmed only {confirmed}; the response is incomplete', 'watchlist.batchIncompleteResponse': 'The batch API received {requested} stock(s) in this chunk but confirmed only {confirmed}; the response is incomplete',
'watchlist.batchPartiallySubmitted': 'Confirmed {accepted} submitted task(s) and {duplicates} already running; {unconfirmed} stock(s) remain unconfirmed. Further submission stopped and the task list was refreshed. Reason: {error}', 'watchlist.batchPartiallySubmitted': 'Confirmed {accepted} submitted task(s) and {duplicates} already running; {unconfirmed} stock(s) remain unconfirmed. Further submission stopped and the task list was refreshed. Reason: {error}',
'watchlist.batchPartiallySubmittedWithRejected': 'Confirmed {accepted} submitted task(s), {duplicates} already running, {rejected} rejected; {unconfirmed} stock(s) remain unconfirmed. Further submission stopped and the task list was refreshed. Reason: {error}',
'watchlist.batchSubmitted': 'Submitted {accepted} task(s), {duplicates} already running', 'watchlist.batchSubmitted': 'Submitted {accepted} task(s), {duplicates} already running',
'watchlist.batchSubmittedWithRejected': 'Submitted {accepted} task(s), {duplicates} already running, {rejected} rejected. First rejection reason: {reason}',
'watchlist.emptyDescription': 'Add stocks here or from a report, then run the whole watchlist from one place.', 'watchlist.emptyDescription': 'Add stocks here or from a report, then run the whole watchlist from one place.',
'watchlist.emptyTitle': 'No watchlist stocks', 'watchlist.emptyTitle': 'No watchlist stocks',
'watchlist.listHint': 'Shown in watchlist order with today status', 'watchlist.listHint': 'Shown in watchlist order with today status',

View File

@@ -27,7 +27,12 @@ import {
import { isNearBottom } from '../utils/chatScroll'; import { isNearBottom } from '../utils/chatScroll';
import { getReportText } from '../utils/reportLanguage'; import { getReportText } from '../utils/reportLanguage';
import { extractStockCodesFromMessage } from '../utils/chatStockCode'; import { extractStockCodesFromMessage } from '../utils/chatStockCode';
import { findMatchingStockCode, includesStockCode, normalizeStockCode } from '../utils/stockCode'; import {
findMatchingStockCode,
includesStockCode,
normalizeStockCode,
resolveRegisteredIndexCanonical,
} from '../utils/stockCode';
import { useStockIndex } from '../hooks/useStockIndex'; import { useStockIndex } from '../hooks/useStockIndex';
import type { StockIndexItem } from '../types/stockIndex'; import type { StockIndexItem } from '../types/stockIndex';
import { useUiLanguage } from '../contexts/UiLanguageContext'; import { useUiLanguage } from '../contexts/UiLanguageContext';
@@ -78,13 +83,33 @@ const resolveUniqueStockNameContext = (
if (!terms.some((term) => normalizedMessage.includes(term.toLocaleLowerCase()))) { if (!terms.some((term) => normalizedMessage.includes(term.toLocaleLowerCase()))) {
continue; continue;
} }
const stockCode = normalizeStockCode(item.canonicalCode); // Index canonical codes (sh000001 / csi930955) must be preserved verbatim —
// normalizeStockCode would strip the exchange prefix and collide with a
// same-digit stock (sh000001 → 000001 vs 平安银行 000001).
const stockCode = item.assetType === 'index'
? item.canonicalCode
: normalizeStockCode(item.canonicalCode);
matches.set(stockCode, { stock_code: stockCode, stock_name: item.nameZh || null }); matches.set(stockCode, { stock_code: stockCode, stock_name: item.nameZh || null });
} }
return matches.size === 1 ? [...matches.values()][0] : null; return matches.size === 1 ? [...matches.values()][0] : null;
}; };
/**
* Determine whether an active stock code resolves to a registered index.
*
* Only an exact registry canonical/display/explicit-alias hit counts; bare
* same-digit stocks must never be typed as indexes through normalization or
* prefix guessing. Stock-only watchlist actions are hidden for these matches.
*/
const isRegisteredIndexCanonicalCode = (
code: string | null,
index: StockIndexItem[],
): boolean => {
if (!code) return false;
return resolveRegisteredIndexCanonical(index, code) !== null;
};
const getMessageSkillNames = (msg: Message): string[] => { const getMessageSkillNames = (msg: Message): string[] => {
if (msg.skillNames?.length) return msg.skillNames; if (msg.skillNames?.length) return msg.skillNames;
if (msg.skillName) return [msg.skillName]; if (msg.skillName) return [msg.skillName];
@@ -113,15 +138,28 @@ const getPipelineBudgetSkippedLabel = (step: ProgressStep): string => {
return `${step.stage || 'pipeline'} skipped: insufficient budget`; return `${step.stage || 'pipeline'} skipped: insufficient budget`;
}; };
// Comparison identity key: registry canonical first (so an index context keeps
// its lowercase canonical and never normalizes into the bare same-code stock),
// then the stock normalization fallback. Never guesses index types from prefixes.
const resolveComparisonStockKey = (
code: string | null | undefined,
index: StockIndexItem[],
): string | null => {
if (!code) return null;
const trimmed = code.trim();
if (!trimmed) return null;
return resolveRegisteredIndexCanonical(index, trimmed) ?? normalizeStockCode(trimmed);
};
const isCompareStockMessage = ( const isCompareStockMessage = (
message: string, message: string,
stockCodes: string[], stockCodes: string[],
currentStockCode?: string | null, currentStockKey?: string | null,
): boolean => { ): boolean => {
if (STRONG_COMPARE_STOCK_MESSAGE_RE.test(message)) { if (STRONG_COMPARE_STOCK_MESSAGE_RE.test(message)) {
return true; return true;
} }
const current = currentStockCode ? normalizeStockCode(currentStockCode) : null; const current = currentStockKey ?? null;
const newStockCodes = current const newStockCodes = current
? stockCodes.filter((code) => code !== current) ? stockCodes.filter((code) => code !== current)
: stockCodes; : stockCodes;
@@ -137,7 +175,7 @@ const isCompareStockMessage = (
if (stockCodes.length >= 2) { if (stockCodes.length >= 2) {
return true; return true;
} }
if (!currentStockCode) { if (!currentStockKey) {
return false; return false;
} }
const hasNewStock = stockCodes.some((code) => code !== current); const hasNewStock = stockCodes.some((code) => code !== current);
@@ -147,26 +185,27 @@ const isCompareStockMessage = (
const resolveActiveStockContextFromMessage = ( const resolveActiveStockContextFromMessage = (
message: string, message: string,
currentContext: ActiveStockContext | null, currentContext: ActiveStockContext | null,
index: StockIndexItem[],
): ActiveStockResolution | null => { ): ActiveStockResolution | null => {
const stockCodes = extractStockCodesFromMessage(message); const stockCodes = extractStockCodesFromMessage(message, index);
const stockCode = stockCodes[0] ?? null; const stockCode = stockCodes[0] ?? null;
if (!stockCode) { if (!stockCode) {
return null; return null;
} }
const isCompare = isCompareStockMessage(message, stockCodes, currentContext?.stock_code); // Registry-first identity keys so an index context (sh000016) is never
// folded with the bare same-code stock when comparing or switching.
const currentStockKey = resolveComparisonStockKey(currentContext?.stock_code, index);
const isCompare = isCompareStockMessage(message, stockCodes, currentStockKey);
const isSwitch = SWITCH_STOCK_MESSAGE_RE.test(message); const isSwitch = SWITCH_STOCK_MESSAGE_RE.test(message);
const currentStockCode = currentContext?.stock_code const newStockCodes = currentStockKey
? normalizeStockCode(currentContext.stock_code) ? stockCodes.filter((code) => code !== currentStockKey)
: null;
const newStockCodes = currentStockCode
? stockCodes.filter((code) => code !== currentStockCode)
: stockCodes; : stockCodes;
// Explicit switches can mention the old stock; use the single new code when present. // Explicit switches can mention the old stock; use the single new code when present.
const targetStockCode = isSwitch && newStockCodes.length === 1 const targetStockCode = isSwitch && newStockCodes.length === 1
? newStockCodes[0] ? newStockCodes[0]
: stockCode; : stockCode;
const isDifferentStock = currentStockCode !== targetStockCode; const isDifferentStock = currentStockKey !== resolveComparisonStockKey(targetStockCode, index);
// Compare messages and implicit follow-ups must not rewrite the active stock context. // Compare messages and implicit follow-ups must not rewrite the active stock context.
if (isCompare || (currentContext && !isSwitch)) { if (isCompare || (currentContext && !isSwitch)) {
@@ -185,13 +224,16 @@ const resolveActiveStockContextFromMessage = (
}; };
}; };
const restoreActiveStockContextFromMessages = (messages: Message[]): ActiveStockContext | null => { const restoreActiveStockContextFromMessages = (
messages: Message[],
index: StockIndexItem[],
): ActiveStockContext | null => {
let restoredContext: ActiveStockContext | null = null; let restoredContext: ActiveStockContext | null = null;
for (const message of messages) { for (const message of messages) {
if (message.role !== 'user') { if (message.role !== 'user') {
continue; continue;
} }
const resolution = resolveActiveStockContextFromMessage(message.content, restoredContext); const resolution = resolveActiveStockContextFromMessage(message.content, restoredContext, index);
if (resolution) { if (resolution) {
restoredContext = resolution.context; restoredContext = resolution.context;
} }
@@ -233,9 +275,9 @@ const ChatPage: React.FC = () => {
const [agentStatus, setAgentStatus] = useState<AgentStatusResponse | null>(null); const [agentStatus, setAgentStatus] = useState<AgentStatusResponse | null>(null);
const [agentStatusError, setAgentStatusError] = useState<string | null>(null); const [agentStatusError, setAgentStatusError] = useState<string | null>(null);
const [agentStatusChecking, setAgentStatusChecking] = useState(true); const [agentStatusChecking, setAgentStatusChecking] = useState(true);
const { index: stockIndex } = useStockIndex( // All Chat backends need the registry before resolving stock identity.
agentStatus?.backend === 'codex_app_server', const { index: stockIndex, loading: stockIndexLoading } = useStockIndex();
);
const watchlistMessageTimerRef = useRef<number | null>(null); const watchlistMessageTimerRef = useRef<number | null>(null);
const copyResetTimerRef = useRef<Partial<Record<string, number>>>({}); const copyResetTimerRef = useRef<Partial<Record<string, number>>>({});
const messagesViewportRef = useRef<HTMLDivElement>(null); const messagesViewportRef = useRef<HTMLDivElement>(null);
@@ -365,13 +407,16 @@ const ChatPage: React.FC = () => {
if (activeStockContext || messages.length === 0) { if (activeStockContext || messages.length === 0) {
return; return;
} }
const restoredContext = restoreActiveStockContextFromMessages(messages); if (stockIndexLoading) {
return;
}
const restoredContext = restoreActiveStockContextFromMessages(messages, stockIndex);
if (!restoredContext) { if (!restoredContext) {
return; return;
} }
setActiveStockContext(restoredContext); setActiveStockContext(restoredContext);
setActiveStockCode(restoredContext.stock_code); setActiveStockCode(restoredContext.stock_code);
}, [activeStockContext, messages, sessionId]); }, [activeStockContext, messages, sessionId, stockIndex, stockIndexLoading]);
const syncScrollState = useCallback(() => { const syncScrollState = useCallback(() => {
const viewport = messagesViewportRef.current; const viewport = messagesViewportRef.current;
@@ -632,7 +677,33 @@ const ChatPage: React.FC = () => {
// Handle follow-up from report page: ?stock=600519&name=贵州茅台&recordId=xxx // Handle follow-up from report page: ?stock=600519&name=贵州茅台&recordId=xxx
useEffect(() => { useEffect(() => {
const stock = sanitizeFollowUpStockCode(searchParams.get('stock')); const rawStockCode = searchParams.get('stock');
if (!rawStockCode) {
// Nothing to follow up — only clear when there actually ARE stale query
// params. Skipping the empty case avoids redundant `replace: true`
// navigations on every effect re-run (the gate below legitimately re-runs
// as status/registry state settles), which would otherwise clobber
// subsequent RouterProvider navigation state asserted by other tests.
if (searchParams.size === 0) {
return;
}
setSearchParams({}, { replace: true });
return;
}
if (stockIndexLoading) {
return;
}
if (agentStatusChecking) {
return;
}
// Registry canonical first for explicit index follow-ups (sh000016 /
// 000016.SH / 930955.CSI / csi930955) so the report-follow-up canonical is
// preserved end-to-end; the sanitize contract is untouched and remains the
// stock fail-open when the registry is unavailable or the code unregistered.
const registryCanonical = resolveRegisteredIndexCanonical(stockIndex, rawStockCode);
const stock = registryCanonical ?? sanitizeFollowUpStockCode(rawStockCode);
const name = sanitizeFollowUpStockName(searchParams.get('name')); const name = sanitizeFollowUpStockName(searchParams.get('name'));
const recordId = parseFollowUpRecordId(searchParams.get('recordId')); const recordId = parseFollowUpRecordId(searchParams.get('recordId'));
@@ -670,7 +741,7 @@ const ChatPage: React.FC = () => {
} }
}); });
setSearchParams({}, { replace: true }); setSearchParams({}, { replace: true });
}, [searchParams, setSearchParams]); }, [searchParams, setSearchParams, stockIndex, stockIndexLoading, agentStatusChecking]);
const handleSend = useCallback( const handleSend = useCallback(
async ( async (
@@ -679,7 +750,7 @@ const ChatPage: React.FC = () => {
overrideStockContext?: ActiveStockContext, overrideStockContext?: ActiveStockContext,
) => { ) => {
const msgText = (overrideMessage ?? input).trim(); const msgText = (overrideMessage ?? input).trim();
if (!msgText || loading || !agentAvailable || !agentStatus) return; if (!msgText || loading || stockIndexLoading || !agentAvailable || !agentStatus) return;
if (overrideMessage !== undefined) { if (overrideMessage !== undefined) {
setInput(msgText); setInput(msgText);
} }
@@ -696,7 +767,7 @@ const ChatPage: React.FC = () => {
let useActiveContextForThisSend = Boolean(codexStockContext); let useActiveContextForThisSend = Boolean(codexStockContext);
const stockResolution = codexStockContext const stockResolution = codexStockContext
? null ? null
: resolveActiveStockContextFromMessage(msgText, activeStockContext); : resolveActiveStockContextFromMessage(msgText, activeStockContext, stockIndex);
if (stockResolution) { if (stockResolution) {
nextActiveStockContext = stockResolution.context; nextActiveStockContext = stockResolution.context;
useActiveContextForThisSend = stockResolution.useForCurrentSend; useActiveContextForThisSend = stockResolution.useForCurrentSend;
@@ -740,7 +811,7 @@ const ChatPage: React.FC = () => {
}, },
}); });
}, },
[activeStockContext, agentAvailable, agentStatus, getSkillNames, input, loading, normalizeSelectedSkillIds, requestScrollToBottom, selectedSkillIds, sessionId, sessionSelectedSkillIds, startStream, stockIndex], [activeStockContext, agentAvailable, agentStatus, getSkillNames, input, loading, normalizeSelectedSkillIds, requestScrollToBottom, selectedSkillIds, sessionId, sessionSelectedSkillIds, startStream, stockIndex, stockIndexLoading],
); );
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => { const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
@@ -1281,7 +1352,7 @@ const ChatPage: React.FC = () => {
<button <button
key={i} key={i}
onClick={() => handleQuickQuestion(q)} onClick={() => handleQuickQuestion(q)}
disabled={!agentAvailable} disabled={!agentAvailable || stockIndexLoading}
className="quick-question-btn disabled:cursor-not-allowed disabled:opacity-60" className="quick-question-btn disabled:cursor-not-allowed disabled:opacity-60"
> >
{q.label} {q.label}
@@ -1619,7 +1690,7 @@ const ChatPage: React.FC = () => {
</div> </div>
)} )}
{activeStockCode && ( {activeStockCode && !isRegisteredIndexCanonicalCode(activeStockCode, stockIndex) && (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-xs text-muted-text font-mono">{activeStockCode}</span> <span className="text-xs text-muted-text font-mono">{activeStockCode}</span>
<Button <Button
@@ -1666,8 +1737,8 @@ const ChatPage: React.FC = () => {
<Button <Button
variant="primary" variant="primary"
onClick={() => handleSend()} onClick={() => handleSend()}
disabled={!input.trim() || loading || !agentAvailable} disabled={!input.trim() || loading || stockIndexLoading || !agentAvailable}
isLoading={loading} isLoading={loading || stockIndexLoading}
className="btn-primary flex-shrink-0" className="btn-primary flex-shrink-0"
> >

View File

@@ -38,7 +38,8 @@ import type {
} from '../types/analysis'; } from '../types/analysis';
import type { RunFlowSnapshotSource } from '../types/runFlow'; import type { RunFlowSnapshotSource } from '../types/runFlow';
import { getTodayInShanghai } from '../utils/format'; import { getTodayInShanghai } from '../utils/format';
import { normalizeStockCode } from '../utils/stockCode'; import { useStockIndex } from '../hooks/useStockIndex';
import { resolveRegisteredIndexCanonical, toAssetAwareCodeKey, type AssetAwareAssetType } from '../utils/stockCode';
type MarketReviewNotice = { type MarketReviewNotice = {
variant: 'success' | 'warning' | 'danger'; variant: 'success' | 'warning' | 'danger';
@@ -142,9 +143,10 @@ function shiftDateKey(dateKey: string, days: number): string {
return date.toISOString().slice(0, 10); return date.toISOString().slice(0, 10);
} }
function getStockCodeKey(code?: string | null): string { function getStockCodeKey(code?: string | null, assetType?: AssetAwareAssetType | null): string {
const trimmed = (code ?? '').trim(); // Direct delegation: `toAssetAwareCodeKey` already treats unknown/stock codes
return trimmed ? normalizeStockCode(trimmed).toUpperCase() : ''; // with the existing stock normalization, so no extra branching is needed.
return toAssetAwareCodeKey(code, assetType);
} }
function chunkStockCodes(codes: string[]): string[][] { function chunkStockCodes(codes: string[]): string[][] {
@@ -180,14 +182,15 @@ function writeTaskPanelCollapsedPreference(collapsed: boolean): void {
} }
} }
function countBatchAccepted(result: AnalyzeAsyncResponse): { accepted: number; duplicates: number } { function countBatchAccepted(result: AnalyzeAsyncResponse): { accepted: number; duplicates: number; rejected: number } {
if ('accepted' in result) { if ('accepted' in result) {
return { return {
accepted: result.accepted.length, accepted: result.accepted.length,
duplicates: result.duplicates.length, duplicates: result.duplicates.length,
rejected: result.rejected?.length ?? 0,
}; };
} }
return { accepted: 1, duplicates: 0 }; return { accepted: 1, duplicates: 0, rejected: 0 };
} }
function toStockBarItemFromHistoryItem(item: HistoryItem): StockBarItem { function toStockBarItemFromHistoryItem(item: HistoryItem): StockBarItem {
@@ -204,6 +207,7 @@ function toStockBarItemFromHistoryItem(item: HistoryItem): StockBarItem {
lastAnalysisTime: item.createdAt, lastAnalysisTime: item.createdAt,
modelUsed: item.modelUsed, modelUsed: item.modelUsed,
marketPhaseSummary: item.marketPhaseSummary ?? null, marketPhaseSummary: item.marketPhaseSummary ?? null,
assetType: item.assetType,
}; };
} }
@@ -248,6 +252,40 @@ const HomePage: React.FC = () => {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { language: uiLanguage, t } = useUiLanguage(); const { language: uiLanguage, t } = useUiLanguage();
const {
index: stockIndexItems,
fallback: isStockIndexFallback,
loaded: isStockIndexLoaded,
} = useStockIndex();
// Registry is "ready" once loaded — or once an explicit fallback happened
// (load failure), which keeps the existing fail-open stock semantics.
const isStockIndexReady = isStockIndexLoaded || isStockIndexFallback;
// PR #2312: raw watchlist strings carry no asset type — only an exact
// canonical/display/alias hit on a loaded ``assetType=index`` registry row
// buckets the code as an index; anything else keeps the legacy stock
// normalization (fail-open). Backend-tagged items/tasks/reports never touch
// the registry — they use their explicit asset type directly.
const getWatchlistCodeIdentity = useCallback(
(code: string | null | undefined): { key: string; assetType: AssetAwareAssetType } => {
const trimmed = (code ?? '').trim();
if (!trimmed) {
return { key: '', assetType: 'stock' };
}
const indexCanonical = resolveRegisteredIndexCanonical(stockIndexItems, trimmed);
if (indexCanonical) {
return { key: indexCanonical, assetType: 'index' };
}
return { key: getStockCodeKey(trimmed, 'stock'), assetType: 'stock' };
},
[stockIndexItems],
);
const getWatchlistCodeKey = useCallback(
(code: string | null | undefined): string => getWatchlistCodeIdentity(code).key,
[getWatchlistCodeIdentity],
);
const [sidebarOpen, setSidebarOpen] = useState(false); const [sidebarOpen, setSidebarOpen] = useState(false);
const [isSubmittingMarketReview, setIsSubmittingMarketReview] = useState(false); const [isSubmittingMarketReview, setIsSubmittingMarketReview] = useState(false);
const [marketReviewNotice, setMarketReviewNotice] = useState<MarketReviewNotice>(null); const [marketReviewNotice, setMarketReviewNotice] = useState<MarketReviewNotice>(null);
@@ -603,7 +641,7 @@ const HomePage: React.FC = () => {
if (task.reportType === 'market_review') { if (task.reportType === 'market_review') {
return; return;
} }
const key = getStockCodeKey(task.stockCode); const key = getStockCodeKey(task.stockCode, task.assetType);
if (!key) { if (!key) {
return; return;
} }
@@ -618,7 +656,7 @@ const HomePage: React.FC = () => {
if (task.reportType === 'market_review') { if (task.reportType === 'market_review') {
return; return;
} }
const key = getStockCodeKey(task.stockCode); const key = getStockCodeKey(task.stockCode, task.assetType);
if (key) { if (key) {
setCompletedTaskRefreshPendingCounts((current) => { setCompletedTaskRefreshPendingCounts((current) => {
const pendingCount = current.get(key) ?? 0; const pendingCount = current.get(key) ?? 0;
@@ -674,14 +712,14 @@ const HomePage: React.FC = () => {
const watchlistCodesByNormalized = useMemo(() => { const watchlistCodesByNormalized = useMemo(() => {
const codesByNormalized = new Map<string, string>(); const codesByNormalized = new Map<string, string>();
for (const code of watchlistState.watchlistCodes) { for (const code of watchlistState.watchlistCodes) {
const key = getStockCodeKey(code); const key = getWatchlistCodeKey(code);
if (!key || key === 'MARKET' || codesByNormalized.has(key)) { if (!key || key === 'MARKET' || codesByNormalized.has(key)) {
continue; continue;
} }
codesByNormalized.set(key, code); codesByNormalized.set(key, code);
} }
return Array.from(codesByNormalized.entries()); return Array.from(codesByNormalized.entries());
}, [watchlistState.watchlistCodes]); }, [getWatchlistCodeKey, watchlistState.watchlistCodes]);
const stockBarItemByCode = useMemo(() => { const stockBarItemByCode = useMemo(() => {
const itemsByCode = new Map<string, StockBarItem>(); const itemsByCode = new Map<string, StockBarItem>();
@@ -689,7 +727,7 @@ const HomePage: React.FC = () => {
if (item.stockCode === 'MARKET') { if (item.stockCode === 'MARKET') {
continue; continue;
} }
const key = getStockCodeKey(item.stockCode); const key = getStockCodeKey(item.stockCode, item.assetType);
if (key) { if (key) {
itemsByCode.set(key, item); itemsByCode.set(key, item);
} }
@@ -697,7 +735,12 @@ const HomePage: React.FC = () => {
return itemsByCode; return itemsByCode;
}, [stockBarItems]); }, [stockBarItems]);
const canLookupWatchlistHistory = !isLoadingStockBar && isStockBarInitialLoadSettled; // Watchlist identity resolution must never run while the stock index registry
// is still loading: a raw index string (`sh000016`) would transiently fall
// into the stock bucket and cross-link with the same-code stock. The lookup
// stays off until the registry is loaded (or an explicit fallback happened),
// at which point the existing fail-open semantics apply.
const canLookupWatchlistHistory = isStockIndexReady && !isLoadingStockBar && isStockBarInitialLoadSettled;
const watchlistMissingHistoryEntries = useMemo( const watchlistMissingHistoryEntries = useMemo(
() => ( () => (
@@ -748,7 +791,7 @@ const HomePage: React.FC = () => {
const next = new Map<string, StockBarItem>(); const next = new Map<string, StockBarItem>();
const failedKeys = new Set<string>(); const failedKeys = new Set<string>();
for (const entry of results) { for (const entry of results) {
const key = getStockCodeKey(entry.code); const key = getWatchlistCodeKey(entry.code);
if (!key) { if (!key) {
continue; continue;
} }
@@ -782,7 +825,7 @@ const HomePage: React.FC = () => {
isCanceled = true; isCanceled = true;
abortController.abort(); abortController.abort();
}; };
}, [canLookupWatchlistHistory, watchlistHistoryRetryVersion, watchlistMissingHistoryEntries, watchlistMissingHistorySignature]); }, [canLookupWatchlistHistory, getWatchlistCodeKey, watchlistHistoryRetryVersion, watchlistMissingHistoryEntries, watchlistMissingHistorySignature]);
const clearMarketReviewState = useCallback(() => { const clearMarketReviewState = useCallback(() => {
stopMarketReviewPolling(); stopMarketReviewPolling();
@@ -1101,7 +1144,7 @@ const HomePage: React.FC = () => {
if (task.reportType === 'market_review') { if (task.reportType === 'market_review') {
continue; continue;
} }
const key = getStockCodeKey(task.stockCode); const key = getStockCodeKey(task.stockCode, task.assetType);
if (key) { if (key) {
tasksByCode.set(key, task); tasksByCode.set(key, task);
} }
@@ -1111,7 +1154,8 @@ const HomePage: React.FC = () => {
const watchlistRows = useMemo<HomeWatchlistRow[]>(() => ( const watchlistRows = useMemo<HomeWatchlistRow[]>(() => (
watchlistState.watchlistCodes.map((code) => { watchlistState.watchlistCodes.map((code) => {
const key = getStockCodeKey(code); const identity = getWatchlistCodeIdentity(code);
const key = identity.key;
const latestItemCandidate = key const latestItemCandidate = key
? stockBarItemByCode.get(key) ?? watchlistHistoryItemsByCode.get(key) ? stockBarItemByCode.get(key) ?? watchlistHistoryItemsByCode.get(key)
: undefined; : undefined;
@@ -1145,6 +1189,11 @@ const HomePage: React.FC = () => {
: latestItemCandidate; : latestItemCandidate;
return { return {
code, code,
assetType: identity.assetType,
// Registry-derived canonical identity: lets the workspace match a
// canonical selected report against an alias-form raw watchlist row
// (e.g. `000016.SH` -> identityKey `sh000016`) without re-parsing.
identityKey: identity.key,
latestItem, latestItem,
analyzedToday: !isTodayStatusLoading && !isTodayStatusUnknown && getShanghaiDateKey(latestItem?.lastAnalysisTime) === todayDateKey, analyzedToday: !isTodayStatusLoading && !isTodayStatusUnknown && getShanghaiDateKey(latestItem?.lastAnalysisTime) === todayDateKey,
isTodayStatusLoading, isTodayStatusLoading,
@@ -1158,6 +1207,7 @@ const HomePage: React.FC = () => {
completedTaskRefreshPendingCounts, completedTaskRefreshPendingCounts,
isLoadingStockBar, isLoadingStockBar,
stockBarRefreshFailed, stockBarRefreshFailed,
getWatchlistCodeIdentity,
stockBarItemByCode, stockBarItemByCode,
todayDateKey, todayDateKey,
watchlistHistoryItemsByCode, watchlistHistoryItemsByCode,
@@ -1213,6 +1263,12 @@ const HomePage: React.FC = () => {
}, [todayDateKey, todayHistoryItems]); }, [todayDateKey, todayHistoryItems]);
const handleAnalyzeWatchlist = useCallback(async (mode: WatchlistAnalyzeMode) => { const handleAnalyzeWatchlist = useCallback(async (mode: WatchlistAnalyzeMode) => {
// The workspace button is disabled while the registry loads, but keep this
// guard at the action boundary so no alternate caller can dedupe an index
// alias with a same-code stock using the temporary fail-open key.
if (!isStockIndexReady) {
return;
}
if (mode === 'pending' && watchlistTodayStatusBlocked) { if (mode === 'pending' && watchlistTodayStatusBlocked) {
setBatchAnalyzeStatus({ setBatchAnalyzeStatus({
variant: 'warning', variant: 'warning',
@@ -1224,7 +1280,7 @@ const HomePage: React.FC = () => {
const sourceCodes = mode === 'pending' ? pendingWatchlistCodes : watchlistState.watchlistCodes; const sourceCodes = mode === 'pending' ? pendingWatchlistCodes : watchlistState.watchlistCodes;
const seen = new Set<string>(); const seen = new Set<string>();
const targetCodes = sourceCodes.filter((code) => { const targetCodes = sourceCodes.filter((code) => {
const key = getStockCodeKey(code); const key = getWatchlistCodeKey(code);
if (!key || seen.has(key)) { if (!key || seen.has(key)) {
return false; return false;
} }
@@ -1244,6 +1300,8 @@ const HomePage: React.FC = () => {
setBatchAnalyzeStatus(null); setBatchAnalyzeStatus(null);
let acceptedCount = 0; let acceptedCount = 0;
let duplicateCount = 0; let duplicateCount = 0;
let rejectedCount = 0;
let rejectedReasons: string[] = [];
let confirmedCodeCount = 0; let confirmedCodeCount = 0;
let submissionError: ParsedApiError | null = null; let submissionError: ParsedApiError | null = null;
try { try {
@@ -1258,7 +1316,17 @@ const HomePage: React.FC = () => {
const counts = countBatchAccepted(result); const counts = countBatchAccepted(result);
acceptedCount += counts.accepted; acceptedCount += counts.accepted;
duplicateCount += counts.duplicates; duplicateCount += counts.duplicates;
const confirmedInChunk = counts.accepted + counts.duplicates; rejectedCount += counts.rejected;
if (counts.rejected > 0 && 'rejected' in result && result.rejected) {
rejectedReasons = rejectedReasons.concat(
result.rejected.map((entry) => `${entry.stockCode}: ${entry.message}`),
);
}
// accepted + duplicates + rejected is the server's full disposition of
// this chunk. Rejected entries are an explicit server response (not a
// short/partial response), so they count toward "confirmed" and must
// not be mistaken for an incomplete response that halts later chunks.
const confirmedInChunk = counts.accepted + counts.duplicates + counts.rejected;
confirmedCodeCount += Math.min(confirmedInChunk, chunk.length); confirmedCodeCount += Math.min(confirmedInChunk, chunk.length);
if (confirmedInChunk !== chunk.length) { if (confirmedInChunk !== chunk.length) {
submissionError = getParsedApiError(new Error(t('watchlist.batchIncompleteResponse', { submissionError = getParsedApiError(new Error(t('watchlist.batchIncompleteResponse', {
@@ -1284,12 +1352,16 @@ const HomePage: React.FC = () => {
setSidebarWorkspaceTab('watchlist'); setSidebarWorkspaceTab('watchlist');
if (submissionError) { if (submissionError) {
if (acceptedCount > 0 || duplicateCount > 0) { if (acceptedCount > 0 || duplicateCount > 0 || rejectedCount > 0) {
const partialKey = rejectedCount > 0
? 'watchlist.batchPartiallySubmittedWithRejected'
: 'watchlist.batchPartiallySubmitted';
setBatchAnalyzeStatus({ setBatchAnalyzeStatus({
variant: 'warning', variant: 'warning',
message: t('watchlist.batchPartiallySubmitted', { message: t(partialKey, {
accepted: acceptedCount, accepted: acceptedCount,
duplicates: duplicateCount, duplicates: duplicateCount,
rejected: rejectedCount,
unconfirmed: targetCodes.length - confirmedCodeCount, unconfirmed: targetCodes.length - confirmedCodeCount,
error: submissionError.message || t('watchlist.batchFailed'), error: submissionError.message || t('watchlist.batchFailed'),
}), }),
@@ -1303,6 +1375,19 @@ const HomePage: React.FC = () => {
return; return;
} }
if (rejectedCount > 0) {
setBatchAnalyzeStatus({
variant: 'warning',
message: t('watchlist.batchSubmittedWithRejected', {
accepted: acceptedCount,
duplicates: duplicateCount,
rejected: rejectedCount,
reason: rejectedReasons[0] ?? t('watchlist.batchFailed'),
}),
});
return;
}
setBatchAnalyzeStatus({ setBatchAnalyzeStatus({
variant: acceptedCount > 0 ? 'success' : 'warning', variant: acceptedCount > 0 ? 'success' : 'warning',
message: t('watchlist.batchSubmitted', { message: t('watchlist.batchSubmitted', {
@@ -1320,6 +1405,8 @@ const HomePage: React.FC = () => {
setIsBatchAnalyzingWatchlist(false); setIsBatchAnalyzingWatchlist(false);
} }
}, [ }, [
getWatchlistCodeKey,
isStockIndexReady,
notify, notify,
pendingWatchlistCodes, pendingWatchlistCodes,
refreshActiveTasks, refreshActiveTasks,
@@ -1369,7 +1456,7 @@ const HomePage: React.FC = () => {
activeTab={sidebarWorkspaceTab} activeTab={sidebarWorkspaceTab}
onTabChange={setSidebarWorkspaceTab} onTabChange={setSidebarWorkspaceTab}
watchlistRows={watchlistRows} watchlistRows={watchlistRows}
watchlistLoading={watchlistState.isLoading} watchlistLoading={watchlistState.isLoading || !isStockIndexReady}
watchlistActioning={watchlistState.isActioning} watchlistActioning={watchlistState.isActioning}
watchlistMessage={watchlistState.actionMessage} watchlistMessage={watchlistState.actionMessage}
onAddToWatchlist={watchlistState.addToWatchlist} onAddToWatchlist={watchlistState.addToWatchlist}
@@ -1385,6 +1472,7 @@ const HomePage: React.FC = () => {
historyItems={mergedStockBarItems} historyItems={mergedStockBarItems}
isLoadingHistory={isLoadingStockBar} isLoadingHistory={isLoadingStockBar}
selectedStockCode={selectedReport?.meta.stockCode} selectedStockCode={selectedReport?.meta.stockCode}
selectedAssetType={selectedReport?.meta.assetType ?? null}
selectedRecordId={selectedReport?.meta.id} selectedRecordId={selectedReport?.meta.id}
onHistoryItemClick={handleHistoryItemClick} onHistoryItemClick={handleHistoryItemClick}
onDeleteStock={handleDeleteStock} onDeleteStock={handleDeleteStock}
@@ -1405,11 +1493,13 @@ const HomePage: React.FC = () => {
isDeletingStock, isDeletingStock,
isLoadingStockBar, isLoadingStockBar,
isLoadingTodayAnalysisItems, isLoadingTodayAnalysisItems,
isStockIndexReady,
isTaskPanelCollapsed, isTaskPanelCollapsed,
todayAnalysisLoadFailed, todayAnalysisLoadFailed,
mergedStockBarItems, mergedStockBarItems,
openTaskRunFlow, openTaskRunFlow,
selectedReport?.meta.id, selectedReport?.meta.id,
selectedReport?.meta.assetType,
selectedReport?.meta.stockCode, selectedReport?.meta.stockCode,
sidebarWorkspaceTab, sidebarWorkspaceTab,
todayAnalysisItems, todayAnalysisItems,

View File

@@ -6,6 +6,7 @@ import { createParsedApiError } from '../../api/error';
import { UiLanguageProvider } from '../../contexts/UiLanguageContext'; import { UiLanguageProvider } from '../../contexts/UiLanguageContext';
import { historyApi } from '../../api/history'; import { historyApi } from '../../api/history';
import type { Message, ProgressStep } from '../../stores/agentChatStore'; import type { Message, ProgressStep } from '../../stores/agentChatStore';
import type { StockIndexItem } from '../../types/stockIndex';
import { UI_LANGUAGE_STORAGE_KEY } from '../../utils/uiLanguage'; import { UI_LANGUAGE_STORAGE_KEY } from '../../utils/uiLanguage';
import ChatPage from '../ChatPage'; import ChatPage from '../ChatPage';
import { extractStockCodeFromMessage, extractStockCodesFromMessage } from '../../utils/chatStockCode'; import { extractStockCodeFromMessage, extractStockCodesFromMessage } from '../../utils/chatStockCode';
@@ -33,25 +34,43 @@ const {
mockDownloadSession, mockDownloadSession,
mockFormatSessionAsMarkdown, mockFormatSessionAsMarkdown,
mockStockIndex, mockStockIndex,
} = vi.hoisted(() => ({ mockStockIndexState,
mockGetSkills: vi.fn(), } = vi.hoisted(() => {
mockGetStatus: vi.fn(), const mockStockIndex = [
mockDeleteChatSession: vi.fn(),
mockSendChat: vi.fn(),
mockGetSystemConfig: vi.fn(),
mockUpdateSystemConfig: vi.fn(),
mockGetWatchlist: vi.fn(),
mockAddToWatchlist: vi.fn(),
mockRemoveFromWatchlist: vi.fn(),
mockDownloadSession: vi.fn(),
mockFormatSessionAsMarkdown: vi.fn(),
mockStockIndex: [
{ canonicalCode: '600519.SH', displayCode: '600519', nameZh: '贵州茅台', aliases: ['茅台'], market: 'CN', assetType: 'stock', active: true }, { canonicalCode: '600519.SH', displayCode: '600519', nameZh: '贵州茅台', aliases: ['茅台'], market: 'CN', assetType: 'stock', active: true },
{ canonicalCode: '300750.SZ', displayCode: '300750', nameZh: '宁德时代', aliases: [], market: 'CN', assetType: 'stock', active: true }, { canonicalCode: '300750.SZ', displayCode: '300750', nameZh: '宁德时代', aliases: [], market: 'CN', assetType: 'stock', active: true },
{ canonicalCode: '000001.SZ', displayCode: '000001', nameZh: '平安银行', aliases: [], market: 'CN', assetType: 'stock', active: true },
{ canonicalCode: 'BABA', displayCode: 'BABA', nameZh: '阿里巴巴', aliases: [], market: 'US', assetType: 'stock', active: true }, { canonicalCode: 'BABA', displayCode: 'BABA', nameZh: '阿里巴巴', aliases: [], market: 'US', assetType: 'stock', active: true },
{ canonicalCode: '09988.HK', displayCode: '09988', nameZh: '阿里巴巴', aliases: [], market: 'HK', assetType: 'stock', active: true }, { canonicalCode: '09988.HK', displayCode: '09988', nameZh: '阿里巴巴', aliases: [], market: 'HK', assetType: 'stock', active: true },
], { canonicalCode: 'sh000001', displayCode: 'sh000001', nameZh: '上证指数', aliases: ['000001.SH'], market: 'CN', assetType: 'index', active: true },
})); { canonicalCode: 'sh000016', displayCode: 'sh000016', nameZh: '上证50', aliases: ['000016.SH'], market: 'CN', assetType: 'index', active: true },
{ canonicalCode: 'sz399001', displayCode: 'sz399001', nameZh: '深证成指', aliases: ['399001.SZ'], market: 'CN', assetType: 'index', active: true },
{ canonicalCode: 'sh000300', displayCode: 'sh000300', nameZh: '沪深300', aliases: ['sz399300', '399300.SZ', '000300.SH', '000300.CSI'], market: 'CN', assetType: 'index', active: true },
{ canonicalCode: 'csi930955', displayCode: '930955.CSI', nameZh: '红利低波100', aliases: [], market: 'CN', assetType: 'index', active: true },
];
return {
mockGetSkills: vi.fn(),
mockGetStatus: vi.fn(),
mockDeleteChatSession: vi.fn(),
mockSendChat: vi.fn(),
mockGetSystemConfig: vi.fn(),
mockUpdateSystemConfig: vi.fn(),
mockGetWatchlist: vi.fn(),
mockAddToWatchlist: vi.fn(),
mockRemoveFromWatchlist: vi.fn(),
mockDownloadSession: vi.fn(),
mockFormatSessionAsMarkdown: vi.fn(),
mockStockIndex,
// Mutable registry-load state for the async window shared by every backend.
mockStockIndexState: {
index: mockStockIndex,
loading: false,
error: null as Error | null,
fallback: false,
loaded: true,
},
};
});
const mockLoadSessions = vi.fn(); const mockLoadSessions = vi.fn();
const mockLoadInitialSession = vi.fn(); const mockLoadInitialSession = vi.fn();
@@ -121,11 +140,11 @@ vi.mock('../../api/history', () => ({
vi.mock('../../hooks/useStockIndex', () => ({ vi.mock('../../hooks/useStockIndex', () => ({
useStockIndex: () => ({ useStockIndex: () => ({
index: mockStockIndex, index: mockStockIndexState.index,
loading: false, loading: mockStockIndexState.loading,
error: null, error: mockStockIndexState.error,
fallback: false, fallback: mockStockIndexState.fallback,
loaded: true, loaded: mockStockIndexState.loaded,
}), }),
})); }));
@@ -257,6 +276,11 @@ beforeEach(() => {
}); });
mockDownloadSession.mockImplementation(() => {}); mockDownloadSession.mockImplementation(() => {});
mockFormatSessionAsMarkdown.mockReturnValue('# exported session'); mockFormatSessionAsMarkdown.mockReturnValue('# exported session');
mockStockIndexState.index = mockStockIndex;
mockStockIndexState.loading = false;
mockStockIndexState.error = null;
mockStockIndexState.fallback = false;
mockStockIndexState.loaded = true;
}); });
describe('ChatPage', () => { describe('ChatPage', () => {
@@ -445,7 +469,11 @@ describe('ChatPage', () => {
expect(input).toBeDisabled(); expect(input).toBeDisabled();
fireEvent.click(screen.getByRole('button', { name: '前往 Agent 设置' })); fireEvent.click(screen.getByRole('button', { name: '前往 Agent 设置' }));
expect(await screen.findByText('Agent settings destination')).toBeInTheDocument(); expect(await screen.findByText('Agent settings destination')).toBeInTheDocument();
expect(router.state.location.search).toBe('?category=agent'); // React Router v7 applies navigations asynchronously; waitFor keeps the
// assertion in an act-wrapped retry loop instead of reading a stale router state.
await waitFor(() => {
expect(router.state.location.search).toBe('?category=agent');
});
}); });
it('keeps sending disabled when backend status cannot be established', async () => { it('keeps sending disabled when backend status cannot be established', async () => {
@@ -507,6 +535,358 @@ describe('ChatPage', () => {
}); });
}); });
it('resolves a registered index name to its canonical code without stripping the prefix', async () => {
mockGetStatus.mockResolvedValueOnce({
backend: 'codex_app_server',
available: true,
experimental: true,
errorCode: null,
message: null,
});
let sentPayload: { context?: { stock_code: string; stock_name: string | null } } | undefined;
mockStartStream.mockImplementation(async (payload) => {
sentPayload = payload as typeof sentPayload;
});
render(
<MemoryRouter initialEntries={['/chat']}>
<ChatPage />
</MemoryRouter>,
);
const input = await screen.findByPlaceholderText(/分析 600519/);
fireEvent.change(input, { target: { value: '分析上证指数' } });
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => expect(mockStartStream).toHaveBeenCalledTimes(1));
// sh000001 (上证指数) must be preserved verbatim — normalizeStockCode would
// strip it to 000001 and collide with 平安银行 (000001.SZ).
expect(sentPayload?.context?.stock_code).toBe('sh000001');
expect(sentPayload?.context?.stock_name).toBe('上证指数');
});
it('resolves a registered CSI index display alias to its canonical code', async () => {
mockGetStatus.mockResolvedValueOnce({
backend: 'codex_app_server',
available: true,
experimental: true,
errorCode: null,
message: null,
});
let sentPayload: { context?: { stock_code: string; stock_name: string | null } } | undefined;
mockStartStream.mockImplementation(async (payload) => {
sentPayload = payload as typeof sentPayload;
});
render(
<MemoryRouter initialEntries={['/chat']}>
<ChatPage />
</MemoryRouter>,
);
const input = await screen.findByPlaceholderText(/分析 600519/);
fireEvent.change(input, { target: { value: '分析红利低波100' } });
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => expect(mockStartStream).toHaveBeenCalledTimes(1));
expect(sentPayload?.context?.stock_code).toBe('csi930955');
expect(sentPayload?.context?.stock_name).toBe('红利低波100');
});
it('hides the watchlist action for a registered index canonical in Codex mode', async () => {
mockGetStatus.mockResolvedValueOnce({
backend: 'codex_app_server',
available: true,
experimental: true,
errorCode: null,
message: null,
});
mockStartStream.mockImplementation(async (_payload, meta) => {
meta?.onAccepted?.({
type: 'accepted',
backend: 'codex_app_server',
request_id: 'request-index',
session_id: 'session-1',
});
});
render(
<MemoryRouter initialEntries={['/chat']}>
<ChatPage />
</MemoryRouter>,
);
const input = await screen.findByPlaceholderText(/分析 600519/);
fireEvent.change(input, { target: { value: '分析上证50' } });
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => expect(mockStartStream).toHaveBeenCalledTimes(1));
// sh000016 is a registered index canonical → stock-only watchlist hidden.
expect(screen.queryByText('加入自选')).not.toBeInTheDocument();
expect(screen.queryByText('从自选删除')).not.toBeInTheDocument();
});
it('keeps the watchlist action for a bare stock code that shares digits with an index', async () => {
mockGetWatchlist.mockResolvedValue([]);
render(
<MemoryRouter initialEntries={['/chat']}>
<ChatPage />
</MemoryRouter>,
);
const input = await screen.findByPlaceholderText(/分析 600519/);
fireEvent.change(input, { target: { value: '分析 000001' } });
fireEvent.keyDown(input, { key: 'Enter' });
// 000001 (平安银行) is a stock; only the sh000001 index canonical hides the
// action, so the bare same-digit stock keeps its watchlist button.
expect(await screen.findByText('加入自选')).toBeInTheDocument();
});
const CODEX_STATUS = {
backend: 'codex_app_server',
available: true,
experimental: true,
errorCode: null,
message: null,
};
const acceptedEvent = (requestId: string) => ({
type: 'accepted' as const,
backend: 'codex_app_server' as const,
request_id: requestId,
session_id: 'session-1' as const,
});
it.each([
['sh000016', 'sh000016'],
['000016.SH', 'sh000016'],
['930955.CSI', 'csi930955'],
['csi930955', 'csi930955'],
['sz399001', 'sz399001'],
] as const)('sends an explicit index code %s as its registry canonical', async (inputCode, expectedCanonical) => {
mockGetStatus.mockResolvedValueOnce(CODEX_STATUS);
let sentPayload: { context?: { stock_code: string; stock_name: string | null } } | undefined;
mockStartStream.mockImplementation(async (payload) => {
sentPayload = payload as typeof sentPayload;
});
render(
<MemoryRouter initialEntries={['/chat']}>
<ChatPage />
</MemoryRouter>,
);
const input = await screen.findByPlaceholderText(/分析 600519/);
fireEvent.change(input, { target: { value: `分析 ${inputCode}` } });
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => expect(mockStartStream).toHaveBeenCalledTimes(1));
// The registry canonical must survive extraction end-to-end — never be
// stripped to a bare same-code stock, and the inner bare digits of a dotted
// alias must not leak as a second code.
expect(sentPayload?.context?.stock_code).toBe(expectedCanonical);
expect(sentPayload?.context?.stock_name).toBeNull();
});
it.each([
['sh000016', 'sh000016'],
['000016.SH', 'sh000016'],
['000016.sh', 'sh000016'],
['CSI930955', 'csi930955'],
['930955.CSI', 'csi930955'],
['csi930955', 'csi930955'],
['sz399001', 'sz399001'],
['sz399300', 'sh000300'],
['399300.SZ', 'sh000300'],
['000300.SH', 'sh000300'],
] as const)('sends %s as %s under the default LiteLLM backend', async (inputCode, expectedCanonical) => {
render(
<MemoryRouter initialEntries={['/chat']}>
<ChatPage />
</MemoryRouter>,
);
const input = await screen.findByPlaceholderText(/分析 600519/);
fireEvent.change(input, { target: { value: `分析 ${inputCode}` } });
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => {
expect(mockStartStream).toHaveBeenLastCalledWith(
expect.objectContaining({
context: { stock_code: expectedCanonical, stock_name: null },
}),
expect.any(Object),
);
});
});
it('blocks every chat send entry point while the index registry is loading', async () => {
mockStockIndexState.index = [];
mockStockIndexState.loading = true;
mockStockIndexState.loaded = false;
render(
<MemoryRouter initialEntries={['/chat']}>
<ChatPage />
</MemoryRouter>,
);
const input = await screen.findByPlaceholderText(/分析 600519/);
fireEvent.change(input, { target: { value: '分析 sh000016' } });
const sendButton = screen.getByRole('button', { name: '处理中...' });
const quickQuestion = screen.getByRole('button', { name: '分析比亚迪趋势' });
expect(sendButton).toBeDisabled();
expect(quickQuestion).toBeDisabled();
fireEvent.keyDown(input, { key: 'Enter' });
fireEvent.click(sendButton);
fireEvent.click(quickQuestion);
expect(mockStartStream).not.toHaveBeenCalled();
});
it.each([
['success', mockStockIndex, null, false, 'sh000016'],
['failure', [], new Error('registry unavailable'), true, '000016'],
['empty', [], null, false, '000016'],
] as const)('releases direct sends after registry %s settle', async (_scenario, index, error, fallback, expectedCode) => {
mockStockIndexState.index = [];
mockStockIndexState.loading = true;
mockStockIndexState.loaded = false;
const { rerender } = render(
<MemoryRouter initialEntries={['/chat']}>
<ChatPage />
</MemoryRouter>,
);
const input = await screen.findByPlaceholderText(/分析 600519/);
fireEvent.change(input, { target: { value: '分析 sh000016' } });
mockStockIndexState.index = [...index];
mockStockIndexState.loading = false;
mockStockIndexState.error = error;
mockStockIndexState.fallback = fallback;
mockStockIndexState.loaded = true;
rerender(
<MemoryRouter initialEntries={['/chat']}>
<ChatPage />
</MemoryRouter>,
);
const sendButton = screen.getByRole('button', { name: '发送' });
await waitFor(() => expect(sendButton).toBeEnabled());
fireEvent.click(sendButton);
await waitFor(() => {
expect(mockStartStream).toHaveBeenLastCalledWith(
expect.objectContaining({
context: { stock_code: expectedCode, stock_name: null },
}),
expect.any(Object),
);
});
});
it('switches from an explicit index canonical to the bare same-code stock', async () => {
mockGetStatus.mockResolvedValueOnce(CODEX_STATUS);
mockStartStream.mockImplementation(async (_payload, meta) => {
meta?.onAccepted?.(acceptedEvent('request-index'));
});
render(
<MemoryRouter initialEntries={['/chat']}>
<ChatPage />
</MemoryRouter>,
);
const input = await screen.findByPlaceholderText(/分析 600519/);
fireEvent.change(input, { target: { value: '分析 sh000016' } });
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => expect(mockStartStream).toHaveBeenCalledTimes(1));
fireEvent.change(input, { target: { value: '换成 000016 看看' } });
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => expect(mockStartStream).toHaveBeenCalledTimes(2));
// The explicit switch must send the BARE stock context — the index name or
// self-selected state must not be reused across identities.
expect(mockStartStream.mock.calls[1][0].context).toEqual({
stock_code: '000016',
stock_name: null,
});
});
it('keeps the active index context untouched for compare messages mixing same-code identities', async () => {
mockGetStatus.mockResolvedValueOnce(CODEX_STATUS);
mockStartStream.mockImplementation(async (_payload, meta) => {
meta?.onAccepted?.(acceptedEvent('request-index'));
});
render(
<MemoryRouter initialEntries={['/chat']}>
<ChatPage />
</MemoryRouter>,
);
const input = await screen.findByPlaceholderText(/分析 600519/);
fireEvent.change(input, { target: { value: '分析 sh000016' } });
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => expect(mockStartStream).toHaveBeenCalledTimes(1));
fireEvent.change(input, { target: { value: '比较 sh000016 和 000016 的差异' } });
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => expect(mockStartStream).toHaveBeenCalledTimes(2));
expect(mockStartStream.mock.calls[1][0].context).toEqual({
stock_code: 'sh000016',
stock_name: null,
});
});
it('hides the stock-only watchlist action after an explicit index code is sent', async () => {
mockGetStatus.mockResolvedValueOnce(CODEX_STATUS);
mockStartStream.mockImplementation(async (_payload, meta) => {
meta?.onAccepted?.(acceptedEvent('request-index'));
});
render(
<MemoryRouter initialEntries={['/chat']}>
<ChatPage />
</MemoryRouter>,
);
const input = await screen.findByPlaceholderText(/分析 600519/);
fireEvent.change(input, { target: { value: '分析 930955.CSI' } });
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => expect(mockStartStream).toHaveBeenCalledTimes(1));
expect(screen.queryByText('加入自选')).not.toBeInTheDocument();
expect(screen.queryByText('从自选删除')).not.toBeInTheDocument();
});
it('keeps the stock guard for sh600519 / SZ000001 even when the registry is loaded', async () => {
mockGetStatus.mockResolvedValueOnce(CODEX_STATUS);
let sentPayload: { context?: { stock_code: string; stock_name: string | null } } | undefined;
mockStartStream.mockImplementation(async (payload) => {
sentPayload = payload as typeof sentPayload;
});
render(
<MemoryRouter initialEntries={['/chat']}>
<ChatPage />
</MemoryRouter>,
);
const input = await screen.findByPlaceholderText(/分析 600519/);
fireEvent.change(input, { target: { value: '分析 sh600519' } });
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => expect(mockStartStream).toHaveBeenCalledTimes(1));
// sh600519 is not a registered index alias → the stock guard keeps the
// bare 600519 identity even with the registry loaded.
expect(sentPayload?.context?.stock_code).toBe('600519');
expect(sentPayload?.context?.stock_name).toBeNull();
});
it('renders the new Codex status copy in English when the UI language is English', async () => { it('renders the new Codex status copy in English when the UI language is English', async () => {
window.localStorage.setItem(UI_LANGUAGE_STORAGE_KEY, 'en'); window.localStorage.setItem(UI_LANGUAGE_STORAGE_KEY, 'en');
mockGetStatus.mockResolvedValueOnce({ mockGetStatus.mockResolvedValueOnce({
@@ -2044,7 +2424,9 @@ describe('ChatPage', () => {
expect(await screen.findByDisplayValue('请深入分析 贵州茅台(600519)')).toBeInTheDocument(); expect(await screen.findByDisplayValue('请深入分析 贵州茅台(600519)')).toBeInTheDocument();
expect(screen.getByText('正在加载历史分析上下文;现在可直接发送追问。')).toBeInTheDocument(); expect(screen.getByText('正在加载历史分析上下文;现在可直接发送追问。')).toBeInTheDocument();
await router.navigate('/chat?stock=AAPL&name=Apple&recordId=2'); await act(async () => {
await router.navigate('/chat?stock=AAPL&name=Apple&recordId=2');
});
expect(await screen.findByDisplayValue('请深入分析 Apple(AAPL)')).toBeInTheDocument(); expect(await screen.findByDisplayValue('请深入分析 Apple(AAPL)')).toBeInTheDocument();
@@ -2119,6 +2501,217 @@ describe('ChatPage', () => {
}); });
}); });
it.each([
['sh000016', 'sh000016'],
['csi930955', 'csi930955'],
['000016.SH', 'sh000016'],
] as const)('restores the %s follow-up URL to the registry canonical and hides the stock-only watchlist action', async (stockParam, expectedCanonical) => {
render(
<MemoryRouter initialEntries={[`/chat?stock=${stockParam}`]}>
<ChatPage />
</MemoryRouter>,
);
const expectedPrompt = `请深入分析 ${expectedCanonical}`;
expect(await screen.findByDisplayValue(expectedPrompt)).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => {
expect(mockStartStream).toHaveBeenLastCalledWith(
expect.objectContaining({
message: expectedPrompt,
context: {
stock_code: expectedCanonical,
stock_name: null,
},
}),
expect.any(Object),
);
});
// The index canonical hides the stock-only watchlist action immediately.
expect(screen.queryByText('加入自选')).not.toBeInTheDocument();
expect(screen.queryByText('从自选删除')).not.toBeInTheDocument();
});
it('defers a default-backend index follow-up until the registry settles', async () => {
mockStockIndexState.index = [];
mockStockIndexState.loading = true;
mockStockIndexState.loaded = false;
const { rerender } = render(
<MemoryRouter initialEntries={['/chat?stock=sh000016']}>
<ChatPage />
</MemoryRouter>,
);
expect(screen.queryByDisplayValue(/请深入分析/)).not.toBeInTheDocument();
mockStockIndexState.index = mockStockIndex;
mockStockIndexState.loading = false;
mockStockIndexState.loaded = true;
rerender(
<MemoryRouter initialEntries={['/chat?stock=sh000016']}>
<ChatPage />
</MemoryRouter>,
);
expect(await screen.findByDisplayValue('请深入分析 sh000016')).toBeInTheDocument();
});
type RegistrySettleRow = [
string,
{ index: typeof mockStockIndexState.index; error: Error | null; fallback: boolean },
string,
];
it.each<RegistrySettleRow>([
[
'success settle with index data',
{ index: mockStockIndex, error: null, fallback: false },
'请深入分析 sh000016',
],
[
'explicit load failure fail-open',
{ index: [], error: new Error('registry unavailable'), fallback: true },
'请深入分析 SH000016',
],
[
'successful-empty registry fail-open',
{ index: [], error: null, fallback: false },
'请深入分析 SH000016',
],
])(
'releases the default-backend index follow-up after the shared registry settles: %s',
async (_scenario, finalRegistry, expectedPrompt) => {
mockStockIndexState.index = [];
mockStockIndexState.loading = true;
mockStockIndexState.error = null;
mockStockIndexState.fallback = false;
mockStockIndexState.loaded = false;
const { rerender } = render(
<MemoryRouter initialEntries={['/chat?stock=sh000016']}>
<ChatPage />
</MemoryRouter>,
);
await waitFor(() => {
expect(screen.queryByDisplayValue(/请深入分析 (SH000016|sh000016)/)).not.toBeInTheDocument();
});
// Settle regardless of outcome: success consumes the registry canonical;
// failure and an empty registry fail open to the stock path.
mockStockIndexState.index = finalRegistry.index;
mockStockIndexState.loading = false;
mockStockIndexState.error = finalRegistry.error;
mockStockIndexState.fallback = finalRegistry.fallback;
mockStockIndexState.loaded = true;
rerender(
<MemoryRouter initialEntries={['/chat?stock=sh000016']}>
<ChatPage />
</MemoryRouter>,
);
expect(await screen.findByDisplayValue(expectedPrompt)).toBeInTheDocument();
},
);
it('restores the active Codex index canonical from a loaded session message and hides the stock-only watchlist action', async () => {
mockGetStatus.mockResolvedValueOnce({
backend: 'codex_app_server',
available: true,
experimental: true,
errorCode: null,
message: null,
});
// Registry already settled with index data before the session loads — the
// approved message-restore path must resolve the explicit SH index
// canonical so the follow-up context and watchlist gating stay consistent.
mockStockIndexState.index = mockStockIndex;
mockStockIndexState.loading = false;
mockStockIndexState.error = null;
mockStockIndexState.fallback = false;
mockStockIndexState.loaded = true;
mockStoreState.messages = [
{ id: 'm-1', role: 'user', content: '分析 sh000016' },
{ id: 'm-2', role: 'assistant', content: '上证50 分析结果', skillName: '指数分析' },
];
render(
<MemoryRouter initialEntries={['/chat']}>
<ChatPage />
</MemoryRouter>,
);
expect(await screen.findByTestId('chat-workspace')).toBeInTheDocument();
// Restored canonical keeps the lowercase index identity → the stock-only
// watchlist button is hidden, exactly like a direct index follow-up.
expect(screen.queryByText('加入自选')).not.toBeInTheDocument();
expect(screen.queryByText('从自选删除')).not.toBeInTheDocument();
fireEvent.change(screen.getByPlaceholderText(/分析 600519/), {
target: { value: '继续看上证50的支撑位' },
});
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => {
expect(mockStartStream).toHaveBeenLastCalledWith(
expect.objectContaining({
message: '继续看上证50的支撑位',
context: {
stock_code: 'sh000016',
stock_name: null,
},
}),
// The send meta uses the session's default skill, not the historical
// assistant message's skill label.
expect.objectContaining({
skillName: '趋势分析',
}),
);
});
});
it('defers default-backend history restoration until the registry settles', async () => {
mockStockIndexState.index = [];
mockStockIndexState.loading = true;
mockStockIndexState.loaded = false;
mockStoreState.messages = [
{ id: 'm-1', role: 'user', content: '分析 sh000016' },
{ id: 'm-2', role: 'assistant', content: '上证50 分析结果', skillName: '指数分析' },
];
const { rerender } = render(
<MemoryRouter initialEntries={['/chat']}>
<ChatPage />
</MemoryRouter>,
);
expect(await screen.findByRole('button', { name: '处理中...' })).toBeDisabled();
mockStockIndexState.index = mockStockIndex;
mockStockIndexState.loading = false;
mockStockIndexState.loaded = true;
rerender(
<MemoryRouter initialEntries={['/chat']}>
<ChatPage />
</MemoryRouter>,
);
const input = screen.getByPlaceholderText(/分析 600519/);
fireEvent.change(input, { target: { value: '继续看支撑位' } });
fireEvent.click(screen.getByRole('button', { name: '发送' }));
await waitFor(() => {
expect(mockStartStream).toHaveBeenLastCalledWith(
expect.objectContaining({
context: { stock_code: 'sh000016', stock_name: null },
}),
expect.any(Object),
);
});
});
it('shows a jump-to-latest action when new content arrives while the user is away from bottom', async () => { it('shows a jump-to-latest action when new content arrives while the user is away from bottom', async () => {
mockStoreState.messages = [ mockStoreState.messages = [
{ id: 'user-1', role: 'user', content: '请分析 600519' }, { id: 'user-1', role: 'user', content: '请分析 600519' },
@@ -2264,6 +2857,75 @@ describe('extractStockCodeFromMessage', () => {
}); });
}); });
describe('extractStockCodesFromMessage with index registry', () => {
// The hoisted mock widens market/assetType to plain strings; the extraction
// parameter is StockIndexItem[], so a structural cast keeps the registry rows.
const registeredIndex = mockStockIndex as unknown as StockIndexItem[];
it('resolves explicit SH-prefixed index tokens to the registry canonical only', () => {
expect(extractStockCodesFromMessage('分析 sh000016', registeredIndex)).toEqual(['sh000016']);
expect(extractStockCodesFromMessage('分析 SH000016', registeredIndex)).toEqual(['sh000016']);
});
it('resolves dotted SH index aliases without leaking the inner bare digits', () => {
expect(extractStockCodesFromMessage('分析 000016.SH', registeredIndex)).toEqual(['sh000016']);
expect(extractStockCodesFromMessage('分析 000016.sh', registeredIndex)).toEqual(['sh000016']);
});
it('resolves CSI display and prefix forms to the single csi canonical', () => {
expect(extractStockCodesFromMessage('分析 930955.CSI', registeredIndex)).toEqual(['csi930955']);
expect(extractStockCodesFromMessage('分析 CSI930955', registeredIndex)).toEqual(['csi930955']);
expect(extractStockCodesFromMessage('分析 csi930955', registeredIndex)).toEqual(['csi930955']);
});
it('resolves a registered SZ index code via its registry canonical', () => {
expect(extractStockCodesFromMessage('分析 sz399001', registeredIndex)).toEqual(['sz399001']);
});
it('keeps the bare same-code stock distinct from a registered index', () => {
expect(extractStockCodesFromMessage('分析 000016', registeredIndex)).toEqual(['000016']);
expect(extractStockCodesFromMessage('SH000016 和 000016', registeredIndex)).toEqual(['sh000016', '000016']);
expect(extractStockCodesFromMessage('000016 和 sh000016', registeredIndex)).toEqual(['000016', 'sh000016']);
});
it('keeps stock semantics for unregistered explicit forms when the registry is loaded', () => {
expect(extractStockCodesFromMessage('分析 sh600519', registeredIndex)).toEqual(['600519']);
expect(extractStockCodesFromMessage('分析 SZ000001', registeredIndex)).toEqual(['000001']);
});
it('fails open to the stock path when no registry is passed', () => {
expect(extractStockCodesFromMessage('分析 sh000016')).toEqual(['000016']);
});
it('keeps the legacy no-registry baseline for dotted index forms without leaking the suffix token', () => {
// Without a registry, `930955.CSI` must behave EXACTLY as before this
// change: the dotted form is NOT a registered index hit, so only the bare
// digits surface (the `.CSI` suffix never leaks as a separate token).
expect(extractStockCodesFromMessage('分析 930955.CSI')).toEqual(['930955']);
});
it('keeps the legacy baseline for csi-prefixed forms without a registry (no output)', () => {
expect(extractStockCodesFromMessage('CSI930955')).toEqual([]);
expect(extractStockCodesFromMessage('csi930955')).toEqual([]);
});
it('keeps SH/SZ stock alias normalization unchanged when the registry miss falls through', () => {
expect(extractStockCodesFromMessage('分析 000016.SH')).toEqual(['000016']);
expect(extractStockCodesFromMessage('分析 SH600519')).toEqual(['600519']);
});
it('does NOT surface the whole dotted form for an UNREGISTERED index even when the registry is loaded', () => {
// The shared mock registry DOES contain the `930955.CSI` alias (csi930955),
// so build a registry WITHOUT it to exercise the genuinely unregistered
// case: it must fall through to the legacy bare-digit behavior, not emit
// the whole token nor suppress the legacy patterns.
const registryWithoutCsi = mockStockIndex.filter(
(item) => item.canonicalCode !== 'csi930955',
) as StockIndexItem[];
expect(extractStockCodesFromMessage('分析 930955.CSI', registryWithoutCsi)).toEqual(['930955']);
});
});
describe('watchlist button with code variants', () => { describe('watchlist button with code variants', () => {
it('shows "从自选删除" when canonical code is in watchlist and user inputs variant', async () => { it('shows "从自选删除" when canonical code is in watchlist and user inputs variant', async () => {
mockGetWatchlist.mockResolvedValue(['600519', 'HK01810']); mockGetWatchlist.mockResolvedValue(['600519', 'HK01810']);

View File

@@ -70,6 +70,26 @@ vi.mock('../../hooks/useTaskStream', () => ({
useTaskStream: vi.fn(), useTaskStream: vi.fn(),
})); }));
let stockIndexItems: Array<{
canonicalCode: string;
displayCode: string;
aliases?: string[];
assetType: string;
}> = [];
let stockIndexLoading = false;
let stockIndexFallback = false;
let stockIndexLoaded = true;
vi.mock('../../hooks/useStockIndex', () => ({
useStockIndex: () => ({
index: stockIndexItems,
loading: stockIndexLoading,
error: null,
fallback: stockIndexFallback,
loaded: stockIndexLoaded,
}),
}));
const historyItem = { const historyItem = {
id: 1, id: 1,
queryId: 'q-1', queryId: 'q-1',
@@ -210,6 +230,10 @@ describe('HomePage', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
navigateMock.mockReset(); navigateMock.mockReset();
stockIndexItems = [];
stockIndexLoading = false;
stockIndexFallback = false;
stockIndexLoaded = true;
window.localStorage.clear(); window.localStorage.clear();
window.sessionStorage.clear(); window.sessionStorage.clear();
window.localStorage.setItem(UI_LANGUAGE_STORAGE_KEY, 'zh'); window.localStorage.setItem(UI_LANGUAGE_STORAGE_KEY, 'zh');
@@ -533,6 +557,196 @@ describe('HomePage', () => {
expect(rowButton).toHaveAttribute('aria-pressed', 'true'); expect(rowButton).toHaveAttribute('aria-pressed', 'true');
}); });
it('keeps index and same-code stock watchlist rows independent when stock-bar and active tasks coexist (PR #2312)', async () => {
stockIndexItems = [
{ canonicalCode: 'sh000016', displayCode: 'sh000016', aliases: ['000016.SH'], assetType: 'index' },
];
// The index watchlist input is the raw ALIAS form (`000016.SH`) while the
// stock-bar items and active tasks stay on the parser canonical
// (`sh000016`) — the registry-resolved identity must bridge them without
// crossing into the bare `000016` stock row.
vi.mocked(systemConfigApi.getWatchlist).mockResolvedValue(['000016.SH', '000016']);
vi.mocked(historyApi.getStockBarList).mockResolvedValue({
total: 2,
items: [
{
id: 61,
stockCode: 'sh000016',
stockName: '上证50',
reportType: 'detailed',
sentimentScore: 70,
operationAdvice: '观察',
analysisCount: 3,
lastAnalysisTime: '2026-03-19T09:00:00+08:00',
assetType: 'index',
},
{
id: 62,
stockCode: '000016',
stockName: '深康佳A',
reportType: 'detailed',
sentimentScore: 75,
operationAdvice: '买入',
analysisCount: 1,
lastAnalysisTime: '2026-03-19T09:00:00+08:00',
assetType: 'stock',
},
],
});
vi.mocked(historyApi.getList).mockResolvedValue({
total: 0,
page: 1,
limit: 20,
items: [],
});
vi.mocked(analysisApi.getTasks).mockResolvedValue({
total: 2,
pending: 1,
processing: 1,
tasks: [
{
taskId: 'task-index',
stockCode: 'sh000016',
stockName: '上证50',
status: 'processing',
progress: 40,
reportType: 'detailed',
createdAt: '2026-03-19T08:00:00Z',
assetType: 'index',
},
{
taskId: 'task-stock',
stockCode: '000016',
stockName: '深康佳A',
status: 'pending',
progress: 0,
reportType: 'detailed',
createdAt: '2026-03-19T08:00:00Z',
assetType: 'stock',
},
],
});
render(
<MemoryRouter>
<HomePage />
</MemoryRouter>,
);
fireEvent.click(await screen.findByRole('button', { name: '自选' }));
// The alias-form watchlist row (`000016.SH`) must pair with the INDEX
// stock-bar row and the INDEX task through the registry-resolved identity;
// the bare `000016` row must pair with the STOCK row/task. Before the fix
// the shared stock-normalization key folded both rows to the stock identity.
const indexRow = await screen.findByTestId('watchlist-row-000016.SH');
const stockRow = screen.getByTestId('watchlist-row-000016');
expect(indexRow).toHaveTextContent('上证50');
expect(indexRow).not.toHaveTextContent('深康佳A');
await waitFor(() => expect(indexRow).toHaveTextContent('任务分析中'));
expect(indexRow).not.toHaveTextContent('任务等待中');
expect(stockRow).toHaveTextContent('深康佳A');
expect(stockRow).not.toHaveTextContent('上证50');
expect(stockRow).toHaveTextContent('任务等待中');
expect(stockRow).not.toHaveTextContent('任务分析中');
});
it('keeps the watchlist workspace loading and defers history lookup until the index registry is ready (PR #2312)', async () => {
stockIndexItems = [
{ canonicalCode: 'sh000016', displayCode: 'sh000016', aliases: ['000016.SH'], assetType: 'index' },
];
stockIndexLoading = true;
stockIndexLoaded = false;
vi.mocked(systemConfigApi.getWatchlist).mockResolvedValue(['000016.SH']);
vi.mocked(historyApi.getStockBarList).mockResolvedValue({ total: 0, items: [] });
vi.mocked(historyApi.getList).mockResolvedValue({
total: 0,
page: 1,
limit: 20,
items: [],
});
const view = render(
<MemoryRouter>
<HomePage />
</MemoryRouter>,
);
fireEvent.click(await screen.findByRole('button', { name: '自选' }));
// Registry still loading: the watchlist stays in the loading state, rows
// are not rendered, and no per-code history lookup runs — no transient
// fallback stock bucketing inside the load window.
expect(screen.getByTestId('home-stock-workspace')).toBeInTheDocument();
await waitFor(() => {
expect(screen.queryByTestId('watchlist-row-000016.SH')).not.toBeInTheDocument();
});
const analyzeAllButton = screen.getByRole('button', { name: '分析全部' });
expect(analyzeAllButton).toBeDisabled();
fireEvent.click(analyzeAllButton);
expect(analysisApi.analyzeAsync).not.toHaveBeenCalled();
stockIndexLoading = false;
stockIndexLoaded = true;
view.rerender(
<MemoryRouter>
<HomePage />
</MemoryRouter>,
);
// Ready: the alias row renders through the registry-resolved canonical
// identity, and the deferred missing-history lookup now runs for the raw
// watchlist code (the backend resolves the alias to the index record and
// the result is keyed by the canonical identity).
await waitFor(() => expect(screen.getByTestId('watchlist-row-000016.SH')).toBeInTheDocument());
expect(historyApi.getList).toHaveBeenCalledWith(
expect.objectContaining({ stockCode: '000016.SH', limit: 1 }),
expect.anything(),
);
});
it('fails open to stock identity after the index registry load fails (PR #2312)', async () => {
stockIndexItems = [];
stockIndexLoading = false;
stockIndexLoaded = false;
stockIndexFallback = true;
vi.mocked(systemConfigApi.getWatchlist).mockResolvedValue(['000016.SH']);
vi.mocked(historyApi.getStockBarList).mockResolvedValue({
total: 1,
items: [{
id: 63,
stockCode: '000016',
stockName: '深康佳A',
reportType: 'detailed',
sentimentScore: 75,
operationAdvice: '买入',
analysisCount: 1,
lastAnalysisTime: '2026-03-19T09:00:00+08:00',
assetType: 'stock',
}],
});
vi.mocked(historyApi.getList).mockResolvedValue({
total: 0,
page: 1,
limit: 20,
items: [],
});
render(
<MemoryRouter>
<HomePage />
</MemoryRouter>,
);
fireEvent.click(await screen.findByRole('button', { name: '自选' }));
const row = await screen.findByTestId('watchlist-row-000016.SH');
expect(row).toHaveTextContent('深康佳A');
expect(screen.getByRole('button', { name: '分析全部' })).toBeEnabled();
});
it('does not open details when removing a watchlist row', async () => { it('does not open details when removing a watchlist row', async () => {
vi.mocked(systemConfigApi.getWatchlist).mockResolvedValue(['600519']); vi.mocked(systemConfigApi.getWatchlist).mockResolvedValue(['600519']);
vi.mocked(historyApi.getStockBarList).mockResolvedValue({ vi.mocked(historyApi.getStockBarList).mockResolvedValue({
@@ -2022,6 +2236,70 @@ describe('HomePage', () => {
expect(vi.mocked(analysisApi.getTasks).mock.calls.length).toBeGreaterThan(taskRefreshCallsBeforeSubmit); expect(vi.mocked(analysisApi.getTasks).mock.calls.length).toBeGreaterThan(taskRefreshCallsBeforeSubmit);
}); });
it('deduplicates index aliases while keeping the same-code stock in analyze-all (PR #2312)', async () => {
stockIndexItems = [
{ canonicalCode: 'sh000016', displayCode: 'sh000016', aliases: ['000016.SH'], assetType: 'index' },
];
vi.mocked(systemConfigApi.getWatchlist).mockResolvedValue(['000016.SH', 'sh000016', '000016']);
vi.mocked(historyApi.getStockBarList).mockResolvedValue({
total: 2,
items: [
{
id: 71,
stockCode: 'sh000016',
stockName: '上证50',
reportType: 'detailed',
sentimentScore: 70,
operationAdvice: '观察',
analysisCount: 1,
lastAnalysisTime: '2026-03-19T09:00:00+08:00',
assetType: 'index',
},
{
id: 72,
stockCode: '000016',
stockName: '深康佳A',
reportType: 'detailed',
sentimentScore: 75,
operationAdvice: '买入',
analysisCount: 1,
lastAnalysisTime: '2026-03-19T09:00:00+08:00',
assetType: 'stock',
},
],
});
vi.mocked(historyApi.getList).mockResolvedValue({
total: 0,
page: 1,
limit: 20,
items: [],
});
vi.mocked(analysisApi.analyzeAsync).mockImplementation(async ({ stockCodes = [] }) => ({
accepted: stockCodes.map((stockCode, index) => ({
taskId: `task-${index}`,
stockCode,
status: 'pending' as const,
})),
duplicates: [],
message: 'accepted',
}));
render(
<MemoryRouter>
<HomePage />
</MemoryRouter>,
);
fireEvent.click(await screen.findByRole('button', { name: '自选' }));
fireEvent.click(await screen.findByRole('button', { name: '分析全部' }));
await waitFor(() => expect(analysisApi.analyzeAsync).toHaveBeenCalledTimes(1));
expect(vi.mocked(analysisApi.analyzeAsync).mock.calls[0]?.[0].stockCodes).toEqual([
'000016.SH',
'000016',
]);
});
it('reports partial watchlist submission and refreshes accepted tasks after a later chunk fails', async () => { it('reports partial watchlist submission and refreshes accepted tasks after a later chunk fails', async () => {
configureWatchlistBatch(51); configureWatchlistBatch(51);
vi.mocked(analysisApi.analyzeAsync) vi.mocked(analysisApi.analyzeAsync)
@@ -2124,6 +2402,53 @@ describe('HomePage', () => {
expect(analysisApi.analyzeAsync).toHaveBeenCalledTimes(1); expect(analysisApi.analyzeAsync).toHaveBeenCalledTimes(1);
}); });
it('continues to the next chunk when a full chunk is accepted+duplicate+rejected', async () => {
configureWatchlistBatch(51);
vi.mocked(analysisApi.analyzeAsync)
.mockImplementationOnce(async ({ stockCodes = [] }) => ({
accepted: stockCodes.slice(0, 40).map((stockCode, index) => ({
taskId: `task-${stockCode}-${index}`,
stockCode,
status: 'pending' as const,
})),
duplicates: stockCodes.slice(40, 45).map((stockCode, index) => ({
stockCode,
existingTaskId: `existing-${index}`,
message: 'already running',
})),
rejected: stockCodes.slice(45).map((stockCode) => ({
stockCode,
message: 'unregistered CSI index',
})),
message: 'accepted',
}))
.mockImplementationOnce(async ({ stockCodes = [] }) => ({
accepted: stockCodes.map((stockCode, index) => ({
taskId: `task-${stockCode}-${index}`,
stockCode,
status: 'pending' as const,
})),
duplicates: [],
message: 'accepted',
}));
render(
<MemoryRouter>
<HomePage />
</MemoryRouter>,
);
fireEvent.click(await screen.findByRole('button', { name: '自选' }));
const taskRefreshCallsBeforeSubmit = vi.mocked(analysisApi.getTasks).mock.calls.length;
fireEvent.click(screen.getByRole('button', { name: '分析全部' }));
// 40 accepted + 5 duplicates + 5 rejected = 50 = full chunk, so the next
// chunk is submitted (2 analyzeAsync calls), not reported as incomplete.
expect(await screen.findByText(/已提交 41 个任务5 个正在运行5 个被拒绝/)).toBeInTheDocument();
expect(analysisApi.analyzeAsync).toHaveBeenCalledTimes(2);
expect(vi.mocked(analysisApi.getTasks).mock.calls.length).toBeGreaterThan(taskRefreshCallsBeforeSubmit);
});
it('removes the MARKET stock bar item after deleting market review history', async () => { it('removes the MARKET stock bar item after deleting market review history', async () => {
let isMarketReviewDeleted = false; let isMarketReviewDeleted = false;
vi.mocked(historyApi.getStockBarList).mockResolvedValue({ vi.mocked(historyApi.getStockBarList).mockResolvedValue({

View File

@@ -554,6 +554,62 @@ describe('stockPoolStore', () => {
})); }));
}); });
it('accepts a registered SH index canonical from autocomplete without local validation errors', async () => {
vi.mocked(analysisApi.analyzeAsync).mockResolvedValue({
taskId: 'task-index-1',
stockCode: 'sh000016',
status: 'pending',
message: 'accepted',
} as never);
await useStockPoolStore.getState().submitAnalysis({
stockCode: 'sh000016',
stockName: '上证50',
originalQuery: 'sh000016',
selectionSource: 'autocomplete',
});
const state = useStockPoolStore.getState();
expect(state.inputError).toBeUndefined();
expect(state.isAnalyzing).toBe(false);
expect(analysisApi.analyzeAsync).toHaveBeenCalledWith(expect.objectContaining({
stockCode: 'SH000016',
reportType: 'detailed',
stockName: '上证50',
originalQuery: 'sh000016',
selectionSource: 'autocomplete',
notify: true,
}));
});
it('accepts a registered CSI index canonical from autocomplete', async () => {
vi.mocked(analysisApi.analyzeAsync).mockResolvedValue({
taskId: 'task-csi-1',
stockCode: 'csi930955',
status: 'pending',
message: 'accepted',
} as never);
await useStockPoolStore.getState().submitAnalysis({
stockCode: 'csi930955',
stockName: '红利低波100',
originalQuery: 'csi930955',
selectionSource: 'autocomplete',
});
const state = useStockPoolStore.getState();
expect(state.inputError).toBeUndefined();
expect(state.isAnalyzing).toBe(false);
expect(analysisApi.analyzeAsync).toHaveBeenCalledWith(expect.objectContaining({
stockCode: 'CSI930955',
reportType: 'detailed',
stockName: '红利低波100',
originalQuery: 'csi930955',
selectionSource: 'autocomplete',
notify: true,
}));
});
it('merges newly discovered history items during silent refresh', async () => { it('merges newly discovered history items during silent refresh', async () => {
useStockPoolStore.setState({ useStockPoolStore.setState({
historyItems: [historyItem], historyItems: [historyItem],
@@ -736,6 +792,92 @@ describe('stockPoolStore', () => {
expect(state.selectedReport?.meta.id).toBe(11); expect(state.selectedReport?.meta.id).toBe(11);
}); });
it('auto-selects the same-code index report, not the bare stock report, on index task completion', async () => {
const indexItem = {
...historyItem,
id: 20,
queryId: 'q-20',
stockCode: 'sh000016',
stockName: '上证50',
assetType: 'index' as const,
};
const stockItem = {
...historyItem,
id: 21,
queryId: 'q-21',
stockCode: '000016',
stockName: '深康佳A',
assetType: 'stock' as const,
};
const indexReport = {
...historyReport,
meta: {
...historyReport.meta,
id: 20,
queryId: 'q-20',
stockCode: 'sh000016',
stockName: '上证50',
assetType: 'index' as const,
},
};
useStockPoolStore.setState({
historyItems: [],
selectedReport: null,
});
vi.mocked(historyApi.getList).mockResolvedValue({
total: 2,
page: 1,
limit: 20,
items: [indexItem, stockItem],
});
vi.mocked(historyApi.getDetail).mockResolvedValue(indexReport);
// The completed index task carries its parser asset type; the bare
// same-code stock row must not capture the selection.
await useStockPoolStore.getState().refreshHistoryForCompletedTask(createTask({
stockCode: 'sh000016',
status: 'completed',
progress: 100,
assetType: 'index',
}));
const state = useStockPoolStore.getState();
expect(historyApi.getDetail).toHaveBeenCalledWith(20);
expect(historyApi.getDetail).not.toHaveBeenCalledWith(21);
expect(state.selectedReport?.meta.stockCode).toBe('sh000016');
expect(state.selectedReport?.meta.assetType).toBe('index');
});
it('carries report.meta.assetType into the history-trend item derived from the selected report (PR #2312)', async () => {
const indexReport = {
...historyReport,
meta: {
...historyReport.meta,
id: 30,
queryId: 'q-30',
stockCode: 'sh000016',
stockName: '上证50',
assetType: 'index' as const,
},
};
useStockPoolStore.setState({ selectedReport: indexReport });
vi.mocked(historyApi.getList).mockResolvedValue({
total: 1,
page: 1,
limit: 20,
items: [historyItem],
});
await useStockPoolStore.getState().openHistoryTrend();
const state = useStockPoolStore.getState();
expect(state.stockHistoryItems).toHaveLength(2);
const derived = state.stockHistoryItems.find((item) => item.stockCode === 'sh000016');
expect(derived).toBeDefined();
expect(derived?.assetType).toBe('index');
});
it('does not replace the selected report when another stock task completes', async () => { it('does not replace the selected report when another stock task completes', async () => {
const otherReport = { const otherReport = {
...historyReport, ...historyReport,

View File

@@ -5,7 +5,7 @@ import { getParsedApiError } from '../api/error';
import { historyApi } from '../api/history'; import { historyApi } from '../api/history';
import type { AnalysisReport, HistoryItem, HistoryListResponse, ReportLanguage, StockBarItem, StockHistoryFilters, StockHistoryRange, TaskInfo } from '../types/analysis'; import type { AnalysisReport, HistoryItem, HistoryListResponse, ReportLanguage, StockBarItem, StockHistoryFilters, StockHistoryRange, TaskInfo } from '../types/analysis';
import { getRecentStartDate, getTodayInShanghai } from '../utils/format'; import { getRecentStartDate, getTodayInShanghai } from '../utils/format';
import { normalizeStockCode } from '../utils/stockCode'; import { toAssetAwareCodeKey, type AssetAwareAssetType } from '../utils/stockCode';
import { isObviouslyInvalidStockQuery, looksLikeStockCode, validateStockCode } from '../utils/validation'; import { isObviouslyInvalidStockQuery, looksLikeStockCode, validateStockCode } from '../utils/validation';
const PAGE_SIZE = 20; const PAGE_SIZE = 20;
@@ -20,6 +20,7 @@ type FetchHistoryOptions = {
reset?: boolean; reset?: boolean;
silent?: boolean; silent?: boolean;
selectLatestForStockCode?: string; selectLatestForStockCode?: string;
selectLatestForStockAssetType?: AssetAwareAssetType | null;
}; };
type SubmitAnalysisOptions = { type SubmitAnalysisOptions = {
@@ -235,6 +236,7 @@ function reportToHistoryItem(report: AnalysisReport): HistoryItem | null {
currentPrice: report.meta.currentPrice, currentPrice: report.meta.currentPrice,
changePct: report.meta.changePct, changePct: report.meta.changePct,
modelUsed: report.meta.modelUsed, modelUsed: report.meta.modelUsed,
assetType: report.meta.assetType,
createdAt: report.meta.createdAt, createdAt: report.meta.createdAt,
}; };
} }
@@ -252,17 +254,22 @@ function normalizeSelectedReport(report: AnalysisReport): AnalysisReport {
}; };
} }
function normalizeStockCodeKey(stockCode: string | undefined): string { function normalizeStockCodeKey(
const trimmed = (stockCode ?? '').trim(); stockCode: string | undefined,
return trimmed ? normalizeStockCode(trimmed).toUpperCase() : ''; assetType?: AssetAwareAssetType | null,
): string {
return toAssetAwareCodeKey(stockCode, assetType);
} }
function queueCompletedTaskSelection( function queueCompletedTaskSelection(
stockCode: string | undefined, stockCode: string | undefined,
selectedReport: AnalysisReport | null, selectedReport: AnalysisReport | null,
assetType?: AssetAwareAssetType | null,
): void { ): void {
const key = normalizeStockCodeKey(stockCode); const key = normalizeStockCodeKey(stockCode, assetType);
if (key) { if (key) {
// Matching only consumes the already-constructed identity key; the stored
// intent carries no assetType (it was only ever used to build the key).
pendingCompletedTaskSelectionKeys.set(key, { pendingCompletedTaskSelectionKeys.set(key, {
manualSelectionSeq: manualSelectionRequestSeq, manualSelectionSeq: manualSelectionRequestSeq,
selectedReportId: selectedReport?.meta.id, selectedReportId: selectedReport?.meta.id,
@@ -285,7 +292,10 @@ function consumeCompletedTaskSelection(items: HistoryItem[], selectedReport: Ana
} }
if (selectedReport) { if (selectedReport) {
const selectedStockCode = normalizeStockCodeKey(selectedReport.meta.stockCode); const selectedStockCode = normalizeStockCodeKey(
selectedReport.meta.stockCode,
selectedReport.meta.assetType,
);
const pendingSelectionIntent = selectedStockCode const pendingSelectionIntent = selectedStockCode
? pendingCompletedTaskSelectionKeys.get(selectedStockCode) ? pendingCompletedTaskSelectionKeys.get(selectedStockCode)
: undefined; : undefined;
@@ -311,7 +321,7 @@ function consumeCompletedTaskSelection(items: HistoryItem[], selectedReport: Ana
const latestItem = items.find( const latestItem = items.find(
(item) => (item) =>
item.reportType !== 'market_review' && item.reportType !== 'market_review' &&
normalizeStockCodeKey(item.stockCode) === selectedStockCode, normalizeStockCodeKey(item.stockCode, item.assetType) === selectedStockCode,
); );
if (latestItem) { if (latestItem) {
pendingCompletedTaskSelectionKeys.delete(selectedStockCode); pendingCompletedTaskSelectionKeys.delete(selectedStockCode);
@@ -323,7 +333,7 @@ function consumeCompletedTaskSelection(items: HistoryItem[], selectedReport: Ana
if (item.reportType === 'market_review') { if (item.reportType === 'market_review') {
return false; return false;
} }
const stockCode = normalizeStockCodeKey(item.stockCode); const stockCode = normalizeStockCodeKey(item.stockCode, item.assetType);
const pendingSelectionIntent = pendingCompletedTaskSelectionKeys.get(stockCode); const pendingSelectionIntent = pendingCompletedTaskSelectionKeys.get(stockCode);
return stockCode.length > 0 && pendingSelectionIntent?.manualSelectionSeq === manualSelectionRequestSeq; return stockCode.length > 0 && pendingSelectionIntent?.manualSelectionSeq === manualSelectionRequestSeq;
}); });
@@ -453,11 +463,16 @@ async function fetchHistory(
reset = true, reset = true,
silent = false, silent = false,
selectLatestForStockCode, selectLatestForStockCode,
selectLatestForStockAssetType,
} = options; } = options;
const currentState = get(); const currentState = get();
const page = reset ? 1 : currentState.currentPage + 1; const page = reset ? 1 : currentState.currentPage + 1;
if (reset) { if (reset) {
queueCompletedTaskSelection(selectLatestForStockCode, currentState.selectedReport); queueCompletedTaskSelection(
selectLatestForStockCode,
currentState.selectedReport,
selectLatestForStockAssetType,
);
} }
const requestId = ++historyRequestSeq; const requestId = ++historyRequestSeq;
@@ -668,6 +683,7 @@ export const useStockPoolStore = create<StockPoolState>((set, get) => ({
reset: true, reset: true,
silent: true, silent: true,
selectLatestForStockCode: task.reportType === 'market_review' ? undefined : task.stockCode, selectLatestForStockCode: task.reportType === 'market_review' ? undefined : task.stockCode,
selectLatestForStockAssetType: task.reportType === 'market_review' ? null : (task.assetType ?? null),
}); });
}, },

View File

@@ -84,6 +84,7 @@ export interface ReportMeta {
changePct?: number; changePct?: number;
modelUsed?: string; // 历史元数据快照,仅用于展示,不用于运行时模型选择 modelUsed?: string; // 历史元数据快照,仅用于展示,不用于运行时模型选择
marketPhaseSummary?: MarketPhaseSummary | null; marketPhaseSummary?: MarketPhaseSummary | null;
assetType?: 'stock' | 'index'; // 后端权威资产类型index 用于隐藏 stock-only 自选操作
} }
/** Sentiment label */ /** Sentiment label */
@@ -439,6 +440,7 @@ export interface BatchTaskAcceptedItem {
status: 'pending' | 'processing'; status: 'pending' | 'processing';
message?: string; message?: string;
analysisPhase?: AnalysisPhase; analysisPhase?: AnalysisPhase;
assetType?: 'stock' | 'index';
} }
export interface BatchDuplicateTaskItem { export interface BatchDuplicateTaskItem {
@@ -447,9 +449,15 @@ export interface BatchDuplicateTaskItem {
message: string; message: string;
} }
export interface BatchRejectedTaskItem {
stockCode: string;
message: string;
}
export interface BatchTaskAcceptedResponse { export interface BatchTaskAcceptedResponse {
accepted: BatchTaskAcceptedItem[]; accepted: BatchTaskAcceptedItem[];
duplicates: BatchDuplicateTaskItem[]; duplicates: BatchDuplicateTaskItem[];
rejected?: BatchRejectedTaskItem[];
message: string; message: string;
} }
@@ -494,6 +502,7 @@ export interface TaskInfo {
analysisPhase?: AnalysisPhase; analysisPhase?: AnalysisPhase;
skills?: string[]; skills?: string[];
region?: string; region?: string;
assetType?: 'stock' | 'index';
} }
/** Task list response */ /** Task list response */
@@ -534,6 +543,7 @@ export interface HistoryItem {
turnoverRate?: number; turnoverRate?: number;
modelUsed?: string; // 历史元数据快照,仅用于列表展示,不影响运行时调用与路由 modelUsed?: string; // 历史元数据快照,仅用于列表展示,不影响运行时调用与路由
marketPhaseSummary?: MarketPhaseSummary | null; marketPhaseSummary?: MarketPhaseSummary | null;
assetType?: 'stock' | 'index';
createdAt: string; createdAt: string;
} }
@@ -595,6 +605,7 @@ export interface StockBarItem {
lastAnalysisTime?: string; lastAnalysisTime?: string;
modelUsed?: string; modelUsed?: string;
marketPhaseSummary?: MarketPhaseSummary | null; marketPhaseSummary?: MarketPhaseSummary | null;
assetType?: 'stock' | 'index';
} }
export interface StockBarResponse { export interface StockBarResponse {

View File

@@ -485,4 +485,58 @@ describe('searchStocks', () => {
expect(results.length).toBeGreaterThan(0); expect(results.length).toBeGreaterThan(0);
}); });
}); });
describe('Index row visibility', () => {
const indexRows: StockIndexItem[] = [
...mockIndex,
{
canonicalCode: 'sh000016',
displayCode: 'sh000016',
nameZh: '上证50',
pinyinFull: 'shangzheng50',
pinyinAbbr: 'sz50',
aliases: ['000016.SH'],
market: 'CN',
assetType: 'index',
active: true,
popularity: 99,
},
{
canonicalCode: 'csi930955',
displayCode: '930955.CSI',
nameZh: '红利低波100',
pinyinFull: 'honglidibo100',
pinyinAbbr: 'hldb100',
aliases: ['930955.CSI'],
market: 'CN',
assetType: 'index',
active: true,
popularity: 98,
},
];
test('search by registered index Chinese name returns the index row', () => {
const results = searchStocks('上证50', indexRows);
expect(results.some(r => r.canonicalCode === 'sh000016')).toBe(true);
const hit = results.find(r => r.canonicalCode === 'sh000016');
expect(hit?.nameZh).toBe('上证50');
});
test('search by registered index canonical code returns the index row', () => {
const results = searchStocks('sh000016', indexRows);
expect(results.some(r => r.canonicalCode === 'sh000016')).toBe(true);
});
test('search by registered CSI canonical and display alias converges to one row', () => {
const byCanonical = searchStocks('csi930955', indexRows);
const byDisplay = searchStocks('930955.CSI', indexRows);
expect(byCanonical.some(r => r.canonicalCode === 'csi930955')).toBe(true);
expect(byDisplay.some(r => r.canonicalCode === 'csi930955')).toBe(true);
});
test('search by registered index alias (000016.SH) returns the index row', () => {
const results = searchStocks('000016.SH', indexRows);
expect(results.some(r => r.canonicalCode === 'sh000016')).toBe(true);
});
});
}); });

View File

@@ -1,5 +1,13 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
import { areStockCodesEquivalent, findMatchingStockCode, includesStockCode, normalizeStockCode } from '../stockCode'; import {
areAssetAwareCodesEquivalent,
areStockCodesEquivalent,
findMatchingStockCode,
includesStockCode,
normalizeStockCode,
resolveRegisteredIndexCanonical,
toAssetAwareCodeKey,
} from '../stockCode';
describe('normalizeStockCode', () => { describe('normalizeStockCode', () => {
it('keeps clean A-share codes as-is', () => { it('keeps clean A-share codes as-is', () => {
@@ -110,3 +118,90 @@ describe('normalizeStockCode', () => {
expect(findMatchingStockCode(codes, 'AAPL')).toBe('aapl'); expect(findMatchingStockCode(codes, 'AAPL')).toBe('aapl');
}); });
}); });
describe('asset-aware identity keys (PR #2312)', () => {
const indexRows = [
{ canonicalCode: 'sh000016', displayCode: 'sh000016', aliases: ['000016.SH'], assetType: 'index' },
{ canonicalCode: 'csi930955', displayCode: '930955.CSI', aliases: ['930955.CSI'], assetType: 'index' },
{ canonicalCode: 'sh000300', displayCode: 'sh000300', aliases: ['sz399300', '000300.SH', '000300.CSI'], assetType: 'index' },
{ canonicalCode: '600519.SH', displayCode: '600519', aliases: [], assetType: 'stock' },
];
it('buckets registered indices by lowercase canonical without stock folding', () => {
expect(toAssetAwareCodeKey('sh000016', 'index')).toBe('sh000016');
expect(toAssetAwareCodeKey('000016', 'stock')).toBe('000016');
expect(toAssetAwareCodeKey('sh000016', 'index')).not.toBe(toAssetAwareCodeKey('000016', 'stock'));
});
it('case-folds legacy uppercase index codes only and never regex-derives a canonical from aliases', () => {
// Backend guarantees API/task/report codes tagged assetType=index are
// already the parser canonical. The frontend only case-folds; it must not
// fabricate a canonical from prefixes/suffixes (000300.CSI -> csi000300 or
// sz399300 -> sz399300 would violate the registry single source of truth).
expect(toAssetAwareCodeKey('SH000016', 'index')).toBe('sh000016');
expect(toAssetAwareCodeKey('CSI930955', 'index')).toBe('csi930955');
expect(toAssetAwareCodeKey('000016.SH', 'index')).toBe('000016.sh');
expect(toAssetAwareCodeKey('000016.SH', 'index')).not.toBe('sh000016');
expect(toAssetAwareCodeKey('930955.CSI', 'index')).toBe('930955.csi');
expect(toAssetAwareCodeKey('930955.CSI', 'index')).not.toBe('csi930955');
expect(toAssetAwareCodeKey('sz399300', 'index')).toBe('sz399300');
expect(toAssetAwareCodeKey('sz399300', 'index')).not.toBe('sh000300');
expect(toAssetAwareCodeKey('000300.CSI', 'index')).toBe('000300.csi');
expect(toAssetAwareCodeKey('000300.CSI', 'index')).not.toBe('csi000300');
expect(toAssetAwareCodeKey('000300.CSI', 'index')).not.toBe('sh000300');
});
it('keeps legacy stock normalization for stock/unknown codes', () => {
expect(toAssetAwareCodeKey('SH600519', 'stock')).toBe('600519');
expect(toAssetAwareCodeKey('600519.SH', undefined)).toBe('600519');
expect(toAssetAwareCodeKey('00700.HK', undefined)).toBe('HK00700');
expect(toAssetAwareCodeKey('sh600519', undefined)).toBe('600519');
});
it('resolves raw codes only through exact registry hits', () => {
expect(resolveRegisteredIndexCanonical(indexRows, 'sh000016')).toBe('sh000016');
expect(resolveRegisteredIndexCanonical(indexRows, 'SH000016')).toBe('sh000016');
expect(resolveRegisteredIndexCanonical(indexRows, '000016.SH')).toBe('sh000016');
expect(resolveRegisteredIndexCanonical(indexRows, 'sz399300')).toBe('sh000300');
expect(resolveRegisteredIndexCanonical(indexRows, '000300.CSI')).toBe('sh000300');
expect(resolveRegisteredIndexCanonical(indexRows, '000300.SH')).toBe('sh000300');
expect(resolveRegisteredIndexCanonical(indexRows, 'csi000300')).toBeNull();
expect(resolveRegisteredIndexCanonical(indexRows, '000016')).toBeNull();
expect(resolveRegisteredIndexCanonical(indexRows, '600519')).toBeNull();
expect(resolveRegisteredIndexCanonical(indexRows, 'sh600519')).toBeNull();
expect(resolveRegisteredIndexCanonical([], 'sh000016')).toBeNull();
});
it('never folds an index with the same-code stock in equivalence', () => {
expect(areAssetAwareCodesEquivalent('sh000016', 'index', 'sh000016', 'index')).toBe(true);
expect(areAssetAwareCodesEquivalent('SH000016', 'index', 'sh000016', 'index')).toBe(true);
// Alias-form index codes are not canonicalized by the frontend — the backend
// guarantees canonical output — so an alias form never self-equates to the
// canonical and never folds with the bare stock either.
expect(areAssetAwareCodesEquivalent('000016.SH', 'index', 'sh000016', 'index')).toBe(false);
expect(areAssetAwareCodesEquivalent('000016.SH', 'index', '000016', 'stock')).toBe(false);
expect(areAssetAwareCodesEquivalent('sh000016', 'index', '000016', 'stock')).toBe(false);
expect(areAssetAwareCodesEquivalent('SH600519', 'stock', '600519', 'stock')).toBe(true);
expect(areAssetAwareCodesEquivalent('', 'index', 'sh000016', 'index')).toBe(false);
});
it('folds case independently of the host locale (Turkish dotless-i safe)', () => {
// Simulate the Turkish locale where `toLocaleLowerCase()` maps `I` to
// dotless `ı` — `CSI930955` would become `csı930955` and never match the
// registry canonical. The asset-aware helpers must use locale-independent
// `toLowerCase()` (plain ASCII namespace), so they keep working even when
// `toLocaleLowerCase` is poisoned.
const original = String.prototype.toLocaleLowerCase;
String.prototype.toLocaleLowerCase = function (this: string) {
return this.replace(/I/g, 'ı').replace(/İ/g, 'i');
};
try {
expect(toAssetAwareCodeKey('CSI930955', 'index')).toBe('csi930955');
expect(toAssetAwareCodeKey('SH000016', 'index')).toBe('sh000016');
expect(resolveRegisteredIndexCanonical(indexRows, 'CSI930955')).toBe('csi930955');
expect(resolveRegisteredIndexCanonical(indexRows, '000300.CSI')).toBe('sh000300');
} finally {
String.prototype.toLocaleLowerCase = original;
}
});
});

View File

@@ -132,6 +132,36 @@ describe('stockIndexLoader', () => {
expect(result.error).toBeInstanceOf(Error); expect(result.error).toBeInstanceOf(Error);
}); });
test('aborts a stalled index request and returns fallback mode', async () => {
vi.useFakeTimers();
let capturedSignal: AbortSignal | undefined;
try {
mockFetch.mockImplementationOnce((_input, init) => {
capturedSignal = init?.signal ?? undefined;
if (!capturedSignal) {
return Promise.reject(new Error('missing AbortSignal'));
}
return new Promise((_resolve, reject) => {
capturedSignal?.addEventListener('abort', () => reject(new Error('request aborted')));
});
});
const resultPromise = loadStockIndex();
expect(capturedSignal).toBeInstanceOf(AbortSignal);
await vi.advanceTimersByTimeAsync(10_000);
const result = await resultPromise;
expect(capturedSignal?.aborted).toBe(true);
expect(result.loaded).toBe(false);
expect(result.fallback).toBe(true);
expect(result.data).toEqual([]);
expect(result.error).toBeInstanceOf(Error);
} finally {
vi.useRealTimers();
}
});
test('returns fallback mode on HTTP error', async () => { test('returns fallback mode on HTTP error', async () => {
mockFetch.mockResolvedValueOnce({ mockFetch.mockResolvedValueOnce({
ok: false, ok: false,
@@ -188,7 +218,7 @@ describe('stockIndexLoader', () => {
expect(fetchCallArgs).toContain('?_t='); expect(fetchCallArgs).toContain('?_t=');
}); });
test('filters out assetType=index rows from the returned data', async () => { test('keeps assetType=index rows in the returned data', async () => {
const withIndex = [ const withIndex = [
...mockIndexData, ...mockIndexData,
{ {
@@ -214,13 +244,16 @@ describe('stockIndexLoader', () => {
expect(result.loaded).toBe(true); expect(result.loaded).toBe(true);
expect(result.fallback).toBe(false); expect(result.fallback).toBe(false);
// Index rows are hidden from the current consumers. // Registered index rows flow to autocomplete/search/group consumers.
expect(result.data.some(item => item.assetType === 'index')).toBe(false); expect(result.data.some(item => item.assetType === 'index')).toBe(true);
const indexRow = result.data.find(item => item.assetType === 'index');
expect(indexRow?.canonicalCode).toBe('sh000300');
expect(indexRow?.nameZh).toBe('沪深300');
// Stock rows are preserved. // Stock rows are preserved.
expect(result.data).toHaveLength(mockIndexData.length); expect(result.data).toHaveLength(mockIndexData.length + 1);
}); });
test('filters index rows from compressed tuple payload', async () => { test('keeps index rows from compressed tuple payload', async () => {
const compressedWithIndex = [ const compressedWithIndex = [
['600519.SH', '600519', '贵州茅台', 'guizhoumaotai', 'gzmt', ['茅台'], 'CN', 'stock', true, 100], ['600519.SH', '600519', '贵州茅台', 'guizhoumaotai', 'gzmt', ['茅台'], 'CN', 'stock', true, 100],
['sh000300', 'sh000300', '沪深300', 'hushen300', 'hs300', ['000300.SH'], 'CN', 'index', true, 100], ['sh000300', 'sh000300', '沪深300', 'hushen300', 'hs300', ['000300.SH'], 'CN', 'index', true, 100],
@@ -234,8 +267,10 @@ describe('stockIndexLoader', () => {
const result = await loadStockIndex(); const result = await loadStockIndex();
expect(result.loaded).toBe(true); expect(result.loaded).toBe(true);
expect(result.data).toHaveLength(1); expect(result.data).toHaveLength(2);
expect(result.data[0].canonicalCode).toBe('600519.SH'); expect(result.data[0].canonicalCode).toBe('600519.SH');
expect(result.data[1].canonicalCode).toBe('sh000300');
expect(result.data[1].nameZh).toBe('沪深300');
}); });
}); });

View File

@@ -27,4 +27,38 @@ describe('stock code validation', () => {
expect(result.valid).toBe(false); expect(result.valid).toBe(false);
} }
); );
test.each([
['sh000016', 'SH000016'],
['sh000300', 'SH000300'],
['sz399001', 'SZ399001'],
])('accepts registered SH/SZ index canonical %s', (input, normalized) => {
expect(looksLikeStockCode(input)).toBe(true);
expect(validateStockCode(input)).toEqual({
valid: true,
normalized,
});
expect(isObviouslyInvalidStockQuery(input)).toBe(false);
});
test.each([
['csi930955', 'CSI930955'],
['930955.CSI', '930955.CSI'],
['CSI930955', 'CSI930955'],
])('accepts registered CSI index forms %s', (input, normalized) => {
expect(looksLikeStockCode(input)).toBe(true);
expect(validateStockCode(input)).toEqual({
valid: true,
normalized,
});
expect(isObviouslyInvalidStockQuery(input)).toBe(false);
});
test('rejects unregistered CSI forms that are not registered index canonicals', () => {
// 930956.CSI is not in the registry; the Web validation layer is
// format-based (registered rows pass), while the API returns a 4xx for
// unregistered CSI at the backend boundary.
expect(looksLikeStockCode('930956.CSI')).toBe(true);
expect(validateStockCode('930956.CSI').valid).toBe(true);
});
}); });

View File

@@ -1,5 +1,6 @@
import { validateStockCode } from './validation'; import { validateStockCode } from './validation';
import { normalizeStockCode } from './stockCode'; import { normalizeStockCode, resolveRegisteredIndexCanonical } from './stockCode';
import type { StockIndexItem } from '../types/stockIndex';
const EXCHANGE_PREFIXES = new Set(['SH', 'SZ', 'BJ', 'HK', 'US', 'SS']); const EXCHANGE_PREFIXES = new Set(['SH', 'SZ', 'BJ', 'HK', 'US', 'SS']);
const LOWERCASE_TICKER_CONTEXT_RE = /换成|改看|分析|看看|研究|诊断|比较|对比|\bvs\b|和[^,。,.!?]{0,40}比|差异(?!化)|区别|不同|相比|对照|比一比|哪个|哪只|哪一个|谁更|更值得|更适合|怎么选|选哪|二选一/i; const LOWERCASE_TICKER_CONTEXT_RE = /换成|改看|分析|看看|研究|诊断|比较|对比|\bvs\b|和[^,。,.!?]{0,40}比|差异(?!化)|区别|不同|相比|对照|比一比|哪个|哪只|哪一个|谁更|更值得|更适合|怎么选|选哪|二选一/i;
@@ -46,13 +47,23 @@ function isDeniedTickerCandidate(value: string, message: string): boolean {
); );
} }
export function extractStockCodeFromMessage(message: string): string | null { export function extractStockCodeFromMessage(
return extractStockCodesFromMessage(message)[0] ?? null; message: string,
index?: ReadonlyArray<StockIndexItem>,
): string | null {
return extractStockCodesFromMessage(message, index)[0] ?? null;
} }
export function extractStockCodesFromMessage(message: string): string[] { export function extractStockCodesFromMessage(
// More specific patterns first to avoid greedy \d{6} capturing inside .SH/.SZ codes message: string,
index?: ReadonlyArray<StockIndexItem>,
): string[] {
// Explicit dotted CSI/SH/SZ and csi-prefixed forms MUST precede the fallbacks so
// a registered alias is captured as one token; the registry hit suppresses its
// inner bare digits (a registry miss leaves the baseline patterns untouched).
const patterns = [ const patterns = [
/\b(\d{6}\.(?:CSI|SH|SZ))\b/gi,
/\b(CSI\d{6})\b/gi,
/\b(30\d{4}\.SZ)\b/gi, /\b(30\d{4}\.SZ)\b/gi,
/\b(68\d{4}\.SH)\b/gi, /\b(68\d{4}\.SH)\b/gi,
/\b(00\d{4}\.SZ)\b/gi, /\b(00\d{4}\.SZ)\b/gi,
@@ -70,7 +81,7 @@ export function extractStockCodesFromMessage(message: string): string[] {
patterns.push(/\b([a-z]{2,5}(?:\.[a-z]{1,2})?)\b/g); patterns.push(/\b([a-z]{2,5}(?:\.[a-z]{1,2})?)\b/g);
} }
const matches: Array<{ value: string; index: number; priority: number }> = []; const matches: Array<{ value: string; index: number; priority: number; end: number }> = [];
patterns.forEach((pattern, priority) => { patterns.forEach((pattern, priority) => {
pattern.lastIndex = 0; pattern.lastIndex = 0;
for (const match of message.matchAll(pattern)) { for (const match of message.matchAll(pattern)) {
@@ -84,6 +95,7 @@ export function extractStockCodesFromMessage(message: string): string[] {
value, value,
index: start, index: start,
priority, priority,
end,
}); });
} }
}); });
@@ -92,17 +104,43 @@ export function extractStockCodesFromMessage(message: string): string[] {
const stockCodes: string[] = []; const stockCodes: string[] = [];
const seen = new Set<string>(); const seen = new Set<string>();
// Spans of already-accepted tokens suppress strictly-inner matches so a
// dotted index alias never leaks its inner bare digits as a separate stock.
const acceptedSpans: Array<{ start: number; end: number }> = [];
for (const match of matches) { for (const match of matches) {
if (acceptedSpans.some((span) => match.index >= span.start && match.end <= span.end)) {
continue;
}
if (EXCHANGE_PREFIXES.has(match.value.toUpperCase())) { if (EXCHANGE_PREFIXES.has(match.value.toUpperCase())) {
continue; continue;
} }
if (isDeniedTickerCandidate(match.value, message)) { if (isDeniedTickerCandidate(match.value, message)) {
continue; continue;
} }
// Registered index exact hit (canonical / display / explicit alias) — use
// the registry canonical verbatim; never stock-normalize it.
const registeredCanonical = index && index.length > 0
? resolveRegisteredIndexCanonical(index, match.value)
: null;
if (registeredCanonical) {
acceptedSpans.push({ start: match.index, end: match.end });
if (!seen.has(registeredCanonical)) {
seen.add(registeredCanonical);
stockCodes.push(registeredCanonical);
}
continue;
}
// Priority 0-1 forms are REGISTRY-ONLY: on a miss, drop the whole token
// (no accepted span) so the legacy patterns beneath keep the no-registry
// baseline (930955.CSI → ['930955'], csi930955 → []).
if (match.priority <= 1) {
continue;
}
const { valid, normalized } = validateStockCode(match.value); const { valid, normalized } = validateStockCode(match.value);
if (!valid) { if (!valid) {
continue; continue;
} }
acceptedSpans.push({ start: match.index, end: match.end });
const stockCode = normalizeStockCode(normalized); const stockCode = normalizeStockCode(normalized);
if (!seen.has(stockCode)) { if (!seen.has(stockCode)) {
seen.add(stockCode); seen.add(stockCode);

View File

@@ -112,3 +112,106 @@ export function findMatchingStockCode(codes: string[], stockCode: string): strin
export function includesStockCode(codes: string[], stockCode: string): boolean { export function includesStockCode(codes: string[], stockCode: string): boolean {
return findMatchingStockCode(codes, stockCode) !== undefined; return findMatchingStockCode(codes, stockCode) !== undefined;
} }
// ============ Asset-aware identity key (PR #2312) ============
//
// Dashboard grouping must not fold a registered index (`sh000016`) with the
// same-code bare stock (`000016`). The backend now exposes an optional
// `asset_type` on tasks / history items / stock-bar rows and reports; the Web
// trusts that field first and only falls back to the loaded stock index
// registry for raw watchlist strings that carry no type. `normalizeStockCode`
// is deliberately NOT changed and is NOT applied to index identities — it
// would strip the `sh`/`sz`/`csi` prefix and re-fold the index with its stock.
export type AssetAwareAssetType = 'stock' | 'index';
export interface RegisteredIndexIdentity {
canonicalCode: string;
displayCode: string;
aliases?: string[];
assetType?: string;
}
/**
* Fold an already-identified index code into its lowercase identity namespace.
*
* The backend guarantees that any API/task/report code tagged ``assetType=index``
* is already the parser canonical (``sh000016`` / ``csi930955``) — including
* legacy uppercase persisted forms (``SH000016`` / ``CSI930955``, folded by
* case). The frontend therefore only case-folds; it must NEVER derive a
* canonical from prefixes/suffixes, because registry aliases such as
* ``000300.CSI`` and ``sz399300`` belong to ``sh000300`` and would be
* fabricated into ``csi000300`` / ``sz399300`` by a regex guess. Only
* ``resolveRegisteredIndexCanonical`` (exact registry hit) maps aliases, and
* it is used solely for raw watchlist strings that carry no asset type.
*/
function foldIndexKey(code: string): string {
// Locale-independent: `toLocaleLowerCase()` under the Turkish locale maps
// `I` to dotless `ı`, which would break `CSI930955` -> `csi930955`. The
// canonical namespace is plain ASCII, so `toLowerCase()` is always correct.
return code.trim().toLowerCase();
}
/**
* Asset-aware identity key shared by stock-bar, watchlist fallback, active
* task, completed-refresh and batch-dedupe grouping.
*
* - `assetType === 'index'` -> case-folded canonical bucket (never runs stock
* normalization, so `sh000016` stays distinct from stock `000016`; never
* regex-derives a canonical from an alias form).
* - otherwise (stock or unknown) -> existing stock normalization semantics.
*/
export function toAssetAwareCodeKey(
code: string | null | undefined,
assetType?: AssetAwareAssetType | null,
): string {
const trimmed = (code ?? '').trim();
if (!trimmed) return '';
if (assetType === 'index') {
return foldIndexKey(trimmed);
}
return normalizeStockCode(trimmed).toUpperCase();
}
/**
* Exact registry hit used only for raw codes WITHOUT an asset type (e.g. raw
* watchlist strings). Only an `assetType=index` row whose canonical/display/
* explicit alias matches the code *exactly* (case-insensitive, no
* normalization, no prefix guessing) buckets it as an index. Returns the
* matched row's canonical (in lowercase canonical form) or null.
*/
export function resolveRegisteredIndexCanonical(
index: ReadonlyArray<RegisteredIndexIdentity>,
code: string | null | undefined,
): string | null {
const trimmed = (code ?? '').trim();
if (!trimmed) return null;
const folded = trimmed.toLowerCase();
for (const item of index) {
if (!item || item.assetType !== 'index') continue;
const candidates = [item.canonicalCode, item.displayCode, ...(item.aliases ?? [])];
for (const candidate of candidates) {
if (candidate && candidate.trim().toLowerCase() === folded) {
const canonical = (item.canonicalCode ?? '').trim();
return canonical ? canonical.toLowerCase() : null;
}
}
}
return null;
}
/**
* Asset-aware equivalence used by row selection / notice matching: an index
* row never matches a same-code stock row, because each side carries its own
* asset type.
*/
export function areAssetAwareCodesEquivalent(
left: string | null | undefined,
leftAssetType: AssetAwareAssetType | null | undefined,
right: string | null | undefined,
rightAssetType: AssetAwareAssetType | null | undefined,
): boolean {
const leftKey = toAssetAwareCodeKey(left, leftAssetType);
const rightKey = toAssetAwareCodeKey(right, rightAssetType);
return Boolean(leftKey && rightKey && leftKey === rightKey);
}

View File

@@ -7,6 +7,8 @@
import type { StockIndexData, StockIndexItem, StockIndexTuple } from '../types/stockIndex'; import type { StockIndexData, StockIndexItem, StockIndexTuple } from '../types/stockIndex';
import { INDEX_FIELD } from './stockIndexFields'; import { INDEX_FIELD } from './stockIndexFields';
const STOCK_INDEX_LOAD_TIMEOUT_MS = 10_000;
export interface IndexLoadResult { export interface IndexLoadResult {
/** Index data */ /** Index data */
data: StockIndexItem[]; data: StockIndexItem[];
@@ -24,9 +26,17 @@ export interface IndexLoadResult {
* @returns Index load result * @returns Index load result
*/ */
export async function loadStockIndex(): Promise<IndexLoadResult> { export async function loadStockIndex(): Promise<IndexLoadResult> {
const abortController = new AbortController();
const timeoutId = globalThis.setTimeout(
() => abortController.abort(),
STOCK_INDEX_LOAD_TIMEOUT_MS,
);
try { try {
// Add time parameter to bypass cache (in case the backend doesn't handle ETag/Cache-Control) // Add time parameter to bypass cache (in case the backend doesn't handle ETag/Cache-Control)
const response = await fetch(`/stocks.index.json?_t=${Math.floor(Date.now() / 3600000)}`); const response = await fetch(
`/stocks.index.json?_t=${Math.floor(Date.now() / 3600000)}`,
{ signal: abortController.signal },
);
if (!response.ok) { if (!response.ok) {
throw new Error(`Failed to load index: ${response.status} ${response.statusText}`); throw new Error(`Failed to load index: ${response.status} ${response.statusText}`);
@@ -39,11 +49,10 @@ export async function loadStockIndex(): Promise<IndexLoadResult> {
? unpackTuples(data as StockIndexTuple[]) ? unpackTuples(data as StockIndexTuple[])
: data as StockIndexItem[]; : data as StockIndexItem[];
// The shared payload may now carry ``assetType=index`` rows, but the // Registered index rows flow to autocomplete/search/group consumers. The
// current autocomplete/popular/group consumers must not see them. Filter // per-consumer gates (popular keeps stock-only) are enforced by each
// index rows out before constructing the successful result so stock/ETF // consumer, not by a global filter here.
// behaviour is unchanged. const visibleItems = items;
const visibleItems = items.filter(item => item.assetType !== 'index');
return { return {
data: visibleItems, data: visibleItems,
@@ -58,6 +67,8 @@ export async function loadStockIndex(): Promise<IndexLoadResult> {
error: error as Error, error: error as Error,
fallback: true, // Load failed, fallback to old mode fallback: true, // Load failed, fallback to old mode
}; };
} finally {
globalThis.clearTimeout(timeoutId);
} }
} }

View File

@@ -8,7 +8,7 @@ const SUPPORTED_QUERY_CHARACTERS = /^[A-Z0-9.\u3400-\u9FFF\s]+$/;
const STOCK_CODE_PATTERNS = [ const STOCK_CODE_PATTERNS = [
/^\d{6}$/, // A-share 6-digit code /^\d{6}$/, // A-share 6-digit code
/^(SH|SZ|BJ)\d{6}$/, // A-share code with exchange prefix /^(SH|SZ|BJ)\d{6}$/, // A-share code with exchange prefix (also covers index canonical sh000016 / sh000300)
/^\d{6}\.(SH|SZ|SS|BJ)$/, // A-share code with exchange suffix /^\d{6}\.(SH|SZ|SS|BJ)$/, // A-share code with exchange suffix
/^\d{5}$/, // HK code without prefix /^\d{5}$/, // HK code without prefix
/^HK\d{1,5}$/, // HK-prefixed code, for example HK00700 /^HK\d{1,5}$/, // HK-prefixed code, for example HK00700
@@ -16,6 +16,8 @@ const STOCK_CODE_PATTERNS = [
/^\d{4,5}\.T$/, // Japan Yahoo suffix format, for example 7203.T /^\d{4,5}\.T$/, // Japan Yahoo suffix format, for example 7203.T
/^\d{6}\.(KS|KQ)$/, // Korea Yahoo suffix format, for example 005930.KS or 035720.KQ /^\d{6}\.(KS|KQ)$/, // Korea Yahoo suffix format, for example 005930.KS or 035720.KQ
/^[A-Z]{1,5}(?:\.(?:US|[A-Z]))?$/, // Common US ticker format /^[A-Z]{1,5}(?:\.(?:US|[A-Z]))?$/, // Common US ticker format
/^CSI\d{6}$/, // Registered CSI index canonical (e.g. csi930955)
/^\d{6}\.CSI$/, // Registered CSI index display form (e.g. 930955.CSI)
]; ];
/** /**

View File

@@ -47,6 +47,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
- [修复] 阻止任意更新的非 bundled 指数候选(含 legacy `static` 子集)在 remote 缺失/损坏时以 active-index 子集覆盖 bundled baseline所有非 bundled 候选必须为 bundled active-index canonical 集合的合法超集,否则回退 bundled 并记录 WARNING。 - [修复] 阻止任意更新的非 bundled 指数候选(含 legacy `static` 子集)在 remote 缺失/损坏时以 active-index 子集覆盖 bundled baseline所有非 bundled 候选必须为 bundled active-index canonical 集合的合法超集,否则回退 bundled 并记录 WARNING。
- [新功能] 桌面端全局右上角增加更新入口,与设置页共用更新状态;普通浏览器 WebUI 不展示,且不会在挂载时重复触发后台检查。 - [新功能] 桌面端全局右上角增加更新入口,与设置页共用更新状态;普通浏览器 WebUI 不展示,且不会在挂载时重复触发后台检查。
- [修复] 桌面端右上角更新入口与设置页共用检查中状态,避免一侧检查时另一侧仍可重复触发 GitHub Releases 检查;主进程手动检查路径同步增加 in-flight 防重。 - [修复] 桌面端右上角更新入口与设置页共用检查中状态,避免一侧检查时另一侧仍可重复触发 GitHub Releases 检查;主进程手动检查路径同步增加 in-flight 防重。
- [新功能] Web 自动补全与搜索放行已登记指数(注册中文名/`sh`/`sz` 前缀/`csi`/`.CSI` 显式形式均可检索与提交),热门候选仍仅股票;`/analyze` API 对显式指数输入构造结构化 `AnalysisTarget`INDEX 用 `canonical_id` 去重与同码个股互不折叠CSI alias 收敛),未登记 CSI 单请求返回明确 4xx、异步批量仅该目标进入响应 `rejected` 列表;指数 target 经任务队列贯穿到 Pipeline `process_single_stock``DecisionSignal``market_override="cn"` 落库并有真实分支测试。
- [修复] `/analyze` 在解析前按 strip 后非空原始 token 数限制 50 上限rejected/duplicate token 也计入,防止用拒绝或重复 token 绕过批量上限;以唯一 `is_single = len(stock_codes) == 1 and not rejected_entries` 统一驱动 metadata、409 与单任务 202duplicate+rejected 混合批次不再误返 legacy 409单 duplicate 仍 409、部分 rejected 仍 202、全 rejected 仍 400
- [新功能] 报告 meta 补充可选 `asset_type``stock`/`index`),由后端 canonical code 经 `parse_analysis_target` 生成权威类型;指数报告在 Web 报告页与 Chat 自选入口隐藏 stock-only 自选操作,裸同码股票(如 `000016`保持股票行为market review 与旧客户端可缺省。
- [修复] Web 批量分析把 accepted/duplicates/rejected 三类计入确认数,含 rejected 的完整 chunk 继续提交下一 chunk 而非误报 incomplete最终以 warning 展示拒绝数量与首个拒绝原因(中英文文案)。
- [修复] 历史筛选/删除/计数与 stock-bar 对已登记指数按 parser canonical 身份隔离:`sh000016`/`SH000016`/`000016.SH` 等显式形态互相可达lowercase canonical + uppercase legacy canonical + 显式 alias但永远不命中同码裸股票裸码查询也不会命中指数记录同一指数多条显式形态旧记录在 `/history/stocks` 合并为一行并计数全部形态,无记录删除仍返回 `deleted=0`。SH/SZ/CSI 共用同一 parser 分支,股票 alias、港股与海外市场行为不变。历史列表、历史详情与 stock-bar 对已登记指数(含旧 uppercase/显式 alias 持久化记录,如 `SZ399300``000300.CSI`)的 API `stock_code` 一律输出 parser canonical`sh000300`/`csi930955`)。
- [改进] 任务列表/SSE 事件、历史列表项与 stock-bar 项追加可选 `asset_type``stock`/`index`):任务侧从已提交的 `analysis_target` 透传(不重新猜测),历史与 stock-bar 侧由持久化 `record.code``parse_analysis_target` 生成;旧客户端与 market review 可缺省,字段可选追加不破坏既有契约。
- [修复] Web 首页与自选工作区改用资产感知身份键:任务/报告/历史的 `assetType` 优先,且被后端保证为 parser canonical 的代码只做**大小写折叠**`SH000016``sh000016`),禁止再用前缀/后缀正则猜 canonical否则 `000300.CSI` 会被误猜成 `csi000300``sz399300` 被误当成独立 canonical违反注册表唯一判型真源仅 watchlist 原始字符串缺少类型时,才用已加载 `stocks.index.json``assetType=index` 行 canonical/display/显式 alias 精确命中(不先 normalize、不用前缀正则猜测加载期间禁用批量分析加载失败或请求超过 10 秒时按既有股票语义 fail-open行选中、active task 与完成自动选中按资产类型分桶,`sh000016` 指数行与 `000016` 股票行状态独立,完成后自动选中正确 canonical 指数报告。
- [修复] Chat 消息恢复、发送与报告追问对显式 SH/SZ/CSI 已登记指数统一按注册表 canonical 判型并覆盖默认 LiteLLM 与 Codex所有 Chat 后端首帧加载 registry加载期间延迟 URL/历史恢复并禁用发送settle 后 `sh000016`/`sz399001`/`000016.SH`/`930955.CSI`/`csi930955` 只产出一个 lowercase canonical后端 `resolve_stock_scope`、工具守卫与 cache key 复用 parser `INDEX` 身份,保持指数与裸同码股票隔离;未登记、空 registry 或加载失败时维持既有股票 fail-open绝不按前缀猜指数。
## [3.31.0] - 2026-08-23 ## [3.31.0] - 2026-08-23

View File

@@ -738,7 +738,24 @@ python main.py --stocks sh000016 --dry-run
指数实时行情使用独立固定链:腾讯 → 新浪 → 东财单股接口 → TickFlowSH/SZ 指数按 `sh000016`/`sz399001` 显式符号请求CSI 指数仅由东财单股接口提供(`2.{code}` secid。显式指数身份全程保留不会退化为同码股票行情。 指数实时行情使用独立固定链:腾讯 → 新浪 → 东财单股接口 → TickFlowSH/SZ 指数按 `sh000016`/`sz399001` 显式符号请求CSI 指数仅由东财单股接口提供(`2.{code}` secid。显式指数身份全程保留不会退化为同码股票行情。
> **Phase 2 边界**:默认 `STOCK_LIST`、`--schedule`、Web/API 自动补全与分析入口、Bot 与 GitHub Actions 每日工作流暂不开放指数入口;本能力仅通过一次性 `--stocks` 提供。 ### 指数 Web/API 入口Phase 2 PR1
Web 自动补全与搜索已放行已登记指数:搜索注册中文名(如 `上证50`)或显式代码(`sh000016``930955.CSI`)会返回对应指数条目并可提交分析;热门候选仍仅展示股票(`assetType=stock`),不包含指数。
API `/analyze` 对显式指数输入构造结构化 `AnalysisTarget``sh000016``asset_type=INDEX``canonical_id=sh000016` 入队,`930955.CSI`/`csi930955` 收敛为 `csi930955`。指数与同码个股(如 `sh000016``000016`)独立去重调度、互不折叠;未登记的 CSI 输入(如 `930956.CSI`)在异步单股或同步模式返回明确的 4xx在异步批量中仅该目标进入响应 `rejected` 列表、同批其他目标正常入队。中文名称输入(如 `贵州茅台`)仍走既有股票名解析,不进入指数判型。
> **Phase 2 边界**:默认 `STOCK_LIST`、`--schedule`、Bot 与 GitHub Actions 每日工作流暂不开放指数入口Web/API 与一次性 `--stocks` 已支持指数Bot/定时/每日工作流入口留待 Phase 2 后续 PR。
### 指数与个股 Dashboard canonical 隔离PR #2312
已登记指数以 lowercase canonical`sh000016`/`sz399001`/`csi930955`写入历史存储历史筛选、按代码删除、计数与个股栏stock-bar聚合统一使用 parser 判型,指数记录与同码裸股票(如 `000016`)严格隔离,互不折叠:
- **历史候选**:指数查询(`sh000016``SH000016``000016.SH``sz399001``csi930955``930955.CSI` 等显式形式)会命中 lowercase canonical、uppercase legacy canonical 与显式 alias 的既有记录,但**不会**命中裸同码股票记录;裸码查询(`000016`/`930955`)也不会命中指数记录。股票 alias、港股与海外市场的既有等价匹配保持不变。
- **删除与计数**`DELETE /api/v1/history/by-code/{code}` 与历史总数对指数 canonical 收敛全部显式形态;无记录时仍返回 `deleted=0`,不引入破坏性 404。
- **个股栏**:同一指数的 `sh000016`/`SH000016`/`000016.SH` 旧记录在 `/history/stocks` 合并为一行并计数全部显式形态,且与裸 `000016` 股票行并列存在、互不合并。stock-bar 判型来自持久化 `record.code`,不从 display code 反推。
- **API `stock_code` 输出 canonical**:历史列表、历史详情与 stock-bar 对已登记指数(含旧 uppercase/显式 alias 持久化记录,例如 `SZ399300``000300.CSI`)一律输出 parser canonical`sh000300`/`csi930955`),前端不再需要从别名猜 canonical。
- **任务 / SSE / API 元数据**:任务列表与 SSE 事件对已提交的 `analysis_target` 暴露可选 `asset_type``stock`/`index`),不重新猜测;历史列表项与 stock-bar 项也追加可选 `asset_type`。不认识该字段的旧客户端直接忽略,字段可选追加不破坏既有契约。
- **Web Dashboard 身份键**:任务/报告/历史的 `assetType` 优先,且被后端保证为 parser canonical 的代码只做**大小写折叠**`SH000016``sh000016`**禁止**再用前缀/后缀正则猜 canonical否则 `000300.CSI` 会被误猜成 `csi000300``sz399300` 被误当成独立 canonical违反注册表唯一判型真源仅 watchlist 原始字符串缺少类型时,才使用已加载 `stocks.index.json``assetType=index` 行的 canonical/display/显式 alias **精确命中**(不做先 normalize 再匹配、不用前缀正则猜测;加载期间禁用批量分析,加载失败或请求超过 10 秒时按既有股票语义 fail-open。行选中、active task 与完成自动选中均按资产类型分桶,指数行与同码股票行状态独立,完成后自动选中正确的 canonical 指数报告。
### Futu 真实持仓作为分析列表 ### Futu 真实持仓作为分析列表

View File

@@ -677,7 +677,24 @@ Indices share the A-share trading-day semantics: when the trading-day check is e
Index realtime quotes use a dedicated fixed chain: Tencent → Sina → Eastmoney single-stock endpoint → TickFlow. SH/SZ indices are requested with explicit symbols (`sh000016`/`sz399001`); CSI indices are served only by the Eastmoney single-stock endpoint (`2.{code}` secid). The explicit index identity is preserved end-to-end and never degrades into the colliding stock quote. Index realtime quotes use a dedicated fixed chain: Tencent → Sina → Eastmoney single-stock endpoint → TickFlow. SH/SZ indices are requested with explicit symbols (`sh000016`/`sz399001`); CSI indices are served only by the Eastmoney single-stock endpoint (`2.{code}` secid). The explicit index identity is preserved end-to-end and never degrades into the colliding stock quote.
> **Phase 2 boundary**: default `STOCK_LIST`, `--schedule`, Web/API autocomplete and analysis entrypoints, Bot, and the GitHub Actions daily workflow do not yet expose index entrypoints; this capability is available only through the one-shot `--stocks` entry. ### Web/API index entry (Phase 2 PR1)
Web autocomplete and search now expose registered indices: searching a registry Chinese name (e.g. `上证50`) or an explicit code (`sh000016`, `930955.CSI`) returns the matching index row and lets you submit its analysis; popular candidates still show stocks only (`assetType=stock`), never indices.
The API `/analyze` endpoint builds a structured `AnalysisTarget` for explicit index inputs: `sh000016` is enqueued as `asset_type=INDEX` with `canonical_id=sh000016`, and `930955.CSI`/`csi930955` converge to `csi930955`. Indices and same-digit stocks (e.g. `sh000016` vs `000016`) are deduplicated independently and never collapse. An unregistered CSI input (e.g. `930956.CSI`) returns an explicit 4xx for a single async or sync request, and in an async batch only that target enters the response `rejected` list while the rest of the batch is enqueued normally. Chinese-name inputs (e.g. `贵州茅台`) keep the existing stock-name resolution path and never enter index classification.
> **Phase 2 boundary**: default `STOCK_LIST`, `--schedule`, Bot, and the GitHub Actions daily workflow do not yet expose index entrypoints; Web/API and the one-shot `--stocks` entry support indices, with Bot/scheduled/daily-workflow entries landing in later Phase 2 PRs.
### Index vs stock Dashboard canonical isolation (PR #2312)
Registered indices are persisted under their lowercase canonical identity (`sh000016` / `sz399001` / `csi930955`). History filtering, delete-by-code, counts, and the stock bar now all use the parser for asset typing, so index records and the same-code bare stock (e.g. `000016`) are strictly isolated and never collapse:
- **History candidates**: index queries (`sh000016`, `SH000016`, `000016.SH`, `sz399001`, `csi930955`, `930955.CSI` and other explicit forms) reach records persisted under the lowercase canonical, the legacy uppercase canonical, or an explicit alias — but **never** the bare same-code stock record; a bare query (`000016` / `930955`) likewise never reaches index records. Stock aliases, HK, and offshore markets keep their existing equivalence semantics.
- **Delete and count**: `DELETE /api/v1/history/by-code/{code}` and history totals converge every explicit index form; a code with no records still returns `deleted=0` (no breaking 404).
- **Stock bar**: legacy index records (`sh000016` / `SH000016` / `000016.SH`) merge into one `/history/stocks` row whose count covers all explicit forms, and that row sits beside the bare `000016` stock row without merging. Stock-bar typing derives from the persisted `record.code`, never from the display code.
- **API `stock_code` output is canonical**: history list, history detail, and stock-bar always surface the parser canonical for registered indices — including legacy uppercase / explicit-alias persisted records such as `SZ399300` or `000300.CSI` (both output `sh000300`) — so the frontend never has to derive a canonical from aliases.
- **Task / SSE / API metadata**: task lists and SSE events expose an optional `asset_type` (`stock`/`index`) derived from the submitted `analysis_target` (never re-guessed); history list items and stock-bar items gain the same optional field. Clients that do not know the field simply ignore it — the field is optional and additive.
- **Web Dashboard identity keys**: `assetType` on tasks/reports/history wins first, and backend-guaranteed canonical codes are only **case-folded** (`SH000016` -> `sh000016`) — prefix/suffix regex canonical guessing is forbidden (it would fabricate `csi000300` from `000300.CSI` or treat `sz399300` as an independent canonical, violating the registry as the single asset-type authority). Only raw watchlist strings without a type use an exact canonical/display/alias hit on the loaded `assetType=index` rows of `stocks.index.json` (never normalize-then-match, never prefix-regex guessing; batch analysis stays disabled while the registry loads, and a load failure or request exceeding 10 seconds falls back to existing stock semantics). Row selection, active-task matching, and completed-task auto-selection bucket by asset type, so an index row and a same-code stock row keep independent states and completion auto-selects the correct canonical index report.
### Use real Futu holdings as the analysis list ### Use real Futu holdings as the analysis list

View File

@@ -40,6 +40,62 @@ _INDICATOR_CONTEXT_PATTERN = re.compile(
re.IGNORECASE, re.IGNORECASE,
) )
# Match complete explicit SH/SZ/CSI tokens; the registry parser remains the
# only authority that can promote a match to index identity.
_INDEX_TOKEN_PATTERNS = (
(r"(?<![a-zA-Z0-9_])(?:sh|sz)\d{6}(?![a-zA-Z0-9_])", re.IGNORECASE),
(r"(?<![a-zA-Z0-9_])csi\d{6}(?![a-zA-Z0-9_])", re.IGNORECASE),
(
r"(?<![a-zA-Z0-9_])\d{6}\.(?:sh|sz|csi)(?![a-zA-Z0-9_])",
re.IGNORECASE,
),
)
def _extract_index_canonical_tokens(
text: str,
registry: Any,
) -> "tuple[list[tuple[int, int]], list[str]]":
"""Return full spans and canonicals for exact registered index tokens."""
spans: List[tuple[int, int]] = []
canonicals: List[str] = []
for pattern, flags in _INDEX_TOKEN_PATTERNS:
for match in re.finditer(pattern, text, flags):
raw = match.group(0)
try:
from src.services.stock_list_parser import (
ParseStatus,
parse_analysis_target,
)
target = parse_analysis_target(raw, registry)
except Exception:
continue
if target.asset_type != ParseStatus.INDEX:
continue
if not target.canonical_id:
continue
start, end = match.span()
if any(s <= start and end <= e for s, e in spans):
continue
spans.append((start, end))
canonicals.append(target.canonical_id)
return spans, canonicals
def _is_inside_index_span(start: int, end: int, spans: List[tuple[int, int]]) -> bool:
return any(span_start <= start and end <= span_end for span_start, span_end in spans)
def _has_ascii_token_boundaries(text: str, start: int, end: int) -> bool:
def _is_word_char(char: str) -> bool:
return bool(char) and char.isascii() and (char.isalnum() or char == "_")
return (
not _is_word_char(text[start - 1:start])
and not _is_word_char(text[end:end + 1])
)
@dataclass(frozen=True) @dataclass(frozen=True)
class StockScope: class StockScope:
@@ -65,8 +121,8 @@ class StockScopeResolution:
stock_scope: Optional[StockScope] stock_scope: Optional[StockScope]
def _normalize_stock_code(value: Any) -> str: def _normalize_stock_code(value: Any, registry: Optional[Any] = None) -> str:
"""Normalize a code with the runner's canonical stock-code rules.""" """Normalize a code, preserving exact registered index canonicals."""
if not isinstance(value, str): if not isinstance(value, str):
return "" return ""
text = value.strip() text = value.strip()
@@ -75,7 +131,7 @@ def _normalize_stock_code(value: Any) -> str:
try: try:
from src.agent.tools.execution import _normalize_tool_stock_code from src.agent.tools.execution import _normalize_tool_stock_code
normalized = _normalize_tool_stock_code(text) normalized = _normalize_tool_stock_code(text, registry)
except Exception: except Exception:
normalized = text.strip().upper() normalized = text.strip().upper()
return normalized if isinstance(normalized, str) else str(normalized) return normalized if isinstance(normalized, str) else str(normalized)
@@ -95,20 +151,31 @@ def _is_denied_candidate(candidate: str, text: str = "") -> bool:
return False return False
def _append_candidate(candidates: List[str], candidate: str, text: str = "") -> None: def _append_candidate(
normalized = _normalize_stock_code(candidate) candidates: List[str],
candidate: str,
text: str = "",
registry: Optional[Any] = None,
) -> None:
normalized = _normalize_stock_code(candidate, registry)
if not normalized or _is_denied_candidate(normalized, text): if not normalized or _is_denied_candidate(normalized, text):
return return
if normalized not in candidates: if normalized not in candidates:
candidates.append(normalized) candidates.append(normalized)
def extract_stock_codes(text: str) -> List[str]: def extract_stock_codes(text: str, registry: Optional[Any] = None) -> List[str]:
"""Extract all explicit stock-code candidates from free text.""" """Extract candidates; no registry preserves the legacy stock-only path."""
if not text: if not text:
return [] return []
candidates: List[str] = [] candidates: List[str] = []
index_spans: List[tuple[int, int]] = []
if registry is not None:
index_spans, canonicals = _extract_index_canonical_tokens(text, registry)
for canonical in canonicals:
if canonical not in candidates:
candidates.append(canonical)
for pattern, flags in ( for pattern, flags in (
(r"(?<![a-zA-Z])(?:SH|SZ|BJ)\d{6}(?!\d)", re.IGNORECASE), (r"(?<![a-zA-Z])(?:SH|SZ|BJ)\d{6}(?!\d)", re.IGNORECASE),
@@ -119,8 +186,13 @@ def extract_stock_codes(text: str) -> List[str]:
(r"(?<![a-zA-Z.])([A-Z]{2,5}(?:\.[A-Z]{1,2})?)(?![a-zA-Z0-9])", 0), (r"(?<![a-zA-Z.])([A-Z]{2,5}(?:\.[A-Z]{1,2})?)(?![a-zA-Z0-9])", 0),
): ):
for match in re.finditer(pattern, text, flags): for match in re.finditer(pattern, text, flags):
start, end = match.span()
if registry is not None and not _has_ascii_token_boundaries(text, start, end):
continue
if _is_inside_index_span(start, end, index_spans):
continue
raw = match.group(1) if match.lastindex else match.group(0) raw = match.group(1) if match.lastindex else match.group(0)
_append_candidate(candidates, raw, text) _append_candidate(candidates, raw, text, registry)
if ( if (
_SWITCH_PATTERN.search(text) _SWITCH_PATTERN.search(text)
@@ -129,12 +201,22 @@ def extract_stock_codes(text: str) -> List[str]:
or _CHOICE_COMPARE_PATTERN.search(text) or _CHOICE_COMPARE_PATTERN.search(text)
): ):
for match in _LOWERCASE_TICKER_PATTERN.finditer(text): for match in _LOWERCASE_TICKER_PATTERN.finditer(text):
_append_candidate(candidates, match.group(1), text) start, end = match.span(1)
if registry is not None and not _has_ascii_token_boundaries(text, start, end):
continue
if _is_inside_index_span(start, end, index_spans):
continue
_append_candidate(candidates, match.group(1), text, registry)
return candidates return candidates
def _is_compare_message(message: str, candidates: List[str], current_code: str) -> bool: def _is_compare_message(
message: str,
candidates: List[str],
current_code: str,
registry: Optional[Any] = None,
) -> bool:
if _STRONG_COMPARE_PATTERN.search(message): if _STRONG_COMPARE_PATTERN.search(message):
return True return True
new_candidates = {code for code in candidates if code != current_code} new_candidates = {code for code in candidates if code != current_code}
@@ -151,7 +233,7 @@ def _is_compare_message(message: str, candidates: List[str], current_code: str)
return False return False
for match in _LINKED_COMPARE_PATTERN.finditer(message): for match in _LINKED_COMPARE_PATTERN.finditer(message):
body_candidates = set(extract_stock_codes(f"比较 {match.group('body')}")) body_candidates = set(extract_stock_codes(f"比较 {match.group('body')}", registry))
if body_candidates & new_candidates: if body_candidates & new_candidates:
return True return True
return False return False
@@ -182,11 +264,22 @@ def resolve_stock_scope(
*, *,
skills: Optional[Iterable[str]] = None, skills: Optional[Iterable[str]] = None,
strict_initial_scope: bool = False, strict_initial_scope: bool = False,
registry: Optional[Any] = None,
) -> StockScopeResolution: ) -> StockScopeResolution:
"""Resolve the effective context and stock tool scope for one chat turn.""" """Resolve one turn with a shared registry, failing open to stock semantics."""
if registry is None:
try:
from src.services.stock_list_parser import default_index_registry
registry = default_index_registry()
except Exception:
registry = None
if registry is not None and not getattr(registry, "_entries", ()):
registry = None
original_context = dict(context or {}) original_context = dict(context or {})
message_text = message or "" message_text = message or ""
current_code = _normalize_stock_code(original_context.get("stock_code")) current_code = _normalize_stock_code(original_context.get("stock_code"), registry)
invalid_context_code = bool(current_code and _is_denied_candidate(current_code, message_text)) invalid_context_code = bool(current_code and _is_denied_candidate(current_code, message_text))
original_context.pop("allowed_stock_codes", None) original_context.pop("allowed_stock_codes", None)
if invalid_context_code: if invalid_context_code:
@@ -196,7 +289,7 @@ def resolve_stock_scope(
if not current_code: if not current_code:
if invalid_context_code or strict_initial_scope: if invalid_context_code or strict_initial_scope:
candidates = extract_stock_codes(message_text) candidates = extract_stock_codes(message_text, registry)
if strict_initial_scope and not invalid_context_code and not candidates: if strict_initial_scope and not invalid_context_code and not candidates:
return StockScopeResolution( return StockScopeResolution(
effective_context=_with_skills(original_context, skills), effective_context=_with_skills(original_context, skills),
@@ -222,14 +315,14 @@ def resolve_stock_scope(
stock_scope=None, stock_scope=None,
) )
candidates = extract_stock_codes(message_text) candidates = extract_stock_codes(message_text, registry)
new_candidates = [code for code in candidates if code != current_code] new_candidates = [code for code in candidates if code != current_code]
mode = "maintain" mode = "maintain"
effective_context = dict(original_context) effective_context = dict(original_context)
expected = current_code expected = current_code
allowed = {current_code} allowed = {current_code}
if _is_compare_message(message_text, candidates, current_code): if _is_compare_message(message_text, candidates, current_code, registry):
mode = "compare" mode = "compare"
allowed.update(candidates) allowed.update(candidates)
elif _SWITCH_PATTERN.search(message_text) and len(new_candidates) == 1: elif _SWITCH_PATTERN.search(message_text) and len(new_candidates) == 1:

View File

@@ -169,45 +169,75 @@ def serialize_tool_result(result: Any) -> str:
return str(result) return str(result)
def _normalize_tool_stock_code(value: Any) -> Any: def _normalize_tool_stock_code(value: Any, registry: Optional[Any] = None) -> Any:
"""Canonicalize stock code arguments so equivalent HK variants share one cache key.""" """Canonicalize a stock argument for tool scope and cache keys.
An injected registry preserves exact parser INDEX canonicals. Direct calls
without one retain the legacy stock/HK normalization path.
"""
if not isinstance(value, str): if not isinstance(value, str):
return value return value
text = value.strip().upper() text = value.strip()
if not text: if not text:
return text return text
if text.endswith(".HK"): if registry is not None:
base = text[:-3] try:
from src.services.stock_list_parser import ParseStatus, parse_analysis_target
target = parse_analysis_target(text, registry)
if target.asset_type == ParseStatus.INDEX and target.canonical_id:
return target.canonical_id
except Exception:
# Registry malformed/unavailable — fall through to the stock path.
pass
upper = text.upper()
if upper.endswith(".HK"):
base = upper[:-3]
if base.isdigit() and 1 <= len(base) <= 5: if base.isdigit() and 1 <= len(base) <= 5:
return f"HK{base.zfill(5)}" return f"HK{base.zfill(5)}"
if text.startswith("HK"): if upper.startswith("HK"):
base = text[2:] base = upper[2:]
if base.isdigit() and 1 <= len(base) <= 5: if base.isdigit() and 1 <= len(base) <= 5:
return f"HK{base.zfill(5)}" return f"HK{base.zfill(5)}"
if text.isdigit() and len(text) == 5: if upper.isdigit() and len(upper) == 5:
return f"HK{text}" return f"HK{upper}"
try: try:
from data_provider.base import canonical_stock_code, normalize_stock_code from data_provider.base import canonical_stock_code, normalize_stock_code
return canonical_stock_code(normalize_stock_code(text)) return canonical_stock_code(normalize_stock_code(upper))
except Exception: except Exception:
return text return upper
def _default_index_registry_or_none() -> Optional[Any]:
"""Return the default IndexRegistry, failing open to stock semantics."""
try:
from src.services.stock_list_parser import default_index_registry
return default_index_registry()
except Exception:
return None
def _build_tool_cache_key(tool_name: str, arguments: Dict[str, Any]) -> Optional[str]: def _build_tool_cache_key(tool_name: str, arguments: Dict[str, Any]) -> Optional[str]:
"""Build a stable cache key for tool calls with normalized stock-code arguments.""" """Build a stable cache key without folding index and bare-stock identities."""
if not isinstance(arguments, dict): if not isinstance(arguments, dict):
return None return None
registry = None
if "stock_code" in arguments:
registry = _default_index_registry_or_none()
normalized_args: Dict[str, Any] = {} normalized_args: Dict[str, Any] = {}
for key, value in arguments.items(): for key, value in arguments.items():
if key == "stock_code": if key == "stock_code":
normalized_args[key] = _normalize_tool_stock_code(value) normalized_args[key] = _normalize_tool_stock_code(value, registry)
else: else:
normalized_args[key] = value normalized_args[key] = value
@@ -234,13 +264,13 @@ def _is_stock_scoped_tool(tool_registry: ToolRegistry, tool_name: str) -> bool:
return any(param.name == "stock_code" for param in tool_def.parameters) return any(param.name == "stock_code" for param in tool_def.parameters)
def _normalize_guard_stock_code(value: Any) -> str: def _normalize_guard_stock_code(value: Any, registry: Optional[Any] = None) -> str:
if value is None: if value is None:
return "" return ""
if isinstance(value, float) and value.is_integer(): if isinstance(value, float) and value.is_integer():
value = int(value) value = int(value)
raw = value if isinstance(value, str) else str(value) raw = value if isinstance(value, str) else str(value)
normalized = _normalize_tool_stock_code(raw) normalized = _normalize_tool_stock_code(raw, registry)
return normalized if isinstance(normalized, str) else str(normalized) return normalized if isinstance(normalized, str) else str(normalized)
@@ -253,7 +283,9 @@ def _guard_tool_stock_scope(
tool_name: str, tool_name: str,
arguments: Dict[str, Any], arguments: Dict[str, Any],
stock_scope: Any, stock_scope: Any,
index_registry: Optional[Any] = None,
) -> Optional[Dict[str, Any]]: ) -> Optional[Dict[str, Any]]:
"""Enforce one tool stock argument against the active canonical scope."""
if stock_scope is None or not isinstance(arguments, dict): if stock_scope is None or not isinstance(arguments, dict):
return None return None
if not _is_stock_scoped_tool(tool_registry, tool_name): if not _is_stock_scoped_tool(tool_registry, tool_name):
@@ -261,12 +293,17 @@ def _guard_tool_stock_scope(
if "stock_code" not in arguments: if "stock_code" not in arguments:
return None return None
requested = _normalize_guard_stock_code(arguments.get("stock_code")) if index_registry is None:
expected = _normalize_guard_stock_code(getattr(stock_scope, "expected_stock_code", "")) index_registry = _default_index_registry_or_none()
requested = _normalize_guard_stock_code(arguments.get("stock_code"), index_registry)
expected = _normalize_guard_stock_code(
getattr(stock_scope, "expected_stock_code", ""), index_registry
)
allowed = { allowed = {
normalized normalized
for code in _iter_allowed_stock_codes(stock_scope) for code in _iter_allowed_stock_codes(stock_scope)
for normalized in [_normalize_guard_stock_code(code)] for normalized in [_normalize_guard_stock_code(code, index_registry)]
if normalized if normalized
} }
if requested and (requested == expected or requested in allowed): if requested and (requested == expected or requested in allowed):

View File

@@ -4051,7 +4051,13 @@ class SearchService:
# 如果提供了关键词,直接使用关键词作为查询 # 如果提供了关键词,直接使用关键词作为查询
query = " ".join(focus_keywords) query = " ".join(focus_keywords)
elif prefer_chinese: elif prefer_chinese:
query = f"{stock_name} {stock_code} 股票 最新消息" # 指数 target 的 stock_code 可能为空Agent 工具 _resolve_search_subject
# 有意只传显示名),空 code 直接省略,避免拼出 "上证50 股票" 双空格。
query = (
f"{stock_name} {stock_code} 股票 最新消息"
if stock_code
else f"{stock_name} 股票 最新消息"
)
elif is_foreign: elif is_foreign:
# 港股/美股使用英文搜索关键词优先使用英文公司名issue #2026 # 港股/美股使用英文搜索关键词优先使用英文公司名issue #2026
if english_aliases and short_name and short_name != effective_name: if english_aliases and short_name and short_name != effective_name:
@@ -4064,13 +4070,13 @@ class SearchService:
# 默认主查询:股票名称 + 核心关键词 # 默认主查询:股票名称 + 核心关键词
query = f"{stock_name} {stock_code} 股票 最新消息" query = f"{stock_name} {stock_code} 股票 最新消息"
subject_label = f"{stock_name}({stock_code})" if stock_code else stock_name
logger.info( logger.info(
( (
"搜索股票新闻: %s(%s), query='%s', 时间范围: 近%s" "搜索股票新闻: %s, query='%s', 时间范围: 近%s"
"(profile=%s, NEWS_MAX_AGE_DAYS=%s, prefer_chinese=%s), 目标条数=%s, provider请求条数=%s" "(profile=%s, NEWS_MAX_AGE_DAYS=%s, prefer_chinese=%s), 目标条数=%s, provider请求条数=%s"
), ),
stock_name, subject_label,
stock_code,
query, query,
search_days, search_days,
self.news_strategy_profile, self.news_strategy_profile,

View File

@@ -36,6 +36,32 @@ from src.services.empty_news import empty_news_disclosure
logger = logging.getLogger(__name__) 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: class AnalysisService:
""" """
分析服务 分析服务
@@ -62,6 +88,7 @@ class AnalysisService:
query_source: str = "api", query_source: str = "api",
portfolio_context: Optional[Dict[str, Any]] = None, portfolio_context: Optional[Dict[str, Any]] = None,
report_language: Optional[str] = None, report_language: Optional[str] = None,
analysis_target: Optional[Any] = None,
) -> Optional[Dict[str, Any]]: ) -> Optional[Dict[str, Any]]:
""" """
执行股票分析 执行股票分析
@@ -73,6 +100,8 @@ class AnalysisService:
query_id: 查询 ID可选 query_id: 查询 ID可选
send_notification: 是否发送通知API 触发默认发送) send_notification: 是否发送通知API 触发默认发送)
analysis_phase: 请求的分析阶段覆盖auto/premarket/intraday/postmarket analysis_phase: 请求的分析阶段覆盖auto/premarket/intraday/postmarket
analysis_target: 可选的结构化分析目标(指数目标贯穿到 pipeline
否则指数会退化为股票语义)
Returns: Returns:
分析结果字典,包含: 分析结果字典,包含:
@@ -128,6 +157,7 @@ class AnalysisService:
skip_analysis=False, skip_analysis=False,
single_stock_notify=send_notification, single_stock_notify=send_notification,
report_type=rt, report_type=rt,
analysis_target=analysis_target,
) )
if result is None: if result is None:
@@ -219,6 +249,7 @@ class AnalysisService:
"change_pct": result.change_pct, "change_pct": result.change_pct,
"model_used": getattr(result, "model_used", None), "model_used": getattr(result, "model_used", None),
"market_phase_summary": market_phase_summary, "market_phase_summary": market_phase_summary,
"asset_type": asset_type_from_canonical_code(result.code),
}, },
"summary": { "summary": {
"analysis_summary": result.analysis_summary, "analysis_summary": result.analysis_summary,

View File

@@ -141,36 +141,50 @@ class HistoryService:
is_bse_code, is_bse_code,
normalize_stock_code, normalize_stock_code,
) )
from src.services.stock_code_utils import ( from src.services.stock_list_parser import ParseStatus, parse_analysis_target
_converge_registered_csi_identity,
)
# PR #2267 review remediation: converge registered CSI aliases # PR #2312: parser-aware index branch. ``parse_analysis_target``
# (``csi930955`` / ``930955.CSI`` / ``CSI930955``) so a record persisted under any # (with the index registry) is the single asset-type authority. When
# equivalent form is reachable from every equivalent query input. # the query token is a registered INDEX, the persisted-read candidate
# This is a *persisted-read* filter path, so the candidate set must # set must be exactly:
# include: # 1. the lowercase parser canonical (``sh000016`` / ``csi930955`` —
# 1. the parser canonical (``csi930955`` — current storage form), # the current storage form),
# 2. the old resolver's uppercase canonical (``CSI930955`` — how # 2. the legacy uppercase canonical (``SH000016`` / ``CSI930955`` —
# pre-fix records were saved), and # how pre-fix records were saved), and
# 3. the IndexEntry's explicit aliases (``930955.CSI``). # 3. every explicit alias/display form (``000016.SH`` /
converged_csi = _converge_registered_csi_identity(raw_code) # ``930955.CSI`` — ``IndexEntry.aliases``).
if converged_csi is not None: # The bare same-code stock (``000016`` / ``930955``) is deliberately
from src.data.stock_index_loader import _load_active_index_rows # excluded so an index record is never reachable through a stock
active_rows = _load_active_index_rows() # query and vice versa. This unifies the previous CSI-only convergence
alias_keys: List[str] = [] # (PR #2267) with the SH/SZ index identity so all three namespaces
display_keys: List[str] = [] # share one branch.
for row in active_rows: target = parse_analysis_target(raw_code)
if row and str(row[0] or "").strip() == converged_csi: if target.asset_type == ParseStatus.INDEX:
display_keys = [str(row[1] or "").strip()] index_keys = [target.canonical_id]
alias_keys = [ legacy_upper = (
str(a) for a in (row[5] if isinstance(row[5], list) else []) canonical_stock_code(target.canonical_id)
if str(a).strip() or target.canonical_id.upper()
] )
break if legacy_upper and legacy_upper not in index_keys:
canonical_upper = canonical_stock_code(converged_csi) or converged_csi.upper() index_keys.append(legacy_upper)
add_keys = [converged_csi, canonical_upper] + display_keys + alias_keys entry = target.matched_index
for key in add_keys: if entry is not None:
for alias in entry.aliases:
alias = str(alias or "").strip()
if not alias:
continue
if alias not in index_keys:
index_keys.append(alias)
# sqlite ``IN`` comparison is case-sensitive: a legacy
# record may have been persisted under the uppercase
# alias form (``SZ399300`` for registry alias
# ``sz399300``), so the persisted-read candidate set must
# carry both the raw alias and its uppercase form. The
# bare same-code stock (``000300``) is still never added.
alias_upper = alias.upper()
if alias_upper and alias_upper not in index_keys:
index_keys.append(alias_upper)
for key in index_keys:
if key and key not in candidates: if key and key not in candidates:
candidates.append(key) candidates.append(key)
return candidates return candidates
@@ -373,6 +387,21 @@ class HistoryService:
code = str(raw_code or "").strip() code = str(raw_code or "").strip()
if not code: if not code:
return code return code
from src.services.stock_list_parser import ParseStatus, parse_analysis_target
try:
target = parse_analysis_target(code)
except Exception:
return resolve_index_stock_code(code) or code
if target.asset_type == ParseStatus.INDEX:
# PR #2312: registered index records always surface the parser
# canonical (lowercase ``sh000300`` / ``csi930955``), even when the
# persisted row was saved under a legacy uppercase canonical or an
# explicit alias (``SH000016`` / ``000016.SH`` / ``sz399300`` /
# ``000300.CSI``). Web identity keys therefore only need a simple
# case fold for API/task/report codes — they must never guess a
# canonical from prefixes/suffixes.
return target.canonical_id
return resolve_index_stock_code(code) or code return resolve_index_stock_code(code) or code
def _display_market_phase_summary(self, stock_code: str, context_snapshot: Any) -> Any: def _display_market_phase_summary(self, stock_code: str, context_snapshot: Any) -> Any:
@@ -433,9 +462,23 @@ class HistoryService:
"model_used": normalize_model_used(model_used), "model_used": normalize_model_used(model_used),
"created_at": self._serialize_created_at(record.created_at), "created_at": self._serialize_created_at(record.created_at),
"market_phase_summary": market_phase_summary, "market_phase_summary": market_phase_summary,
"asset_type": self._asset_type_for_record(record),
**market_fields, **market_fields,
} }
@staticmethod
def _asset_type_for_record(record) -> Optional[str]:
"""Return the parser-derived optional ``asset_type`` for a history row.
Uses the *persisted* ``record.code`` (never the display code) via
:func:`asset_type_from_canonical_code`, so ``sh000016`` is ``index``,
bare ``000016`` is ``stock`` and market review rows (``MARKET``) omit
the field. Optional by contract: legacy clients simply ignore it.
"""
from src.services.analysis_service import asset_type_from_canonical_code
return asset_type_from_canonical_code(getattr(record, "code", None))
def _resolve_record( def _resolve_record(
self, self,
record_id: str, record_id: str,

View File

@@ -49,6 +49,47 @@ def _dedupe_stock_code_key(stock_code: str) -> str:
return resolve_index_stock_code_for_analysis(normalize_stock_code(stock_code)) return resolve_index_stock_code_for_analysis(normalize_stock_code(stock_code))
def _dedupe_task_key(
stock_code: str,
analysis_target: Optional[Any] = None,
) -> str:
"""
Build the duplicate-detection key for a submitted task.
Index targets dedupe by their canonical id (``sh000016`` / ``csi930955``),
so an index never collapses with a same-digit stock (``000016``) and
registered CSI aliases converge to one key. Stock targets keep the legacy
code-based key, preserving existing dedup semantics for
``600519``/``600519.SH`` and friends.
"""
from src.services.stock_list_parser import ParseStatus
if analysis_target is not None and analysis_target.asset_type == ParseStatus.INDEX:
return analysis_target.canonical_id
return _dedupe_stock_code_key(stock_code)
def asset_type_from_analysis_target(analysis_target: Optional[Any]) -> Optional[str]:
"""Derive the optional ``asset_type`` from a submitted analysis target.
Uses the parser target only (never re-guesses from the code). Registered
index targets (``ParseStatus.INDEX``) yield ``"index"``, explicit stock
targets yield ``"stock"``, and targets that were not carried downstream
(plain stock submissions, market review) yield ``None`` so the field is
simply omitted from task payloads — keeping legacy clients compatible.
"""
from src.services.stock_list_parser import ParseStatus
if analysis_target is None:
return None
asset_type = getattr(analysis_target, "asset_type", None)
if asset_type == ParseStatus.INDEX:
return "index"
if asset_type == ParseStatus.STOCK:
return "stock"
return None
class TaskStatus(str, Enum): class TaskStatus(str, Enum):
"""Task status enumeration""" """Task status enumeration"""
PENDING = "pending" # Waiting for execution PENDING = "pending" # Waiting for execution
@@ -88,6 +129,13 @@ class TaskInfo:
trace_id: Optional[str] = None trace_id: Optional[str] = None
region: Optional[str] = None region: Optional[str] = None
flow_events: List[Dict[str, Any]] = field(default_factory=list) flow_events: List[Dict[str, Any]] = field(default_factory=list)
# 固化去重键submit 时按 asset_type 分支算好,完成/失败移除时直接使用,
# 避免 key 生成/移除不一致导致 _analyzing_stocks 残留。
dedupe_key: Optional[str] = None
analysis_target: Optional[Any] = None
# parser 来源的可选资产类型(``index``/``stock``/``None``SSE 与任务列表
# 从这里透传,不得在消费端重新猜测。
asset_type: Optional[str] = None
def to_dict(self) -> Dict[str, Any]: def to_dict(self) -> Dict[str, Any]:
"""Convert task info into an API-friendly dictionary.""" """Convert task info into an API-friendly dictionary."""
@@ -111,6 +159,8 @@ class TaskInfo:
} }
if self.region is not None: if self.region is not None:
payload["region"] = self.region payload["region"] = self.region
if self.asset_type is not None:
payload["asset_type"] = self.asset_type
return payload return payload
def copy(self) -> 'TaskInfo': def copy(self) -> 'TaskInfo':
@@ -138,6 +188,9 @@ class TaskInfo:
trace_id=self.trace_id or self.task_id, trace_id=self.trace_id or self.task_id,
region=self.region, region=self.region,
flow_events=copy.deepcopy(self.flow_events), flow_events=copy.deepcopy(self.flow_events),
dedupe_key=self.dedupe_key,
analysis_target=self.analysis_target,
asset_type=self.asset_type,
) )
@@ -337,6 +390,7 @@ class AnalysisTaskQueue:
force_refresh: bool = False, force_refresh: bool = False,
skills: Optional[List[str]] = None, skills: Optional[List[str]] = None,
report_language: Optional[str] = None, report_language: Optional[str] = None,
analysis_target: Optional[Any] = None,
) -> TaskInfo: ) -> TaskInfo:
""" """
Submit a single analysis task. Submit a single analysis task.
@@ -349,6 +403,8 @@ class AnalysisTaskQueue:
report_type: Report type report_type: Report type
analysis_phase: Requested analysis phase override analysis_phase: Requested analysis phase override
force_refresh: Whether to bypass cache force_refresh: Whether to bypass cache
analysis_target: Optional structured analysis target (index targets
dedupe by canonical id and flow through to the pipeline)
Returns: Returns:
TaskInfo: Accepted task information TaskInfo: Accepted task information
@@ -372,6 +428,7 @@ class AnalysisTaskQueue:
force_refresh=force_refresh, force_refresh=force_refresh,
skills=skills, skills=skills,
report_language=report_language, report_language=report_language,
analysis_targets=([analysis_target] if analysis_target is not None else None),
) )
if duplicates: if duplicates:
raise duplicates[0] raise duplicates[0]
@@ -391,12 +448,16 @@ class AnalysisTaskQueue:
notify: bool = True, notify: bool = True,
skills: Optional[List[str]] = None, skills: Optional[List[str]] = None,
report_language: Optional[str] = None, report_language: Optional[str] = None,
analysis_targets: Optional[List[Any]] = None,
) -> Tuple[List[TaskInfo], List[DuplicateTaskError]]: ) -> Tuple[List[TaskInfo], List[DuplicateTaskError]]:
""" """
Submit analysis tasks in batch. Submit analysis tasks in batch.
- Duplicate stocks are skipped and recorded in duplicates. - Duplicate stocks are skipped and recorded in duplicates.
- If executor submission fails, the current batch is rolled back. - If executor submission fails, the current batch is rolled back.
- ``analysis_targets`` is an optional per-code structured target list.
Index targets dedupe by ``canonical_id`` (never collapsing with a
same-digit stock); stock targets keep the legacy code-based key.
""" """
self.validate_selection_source(selection_source) self.validate_selection_source(selection_source)
@@ -404,14 +465,34 @@ class AnalysisTaskQueue:
duplicates: List[DuplicateTaskError] = [] duplicates: List[DuplicateTaskError] = []
created_task_ids: List[str] = [] created_task_ids: List[str] = []
canonical_codes = [ # Align per-code targets with the canonicalized code list. Index targets
normalized for normalized in (resolve_index_stock_code_for_analysis(code) for code in stock_codes) # keep their parser canonical id (lowercase ``sh000016`` / ``csi930955``)
if normalized # so the pipeline and fetchers see the exact identity the API resolved;
] # stock codes keep the legacy canonicalization path.
targets_by_index: Dict[int, Any] = {}
for idx, code in enumerate(stock_codes):
target = (
analysis_targets[idx]
if analysis_targets is not None and idx < len(analysis_targets)
else None
)
if target is not None and getattr(target, "asset_type", None) == "index":
targets_by_index[idx] = target
canonical_codes = []
for idx, code in enumerate(stock_codes):
target = targets_by_index.get(idx)
if target is not None:
canonical_codes.append(target.canonical_id)
else:
normalized = resolve_index_stock_code_for_analysis(code)
if normalized:
canonical_codes.append(normalized)
with self._data_lock: with self._data_lock:
for stock_code in canonical_codes: for idx, stock_code in enumerate(canonical_codes):
dedupe_key = _dedupe_stock_code_key(stock_code) analysis_target = targets_by_index.get(idx)
dedupe_key = _dedupe_task_key(stock_code, analysis_target)
if dedupe_key in self._analyzing_stocks: if dedupe_key in self._analyzing_stocks:
existing_task_id = self._analyzing_stocks[dedupe_key] existing_task_id = self._analyzing_stocks[dedupe_key]
duplicates.append(DuplicateTaskError(stock_code, existing_task_id)) duplicates.append(DuplicateTaskError(stock_code, existing_task_id))
@@ -434,6 +515,9 @@ class AnalysisTaskQueue:
portfolio_context=dict(portfolio_context) if isinstance(portfolio_context, dict) else None, portfolio_context=dict(portfolio_context) if isinstance(portfolio_context, dict) else None,
skills=task_skills, skills=task_skills,
report_language=report_language, report_language=report_language,
dedupe_key=dedupe_key,
analysis_target=analysis_target,
asset_type=asset_type_from_analysis_target(analysis_target),
) )
self._tasks[task_id] = task_info self._tasks[task_id] = task_info
self._analyzing_stocks[dedupe_key] = task_id self._analyzing_stocks[dedupe_key] = task_id
@@ -448,6 +532,7 @@ class AnalysisTaskQueue:
notify, notify,
task_skills, task_skills,
report_language, report_language,
analysis_target,
) )
except Exception: except Exception:
# Roll back the current batch to avoid partial submission. # Roll back the current batch to avoid partial submission.
@@ -521,7 +606,12 @@ class AnalysisTaskQueue:
task = self._tasks.pop(task_id, None) task = self._tasks.pop(task_id, None)
if task: if task:
dedupe_key = _dedupe_stock_code_key(task.stock_code) # 使用 submit 时固化的 dedupe_key若存在避免按 code 重算
# 与提交路径不一致导致 _analyzing_stocks 残留。
dedupe_key = task.dedupe_key or _dedupe_task_key(
task.stock_code,
getattr(task, "analysis_target", None),
)
if self._analyzing_stocks.get(dedupe_key) == task_id: if self._analyzing_stocks.get(dedupe_key) == task_id:
del self._analyzing_stocks[dedupe_key] del self._analyzing_stocks[dedupe_key]
@@ -674,6 +764,7 @@ class AnalysisTaskQueue:
notify: bool = True, notify: bool = True,
skills: Optional[List[str]] = None, skills: Optional[List[str]] = None,
report_language: Optional[str] = None, report_language: Optional[str] = None,
analysis_target: Optional[Any] = None,
) -> Optional[Dict[str, Any]]: ) -> Optional[Dict[str, Any]]:
""" """
执行分析任务(在线程池中运行) 执行分析任务(在线程池中运行)
@@ -683,6 +774,7 @@ class AnalysisTaskQueue:
stock_code: 股票代码 stock_code: 股票代码
report_type: 报告类型 report_type: 报告类型
force_refresh: 是否强制刷新 force_refresh: 是否强制刷新
analysis_target: 可选的结构化分析目标(指数目标透传到 pipeline
Returns: Returns:
分析结果字典 分析结果字典
@@ -736,6 +828,7 @@ class AnalysisTaskQueue:
query_source=query_source, query_source=query_source,
portfolio_context=portfolio_context, portfolio_context=portfolio_context,
report_language=report_language, report_language=report_language,
analysis_target=analysis_target,
) )
reset_run_diagnostic_context(diag_token) reset_run_diagnostic_context(diag_token)
diag_token = None diag_token = None
@@ -752,8 +845,11 @@ class AnalysisTaskQueue:
task.message = "分析完成" task.message = "分析完成"
task.stock_name = result.get("stock_name", task.stock_name) task.stock_name = result.get("stock_name", task.stock_name)
# 从分析中集合移除 # 从分析中集合移除(使用 submit 时固化的 key避免不一致残留
dedupe_key = _dedupe_stock_code_key(task.stock_code) dedupe_key = task.dedupe_key or _dedupe_task_key(
task.stock_code,
getattr(task, "analysis_target", None),
)
if dedupe_key in self._analyzing_stocks: if dedupe_key in self._analyzing_stocks:
del self._analyzing_stocks[dedupe_key] del self._analyzing_stocks[dedupe_key]
@@ -782,8 +878,11 @@ class AnalysisTaskQueue:
task.error = error_msg[:200] # 限制错误信息长度 task.error = error_msg[:200] # 限制错误信息长度
task.message = f"分析失败: {error_msg[:50]}" task.message = f"分析失败: {error_msg[:50]}"
# 从分析中集合移除 # 从分析中集合移除(使用 submit 时固化的 key避免不一致残留
dedupe_key = _dedupe_stock_code_key(task.stock_code) dedupe_key = task.dedupe_key or _dedupe_task_key(
task.stock_code,
getattr(task, "analysis_target", None),
)
if dedupe_key in self._analyzing_stocks: if dedupe_key in self._analyzing_stocks:
del self._analyzing_stocks[dedupe_key] del self._analyzing_stocks[dedupe_key]

View File

@@ -113,6 +113,44 @@ def _make_mock_adapter():
return adapter return adapter
def _make_index_registry():
"""Build the registered identities exercised by the scope tests."""
from src.services.stock_list_parser import IndexEntry, IndexRegistry
return IndexRegistry(
[
IndexEntry(
bare_code="000016",
exchange="SH",
canonical_id="sh000016",
display_name="上证50",
aliases=("000016.SH",),
),
IndexEntry(
bare_code="000300",
exchange="SH",
canonical_id="sh000300",
display_name="沪深300",
aliases=("sz399300", "000300.SH", "000300.CSI"),
),
IndexEntry(
bare_code="399001",
exchange="SZ",
canonical_id="sz399001",
display_name="深证成指",
aliases=("399001.SZ",),
),
IndexEntry(
bare_code="930955",
exchange="CSI",
canonical_id="csi930955",
display_name="红利低波100",
aliases=("930955.CSI",),
),
]
)
def _build_analysis_context_pack_summary( def _build_analysis_context_pack_summary(
*, *,
realtime_quote=None, realtime_quote=None,
@@ -422,6 +460,183 @@ class TestAgentExecutor(unittest.TestCase):
self.assertEqual(result.effective_context["stock_code"], "600519") self.assertEqual(result.effective_context["stock_code"], "600519")
self.assertEqual(result.stock_scope.allowed_stock_codes, {"600519"}) self.assertEqual(result.stock_scope.allowed_stock_codes, {"600519"})
def test_resolve_stock_scope_registry_explicit_index_forms_fold_to_canonical(self):
# PR #2312 loop 1: default chat (litellm) + explicit registered index
# tokens must keep the registry canonical — never stock-normalized into
# a bare same-code or a prefix-derived fabrication. Case-insensitive,
# dotted SH/SZ/CSI, and CSI-prefix forms all fold to the registry
# canonical; cross-exchange aliases (sz399300/399300.SZ/000300.SH) fold
# to sh000300 without guessing a canonical from the input prefix.
registry = _make_index_registry()
cases = [
("换成 SH000016 看看", "sh000016"),
("换成 000016.SH 看看", "sh000016"),
("换成 000016.sh 看看", "sh000016"),
("换成 sz399001 看看", "sz399001"),
("换成 399001.SZ 看看", "sz399001"),
("换成 CSI930955 看看", "csi930955"),
("换成 930955.CSI 看看", "csi930955"),
("换成 csi930955 看看", "csi930955"),
("换成 sz399300 看看", "sh000300"),
("换成 399300.SZ 看看", "sh000300"),
("换成 000300.SH 看看", "sh000300"),
]
for message, expected in cases:
with self.subTest(message=message, expected=expected):
result = resolve_stock_scope(
message,
{"stock_code": "600519", "stock_name": "贵州茅台"},
registry=registry,
)
self.assertEqual(result.stock_scope.mode, "switch")
self.assertEqual(result.stock_scope.expected_stock_code, expected)
self.assertEqual(result.stock_scope.allowed_stock_codes, {expected})
self.assertEqual(result.effective_context["stock_code"], expected)
# The switched context must not reuse the previous stock name.
self.assertEqual(result.effective_context["stock_name"], "")
def test_resolve_stock_scope_registry_keeps_bare_code_as_stock_identity(self):
registry = _make_index_registry()
result = resolve_stock_scope(
"换成 000016 看看",
{"stock_code": "600519", "stock_name": "贵州茅台"},
registry=registry,
)
# Bare 000016 shares digits with sh000016 but stays a stock identity.
self.assertEqual(result.stock_scope.mode, "switch")
self.assertEqual(result.stock_scope.expected_stock_code, "000016")
self.assertEqual(result.effective_context["stock_code"], "000016")
def test_resolve_stock_scope_registry_same_code_switch_isolates_identities(self):
registry = _make_index_registry()
result = resolve_stock_scope(
"换成 000016 看看",
{"stock_code": "sh000016", "stock_name": "上证50"},
registry=registry,
)
# Explicit switch index -> bare same-code stock must send the bare
# identity and never reuse the index name.
self.assertEqual(result.stock_scope.mode, "switch")
self.assertEqual(result.stock_scope.expected_stock_code, "000016")
self.assertEqual(result.stock_scope.allowed_stock_codes, {"000016"})
self.assertEqual(result.effective_context["stock_code"], "000016")
self.assertEqual(result.effective_context["stock_name"], "")
def test_resolve_stock_scope_registry_compare_keeps_both_identities(self):
registry = _make_index_registry()
result = resolve_stock_scope(
"比较 sh000016 和 000016 的差异",
{"stock_code": "sh000016", "stock_name": "上证50"},
registry=registry,
)
# Compare keeps the active index context and admits both identities
# without folding them.
self.assertEqual(result.stock_scope.mode, "compare")
self.assertEqual(result.stock_scope.expected_stock_code, "sh000016")
self.assertEqual(
result.stock_scope.allowed_stock_codes,
{"sh000016", "000016"},
)
self.assertEqual(result.effective_context["stock_code"], "sh000016")
def test_resolve_stock_scope_registry_embedded_ascii_never_leaks_identity(self):
registry = _make_index_registry()
cases = [
"换成 10SH000016 看看",
"换成 SH000016yy 看看",
"换成 _SH000016 看看",
"换成 SH000016_x 看看",
"换成 10sz399001 看看",
"换成 sz399001xx 看看",
]
for message in cases:
with self.subTest(message=message):
result = resolve_stock_scope(
message,
{"stock_code": "600519", "stock_name": "贵州茅台"},
registry=registry,
)
# No index canonical and no bare code may leak from embedded
# ASCII strings; the current stock context stays untouched.
self.assertEqual(result.stock_scope.mode, "maintain")
self.assertEqual(result.stock_scope.expected_stock_code, "600519")
self.assertEqual(result.stock_scope.allowed_stock_codes, {"600519"})
self.assertEqual(result.effective_context["stock_code"], "600519")
def test_resolve_stock_scope_registry_embedded_ascii_strict_initial_scope(self):
registry = _make_index_registry()
result = resolve_stock_scope(
"比较 10SH000016 和 SH000016yy 的差异",
None,
strict_initial_scope=True,
registry=registry,
)
self.assertIsNone(result.stock_scope)
def test_resolve_stock_scope_empty_registry_falls_back_to_stock_semantics(self):
from src.services.stock_list_parser import IndexRegistry
for message in ("换成 sh000016 看看", "换成 10SH000016 看看"):
with self.subTest(message=message):
result = resolve_stock_scope(
message,
{"stock_code": "600519", "stock_name": "贵州茅台"},
registry=IndexRegistry([]),
)
self.assertEqual(result.stock_scope.mode, "switch")
self.assertEqual(result.stock_scope.expected_stock_code, "000016")
def test_resolve_stock_scope_default_index_registry_production_branch(self):
cases = [
("sh000016", "sh000016"),
("000016.SH", "sh000016"),
("930955.CSI", "csi930955"),
("sz399300", "sh000300"),
]
for form, expected in cases:
with self.subTest(form=form):
result = resolve_stock_scope(
f"换成 {form} 看看",
{"stock_code": "600519", "stock_name": "贵州茅台"},
)
self.assertEqual(result.stock_scope.mode, "switch")
self.assertEqual(result.stock_scope.expected_stock_code, expected)
def test_resolve_stock_scope_default_registry_failure_falls_back_to_stock(self):
with patch(
"src.services.stock_list_parser.default_index_registry",
side_effect=RuntimeError("registry unavailable"),
):
result = resolve_stock_scope(
"换成 sh000016 看看",
{"stock_code": "600519", "stock_name": "贵州茅台"},
)
self.assertEqual(result.stock_scope.mode, "switch")
self.assertEqual(result.stock_scope.expected_stock_code, "000016")
def test_resolve_stock_scope_production_stock_guard_unchanged(self):
# sh600519 / SZ000001 / bare 000016 must keep stock semantics even with
# the default registry loaded (they are not registered index aliases).
cases = [
("换成 sh600519 看看", "600519"),
("换成 SZ000001 看看", "000001"),
("换成 000016 看看", "000016"),
("换成 00700.HK 看看", "HK00700"),
("换成 AAPL 看看", "AAPL"),
]
for message, expected in cases:
with self.subTest(message=message, expected=expected):
result = resolve_stock_scope(
message,
{"stock_code": "600519", "stock_name": "贵州茅台"},
)
self.assertEqual(result.stock_scope.expected_stock_code, expected)
def test_run_agent_loop_does_not_persist_agent_usage_without_provider_usage(self): def test_run_agent_loop_does_not_persist_agent_usage_without_provider_usage(self):
registry = _make_registry_with_echo() registry = _make_registry_with_echo()
adapter = _make_mock_adapter() adapter = _make_mock_adapter()

View File

@@ -7,6 +7,7 @@ import json
import threading import threading
import time import time
from pathlib import Path from pathlib import Path
from unittest.mock import patch
from src.agent.stock_scope import StockScope from src.agent.stock_scope import StockScope
from src.agent.tool_surface import ToolSurface from src.agent.tool_surface import ToolSurface
@@ -415,6 +416,154 @@ def test_declared_stock_scope_requires_explicit_stock_context_before_handler() -
assert calls == [] assert calls == []
def _register_quote_tool(registry: ToolRegistry) -> None:
registry.register(
ToolDefinition(
name="quote",
description="Quote",
parameters=[ToolParameter(name="stock_code", type="string", description="Stock")],
handler=lambda stock_code: {"code": stock_code},
policy=ToolPolicy.declared(
read_only=True,
permissions=["market_data:read"],
scope_dimensions=["stock"],
),
)
)
def test_index_canonical_passes_and_bare_same_code_rejected_with_default_registry() -> None:
# Real ToolSurface WITHOUT any index-registry injection: the guard resolves
# the default bundled registry once, so an explicit index scope
# (`sh000016`) admits its canonical/dotted alias forms but rejects the bare
# same-code stock (`000016`) with the existing `stock_scope_violation`.
tool_registry = ToolRegistry()
_register_quote_tool(tool_registry)
surface = ToolSurface(tool_registry)
scope = StockScope(expected_stock_code="sh000016", allowed_stock_codes={"sh000016"})
for alias in ("sh000016", "000016.SH", "SH000016"):
ok = surface.execute_tool(
"quote",
{"stock_code": alias},
ToolAccessContext(stock_scope=scope),
)
assert ok["ok"] is True, alias
assert ok["result"] == {"code": alias}
rejected = surface.execute_tool(
"quote",
{"stock_code": "000016"},
ToolAccessContext(stock_scope=scope),
)
assert rejected["ok"] is False
assert rejected["error"]["code"] == "stock_scope_violation"
assert rejected["error"]["details"]["requested_stock_code"] == "000016"
def test_tool_cache_key_isolates_index_canonical_from_bare_same_code() -> None:
from src.agent.tools.execution import _build_tool_cache_key
index_key = _build_tool_cache_key("quote", {"stock_code": "sh000016"})
alias_key = _build_tool_cache_key("quote", {"stock_code": "000016.SH"})
bare_key = _build_tool_cache_key("quote", {"stock_code": "000016"})
assert index_key is not None
assert index_key == alias_key
assert index_key != bare_key
# No stock_code in the arguments -> legacy key, no registry resolution.
assert _build_tool_cache_key("quote", {"limit": 5}) == "quote:{\"limit\": 5}"
def test_normalize_tool_stock_code_registry_seam() -> None:
from src.agent.tools.execution import _normalize_tool_stock_code
from src.services.stock_list_parser import IndexEntry, IndexRegistry
registry = IndexRegistry(
[
IndexEntry(
bare_code="000016",
exchange="SH",
canonical_id="sh000016",
display_name="上证50",
aliases=("000016.SH",),
)
]
)
assert _normalize_tool_stock_code("000016.SH", registry) == "sh000016"
assert _normalize_tool_stock_code("sh000016", registry) == "sh000016"
assert _normalize_tool_stock_code("SH000016", registry) == "sh000016"
# Explicit index token with an EMPTY registry falls open to stock semantics.
assert _normalize_tool_stock_code("sh000016", IndexRegistry([])) == "000016"
# Direct call without a registry keeps the legacy stock identity byte-for-byte.
assert _normalize_tool_stock_code("000016.SH") == "000016"
def test_guard_tool_stock_scope_registry_injection_seam() -> None:
from src.agent.tools.execution import _guard_tool_stock_scope
from src.services.stock_list_parser import IndexEntry, IndexRegistry
index_registry = IndexRegistry(
[
IndexEntry(
bare_code="000016",
exchange="SH",
canonical_id="sh000016",
display_name="上证50",
aliases=("000016.SH",),
)
]
)
tool_registry = ToolRegistry()
_register_quote_tool(tool_registry)
scope = StockScope(expected_stock_code="sh000016", allowed_stock_codes={"sh000016"})
assert (
_guard_tool_stock_scope(
tool_registry,
"quote",
{"stock_code": "sh000016"},
scope,
index_registry=index_registry,
)
is None
)
violation = _guard_tool_stock_scope(
tool_registry,
"quote",
{"stock_code": "000016"},
scope,
index_registry=index_registry,
)
assert violation is not None
assert violation["error"] == "stock_scope_violation"
assert violation["requested_stock_code"] == "000016"
def test_tool_guard_and_cache_registry_failure_use_legacy_stock_semantics() -> None:
from src.agent.tools.execution import _build_tool_cache_key
tool_registry = ToolRegistry()
_register_quote_tool(tool_registry)
surface = ToolSurface(tool_registry)
scope = StockScope(expected_stock_code="sh000016", allowed_stock_codes={"sh000016"})
with patch(
"src.services.stock_list_parser.default_index_registry",
side_effect=RuntimeError("registry unavailable"),
):
result = surface.execute_tool(
"quote",
{"stock_code": "000016.SH"},
ToolAccessContext(stock_scope=scope),
)
alias_key = _build_tool_cache_key("quote", {"stock_code": "000016.SH"})
bare_key = _build_tool_cache_key("quote", {"stock_code": "000016"})
assert result["ok"] is True
assert alias_key == bare_key
def test_handler_error_is_structured_without_traceback() -> None: def test_handler_error_is_structured_without_traceback() -> None:
def _fail(): def _fail():
raise RuntimeError("secret stack") raise RuntimeError("secret stack")

View File

@@ -49,7 +49,7 @@ from src.enums import ReportType
from src.config import Config from src.config import Config
from src.services.analysis_service import AnalysisService from src.services.analysis_service import AnalysisService
from src.services.image_stock_extractor import _call_litellm_vision from src.services.image_stock_extractor import _call_litellm_vision
from src.services.task_queue import AnalysisTaskQueue, TaskInfo as QueueTaskInfo, TaskStatus from src.services.task_queue import AnalysisTaskQueue, DuplicateTaskError, TaskInfo as QueueTaskInfo, TaskStatus
def tearDownModule() -> None: def tearDownModule() -> None:
@@ -3119,6 +3119,460 @@ class AnalysisApiContractTestCase(unittest.TestCase):
notify=True, notify=True,
) )
def test_trigger_analysis_async_index_submits_structured_target(self) -> None:
if trigger_analysis is None:
self.skipTest("fastapi is not installed in this test environment")
task = SimpleNamespace(
task_id="task-index-1",
trace_id="trace-index-1",
stock_code="sh000016",
analysis_phase="auto",
)
queue = MagicMock()
queue.submit_tasks_batch.return_value = ([task], [])
with patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue):
response = trigger_analysis(
request=SimpleNamespace(
stock_code="sh000016",
stock_codes=None,
stock_name=None,
original_query="sh000016",
selection_source="manual",
report_type="detailed",
force_refresh=False,
async_mode=True,
notify=True,
analysis_phase="auto",
),
config=SimpleNamespace(),
)
self.assertEqual(response.status_code, 202)
call_kwargs = queue.submit_tasks_batch.call_args.kwargs
self.assertEqual(call_kwargs["stock_codes"], ["sh000016"])
targets = call_kwargs["analysis_targets"]
self.assertEqual(len(targets), 1)
self.assertIsNotNone(targets[0])
self.assertEqual(targets[0].asset_type, "index")
self.assertEqual(targets[0].canonical_id, "sh000016")
def test_trigger_analysis_async_csi_alias_converges_to_canonical(self) -> None:
if trigger_analysis is None:
self.skipTest("fastapi is not installed in this test environment")
task = SimpleNamespace(
task_id="task-csi-1",
trace_id="trace-csi-1",
stock_code="csi930955",
analysis_phase="auto",
)
queue = MagicMock()
queue.submit_tasks_batch.return_value = ([task], [])
with patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue):
response = trigger_analysis(
request=SimpleNamespace(
stock_code="930955.CSI",
stock_codes=None,
stock_name=None,
original_query="930955.CSI",
selection_source="manual",
report_type="detailed",
force_refresh=False,
async_mode=True,
notify=True,
analysis_phase="auto",
),
config=SimpleNamespace(),
)
self.assertEqual(response.status_code, 202)
call_kwargs = queue.submit_tasks_batch.call_args.kwargs
self.assertEqual(call_kwargs["stock_codes"], ["csi930955"])
self.assertEqual(call_kwargs["analysis_targets"][0].canonical_id, "csi930955")
def test_trigger_analysis_batch_index_and_same_digit_stock_do_not_collapse(self) -> None:
if trigger_analysis is None:
self.skipTest("fastapi is not installed in this test environment")
queue = MagicMock()
queue.submit_tasks_batch.return_value = ([], [])
with patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue):
response = trigger_analysis(
request=SimpleNamespace(
stock_code=None,
stock_codes=["sh000016", "000016"],
stock_name=None,
original_query=None,
selection_source="manual",
report_type="detailed",
force_refresh=False,
async_mode=True,
notify=True,
analysis_phase="auto",
),
config=SimpleNamespace(),
)
self.assertEqual(response.status_code, 202)
call_kwargs = queue.submit_tasks_batch.call_args.kwargs
self.assertEqual(call_kwargs["stock_codes"], ["sh000016", "000016"])
targets = call_kwargs["analysis_targets"]
self.assertEqual(targets[0].asset_type, "index")
self.assertEqual(targets[0].canonical_id, "sh000016")
self.assertIsNone(targets[1])
def test_trigger_analysis_raw_token_limit_counts_rejected_and_duplicate_tokens(self) -> None:
if trigger_analysis is None:
self.skipTest("fastapi is not installed in this test environment")
queue = MagicMock()
queue.submit_tasks_batch.return_value = ([], [])
# 51 non-blank raw tokens: 1 valid + 50 unregistered CSI (rejected).
# The pre-resolution limit must reject, otherwise the post-dedup check
# (which only sees the single accepted code) would let the request through.
codes = ["600519"] + [f"{i:06d}.CSI" for i in range(100000, 100050)]
with patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue):
with self.assertRaises(Exception) as ctx:
trigger_analysis(
request=SimpleNamespace(
stock_code=None,
stock_codes=codes,
stock_name=None,
original_query=None,
selection_source="manual",
report_type="detailed",
force_refresh=False,
async_mode=True,
notify=True,
analysis_phase="auto",
),
config=SimpleNamespace(),
)
self.assertEqual(ctx.exception.status_code, 400)
self.assertIn("最多支持 50", ctx.exception.detail["message"])
queue.submit_tasks_batch.assert_not_called()
def test_trigger_analysis_exactly_fifty_raw_tokens_is_accepted(self) -> None:
if trigger_analysis is None:
self.skipTest("fastapi is not installed in this test environment")
queue = MagicMock()
queue.submit_tasks_batch.return_value = ([], [])
codes = [f"{i:06d}" for i in range(100000, 100050)]
with patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue), \
patch("api.v1.endpoints.analysis.resolve_name_to_code", return_value=None):
response = trigger_analysis(
request=SimpleNamespace(
stock_code=None,
stock_codes=codes,
stock_name=None,
original_query=None,
selection_source="manual",
report_type="detailed",
force_refresh=False,
async_mode=True,
notify=True,
analysis_phase="auto",
),
config=SimpleNamespace(),
)
self.assertEqual(response.status_code, 202)
self.assertEqual(len(queue.submit_tasks_batch.call_args.kwargs["stock_codes"]), 50)
def test_trigger_analysis_duplicate_plus_rejected_is_batch_not_legacy_409(self) -> None:
if trigger_analysis is None:
self.skipTest("fastapi is not installed in this test environment")
# One accepted code that is already analyzing (duplicate) plus one
# rejected code must be a batch: is_single=false, so no legacy 409 and
# no single-stock 202. Both the duplicate and the rejected survive in
# the batch payload.
dup = DuplicateTaskError("600519", "existing-1")
queue = MagicMock()
queue.submit_tasks_batch.return_value = ([], [dup])
with patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue):
response = trigger_analysis(
request=SimpleNamespace(
stock_code=None,
stock_codes=["600519", "930956.CSI"],
stock_name=None,
original_query=None,
selection_source="manual",
report_type="detailed",
force_refresh=False,
async_mode=True,
notify=True,
analysis_phase="auto",
),
config=SimpleNamespace(),
)
self.assertEqual(response.status_code, 202)
payload = json.loads(response.body)
self.assertEqual(len(payload["accepted"]), 0)
self.assertEqual(len(payload["duplicates"]), 1)
self.assertEqual(payload["duplicates"][0]["stock_code"], "600519")
self.assertEqual(len(payload["rejected"]), 1)
self.assertEqual(payload["rejected"][0]["stock_code"], "930956.CSI")
def test_trigger_analysis_valid_plus_rejected_metadata_is_batch(self) -> None:
if trigger_analysis is None:
self.skipTest("fastapi is not installed in this test environment")
accepted_task = SimpleNamespace(
task_id="task-600519",
trace_id="trace-600519",
stock_code="600519",
analysis_phase="auto",
)
queue = MagicMock()
queue.submit_tasks_batch.return_value = ([accepted_task], [])
with patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue):
response = trigger_analysis(
request=SimpleNamespace(
stock_code=None,
stock_codes=["600519", "930956.CSI"],
stock_name="贵州茅台",
original_query="贵州茅台,930956.CSI",
selection_source="manual",
report_type="detailed",
force_refresh=False,
async_mode=True,
notify=True,
analysis_phase="auto",
),
config=SimpleNamespace(),
)
self.assertEqual(response.status_code, 202)
payload = json.loads(response.body)
# Batch payload (not single-stock TaskAccepted), with rejected present.
self.assertIn("accepted", payload)
self.assertEqual(len(payload["rejected"]), 1)
# Single-stock metadata must NOT leak into the batch: stock_name is None.
self.assertIsNone(queue.submit_tasks_batch.call_args.kwargs["stock_name"])
self.assertIsNone(queue.submit_tasks_batch.call_args.kwargs["original_query"])
def test_trigger_analysis_single_duplicate_still_returns_409(self) -> None:
if trigger_analysis is None:
self.skipTest("fastapi is not installed in this test environment")
dup = DuplicateTaskError("600519", "existing-1")
queue = MagicMock()
queue.submit_tasks_batch.return_value = ([], [dup])
with patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue):
response = trigger_analysis(
request=SimpleNamespace(
stock_code="600519",
stock_codes=None,
stock_name=None,
original_query=None,
selection_source="manual",
report_type="detailed",
force_refresh=False,
async_mode=True,
notify=True,
analysis_phase="auto",
),
config=SimpleNamespace(),
)
self.assertEqual(response.status_code, 409)
payload = json.loads(response.body)
self.assertEqual(payload["error"], "duplicate_task")
self.assertEqual(payload["stock_code"], "600519")
def test_trigger_analysis_unregistered_csi_single_returns_400(self) -> None:
if trigger_analysis is None:
self.skipTest("fastapi is not installed in this test environment")
queue = MagicMock()
with patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue):
with self.assertRaises(Exception) as ctx:
trigger_analysis(
request=SimpleNamespace(
stock_code="930956.CSI",
stock_codes=None,
stock_name=None,
original_query="930956.CSI",
selection_source="manual",
report_type="detailed",
force_refresh=False,
async_mode=True,
notify=True,
analysis_phase="auto",
),
config=SimpleNamespace(),
)
self.assertEqual(ctx.exception.status_code, 400)
self.assertIn("unregistered CSI index", ctx.exception.detail["message"])
queue.submit_tasks_batch.assert_not_called()
def test_trigger_analysis_unregistered_csi_sync_single_returns_400(self) -> None:
if trigger_analysis is None:
self.skipTest("fastapi is not installed in this test environment")
with self.assertRaises(Exception) as ctx:
trigger_analysis(
request=SimpleNamespace(
stock_code="csi930956",
stock_codes=None,
stock_name=None,
original_query="csi930956",
selection_source="manual",
report_type="detailed",
force_refresh=False,
async_mode=False,
notify=True,
analysis_phase="auto",
),
config=SimpleNamespace(),
)
self.assertEqual(ctx.exception.status_code, 400)
self.assertIn("unregistered CSI index", ctx.exception.detail["message"])
def test_trigger_analysis_batch_rejects_unregistered_csi_in_rejected_field(self) -> None:
if trigger_analysis is None:
self.skipTest("fastapi is not installed in this test environment")
accepted_task = SimpleNamespace(
task_id="task-600519",
trace_id="trace-600519",
stock_code="600519",
analysis_phase="auto",
)
queue = MagicMock()
queue.submit_tasks_batch.return_value = ([accepted_task], [])
with patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue):
response = trigger_analysis(
request=SimpleNamespace(
stock_code=None,
stock_codes=["600519", "930956.CSI"],
stock_name=None,
original_query=None,
selection_source="manual",
report_type="detailed",
force_refresh=False,
async_mode=True,
notify=True,
analysis_phase="auto",
),
config=SimpleNamespace(),
)
self.assertEqual(response.status_code, 202)
body = response.body if hasattr(response, "body") else None
if body is not None:
import json as _json
payload = _json.loads(body)
else:
payload = response.model_dump()
self.assertEqual(payload["accepted"][0]["stock_code"], "600519")
self.assertEqual(len(payload["rejected"]), 1)
self.assertEqual(payload["rejected"][0]["stock_code"], "930956.CSI")
self.assertIn("unregistered CSI index", payload["rejected"][0]["message"])
# The valid stock still reaches the queue.
self.assertEqual(queue.submit_tasks_batch.call_args.kwargs["stock_codes"], ["600519"])
def test_trigger_analysis_name_input_stays_on_name_resolution(self) -> None:
if trigger_analysis is None:
self.skipTest("fastapi is not installed in this test environment")
queue = MagicMock()
queue.submit_tasks_batch.return_value = ([], [])
with patch("api.v1.endpoints.analysis.resolve_name_to_code", return_value="600519") as resolve_mock, \
patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue):
response = trigger_analysis(
request=SimpleNamespace(
stock_code="贵州茅台",
stock_codes=None,
stock_name="贵州茅台",
original_query="贵州茅台",
selection_source="manual",
report_type="detailed",
force_refresh=False,
async_mode=True,
notify=True,
analysis_phase="auto",
),
config=SimpleNamespace(),
)
self.assertEqual(response.status_code, 202)
resolve_mock.assert_called_once_with("贵州茅台")
call_kwargs = queue.submit_tasks_batch.call_args.kwargs
self.assertEqual(call_kwargs["stock_codes"], ["600519"])
self.assertNotIn("analysis_targets", call_kwargs)
def test_trigger_analysis_sync_all_rejected_returns_400(self) -> None:
"""Sync mode with only unregistered targets must 400, not IndexError."""
if trigger_analysis is None:
self.skipTest("fastapi is not installed in this test environment")
with self.assertRaises(Exception) as ctx:
trigger_analysis(
request=SimpleNamespace(
stock_code=None,
stock_codes=["930956.CSI", "csi930956"],
stock_name=None,
original_query=None,
selection_source="manual",
report_type="detailed",
force_refresh=False,
async_mode=False,
notify=True,
analysis_phase="auto",
),
config=SimpleNamespace(),
)
self.assertEqual(ctx.exception.status_code, 400)
self.assertIn("unregistered CSI index", ctx.exception.detail["message"])
def test_trigger_analysis_async_all_rejected_returns_400(self) -> None:
"""Async batch with only unregistered targets must 400, not 202+empty."""
if trigger_analysis is None:
self.skipTest("fastapi is not installed in this test environment")
queue = MagicMock()
with patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue):
with self.assertRaises(Exception) as ctx:
trigger_analysis(
request=SimpleNamespace(
stock_code=None,
stock_codes=["930956.CSI", "csi930957"],
stock_name=None,
original_query=None,
selection_source="manual",
report_type="detailed",
force_refresh=False,
async_mode=True,
notify=True,
analysis_phase="auto",
),
config=SimpleNamespace(),
)
self.assertEqual(ctx.exception.status_code, 400)
self.assertIn("unregistered CSI index", ctx.exception.detail["message"])
queue.submit_tasks_batch.assert_not_called()
def test_trigger_analysis_accepts_camel_case_report_language_alias(self) -> None: def test_trigger_analysis_accepts_camel_case_report_language_alias(self) -> None:
if trigger_analysis is None or analysis_endpoint_module is None: if trigger_analysis is None or analysis_endpoint_module is None:
self.skipTest("analysis endpoint helpers unavailable in this environment") self.skipTest("analysis endpoint helpers unavailable in this environment")
@@ -3674,6 +4128,316 @@ class AnalysisApiContractTestCase(unittest.TestCase):
self.assertEqual(response.tasks[0].analysis_phase, "postmarket") self.assertEqual(response.tasks[0].analysis_phase, "postmarket")
self.assertEqual(response.tasks[0].skills, ["growth_quality"]) self.assertEqual(response.tasks[0].skills, ["growth_quality"])
def test_task_list_exposes_parser_asset_type(self) -> None:
"""Valid asset_type literals pass through verbatim on the task list."""
if get_task_list is None:
self.skipTest("analysis endpoint helpers unavailable in this environment")
for expected_asset_type, stock_code, stock_name in (
("index", "sh000016", "上证50"),
("stock", "600519", "贵州茅台"),
):
with self.subTest(asset_type=expected_asset_type):
task = SimpleNamespace(
task_id="task-list-asset-type",
trace_id="trace-list-asset-type",
stock_code=stock_code,
stock_name=stock_name,
status=TaskStatus.PROCESSING,
progress=42,
message="running",
report_type="detailed",
created_at=datetime(2026, 4, 10, 12, 0, 0),
started_at=datetime(2026, 4, 10, 12, 0, 1),
completed_at=None,
error=None,
original_query=stock_code,
selection_source="manual",
analysis_phase="intraday",
skills=None,
region=None,
asset_type=expected_asset_type,
)
queue = MagicMock()
queue.list_all_tasks.return_value = [task]
queue.get_task_stats.return_value = {
"total": 1,
"pending": 0,
"processing": 1,
"completed": 0,
"failed": 0,
}
with patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue):
response = get_task_list(status=None, limit=20)
self.assertEqual(response.tasks[0].stock_code, stock_code)
self.assertEqual(response.tasks[0].asset_type, expected_asset_type)
def test_task_list_omits_asset_type_for_legacy_tasks(self) -> None:
if get_task_list is None:
self.skipTest("analysis endpoint helpers unavailable in this environment")
task = SimpleNamespace(
task_id="task-list-legacy",
trace_id="trace-list-legacy",
stock_code="600519",
stock_name="贵州茅台",
status=TaskStatus.PENDING,
progress=0,
message="waiting",
report_type="detailed",
created_at=datetime(2026, 4, 10, 12, 0, 0),
started_at=None,
completed_at=None,
error=None,
original_query=None,
selection_source=None,
analysis_phase="auto",
skills=None,
region=None,
)
queue = MagicMock()
queue.list_all_tasks.return_value = [task]
queue.get_task_stats.return_value = {
"total": 1,
"pending": 1,
"processing": 0,
"completed": 0,
"failed": 0,
}
with patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue):
response = get_task_list(status=None, limit=20)
self.assertIsNone(response.tasks[0].asset_type)
def test_task_info_to_dict_exposes_parser_asset_type(self) -> None:
from src.services.stock_list_parser import parse_analysis_target
queue = AnalysisTaskQueue(max_workers=1)
queue._executor = type("ExecutorStub", (), {"submit": lambda self, *args, **kwargs: Future()})()
index_target = parse_analysis_target("sh000016")
index_tasks, _ = queue.submit_tasks_batch(
["sh000016"],
analysis_targets=[index_target],
report_type="detailed",
)
plain_tasks, _ = queue.submit_tasks_batch(
["000016"],
report_type="detailed",
)
# Index targets are carried on TaskInfo, so the SSE/task payload exposes
# the parser asset type. Stock targets are intentionally NOT carried
# downstream (PR #2303 contract: stock semantics unchanged), so the
# optional field is simply omitted there.
index_payload = index_tasks[0].to_dict()
plain_payload = plain_tasks[0].to_dict()
self.assertEqual(index_payload["asset_type"], "index")
self.assertNotIn("asset_type", plain_payload)
def test_batch_accepted_item_exposes_parser_asset_type(self) -> None:
"""Valid asset_type literals pass through verbatim on batch accepted items."""
if trigger_analysis is None:
self.skipTest("fastapi is not installed in this test environment")
for expected_asset_type, stock_code, stock_name, second_code in (
("index", "sh000016", "上证50", "000016"),
("stock", "600519", "贵州茅台", "000001"),
):
request_codes = [stock_code, second_code]
with self.subTest(asset_type=expected_asset_type):
task = SimpleNamespace(
task_id="task-batch-asset-type",
trace_id="trace-batch-asset-type",
stock_code=stock_code,
stock_name=stock_name,
analysis_phase="auto",
asset_type=expected_asset_type,
)
queue = MagicMock()
queue.submit_tasks_batch.return_value = ([task], [])
with patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue):
response = trigger_analysis(
request=SimpleNamespace(
stock_code=None,
stock_codes=request_codes,
stock_name=None,
original_query=",".join(request_codes),
selection_source="manual",
report_type="detailed",
force_refresh=False,
async_mode=True,
notify=True,
analysis_phase="auto",
),
config=SimpleNamespace(),
)
self.assertEqual(response.status_code, 202)
payload = json.loads(response.body)
accepted = payload["accepted"]
self.assertEqual(len(accepted), 1)
self.assertEqual(accepted[0]["stock_code"], stock_code)
self.assertEqual(accepted[0]["asset_type"], expected_asset_type)
def _legacy_task_mock(
self,
*,
task_id: str,
asset_type: str | None = None,
with_task_list_fields: bool = False,
) -> MagicMock:
"""Build a legacy MagicMock proxy task; ``asset_type`` stays implicit
unless explicitly requested so ``getattr`` yields a MagicMock child."""
kwargs: dict = {
"task_id": task_id,
"trace_id": f"trace-{task_id}",
"stock_code": "600519",
"stock_name": "贵州茅台",
"analysis_phase": "auto",
}
if with_task_list_fields:
kwargs.update(
status=TaskStatus.PENDING,
progress=0,
message="waiting",
report_type="detailed",
created_at=datetime(2026, 4, 10, 12, 0, 0),
started_at=None,
completed_at=None,
error=None,
original_query=None,
selection_source=None,
skills=None,
region=None,
)
if asset_type is not None:
kwargs["asset_type"] = asset_type
return MagicMock(**kwargs)
def _task_list_asset_type_value(self, task) -> object:
"""Run get_task_list against a mocked queue and return the asset_type."""
queue = MagicMock()
queue.list_all_tasks.return_value = [task]
queue.get_task_stats.return_value = {
"total": 1,
"pending": 1,
"processing": 0,
"completed": 0,
"failed": 0,
}
with patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue):
response = get_task_list(status=None, limit=20)
# 走真实 JSON 序列化再读取,证明 GET /tasks 响应层面降级为 null
# 而非仅依赖 Pydantic 对象属性。
payload = json.loads(response.model_dump_json())
return payload["tasks"][0]["asset_type"]
def _batch_accepted_asset_type_value(self, task) -> object:
"""Run trigger_analysis in batch mode against a mocked queue and return
the first accepted item's asset_type."""
queue = MagicMock()
queue.submit_tasks_batch.return_value = ([task], [])
with patch("api.v1.endpoints.analysis.get_task_queue", return_value=queue):
response = trigger_analysis(
request=SimpleNamespace(
stock_code=None,
stock_codes=["600519", "000001"],
stock_name=None,
original_query="600519,000001",
selection_source="manual",
report_type="detailed",
force_refresh=False,
async_mode=True,
notify=True,
analysis_phase="auto",
),
config=SimpleNamespace(),
)
self.assertEqual(response.status_code, 202)
payload = json.loads(response.body)
accepted = payload["accepted"]
self.assertEqual(len(accepted), 1)
return accepted[0]["asset_type"]
def test_asset_type_degrades_magicmock_proxy_without_warning(self) -> None:
"""Legacy MagicMock proxy (implicit asset_type) degrades to None on both
response entries and never emits a warning."""
if get_task_list is None or trigger_analysis is None:
self.skipTest("analysis endpoint helpers unavailable in this environment")
with self.subTest(entry="task_list"):
with self.assertNoLogs("api.v1.endpoints.analysis", level="WARNING"):
value = self._task_list_asset_type_value(
self._legacy_task_mock(
task_id="task-list-magic-asset-type",
with_task_list_fields=True,
)
)
self.assertIsNone(value)
with self.subTest(entry="batch_accepted"):
with self.assertNoLogs("api.v1.endpoints.analysis", level="WARNING"):
value = self._batch_accepted_asset_type_value(
self._legacy_task_mock(task_id="task-batch-magic-asset-type")
)
self.assertIsNone(value)
def test_asset_type_degrades_out_of_domain_string_with_warning(self) -> None:
"""A real string outside the literal domain (including blank and
whitespace-only forms) degrades to None on both response entries and
logs a warning carrying the task id and value on every entry."""
if get_task_list is None or trigger_analysis is None:
self.skipTest("analysis endpoint helpers unavailable in this environment")
invalid_values = ("etf", " ")
for invalid_value in invalid_values:
with self.subTest(invalid_value=invalid_value):
with self.subTest(entry="task_list"):
with self.assertLogs("api.v1.endpoints.analysis", level="WARNING") as logs:
value = self._task_list_asset_type_value(
self._legacy_task_mock(
task_id="task-list-out-of-domain-asset-type",
asset_type=invalid_value,
with_task_list_fields=True,
)
)
self.assertIsNone(value)
self.assertTrue(
any(
"task_id=task-list-out-of-domain-asset-type" in line
and f"asset_type={invalid_value!r}" in line
for line in logs.output
),
"expected warning carrying task id and "
f"{invalid_value!r} value, got: {logs.output}",
)
with self.subTest(entry="batch_accepted"):
with self.assertLogs("api.v1.endpoints.analysis", level="WARNING") as logs:
value = self._batch_accepted_asset_type_value(
self._legacy_task_mock(
task_id="task-batch-out-of-domain-asset-type",
asset_type=invalid_value,
)
)
self.assertIsNone(value)
self.assertTrue(
any(
"task_id=task-batch-out-of-domain-asset-type" in line
and f"asset_type={invalid_value!r}" in line
for line in logs.output
),
"expected warning carrying task id and "
f"{invalid_value!r} value, got: {logs.output}",
)
class BatchTaskQueueContractTestCase(unittest.TestCase): class BatchTaskQueueContractTestCase(unittest.TestCase):
def setUp(self) -> None: def setUp(self) -> None:
@@ -3853,6 +4617,196 @@ class BatchTaskQueueContractTestCase(unittest.TestCase):
self.assertEqual(updated.message, "LLM 正在生成分析结果") self.assertEqual(updated.message, "LLM 正在生成分析结果")
self.assertEqual(events, [("task_progress", updated.to_dict())]) self.assertEqual(events, [("task_progress", updated.to_dict())])
def _executor_stub_queue(self) -> AnalysisTaskQueue:
queue = AnalysisTaskQueue(max_workers=1)
queue._executor = type("ExecutorStub", (), {"submit": lambda self, *args, **kwargs: Future()})()
return queue
def test_index_and_same_digit_stock_do_not_collapse_in_task_queue(self) -> None:
from src.services.stock_list_parser import parse_analysis_target
index_target = parse_analysis_target("sh000016")
self.assertEqual(index_target.asset_type, "index")
queue = self._executor_stub_queue()
accepted, duplicates = queue.submit_tasks_batch(
["sh000016", "000016"],
analysis_targets=[index_target, None],
report_type="detailed",
)
self.assertEqual(duplicates, [])
self.assertEqual(len(accepted), 2)
codes = sorted(task.stock_code for task in accepted)
self.assertEqual(codes, ["000016", "sh000016"])
# The index task keeps a canonical-id dedupe key; the stock keeps its
# code-based key, so neither collapses with the other.
index_task = next(task for task in accepted if task.stock_code == "sh000016")
stock_task = next(task for task in accepted if task.stock_code == "000016")
self.assertEqual(index_task.dedupe_key, "sh000016")
self.assertNotEqual(stock_task.dedupe_key, "sh000016")
self.assertIs(index_task.analysis_target, index_target)
def test_alias_index_input_submits_canonical_task_info(self) -> None:
"""``000300.CSI`` (a registered alias of ``sh000300``) must submit a
task whose stock_code / to_dict().stock_code / dedupe_key are all the
parser canonical ``sh000300`` with asset_type ``index`` — locking the
REST/SSE premise that consumers only ever receive canonical index
codes (the frontend only case-folds them)."""
from src.services.stock_list_parser import parse_analysis_target
target = parse_analysis_target("000300.CSI")
self.assertEqual(target.asset_type, "index")
self.assertEqual(target.canonical_id, "sh000300")
queue = self._executor_stub_queue()
accepted, duplicates = queue.submit_tasks_batch(
["000300.CSI"],
analysis_targets=[target],
report_type="detailed",
)
self.assertEqual(duplicates, [])
self.assertEqual(len(accepted), 1)
task = accepted[0]
self.assertEqual(task.stock_code, "sh000300")
self.assertEqual(task.to_dict()["stock_code"], "sh000300")
self.assertEqual(task.to_dict()["asset_type"], "index")
self.assertEqual(task.dedupe_key, "sh000300")
self.assertIs(task.analysis_target, target)
def test_csi_aliases_converge_to_single_task_queue_key(self) -> None:
from src.services.stock_list_parser import parse_analysis_target
a = parse_analysis_target("930955.CSI")
b = parse_analysis_target("csi930955")
self.assertEqual(a.asset_type, "index")
self.assertEqual(b.asset_type, "index")
self.assertEqual(a.canonical_id, "csi930955")
self.assertEqual(b.canonical_id, "csi930955")
queue = self._executor_stub_queue()
accepted, duplicates = queue.submit_tasks_batch(
["930955.CSI", "csi930955"],
analysis_targets=[a, b],
report_type="detailed",
)
self.assertEqual(len(accepted), 1)
self.assertEqual(len(duplicates), 1)
self.assertEqual(accepted[0].stock_code, "csi930955")
self.assertEqual(accepted[0].dedupe_key, "csi930955")
def test_mixed_quad_batch_produces_four_independent_tasks(self) -> None:
from src.services.stock_list_parser import parse_analysis_target
index_sh = parse_analysis_target("sh000016")
stock_bare = parse_analysis_target("000016")
stock_600 = parse_analysis_target("600519")
index_csi = parse_analysis_target("930955.CSI")
for target in (index_sh, index_csi):
self.assertEqual(target.asset_type, "index")
for target in (stock_bare, stock_600):
self.assertEqual(target.asset_type, "stock")
queue = self._executor_stub_queue()
accepted, duplicates = queue.submit_tasks_batch(
["sh000016", "000016", "600519", "930955.CSI"],
analysis_targets=[index_sh, stock_bare, stock_600, index_csi],
report_type="detailed",
)
self.assertEqual(duplicates, [])
self.assertEqual(len(accepted), 4)
# Index targets keep their parser canonical id (lowercase), so CSI alias
# input is submitted under its canonical identity.
self.assertEqual(
sorted(task.stock_code for task in accepted),
["000016", "600519", "csi930955", "sh000016"],
)
# INDEX keys are canonical ids; STOCK keys stay code-based and distinct.
keys = {task.stock_code: task.dedupe_key for task in accepted}
self.assertEqual(keys["sh000016"], "sh000016")
self.assertEqual(keys["csi930955"], "csi930955")
self.assertNotEqual(keys["000016"], "sh000016")
self.assertNotEqual(keys["600519"], "csi930955")
def test_stock_dedup_semantics_unchanged_with_analysis_targets_none(self) -> None:
queue = self._executor_stub_queue()
accepted, duplicates = queue.submit_tasks_batch(["600519"], report_type="detailed")
self.assertEqual(len(accepted), 1)
self.assertEqual(duplicates, [])
self.assertTrue(queue.is_analyzing("600519.SH"))
self.assertEqual(queue.get_analyzing_task_id("600519.SH"), accepted[0].task_id)
self.assertIsNone(accepted[0].analysis_target)
self.assertIsNotNone(accepted[0].dedupe_key)
def test_worker_removes_index_task_by_stored_dedupe_key(self) -> None:
from src.services.stock_list_parser import parse_analysis_target
index_target = parse_analysis_target("sh000016")
queue = self._executor_stub_queue()
accepted, _ = queue.submit_tasks_batch(
["sh000016"],
analysis_targets=[index_target],
report_type="detailed",
)
task = accepted[0]
service_instance = MagicMock()
service_instance.analyze_stock.return_value = {"stock_code": "sh000016", "stock_name": "上证50"}
with patch("src.services.analysis_service.AnalysisService", return_value=service_instance):
result = queue._execute_task(
task.task_id,
task.stock_code,
task.report_type,
False,
True,
None,
None,
index_target,
)
self.assertIsNotNone(result)
# The canonical-id key must be removed on completion, leaving no residue.
self.assertNotIn("sh000016", queue._analyzing_stocks)
self.assertIs(
service_instance.analyze_stock.call_args.kwargs["analysis_target"],
index_target,
)
def test_worker_failure_removes_index_task_by_stored_dedupe_key(self) -> None:
from src.services.stock_list_parser import parse_analysis_target
index_target = parse_analysis_target("sh000016")
queue = self._executor_stub_queue()
accepted, _ = queue.submit_tasks_batch(
["sh000016"],
analysis_targets=[index_target],
report_type="detailed",
)
task = accepted[0]
self.assertIn("sh000016", queue._analyzing_stocks)
service_instance = MagicMock()
service_instance.analyze_stock.side_effect = RuntimeError("boom")
with patch("src.services.analysis_service.AnalysisService", return_value=service_instance):
queue._execute_task(
task.task_id,
task.stock_code,
task.report_type,
False,
True,
None,
None,
index_target,
)
# The failure branch must clear the stored canonical-id key so the index
# does not linger in the analyzing set (a residue would force 409 forever).
self.assertNotIn("sh000016", queue._analyzing_stocks)
class ImageStockExtractorContractTestCase(unittest.TestCase): class ImageStockExtractorContractTestCase(unittest.TestCase):
def test_litellm_completion_patch_target_remains_available(self) -> None: def test_litellm_completion_patch_target_remains_available(self) -> None:

View File

@@ -141,6 +141,95 @@ class TestHistoryCsiCandidateConvergence(unittest.TestCase):
db.get_analysis_history_paginated.assert_not_called() db.get_analysis_history_paginated.assert_not_called()
class TestHistoryIndexCanonicalCandidates(unittest.TestCase):
"""PR #2312 review remediation: registered SH/SZ indices (not just CSI)
must use parser-aware persisted-read candidates — lowercase canonical +
uppercase legacy canonical + explicit aliases — and must never include the
bare same-code stock, so an index record is never reachable through a
stock query and vice versa."""
def test_registered_sh_index_forms_include_canonical_uppercase_and_aliases(self):
"""``sh000016`` must map to canonical + uppercase legacy + ``000016.SH``
alias, and must NOT include the bare ``000016`` stock."""
for code in ("sh000016", "SH000016", "000016.SH", " sh000016 "):
candidates = HistoryService._history_code_filter_candidates(code)
self.assertEqual(
set(candidates),
{"sh000016", "SH000016", "000016.SH"},
)
self.assertEqual(len(candidates), len(set(candidates)))
self.assertNotIn("000016", candidates)
def test_registered_sz_index_forms_include_canonical_uppercase_and_aliases(self):
for code in ("sz399001", "SZ399001", "399001.SZ"):
candidates = HistoryService._history_code_filter_candidates(code)
self.assertEqual(
set(candidates),
{"sz399001", "SZ399001", "399001.SZ"},
)
self.assertNotIn("399001", candidates)
def test_multi_alias_sh300_index_candidates_include_uppercase_alias_forms(self):
"""sqlite `IN` 大小写敏感:`sz399300` 的旧 uppercase 持久化形态
`SZ399300` 必须进入候选集,否则旧记录查不到。"""
expected = {
"sh000300",
"SH000300",
"sz399300",
"SZ399300",
"000300.SH",
"000300.CSI",
}
for code in ("sh000300", "sz399300", "SZ399300", "000300.SH", "000300.CSI"):
candidates = HistoryService._history_code_filter_candidates(code)
self.assertEqual(set(candidates), expected)
self.assertEqual(len(candidates), len(set(candidates)))
self.assertNotIn("000300", candidates)
def test_registered_csi_forms_keep_existing_converged_candidates(self):
"""The unified parser-aware branch must preserve the PR #2267 CSI
candidate contract exactly."""
for code in ("csi930955", "930955.CSI", "CSI930955", " csi930955 "):
candidates = HistoryService._history_code_filter_candidates(code)
self.assertEqual(
set(candidates),
{"csi930955", "CSI930955", "930955.CSI"},
)
self.assertEqual(len(candidates), len(set(candidates)))
self.assertNotIn("930955", candidates)
def test_multi_alias_sh300_index_converges_all_explicit_forms(self):
"""``sh000300`` owns ``sz399300`` / ``000300.SH`` / ``000300.CSI``
aliases; every explicit form must converge to the same candidate set,
including the uppercase alias form ``SZ399300`` (case-sensitive SQL)."""
for code in ("sh000300", "sz399300", "SZ399300", "000300.SH", "000300.CSI"):
candidates = HistoryService._history_code_filter_candidates(code)
self.assertEqual(
set(candidates),
{"sh000300", "SH000300", "sz399300", "SZ399300", "000300.SH", "000300.CSI"},
)
self.assertNotIn("000300", candidates)
def test_bare_stock_does_not_include_index_identity(self):
"""Filtering the bare stock ``000016`` must not reach the ``sh000016``
index or its ``000016.SH`` alias."""
candidates = HistoryService._history_code_filter_candidates("000016")
self.assertIn("000016", candidates)
self.assertNotIn("sh000016", candidates)
self.assertNotIn("000016.SH", candidates)
def test_unregistered_prefixed_index_form_stays_stock(self):
"""An unregistered ``sh``-prefixed token parses as a stock and must
keep the legacy stock candidate path untouched — no lowercase index
canonical (``sh900999``) and no registry alias are invented."""
candidates = HistoryService._history_code_filter_candidates("sh900999")
self.assertEqual(
set(candidates),
{"SH900999", "900999", "900999.SH", "900999.SS", "SS900999"},
)
self.assertNotIn("sh900999", candidates)
def _analysis_context_pack_overview() -> dict: def _analysis_context_pack_overview() -> dict:
return { return {
"pack_version": "1.0", "pack_version": "1.0",
@@ -1248,6 +1337,67 @@ class AnalysisHistoryTestCase(unittest.TestCase):
self.assertEqual(report.meta.current_price, 200.0) self.assertEqual(report.meta.current_price, 200.0)
self.assertEqual(report.meta.change_pct, 1.23) self.assertEqual(report.meta.change_pct, 1.23)
def test_history_detail_reports_index_asset_type_from_canonical_code(self) -> None:
"""Index reports must expose meta.asset_type='index' so the Web can hide
the stock-only watchlist action, and bare same-digit stock codes must
remain 'stock' (never index via display normalization)."""
if get_history_detail is None:
self.skipTest("fastapi is not installed in this test environment")
def save_record(code: str, query_id: str) -> int:
result = self._build_result()
result.code = code
saved = self.db.save_analysis_history(
result=result,
query_id=query_id,
report_type="simple",
news_content="新闻摘要",
context_snapshot=None,
save_snapshot=False,
)
self.assertGreater(saved, 0)
with self.db.get_session() as session:
row = session.query(AnalysisHistory).filter(AnalysisHistory.query_id == query_id).first()
self.assertIsNotNone(row)
return row.id
index_id = save_record("sh000016", "query_asset_type_index")
stock_id = save_record("000016", "query_asset_type_stock")
stock_id2 = save_record("600519", "query_asset_type_stock2")
index_report = get_history_detail(str(index_id), db_manager=self.db)
self.assertEqual(index_report.meta.asset_type, "index")
stock_report = get_history_detail(str(stock_id), db_manager=self.db)
self.assertEqual(stock_report.meta.asset_type, "stock")
stock_report2 = get_history_detail(str(stock_id2), db_manager=self.db)
self.assertEqual(stock_report2.meta.asset_type, "stock")
def test_history_detail_omits_asset_type_for_market_review(self) -> None:
"""Market review records must omit the optional asset_type field."""
if get_history_detail is None:
self.skipTest("fastapi is not installed in this test environment")
result = self._build_result()
result.code = "MARKET"
saved = self.db.save_analysis_history(
result=result,
query_id="query_asset_type_market_review",
report_type="market_review",
news_content="大盘复盘",
context_snapshot=None,
save_snapshot=False,
)
self.assertGreater(saved, 0)
with self.db.get_session() as session:
row = session.query(AnalysisHistory).filter(AnalysisHistory.query_id == "query_asset_type_market_review").first()
self.assertIsNotNone(row)
record_id = row.id
report = get_history_detail(str(record_id), db_manager=self.db)
self.assertIsNone(report.meta.asset_type)
@patch("src.auth.is_auth_enabled", return_value=False) @patch("src.auth.is_auth_enabled", return_value=False)
def test_history_detail_ignores_non_dict_realtime_quote_raw(self, mock_auth) -> None: def test_history_detail_ignores_non_dict_realtime_quote_raw(self, mock_auth) -> None:
"""GET /api/v1/history/{id} should tolerate truthy non-dict realtime_quote_raw.""" """GET /api/v1/history/{id} should tolerate truthy non-dict realtime_quote_raw."""
@@ -2528,6 +2678,311 @@ class AnalysisHistoryTestCase(unittest.TestCase):
report = get_history_detail(str(record_id), db_manager=self.db) report = get_history_detail(str(record_id), db_manager=self.db)
self.assertIsNone(report.details.empty_news_disclosure) self.assertIsNone(report.details.empty_news_disclosure)
# ------------------------------------------------------------------
# PR #2312: 指数 canonical 历史隔离(并入本类,避免子类重复继承放大测试)
# ------------------------------------------------------------------
def _save_result_with_code(self, code: str, query_id: str) -> int:
result = self._build_result()
result.code = code
saved = self.db.save_analysis_history(
result=result,
query_id=query_id,
report_type="simple",
news_content="新闻摘要",
context_snapshot=None,
save_snapshot=False,
)
self.assertGreater(saved, 0)
return saved
def test_history_filter_isolates_index_from_same_code_stock(self):
self._save_result_with_code("sh000016", "query_index")
self._save_result_with_code("000016", "query_stock")
index_listing = HistoryService(self.db).get_history_list(
stock_code="sh000016", page=1, limit=10
)
stock_listing = HistoryService(self.db).get_history_list(
stock_code="000016", page=1, limit=10
)
self.assertEqual(index_listing["total"], 1)
self.assertEqual(
{item["query_id"] for item in index_listing["items"]},
{"query_index"},
)
self.assertEqual(stock_listing["total"], 1)
self.assertEqual(
{item["query_id"] for item in stock_listing["items"]},
{"query_stock"},
)
def test_history_filter_reaches_legacy_uppercase_and_alias_index_records(self):
self._save_result_with_code("sh000016", "query_canonical")
self._save_result_with_code("SH000016", "query_upper")
self._save_result_with_code("000016.SH", "query_alias")
for code in ("sh000016", "SH000016", "000016.SH"):
listing = HistoryService(self.db).get_history_list(
stock_code=code, page=1, limit=10
)
self.assertEqual(listing["total"], 3)
self.assertEqual(
{item["query_id"] for item in listing["items"]},
{"query_canonical", "query_upper", "query_alias"},
)
stock_listing = HistoryService(self.db).get_history_list(
stock_code="000016", page=1, limit=10
)
self.assertEqual(stock_listing["total"], 0)
def test_sz_index_history_filter_and_delete_isolate_from_same_code_stock(self):
"""I/O matrix INDEX_HISTORY 的 SZ 侧真实 SQL 路径:``sz399001``
lowercase canonical 筛选、删除与计数均命中自身记录,不并入裸
``399001`` 股票。"""
self._save_result_with_code("sz399001", "query_sz_index")
self._save_result_with_code("399001", "query_sz_stock")
sz_listing = HistoryService(self.db).get_history_list(
stock_code="sz399001", page=1, limit=10
)
stock_listing = HistoryService(self.db).get_history_list(
stock_code="399001", page=1, limit=10
)
self.assertEqual(sz_listing["total"], 1)
self.assertEqual(
{item["query_id"] for item in sz_listing["items"]},
{"query_sz_index"},
)
self.assertEqual(stock_listing["total"], 1)
self.assertEqual(
{item["query_id"] for item in stock_listing["items"]},
{"query_sz_stock"},
)
if delete_history_by_code is not None:
response = delete_history_by_code("sz399001", db_manager=self.db)
self.assertEqual(response.deleted, 1)
remaining = HistoryService(self.db).get_history_list(
stock_code="399001", page=1, limit=10
)
self.assertEqual(remaining["total"], 1)
self.assertEqual(
{item["query_id"] for item in remaining["items"]},
{"query_sz_stock"},
)
def test_history_detail_displays_parser_canonical_for_legacy_index_record(self):
"""报告详情 meta 对旧 uppercase/alias 指数记录输出 parser canonical。"""
if get_history_detail is None:
self.skipTest("fastapi is not installed in this test environment")
self._save_result_with_code("SZ399300", "query_sz399300_upper")
with self.db.get_session() as session:
row = session.query(AnalysisHistory).filter(
AnalysisHistory.query_id == "query_sz399300_upper"
).first()
if row is None:
self.fail("未找到保存的历史记录")
report = get_history_detail(str(row.id), db_manager=self.db)
self.assertEqual(report.meta.stock_code, "sh000300")
self.assertEqual(report.meta.asset_type, "index")
def test_history_list_displays_parser_canonical_for_legacy_index_records(self):
"""已登记指数旧记录uppercase legacy / 显式 alias的 API
``stock_code`` 输出 parser canonical``sz399300``/``000300.CSI`` ->
``sh000300``),前端只做大小写折叠即可,无需前缀/后缀正则猜 canonical。"""
self._save_result_with_code("SZ399300", "query_sz399300_upper")
self._save_result_with_code("000300.CSI", "query_000300_csi")
listing = HistoryService(self.db).get_history_list(page=1, limit=10)
by_query = {item["query_id"]: item for item in listing["items"]}
self.assertEqual(by_query["query_sz399300_upper"]["stock_code"], "sh000300")
self.assertEqual(by_query["query_000300_csi"]["stock_code"], "sh000300")
def test_sh300_uppercase_alias_filter_delete_and_stock_bar_count_real_sql(self):
"""sqlite ``IN`` 大小写敏感回归:``SZ399300``/``sz399300``/
``000300.CSI``/``sh000300`` 任一查询都命中全部显式形态旧记录(含
uppercase alias 持久化记录),并与裸 ``000300`` 股票隔离。"""
self._save_result_with_code("sh000300", "query_canonical")
self._save_result_with_code("SZ399300", "query_uppercase_alias")
self._save_result_with_code("000300.CSI", "query_dotted_alias")
self._save_result_with_code("000300", "query_bare_stock")
for code in ("sh000300", "sz399300", "SZ399300", "000300.CSI"):
listing = HistoryService(self.db).get_history_list(
stock_code=code, page=1, limit=10
)
self.assertEqual(listing["total"], 3)
self.assertEqual(
{item["query_id"] for item in listing["items"]},
{"query_canonical", "query_uppercase_alias", "query_dotted_alias"},
)
stock_listing = HistoryService(self.db).get_history_list(
stock_code="000300", page=1, limit=10
)
self.assertEqual(stock_listing["total"], 1)
self.assertEqual(
{item["query_id"] for item in stock_listing["items"]},
{"query_bare_stock"},
)
if get_stock_bar is not None:
stock_bar = get_stock_bar(
start_date=None,
end_date=None,
limit=10,
db_manager=self.db,
)
self.assertEqual(len(stock_bar.items), 2)
index_item = next(
item for item in stock_bar.items if item.asset_type == "index"
)
stock_item = next(
item for item in stock_bar.items if item.asset_type == "stock"
)
self.assertEqual(index_item.stock_code, "sh000300")
self.assertEqual(index_item.analysis_count, 3)
self.assertEqual(stock_item.stock_code, "000300")
self.assertEqual(stock_item.analysis_count, 1)
if delete_history_by_code is not None:
response = delete_history_by_code("SZ399300", db_manager=self.db)
self.assertEqual(response.deleted, 3)
remaining = HistoryService(self.db).get_history_list(
stock_code="000300", page=1, limit=10
)
self.assertEqual(remaining["total"], 1)
self.assertEqual(
{item["query_id"] for item in remaining["items"]},
{"query_bare_stock"},
)
def test_stock_bar_isolates_index_and_same_code_stock(self):
if get_stock_bar is None:
self.skipTest("fastapi is not installed in this test environment")
self._save_result_with_code("sh000016", "query_index")
self._save_result_with_code("000016", "query_stock")
stock_bar = get_stock_bar(
start_date=None,
end_date=None,
limit=10,
db_manager=self.db,
)
self.assertEqual(len(stock_bar.items), 2)
by_code = {item.stock_code: item for item in stock_bar.items}
self.assertIn("sh000016", by_code)
self.assertIn("000016", by_code)
self.assertEqual(by_code["sh000016"].analysis_count, 1)
self.assertEqual(by_code["000016"].analysis_count, 1)
self.assertEqual(by_code["sh000016"].asset_type, "index")
self.assertEqual(by_code["000016"].asset_type, "stock")
def test_stock_bar_merges_index_explicit_forms_and_counts_them(self):
if get_stock_bar is None:
self.skipTest("fastapi is not installed in this test environment")
self._save_result_with_code("sh000016", "query_canonical")
self._save_result_with_code("SH000016", "query_upper")
self._save_result_with_code("000016.SH", "query_alias")
self._save_result_with_code("000016", "query_stock")
stock_bar = get_stock_bar(
start_date=None,
end_date=None,
limit=10,
db_manager=self.db,
)
self.assertEqual(len(stock_bar.items), 2)
index_item = next(
item for item in stock_bar.items if item.asset_type == "index"
)
stock_item = next(
item for item in stock_bar.items if item.asset_type == "stock"
)
self.assertEqual(index_item.analysis_count, 3)
self.assertEqual(index_item.stock_code, "sh000016")
self.assertEqual(stock_item.stock_code, "000016")
self.assertEqual(stock_item.analysis_count, 1)
def test_stock_bar_exposes_sz_index_canonical_row_with_independent_count(self):
"""``sz399001`` 在 stock-bar 以 canonical 单行展示,``analysis_count``
只计自身记录,不与裸 ``399001`` 股票合并。"""
if get_stock_bar is None:
self.skipTest("fastapi is not installed in this test environment")
self._save_result_with_code("sz399001", "query_sz_index")
self._save_result_with_code("399001", "query_sz_stock")
stock_bar = get_stock_bar(
start_date=None,
end_date=None,
limit=10,
db_manager=self.db,
)
self.assertEqual(len(stock_bar.items), 2)
by_code = {item.stock_code: item for item in stock_bar.items}
self.assertIn("sz399001", by_code)
self.assertIn("399001", by_code)
self.assertEqual(by_code["sz399001"].analysis_count, 1)
self.assertEqual(by_code["sz399001"].asset_type, "index")
self.assertEqual(by_code["399001"].analysis_count, 1)
self.assertEqual(by_code["399001"].asset_type, "stock")
def test_delete_index_by_code_deletes_all_explicit_forms_only(self):
if delete_history_by_code is None:
self.skipTest("fastapi is not installed in this test environment")
self._save_result_with_code("sh000016", "query_canonical")
self._save_result_with_code("SH000016", "query_upper")
self._save_result_with_code("000016.SH", "query_alias")
self._save_result_with_code("000016", "query_stock")
response = delete_history_by_code("sh000016", db_manager=self.db)
self.assertEqual(response.deleted, 3)
remaining = HistoryService(self.db).get_history_list(
stock_code="000016", page=1, limit=10
)
self.assertEqual(remaining["total"], 1)
self.assertEqual(
{item["query_id"] for item in remaining["items"]},
{"query_stock"},
)
def test_delete_index_with_no_records_returns_zero(self):
if delete_history_by_code is None:
self.skipTest("fastapi is not installed in this test environment")
response = delete_history_by_code("sh000016", db_manager=self.db)
self.assertEqual(response.deleted, 0)
def test_history_list_exposes_parser_asset_type(self):
self._save_result_with_code("sh000016", "query_index")
self._save_result_with_code("000016", "query_stock")
self._save_result_with_code("600519", "query_stock2")
listing = HistoryService(self.db).get_history_list(page=1, limit=10)
by_query = {item["query_id"]: item for item in listing["items"]}
self.assertEqual(by_query["query_index"]["asset_type"], "index")
self.assertEqual(by_query["query_stock"]["asset_type"], "stock")
self.assertEqual(by_query["query_stock2"]["asset_type"], "stock")
def test_history_list_omits_asset_type_for_market_review(self):
self._save_result_with_code("MARKET", "query_market_review")
listing = HistoryService(self.db).get_history_list(page=1, limit=10)
by_query = {item["query_id"]: item for item in listing["items"]}
self.assertTrue(by_query["query_market_review"]["asset_type"] is None)
class HistoryItemSchemaNegativeSentimentTest(unittest.TestCase): class HistoryItemSchemaNegativeSentimentTest(unittest.TestCase):
"""Regression: HistoryItem / ReportSummary must accept out-of-range sentiment_score from DB rows.""" """Regression: HistoryItem / ReportSummary must accept out-of-range sentiment_score from DB rows."""

View File

@@ -67,6 +67,9 @@ class TestAnalysisIntegration:
data = response.json() data = response.json()
assert data["task_id"] == "test_task_123" assert data["task_id"] == "test_task_123"
assert data["status"] == "pending" assert data["status"] == "pending"
# 单股 202TaskAccepted schema不包含可选的 asset_type 字段legacy
# MagicMock 任务即使 asset_type 是 MagicMock 子对象也必须保持该契约。
assert "asset_type" not in data
# Verify task queue received the correct resolved code and metadata. # Verify task queue received the correct resolved code and metadata.
# Use call_args so this integration test stays focused on analysis flow # Use call_args so this integration test stays focused on analysis flow

View File

@@ -845,6 +845,69 @@ class PipelineMarketPhaseContextTestCase(unittest.TestCase):
self.assertEqual(kwargs["query_source"], "api") self.assertEqual(kwargs["query_source"], "api")
self.assertEqual(kwargs["report_type"], ReportType.SIMPLE.value) self.assertEqual(kwargs["report_type"], ReportType.SIMPLE.value)
self.assertEqual(kwargs["profile_source"], "auto_default") self.assertEqual(kwargs["profile_source"], "auto_default")
# No index target -> no market override.
self.assertIsNone(kwargs["market_override"])
def test_legacy_pipeline_passes_market_override_cn_for_index_target(self):
"""V8 — index targets must reach extract_and_persist_from_analysis_result
with market_override="cn" via the real wiring (not a mocked helper)."""
from src.services.stock_list_parser import ParseStatus, parse_analysis_target
target = parse_analysis_target("sh000016")
self.assertEqual(target.asset_type, ParseStatus.INDEX)
pipeline = _make_pipeline(agent_mode=False, save_context_snapshot=True)
pipeline.trace_id = "trace-index"
pipeline.query_source = "api"
pipeline.db.save_analysis_history.return_value = 42
phase_context = SimpleNamespace(to_dict=MagicMock(return_value=_phase_payload()))
with (
patch("src.core.pipeline.build_market_phase_context", return_value=phase_context),
patch("src.core.pipeline.extract_and_persist_from_analysis_result") as mock_extract,
):
result = pipeline.analyze_stock(
"sh000016",
ReportType.SIMPLE,
"q-index-signal",
current_time=datetime(2026, 3, 27, 10, 0),
analysis_target=target,
)
self.assertIsNotNone(result)
mock_extract.assert_called_once()
kwargs = mock_extract.call_args.kwargs
self.assertEqual(kwargs["source_report_id"], 42)
self.assertEqual(kwargs["market_override"], "cn")
def test_legacy_pipeline_passes_market_override_none_for_stock_target(self):
"""Stock targets (and analysis_target=None) must keep market_override=None."""
from src.services.stock_list_parser import parse_analysis_target
target = parse_analysis_target("600519")
self.assertEqual(target.asset_type, "stock")
pipeline = _make_pipeline(agent_mode=False, save_context_snapshot=True)
pipeline.trace_id = "trace-stock"
pipeline.query_source = "api"
pipeline.db.save_analysis_history.return_value = 42
phase_context = SimpleNamespace(to_dict=MagicMock(return_value=_phase_payload()))
with (
patch("src.core.pipeline.build_market_phase_context", return_value=phase_context),
patch("src.core.pipeline.extract_and_persist_from_analysis_result") as mock_extract,
):
result = pipeline.analyze_stock(
"600519",
ReportType.SIMPLE,
"q-stock-signal",
current_time=datetime(2026, 3, 27, 10, 0),
analysis_target=target,
)
self.assertIsNotNone(result)
mock_extract.assert_called_once()
self.assertIsNone(mock_extract.call_args.kwargs["market_override"])
def test_legacy_pipeline_does_not_extract_when_history_save_fails(self): def test_legacy_pipeline_does_not_extract_when_history_save_fails(self):
pipeline = _make_pipeline(agent_mode=False, save_context_snapshot=True) pipeline = _make_pipeline(agent_mode=False, save_context_snapshot=True)