mirror of
https://github.com/ZhuLinsen/daily_stock_analysis
synced 2026-09-20 02:43:35 +08:00
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:
@@ -21,6 +21,7 @@ import copy
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import unicodedata
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@@ -38,6 +39,7 @@ from api.v1.schemas.analysis import (
|
||||
BatchTaskAcceptedResponse,
|
||||
BatchTaskAcceptedItem,
|
||||
BatchDuplicateTaskItem,
|
||||
RejectedTaskItem,
|
||||
TaskStatus,
|
||||
TaskInfo,
|
||||
TaskListResponse,
|
||||
@@ -75,6 +77,7 @@ from src.market_phase_summary import (
|
||||
rebuild_market_phase_summary_for_stock_code,
|
||||
)
|
||||
from src.services.stock_code_utils import is_code_like, resolve_index_stock_code_for_analysis
|
||||
from src.services.stock_list_parser import ParseStatus, parse_analysis_target
|
||||
from src.report_language import get_localized_stock_name, normalize_report_language
|
||||
from src.schemas.decision_action import build_action_fields
|
||||
from src.services.name_to_code_resolver import resolve_name_to_code
|
||||
@@ -83,6 +86,7 @@ from src.services.task_queue import (
|
||||
DuplicateTaskError,
|
||||
TaskStatus as TaskStatusEnum,
|
||||
)
|
||||
from src.services.analysis_service import asset_type_from_canonical_code
|
||||
from src.services.run_diagnostics import build_run_diagnostic_summary
|
||||
from src.services.run_flow import build_task_run_flow_snapshot
|
||||
from src.services.empty_news import empty_news_disclosure_from_stored
|
||||
@@ -113,6 +117,32 @@ def _get_task_trace_id(task: Any) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def _task_asset_type(task: Any) -> Optional[str]:
|
||||
"""Return the task's optional asset type only when it is a real literal.
|
||||
|
||||
The Pydantic literal domain (``stock``/``index``) is the only allowed set:
|
||||
real in-domain strings pass through verbatim. Legacy mock/proxy tasks whose
|
||||
``getattr`` yields a ``MagicMock`` child, and missing values, degrade to
|
||||
``None`` so schema defaults keep legacy responses unchanged. Genuine
|
||||
out-of-domain strings are logged and degraded instead of widening the enum
|
||||
or silently masking drift.
|
||||
"""
|
||||
raw = getattr(task, "asset_type", None)
|
||||
if not isinstance(raw, str):
|
||||
return None
|
||||
if raw in {"stock", "index"}:
|
||||
return raw
|
||||
# 任何真实字符串只要不精确等于字面量域(含空串、纯空白、大小写或带
|
||||
# 空格形态)都必须记录 warning 后降级,避免静默掩盖漂移;仅非字符串
|
||||
# 代理(如 MagicMock 子对象)保持静默 None。
|
||||
logger.warning(
|
||||
"task asset_type 超出字面量域,降级为 None: task_id=%s asset_type=%r",
|
||||
getattr(task, "task_id", None),
|
||||
raw,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _market_review_lock_path(config: Config) -> Path:
|
||||
return market_review_lock_path(config)
|
||||
|
||||
@@ -226,32 +256,68 @@ def _is_obviously_invalid_analysis_input(text: str) -> bool:
|
||||
return has_letters and has_digits
|
||||
|
||||
|
||||
def _resolve_and_normalize_input(raw_value: str) -> str:
|
||||
def _resolve_analysis_input(raw_value: str):
|
||||
"""
|
||||
Resolve and normalize a stock input for analysis requests.
|
||||
Resolve one analysis request input into ``(code, analysis_target)``.
|
||||
|
||||
Code-like values keep the existing canonical path.
|
||||
Non-code inputs must resolve to a known stock code. Obvious garbage
|
||||
input is rejected before expensive resolver and task-queue work.
|
||||
Code-like tokens go through :func:`parse_analysis_target` (the single
|
||||
asset-type authority): registered indices keep their structured
|
||||
``AnalysisTarget`` (canonical id + index semantics), unsupported targets
|
||||
(e.g. unregistered ``930956.CSI``) are surfaced with their reason so the
|
||||
caller can reject them explicitly, and stock tokens keep the legacy
|
||||
resolution path. Non-code names (e.g. ``贵州茅台``) keep
|
||||
:func:`resolve_name_to_code` and never enter index classification.
|
||||
"""
|
||||
text = (raw_value or "").strip()
|
||||
if not text:
|
||||
return ""
|
||||
return None
|
||||
|
||||
# CSI explicit forms (``csi930955`` / ``930955.CSI`` / ``CSI930955``) are
|
||||
# explicit code forms but ``is_code_like`` does not recognise the ``.CSI``
|
||||
# suffix; route them through parse_analysis_target so registered CSI
|
||||
# converges to its canonical index identity and unregistered CSI surfaces
|
||||
# as an explicit unsupported error (never a name-resolution fallback).
|
||||
normalized_csi = unicodedata.normalize("NFKC", text).strip().casefold()
|
||||
if re.fullmatch(r"(?:csi\d{6}|\d{6}\.csi)", normalized_csi):
|
||||
target = parse_analysis_target(text)
|
||||
if target.asset_type == ParseStatus.INDEX:
|
||||
return (target.canonical_id, target)
|
||||
return (text, target)
|
||||
|
||||
# SH/SZ prefixed six-digit forms (``sh000016`` / ``SH000016``) are explicit
|
||||
# index/stock code shapes, but ``is_code_like`` rejects them when the bare
|
||||
# digits do not match the exchange's *stock* digit rules (``000016`` is an
|
||||
# SH index yet classifies as an SZ stock digit shape). Route them through
|
||||
# parse_analysis_target so registered SH/SZ indices keep their structured
|
||||
# target; unknown prefixed forms degrade to the stock path as usual.
|
||||
normalized_sh_sz = re.fullmatch(r"(sh|sz)(\d{6})", normalized_csi)
|
||||
if normalized_sh_sz is not None:
|
||||
target = parse_analysis_target(text)
|
||||
if target.asset_type == ParseStatus.INDEX:
|
||||
return (target.canonical_id, target)
|
||||
return (resolve_index_stock_code_for_analysis(text), None)
|
||||
|
||||
if is_code_like(text):
|
||||
return resolve_index_stock_code_for_analysis(text)
|
||||
target = parse_analysis_target(text)
|
||||
if target.asset_type == ParseStatus.INDEX:
|
||||
return (target.canonical_id, target)
|
||||
if target.asset_type == ParseStatus.UNSUPPORTED:
|
||||
return (text, target)
|
||||
# Stock target: keep the existing canonical resolution path and do not
|
||||
# carry a stock target downstream (stock semantics must not change).
|
||||
return (resolve_index_stock_code_for_analysis(text), None)
|
||||
|
||||
if text.isdigit() and len(text) == 4:
|
||||
resolved_index_code = resolve_index_stock_code_for_analysis(text)
|
||||
if resolved_index_code != canonical_stock_code(text):
|
||||
return resolved_index_code
|
||||
return (resolved_index_code, None)
|
||||
|
||||
if _is_obviously_invalid_analysis_input(text):
|
||||
raise _invalid_analysis_input_error()
|
||||
|
||||
resolved = resolve_name_to_code(text)
|
||||
if resolved:
|
||||
return canonical_stock_code(resolved)
|
||||
return (canonical_stock_code(resolved), None)
|
||||
|
||||
raise _invalid_analysis_input_error()
|
||||
|
||||
@@ -313,29 +379,56 @@ def trigger_analysis(
|
||||
if not stock_codes:
|
||||
raise api_error(400, "validation_error", "必须提供 stock_code 或 stock_codes 参数")
|
||||
|
||||
# Limit the number of non-blank raw tokens BEFORE resolution. Rejected and
|
||||
# duplicate tokens must also count toward the cap, otherwise a request that
|
||||
# mixes one valid token with many rejected/duplicate tokens could bypass the
|
||||
# DoS limit via the post-dedup check below.
|
||||
MAX_BATCH_SIZE = 50
|
||||
non_empty_raw_tokens = [c for c in stock_codes if str(c or "").strip()]
|
||||
if len(non_empty_raw_tokens) > MAX_BATCH_SIZE:
|
||||
raise api_error(400, "validation_error", f"单次分析请求最多支持 {MAX_BATCH_SIZE} 只股票")
|
||||
|
||||
# Normalize and de-duplicate inputs while preserving compatibility.
|
||||
resolved = [_resolve_and_normalize_input(c) for c in stock_codes]
|
||||
|
||||
# Code-like tokens go through parse_analysis_target (the single asset-type
|
||||
# authority) so registered indices keep their structured target; non-code
|
||||
# names keep the legacy stock-name resolution path.
|
||||
resolved_entries = [_resolve_analysis_input(c) for c in stock_codes]
|
||||
|
||||
seen = set()
|
||||
unique_codes = []
|
||||
for code in resolved:
|
||||
unique_targets = []
|
||||
rejected_entries = []
|
||||
for entry in resolved_entries:
|
||||
if entry is None:
|
||||
continue
|
||||
code, target = entry
|
||||
if not code:
|
||||
continue
|
||||
# Use normalize_stock_code to ensure '600519' and '600519.SH' are merged
|
||||
norm = normalize_stock_code(code)
|
||||
if target is not None and target.asset_type == ParseStatus.UNSUPPORTED:
|
||||
rejected_entries.append((code, target))
|
||||
continue
|
||||
# 去重键按 asset_type 分支:INDEX 用 canonical_id(指数与同码股票不折叠、
|
||||
# CSI alias 收敛),STOCK 保持 normalize_stock_code 既有语义
|
||||
# ('600519' 与 '600519.SH' 合并)。
|
||||
if target is not None and target.asset_type == ParseStatus.INDEX:
|
||||
norm = target.canonical_id
|
||||
else:
|
||||
norm = normalize_stock_code(code)
|
||||
if norm not in seen:
|
||||
seen.add(norm)
|
||||
unique_codes.append(code)
|
||||
|
||||
unique_targets.append(target)
|
||||
|
||||
stock_codes = unique_codes
|
||||
|
||||
# Limit the number of stocks in a single request to prevent DoS
|
||||
MAX_BATCH_SIZE = 50
|
||||
if len(stock_codes) > MAX_BATCH_SIZE:
|
||||
raise api_error(400, "validation_error", f"单次分析请求最多支持 {MAX_BATCH_SIZE} 只股票")
|
||||
|
||||
if not stock_codes:
|
||||
raise api_error(400, "validation_error", "股票代码不能为空或仅包含空白字符")
|
||||
if not rejected_entries:
|
||||
raise api_error(400, "validation_error", "股票代码不能为空或仅包含空白字符")
|
||||
# 全部目标都被拒绝(单请求或批量全拒):无任务被接受时返回 202 会误导
|
||||
# 客户端,一律明确 400;部分被拒则走下方 rejected 语义。
|
||||
code, target = rejected_entries[0]
|
||||
reason = target.unsupported_reason or f"不支持的目标: {code}"
|
||||
raise api_error(400, "validation_error", reason)
|
||||
|
||||
# Sync mode only supports single-stock analysis.
|
||||
if not request.async_mode:
|
||||
@@ -345,25 +438,45 @@ def trigger_analysis(
|
||||
"validation_error",
|
||||
"同步模式仅支持单只股票分析,请使用 async_mode=true 进行批量分析",
|
||||
)
|
||||
return _handle_sync_analysis(stock_codes[0], request)
|
||||
if rejected_entries:
|
||||
# 同步模式不支持 rejected 语义:只要存在被拒绝目标(含全部被拒绝)
|
||||
# 就以第一个未登记目标的明确校验错误返回 4xx。
|
||||
code, target = rejected_entries[0]
|
||||
reason = target.unsupported_reason or f"不支持的目标: {code}"
|
||||
raise api_error(400, "validation_error", reason)
|
||||
return _handle_sync_analysis(stock_codes[0], request, analysis_target=unique_targets[0] if unique_targets else None)
|
||||
|
||||
# Async mode submits one task per stock.
|
||||
return _handle_async_analysis_batch(stock_codes, request)
|
||||
return _handle_async_analysis_batch(stock_codes, request, analysis_targets=unique_targets, rejected_entries=rejected_entries)
|
||||
|
||||
|
||||
def _handle_async_analysis_batch(
|
||||
stock_codes: list,
|
||||
request: AnalyzeRequest
|
||||
request: AnalyzeRequest,
|
||||
analysis_targets: Optional[list] = None,
|
||||
rejected_entries: Optional[list] = None,
|
||||
) -> JSONResponse:
|
||||
"""
|
||||
Handle asynchronous analysis requests, including batch submission.
|
||||
|
||||
Args:
|
||||
stock_codes: canonical codes to submit
|
||||
request: the analysis request
|
||||
analysis_targets: optional per-code structured targets (index targets
|
||||
flow through to the pipeline)
|
||||
rejected_entries: optional list of ``(code, target)`` pairs that were
|
||||
explicitly rejected (e.g. unregistered CSI); returned in the
|
||||
``rejected`` field for batch requests only.
|
||||
"""
|
||||
task_queue = get_task_queue()
|
||||
|
||||
# Preserve metadata for single-stock requests. For batch requests,
|
||||
# only carry through metadata that semantically applies to the whole
|
||||
# batch, such as import/image source tracking.
|
||||
is_single = len(stock_codes) == 1
|
||||
# A single "accepted" code alongside any rejected entries is a batch:
|
||||
# rejected entries mean the server has not fully disposed of a single-stock
|
||||
# request, so single-stock metadata/409/single-202 semantics must not apply.
|
||||
is_single = len(stock_codes) == 1 and not rejected_entries
|
||||
preserve_batch_metadata = request.selection_source in {"import", "image"}
|
||||
|
||||
stock_name = request.stock_name if is_single else None
|
||||
@@ -388,6 +501,10 @@ def _handle_async_analysis_batch(
|
||||
submit_kwargs["report_language"] = report_language
|
||||
if skills is not None:
|
||||
submit_kwargs["skills"] = skills
|
||||
# 仅当存在非 None 的结构化 target(如指数)时才传递,保持纯股票请求
|
||||
# 的既有 kwargs 契约不变。
|
||||
if analysis_targets is not None and any(t is not None for t in analysis_targets):
|
||||
submit_kwargs["analysis_targets"] = analysis_targets
|
||||
|
||||
accepted_tasks, duplicate_errors = task_queue.submit_tasks_batch(**submit_kwargs)
|
||||
|
||||
@@ -399,6 +516,7 @@ def _handle_async_analysis_batch(
|
||||
status="pending",
|
||||
message=f"分析任务已加入队列: {task.stock_code}",
|
||||
analysis_phase=task.analysis_phase,
|
||||
asset_type=_task_asset_type(task),
|
||||
)
|
||||
for task in accepted_tasks
|
||||
]
|
||||
@@ -410,9 +528,16 @@ def _handle_async_analysis_batch(
|
||||
)
|
||||
for dup in duplicate_errors
|
||||
]
|
||||
rejected = [
|
||||
RejectedTaskItem(
|
||||
stock_code=code,
|
||||
message=(target.unsupported_reason or f"不支持的目标: {code}"),
|
||||
)
|
||||
for code, target in (rejected_entries or [])
|
||||
]
|
||||
|
||||
# 单只股票且被拒绝:保持 409 兼容性
|
||||
if len(stock_codes) == 1 and duplicates:
|
||||
if is_single and duplicates:
|
||||
dup = duplicates[0]
|
||||
error_response = DuplicateTaskErrorResponse(
|
||||
error="duplicate_task",
|
||||
@@ -425,8 +550,8 @@ def _handle_async_analysis_batch(
|
||||
content=error_response.model_dump()
|
||||
)
|
||||
|
||||
# 单只股票成功:保持原有响应格式兼容性
|
||||
if len(stock_codes) == 1 and accepted:
|
||||
# 单只股票成功(且无 rejected):保持原有响应格式兼容性
|
||||
if is_single and accepted and not rejected:
|
||||
task_accepted = TaskAccepted(
|
||||
task_id=accepted[0].task_id,
|
||||
trace_id=accepted[0].trace_id,
|
||||
@@ -439,11 +564,17 @@ def _handle_async_analysis_batch(
|
||||
content=task_accepted.model_dump()
|
||||
)
|
||||
|
||||
# 批量:返回汇总结果
|
||||
# 批量:返回汇总结果(rejected 仅 async 批量返回)
|
||||
rejected_count = len(rejected)
|
||||
if rejected_count:
|
||||
message = f"已提交 {len(accepted)} 个任务,{len(duplicates)} 个重复跳过,{rejected_count} 个被拒绝"
|
||||
else:
|
||||
message = f"已提交 {len(accepted)} 个任务,{len(duplicates)} 个重复跳过"
|
||||
batch_response = BatchTaskAcceptedResponse(
|
||||
accepted=accepted,
|
||||
duplicates=duplicates,
|
||||
message=f"已提交 {len(accepted)} 个任务,{len(duplicates)} 个重复跳过",
|
||||
rejected=rejected if rejected else None,
|
||||
message=message,
|
||||
)
|
||||
return JSONResponse(
|
||||
status_code=202,
|
||||
@@ -453,7 +584,8 @@ def _handle_async_analysis_batch(
|
||||
|
||||
def _handle_sync_analysis(
|
||||
stock_code: str,
|
||||
request: AnalyzeRequest
|
||||
request: AnalyzeRequest,
|
||||
analysis_target: Optional[Any] = None,
|
||||
) -> AnalysisResultResponse:
|
||||
"""
|
||||
处理同步分析请求
|
||||
@@ -476,6 +608,7 @@ def _handle_sync_analysis(
|
||||
skills=getattr(request, "skills", None),
|
||||
analysis_phase=request.analysis_phase,
|
||||
report_language=getattr(request, "report_language", None),
|
||||
analysis_target=analysis_target,
|
||||
)
|
||||
|
||||
if result is None:
|
||||
@@ -647,6 +780,7 @@ def get_task_list(
|
||||
analysis_phase=t.analysis_phase,
|
||||
skills=getattr(t, "skills", None),
|
||||
region=t.region,
|
||||
asset_type=_task_asset_type(t),
|
||||
)
|
||||
for t in all_tasks
|
||||
]
|
||||
@@ -1232,6 +1366,7 @@ def get_analysis_status(task_id: str) -> TaskStatus:
|
||||
current_price=current_price,
|
||||
change_pct=change_pct,
|
||||
market_phase_summary=market_phase_summary,
|
||||
asset_type=asset_type_from_canonical_code(record.code),
|
||||
),
|
||||
summary=ReportSummary(
|
||||
sentiment_score=record.sentiment_score,
|
||||
@@ -1394,6 +1529,7 @@ def _build_analysis_report(
|
||||
change_pct=change_pct,
|
||||
model_used=normalize_model_used(meta_data.get("model_used")),
|
||||
market_phase_summary=market_phase_summary,
|
||||
asset_type=asset_type_from_canonical_code(raw_stock_code),
|
||||
)
|
||||
|
||||
def _looks_like_raw_result_payload(candidate: Any) -> bool:
|
||||
|
||||
@@ -44,6 +44,7 @@ from src.report_language import (
|
||||
normalize_report_language,
|
||||
)
|
||||
from src.services.history_service import HistoryService, MarkdownReportGenerationError
|
||||
from src.services.analysis_service import asset_type_from_canonical_code
|
||||
from src.schemas.decision_action import build_action_fields
|
||||
from src.utils.data_processing import (
|
||||
normalize_model_used,
|
||||
@@ -129,14 +130,30 @@ def _history_share_image_branding(config: object) -> ShareImageBranding:
|
||||
return share_image_branding_from_config(config)
|
||||
|
||||
|
||||
def _normalize_code_for_grouping(code: str) -> str:
|
||||
"""Normalize stock code for deduplication grouping.
|
||||
def _stock_bar_group_key(record_code: str, display_code: str) -> str:
|
||||
"""Build the stock-bar grouping key from the *persisted* code.
|
||||
|
||||
Delegates to data_provider.base.normalize_stock_code which handles
|
||||
SH600519, 600519.SH, HK00700, 00700.HK, BJ920748, etc.
|
||||
PR #2312: registered indices are typed by the persisted ``record.code``
|
||||
(never guessed from the display code) and group by the parser canonical
|
||||
(lowercase ``sh000016`` / ``csi930955``), so every explicit index form
|
||||
(uppercase legacy / dotted alias) converges to one row and never folds
|
||||
with the bare same-code stock. Stocks keep the legacy display-based
|
||||
normalization (``SH600519``/``600519.SH`` -> ``600519``, JP/KR legacy bare
|
||||
``005930`` merging with ``005930.KS`` etc.), preserving existing semantics.
|
||||
"""
|
||||
from data_provider.base import normalize_stock_code
|
||||
return normalize_stock_code(code or "")
|
||||
from src.services.stock_list_parser import ParseStatus, parse_analysis_target
|
||||
|
||||
code = str(record_code or "").strip()
|
||||
if not code:
|
||||
return normalize_stock_code(display_code or "")
|
||||
try:
|
||||
target = parse_analysis_target(code)
|
||||
except Exception:
|
||||
return normalize_stock_code(display_code or "")
|
||||
if target.asset_type == ParseStatus.INDEX:
|
||||
return target.canonical_id
|
||||
return normalize_stock_code(display_code or "")
|
||||
|
||||
|
||||
def _raw_result_value(raw_result: Any, key: str) -> Any:
|
||||
@@ -274,6 +291,7 @@ def get_history_list(
|
||||
model_used=item.get("model_used"),
|
||||
created_at=item.get("created_at"),
|
||||
market_phase_summary=item.get("market_phase_summary"),
|
||||
asset_type=item.get("asset_type"),
|
||||
)
|
||||
for item in result.get("items", [])
|
||||
]
|
||||
@@ -431,7 +449,7 @@ def get_stock_bar(
|
||||
seen: dict = {}
|
||||
for record in records:
|
||||
display_code = service._display_stock_code(record.code or "")
|
||||
norm_code = _normalize_code_for_grouping(display_code)
|
||||
norm_code = _stock_bar_group_key(record.code or "", display_code)
|
||||
if norm_code not in seen or record.id > seen[norm_code].id:
|
||||
seen[norm_code] = record
|
||||
|
||||
@@ -482,6 +500,7 @@ def get_stock_bar(
|
||||
record.code,
|
||||
getattr(record, "context_snapshot", None),
|
||||
),
|
||||
asset_type=asset_type_from_canonical_code(record.code),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -589,6 +608,9 @@ def get_history_detail(
|
||||
change_pct=change_pct,
|
||||
model_used=normalize_model_used(result.get("model_used")),
|
||||
market_phase_summary=market_phase_summary,
|
||||
asset_type=asset_type_from_canonical_code(
|
||||
result.get("storage_stock_code") or result.get("stock_code")
|
||||
),
|
||||
)
|
||||
|
||||
summary = ReportSummary(
|
||||
|
||||
@@ -29,6 +29,7 @@ from api.v1.schemas.analysis import (
|
||||
AnalysisResultResponse,
|
||||
TaskAccepted,
|
||||
BatchTaskAcceptedResponse,
|
||||
RejectedTaskItem,
|
||||
TaskStatus,
|
||||
)
|
||||
from api.v1.schemas.history import (
|
||||
@@ -168,6 +169,7 @@ __all__ = [
|
||||
"AnalysisResultResponse",
|
||||
"TaskAccepted",
|
||||
"BatchTaskAcceptedResponse",
|
||||
"RejectedTaskItem",
|
||||
"TaskStatus",
|
||||
# history
|
||||
"HistoryItem",
|
||||
|
||||
@@ -231,6 +231,10 @@ class BatchTaskAcceptedItem(BaseModel):
|
||||
)
|
||||
message: Optional[str] = Field(None, description="提示信息")
|
||||
analysis_phase: AnalysisPhase = Field("auto", description="请求的分析阶段")
|
||||
asset_type: Optional[Literal["stock", "index"]] = Field(
|
||||
None,
|
||||
description="parser 来源的可选资产类型(stock/index);由已提交的 analysis_target 透传,旧客户端可缺省",
|
||||
)
|
||||
|
||||
model_config = ConfigDict(json_schema_extra={
|
||||
"example": {
|
||||
@@ -259,11 +263,29 @@ class BatchDuplicateTaskItem(BaseModel):
|
||||
})
|
||||
|
||||
|
||||
class RejectedTaskItem(BaseModel):
|
||||
"""批量异步任务中被明确拒绝的单个目标项(如未登记 CSI 指数)。"""
|
||||
|
||||
stock_code: str = Field(..., description="被拒绝的目标代码")
|
||||
message: str = Field(..., description="拒绝原因")
|
||||
|
||||
model_config = ConfigDict(json_schema_extra={
|
||||
"example": {
|
||||
"stock_code": "930956.CSI",
|
||||
"message": "unregistered CSI index: '930956.CSI' is not in the index registry",
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
class BatchTaskAcceptedResponse(BaseModel):
|
||||
"""批量异步任务接受响应。"""
|
||||
|
||||
accepted: List[BatchTaskAcceptedItem] = Field(default_factory=list, description="成功提交的任务列表")
|
||||
duplicates: List[BatchDuplicateTaskItem] = Field(default_factory=list, description="重复而跳过的任务列表")
|
||||
rejected: Optional[List[RejectedTaskItem]] = Field(
|
||||
None,
|
||||
description="批量中被明确拒绝的目标列表(如未登记 CSI 指数),仅在异步批量请求中返回",
|
||||
)
|
||||
message: str = Field(..., description="汇总信息")
|
||||
|
||||
model_config = ConfigDict(json_schema_extra={
|
||||
@@ -385,6 +407,10 @@ class TaskInfo(BaseModel):
|
||||
None,
|
||||
description="大盘复盘任务实际执行的 canonical 市场范围",
|
||||
)
|
||||
asset_type: Optional[Literal["stock", "index"]] = Field(
|
||||
None,
|
||||
description="parser 来源的可选资产类型(stock/index);由已提交的 analysis_target 透传,旧客户端可缺省",
|
||||
)
|
||||
|
||||
model_config = ConfigDict(json_schema_extra={
|
||||
"example": {
|
||||
|
||||
@@ -52,6 +52,10 @@ class HistoryItem(BaseModel):
|
||||
description="本次分析市场阶段低敏摘要",
|
||||
)
|
||||
created_at: Optional[str] = Field(None, description="创建时间")
|
||||
asset_type: Optional[Literal["stock", "index"]] = Field(
|
||||
None,
|
||||
description="后端权威资产类型(stock/index);由持久化代码经 parser 生成。旧客户端与 market review 可缺省。",
|
||||
)
|
||||
|
||||
model_config = ConfigDict(json_schema_extra={
|
||||
"example": {
|
||||
@@ -149,6 +153,10 @@ class ReportMeta(BaseModel):
|
||||
None,
|
||||
description="本次分析市场阶段低敏摘要",
|
||||
)
|
||||
asset_type: Optional[Literal["stock", "index"]] = Field(
|
||||
None,
|
||||
description="后端权威资产类型(stock/index);指数报告用于隐藏 stock-only 自选操作。market review 与旧客户端可缺省。",
|
||||
)
|
||||
|
||||
|
||||
class ReportSummary(BaseModel):
|
||||
@@ -371,6 +379,10 @@ class StockBarItem(BaseModel):
|
||||
None,
|
||||
description="最新分析市场阶段低敏摘要",
|
||||
)
|
||||
asset_type: Optional[Literal["stock", "index"]] = Field(
|
||||
None,
|
||||
description="后端权威资产类型(stock/index);由持久化代码经 parser 生成。旧客户端可缺省。",
|
||||
)
|
||||
model_config = ConfigDict(json_schema_extra={
|
||||
"example": {
|
||||
"id": 1234,
|
||||
|
||||
@@ -381,7 +381,7 @@ export const ReportOverview: React.FC<ReportOverviewProps> = ({
|
||||
|
||||
{/* 右侧:情绪指标 / 自选操作 */}
|
||||
<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">
|
||||
<div className="text-center space-y-3">
|
||||
<span className="label-uppercase">{t('report.watchlist')}</span>
|
||||
|
||||
@@ -323,4 +323,57 @@ describe('ReportOverview', () => {
|
||||
expect(screen.getByText('领跌')).toBeInTheDocument();
|
||||
expect(screen.getByText('-2.50%')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the stock-only watchlist card for index reports', () => {
|
||||
render(
|
||||
<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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -20,7 +20,7 @@ import type { StockBarItem, TaskInfo } from '../../types/analysis';
|
||||
import { getSentimentColor } from '../../types/analysis';
|
||||
import { buildDecisionActionLabelMap, getDecisionActionLabel } from '../../utils/decisionAction';
|
||||
import { formatDateTime } from '../../utils/format';
|
||||
import { areStockCodesEquivalent } from '../../utils/stockCode';
|
||||
import { areAssetAwareCodesEquivalent, toAssetAwareCodeKey, type AssetAwareAssetType } from '../../utils/stockCode';
|
||||
import { truncateStockName } from '../../utils/stockName';
|
||||
import { useUiLanguage } from '../../contexts/UiLanguageContext';
|
||||
import type { UiTextKey, UiTextParams } from '../../i18n/uiText';
|
||||
@@ -30,6 +30,15 @@ export type WatchlistAnalyzeMode = 'all' | 'pending';
|
||||
|
||||
export interface HomeWatchlistRow {
|
||||
code: string;
|
||||
assetType?: AssetAwareAssetType;
|
||||
/**
|
||||
* Asset-aware identity key already resolved by HomePage from the stock index
|
||||
* registry (canonical for registered indices, e.g. `sh000016` for a raw
|
||||
* watchlist string `000016.SH`). Row-selection compares against this key
|
||||
* FIRST instead of re-parsing the raw alias, so a canonical selected report
|
||||
* always selects its own alias-form row and never a same-code stock row.
|
||||
*/
|
||||
identityKey?: string;
|
||||
latestItem?: StockBarItem;
|
||||
analyzedToday: boolean;
|
||||
isTodayStatusLoading?: boolean;
|
||||
@@ -62,6 +71,7 @@ interface HomeStockWorkspaceProps {
|
||||
historyItems: StockBarItem[];
|
||||
isLoadingHistory: boolean;
|
||||
selectedStockCode?: string;
|
||||
selectedAssetType?: AssetAwareAssetType | null;
|
||||
selectedRecordId?: number;
|
||||
onHistoryItemClick: (recordId: number) => void;
|
||||
onDeleteStock?: (stockCode: string) => Promise<void> | void;
|
||||
@@ -77,6 +87,21 @@ function getTaskStatusLabel(task: TaskInfo | undefined, t: (key: UiTextKey, para
|
||||
return task.status;
|
||||
}
|
||||
|
||||
function rowHasSelectedIdentity(
|
||||
row: HomeWatchlistRow,
|
||||
selectedStockCode: string | null | undefined,
|
||||
selectedAssetType: AssetAwareAssetType | null | undefined,
|
||||
): boolean {
|
||||
// The registry-derived canonical identity (HomePage) wins: an alias-form raw
|
||||
// watchlist code (e.g. `000016.SH`) whose identityKey is `sh000016` must
|
||||
// match the canonical selected report without re-parsing the raw alias, and
|
||||
// must never fold with the bare `000016` stock row.
|
||||
if (row.identityKey) {
|
||||
return toAssetAwareCodeKey(selectedStockCode, selectedAssetType) === row.identityKey;
|
||||
}
|
||||
return areAssetAwareCodesEquivalent(selectedStockCode, selectedAssetType, row.code, row.assetType);
|
||||
}
|
||||
|
||||
const ScoreBadge: React.FC<{ item?: StockBarItem }> = ({ item }) => {
|
||||
const { t } = useUiLanguage();
|
||||
const score = typeof item?.sentimentScore === 'number' ? item.sentimentScore : null;
|
||||
@@ -258,6 +283,7 @@ export const HomeStockWorkspace: React.FC<HomeStockWorkspaceProps> = ({
|
||||
historyItems,
|
||||
isLoadingHistory,
|
||||
selectedStockCode,
|
||||
selectedAssetType,
|
||||
selectedRecordId,
|
||||
onHistoryItemClick,
|
||||
onDeleteStock,
|
||||
@@ -266,7 +292,12 @@ export const HomeStockWorkspace: React.FC<HomeStockWorkspaceProps> = ({
|
||||
}) => {
|
||||
const { t } = useUiLanguage();
|
||||
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
|
||||
.filter((row) => !row.analyzedToday && !row.isTodayStatusLoading && !row.isTodayStatusUnknown)
|
||||
.length;
|
||||
@@ -286,8 +317,10 @@ export const HomeStockWorkspace: React.FC<HomeStockWorkspaceProps> = ({
|
||||
}, [batchStatus]);
|
||||
|
||||
const visibleWorkspaceNotice = useMemo(() => {
|
||||
if (!workspaceNoticeCode) return null;
|
||||
const row = watchlistRows.find((item) => areStockCodesEquivalent(item.code, workspaceNoticeCode));
|
||||
if (!workspaceNotice) return null;
|
||||
const row = watchlistRows.find(
|
||||
(item) => areAssetAwareCodesEquivalent(item.code, item.assetType, workspaceNotice.code, workspaceNotice.assetType),
|
||||
);
|
||||
if (!row) return null;
|
||||
if (row.isTodayStatusLoading) {
|
||||
return { message: t('watchlist.latestDetailLoading') };
|
||||
@@ -297,28 +330,28 @@ export const HomeStockWorkspace: React.FC<HomeStockWorkspaceProps> = ({
|
||||
}
|
||||
if (row.latestItem) return null;
|
||||
return { message: t('watchlist.noLatestDetail') };
|
||||
}, [t, watchlistRows, workspaceNoticeCode]);
|
||||
}, [t, watchlistRows, workspaceNotice]);
|
||||
|
||||
const handleAddSubmit = (event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
const code = draftCode.trim();
|
||||
if (!code) return;
|
||||
setWorkspaceNoticeCode(null);
|
||||
setWorkspaceNotice(null);
|
||||
void onAddToWatchlist(code).then(() => setDraftCode(''));
|
||||
};
|
||||
|
||||
const handleWatchlistRowOpen = (row: HomeWatchlistRow) => {
|
||||
if (row.isTodayStatusLoading || row.isTodayStatusUnknown) {
|
||||
setWorkspaceNoticeCode(row.code);
|
||||
setWorkspaceNotice({ code: row.code, assetType: row.assetType ?? 'stock' });
|
||||
return;
|
||||
}
|
||||
const recordId = row.latestItem?.id;
|
||||
if (typeof recordId === 'number') {
|
||||
setWorkspaceNoticeCode(null);
|
||||
setWorkspaceNotice(null);
|
||||
onHistoryItemClick(recordId);
|
||||
return;
|
||||
}
|
||||
setWorkspaceNoticeCode(row.code);
|
||||
setWorkspaceNotice({ code: row.code, assetType: row.assetType ?? 'stock' });
|
||||
};
|
||||
|
||||
const renderTabs = (
|
||||
@@ -334,7 +367,7 @@ export const HomeStockWorkspace: React.FC<HomeStockWorkspaceProps> = ({
|
||||
selected ? 'bg-primary/15 text-primary shadow-inner' : 'text-secondary-text hover:bg-hover hover:text-foreground'
|
||||
}`}
|
||||
onClick={() => {
|
||||
setWorkspaceNoticeCode(null);
|
||||
setWorkspaceNotice(null);
|
||||
onTabChange(tab.key);
|
||||
}}
|
||||
>
|
||||
@@ -391,7 +424,7 @@ export const HomeStockWorkspace: React.FC<HomeStockWorkspaceProps> = ({
|
||||
className="h-7 w-7 px-0"
|
||||
disabled={watchlistLoading}
|
||||
onClick={() => {
|
||||
setWorkspaceNoticeCode(null);
|
||||
setWorkspaceNotice(null);
|
||||
void onRefreshWatchlist();
|
||||
}}
|
||||
aria-label={t('watchlist.refreshAria')}
|
||||
@@ -415,7 +448,7 @@ export const HomeStockWorkspace: React.FC<HomeStockWorkspaceProps> = ({
|
||||
size="sm"
|
||||
variant="home-action-ai"
|
||||
className="h-8 flex-1 whitespace-nowrap px-2 text-xs sm:flex-none"
|
||||
disabled={watchlistRows.length === 0 || isBatchAnalyzing}
|
||||
disabled={watchlistLoading || watchlistRows.length === 0 || isBatchAnalyzing}
|
||||
isLoading={isBatchAnalyzing}
|
||||
loadingText={t('watchlist.submitting')}
|
||||
onClick={() => void onAnalyzeWatchlist('all')}
|
||||
@@ -428,7 +461,7 @@ export const HomeStockWorkspace: React.FC<HomeStockWorkspaceProps> = ({
|
||||
size="sm"
|
||||
variant="home-action-report"
|
||||
className="h-8 flex-1 whitespace-nowrap px-2 text-xs sm:flex-none"
|
||||
disabled={pendingWatchlistCount === 0 || isTodayStatusUnavailable || isBatchAnalyzing}
|
||||
disabled={watchlistLoading || pendingWatchlistCount === 0 || isTodayStatusUnavailable || isBatchAnalyzing}
|
||||
onClick={() => void onAnalyzeWatchlist('pending')}
|
||||
>
|
||||
<CheckCircle2 className="h-4 w-4" aria-hidden="true" />
|
||||
@@ -520,7 +553,7 @@ export const HomeStockWorkspace: React.FC<HomeStockWorkspaceProps> = ({
|
||||
key={row.code}
|
||||
row={row}
|
||||
onRemove={async (code) => {
|
||||
setWorkspaceNoticeCode(null);
|
||||
setWorkspaceNotice(null);
|
||||
await onRemoveFromWatchlist(code);
|
||||
}}
|
||||
onOpenDetail={handleWatchlistRowOpen}
|
||||
@@ -530,8 +563,8 @@ export const HomeStockWorkspace: React.FC<HomeStockWorkspaceProps> = ({
|
||||
|| (
|
||||
Boolean(selectedStockCode)
|
||||
&& (
|
||||
areStockCodesEquivalent(selectedStockCode ?? '', row.code)
|
||||
|| areStockCodesEquivalent(selectedStockCode ?? '', row.latestItem?.stockCode ?? '')
|
||||
rowHasSelectedIdentity(row, selectedStockCode, selectedAssetType)
|
||||
|| areAssetAwareCodesEquivalent(selectedStockCode ?? '', selectedAssetType, row.latestItem?.stockCode ?? '', row.latestItem?.assetType)
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,11 +9,13 @@ function renderWorkspace({
|
||||
watchlistRows,
|
||||
selectedRecordId,
|
||||
selectedStockCode,
|
||||
selectedAssetType,
|
||||
activeTab = 'watchlist',
|
||||
}: {
|
||||
watchlistRows: HomeWatchlistRow[];
|
||||
selectedRecordId?: number;
|
||||
selectedStockCode?: string;
|
||||
selectedAssetType?: 'stock' | 'index' | null;
|
||||
activeTab?: HomeWorkspaceTab;
|
||||
}) {
|
||||
const onHistoryItemClick = vi.fn();
|
||||
@@ -42,6 +44,7 @@ function renderWorkspace({
|
||||
historyItems={[]}
|
||||
isLoadingHistory={false}
|
||||
selectedStockCode={selectedStockCode}
|
||||
selectedAssetType={selectedAssetType ?? null}
|
||||
selectedRecordId={selectedRecordId}
|
||||
onHistoryItemClick={onHistoryItemClick}
|
||||
/>
|
||||
@@ -324,4 +327,159 @@ describe('HomeStockWorkspace', () => {
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -95,8 +95,9 @@ describe('useTaskStream', () => {
|
||||
data: JSON.stringify({
|
||||
task_id: 'task-1',
|
||||
trace_id: 'trace-task-1',
|
||||
stock_code: '600519',
|
||||
stock_name: '贵州茅台',
|
||||
stock_code: 'sh000016',
|
||||
stock_name: '上证50',
|
||||
asset_type: 'index',
|
||||
status: 'processing',
|
||||
progress: 72,
|
||||
message: 'LLM 正在生成分析结果',
|
||||
@@ -129,8 +130,8 @@ describe('useTaskStream', () => {
|
||||
expect(onTaskProgress).toHaveBeenCalledWith({
|
||||
taskId: 'task-1',
|
||||
traceId: 'trace-task-1',
|
||||
stockCode: '600519',
|
||||
stockName: '贵州茅台',
|
||||
stockCode: 'sh000016',
|
||||
stockName: '上证50',
|
||||
status: 'processing',
|
||||
progress: 72,
|
||||
message: 'LLM 正在生成分析结果',
|
||||
@@ -144,6 +145,7 @@ describe('useTaskStream', () => {
|
||||
analysisPhase: 'intraday',
|
||||
skills: ['growth_quality'],
|
||||
region: 'jp,kr',
|
||||
assetType: 'index',
|
||||
});
|
||||
expect(onTaskFlowEvent).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ taskId: 'task-1' }),
|
||||
|
||||
@@ -114,6 +114,7 @@ const toTaskInfo = (data: Record<string, unknown>): TaskInfo => {
|
||||
selectionSource: data.selection_source as string | undefined,
|
||||
analysisPhase: data.analysis_phase as TaskInfo['analysisPhase'],
|
||||
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()) {
|
||||
|
||||
@@ -448,7 +448,9 @@ const zh = {
|
||||
'watchlist.batchFailed': '自选股批量分析提交失败',
|
||||
'watchlist.batchIncompleteResponse': '批量接口本组请求 {requested} 只,仅确认 {confirmed} 只,返回结果不完整',
|
||||
'watchlist.batchPartiallySubmitted': '已确认提交 {accepted} 个任务,{duplicates} 个正在运行;另有 {unconfirmed} 只未确认,已停止后续提交并刷新任务列表。原因:{error}',
|
||||
'watchlist.batchPartiallySubmittedWithRejected': '已确认提交 {accepted} 个任务,{duplicates} 个正在运行,{rejected} 个被拒绝;另有 {unconfirmed} 只未确认,已停止后续提交并刷新任务列表。原因:{error}',
|
||||
'watchlist.batchSubmitted': '已提交 {accepted} 个任务,{duplicates} 个正在运行',
|
||||
'watchlist.batchSubmittedWithRejected': '已提交 {accepted} 个任务,{duplicates} 个正在运行,{rejected} 个被拒绝。首个拒绝原因:{reason}',
|
||||
'watchlist.emptyDescription': '在报告详情或这里添加股票后,可一键运行整组自选股。',
|
||||
'watchlist.emptyTitle': '暂无自选股',
|
||||
'watchlist.listHint': '按自选顺序展示,今日状态实时标记',
|
||||
@@ -1405,7 +1407,9 @@ const en: Record<UiTextKey, string> = {
|
||||
'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.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.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.emptyTitle': 'No watchlist stocks',
|
||||
'watchlist.listHint': 'Shown in watchlist order with today status',
|
||||
|
||||
@@ -27,7 +27,12 @@ import {
|
||||
import { isNearBottom } from '../utils/chatScroll';
|
||||
import { getReportText } from '../utils/reportLanguage';
|
||||
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 type { StockIndexItem } from '../types/stockIndex';
|
||||
import { useUiLanguage } from '../contexts/UiLanguageContext';
|
||||
@@ -78,13 +83,33 @@ const resolveUniqueStockNameContext = (
|
||||
if (!terms.some((term) => normalizedMessage.includes(term.toLocaleLowerCase()))) {
|
||||
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 });
|
||||
}
|
||||
|
||||
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[] => {
|
||||
if (msg.skillNames?.length) return msg.skillNames;
|
||||
if (msg.skillName) return [msg.skillName];
|
||||
@@ -113,15 +138,28 @@ const getPipelineBudgetSkippedLabel = (step: ProgressStep): string => {
|
||||
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 = (
|
||||
message: string,
|
||||
stockCodes: string[],
|
||||
currentStockCode?: string | null,
|
||||
currentStockKey?: string | null,
|
||||
): boolean => {
|
||||
if (STRONG_COMPARE_STOCK_MESSAGE_RE.test(message)) {
|
||||
return true;
|
||||
}
|
||||
const current = currentStockCode ? normalizeStockCode(currentStockCode) : null;
|
||||
const current = currentStockKey ?? null;
|
||||
const newStockCodes = current
|
||||
? stockCodes.filter((code) => code !== current)
|
||||
: stockCodes;
|
||||
@@ -137,7 +175,7 @@ const isCompareStockMessage = (
|
||||
if (stockCodes.length >= 2) {
|
||||
return true;
|
||||
}
|
||||
if (!currentStockCode) {
|
||||
if (!currentStockKey) {
|
||||
return false;
|
||||
}
|
||||
const hasNewStock = stockCodes.some((code) => code !== current);
|
||||
@@ -147,26 +185,27 @@ const isCompareStockMessage = (
|
||||
const resolveActiveStockContextFromMessage = (
|
||||
message: string,
|
||||
currentContext: ActiveStockContext | null,
|
||||
index: StockIndexItem[],
|
||||
): ActiveStockResolution | null => {
|
||||
const stockCodes = extractStockCodesFromMessage(message);
|
||||
const stockCodes = extractStockCodesFromMessage(message, index);
|
||||
const stockCode = stockCodes[0] ?? null;
|
||||
if (!stockCode) {
|
||||
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 currentStockCode = currentContext?.stock_code
|
||||
? normalizeStockCode(currentContext.stock_code)
|
||||
: null;
|
||||
const newStockCodes = currentStockCode
|
||||
? stockCodes.filter((code) => code !== currentStockCode)
|
||||
const newStockCodes = currentStockKey
|
||||
? stockCodes.filter((code) => code !== currentStockKey)
|
||||
: stockCodes;
|
||||
// Explicit switches can mention the old stock; use the single new code when present.
|
||||
const targetStockCode = isSwitch && newStockCodes.length === 1
|
||||
? newStockCodes[0]
|
||||
: stockCode;
|
||||
const isDifferentStock = currentStockCode !== targetStockCode;
|
||||
const isDifferentStock = currentStockKey !== resolveComparisonStockKey(targetStockCode, index);
|
||||
|
||||
// Compare messages and implicit follow-ups must not rewrite the active stock context.
|
||||
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;
|
||||
for (const message of messages) {
|
||||
if (message.role !== 'user') {
|
||||
continue;
|
||||
}
|
||||
const resolution = resolveActiveStockContextFromMessage(message.content, restoredContext);
|
||||
const resolution = resolveActiveStockContextFromMessage(message.content, restoredContext, index);
|
||||
if (resolution) {
|
||||
restoredContext = resolution.context;
|
||||
}
|
||||
@@ -233,9 +275,9 @@ const ChatPage: React.FC = () => {
|
||||
const [agentStatus, setAgentStatus] = useState<AgentStatusResponse | null>(null);
|
||||
const [agentStatusError, setAgentStatusError] = useState<string | null>(null);
|
||||
const [agentStatusChecking, setAgentStatusChecking] = useState(true);
|
||||
const { index: stockIndex } = useStockIndex(
|
||||
agentStatus?.backend === 'codex_app_server',
|
||||
);
|
||||
// All Chat backends need the registry before resolving stock identity.
|
||||
const { index: stockIndex, loading: stockIndexLoading } = useStockIndex();
|
||||
|
||||
const watchlistMessageTimerRef = useRef<number | null>(null);
|
||||
const copyResetTimerRef = useRef<Partial<Record<string, number>>>({});
|
||||
const messagesViewportRef = useRef<HTMLDivElement>(null);
|
||||
@@ -365,13 +407,16 @@ const ChatPage: React.FC = () => {
|
||||
if (activeStockContext || messages.length === 0) {
|
||||
return;
|
||||
}
|
||||
const restoredContext = restoreActiveStockContextFromMessages(messages);
|
||||
if (stockIndexLoading) {
|
||||
return;
|
||||
}
|
||||
const restoredContext = restoreActiveStockContextFromMessages(messages, stockIndex);
|
||||
if (!restoredContext) {
|
||||
return;
|
||||
}
|
||||
setActiveStockContext(restoredContext);
|
||||
setActiveStockCode(restoredContext.stock_code);
|
||||
}, [activeStockContext, messages, sessionId]);
|
||||
}, [activeStockContext, messages, sessionId, stockIndex, stockIndexLoading]);
|
||||
|
||||
const syncScrollState = useCallback(() => {
|
||||
const viewport = messagesViewportRef.current;
|
||||
@@ -632,7 +677,33 @@ const ChatPage: React.FC = () => {
|
||||
|
||||
// Handle follow-up from report page: ?stock=600519&name=贵州茅台&recordId=xxx
|
||||
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 recordId = parseFollowUpRecordId(searchParams.get('recordId'));
|
||||
|
||||
@@ -670,7 +741,7 @@ const ChatPage: React.FC = () => {
|
||||
}
|
||||
});
|
||||
setSearchParams({}, { replace: true });
|
||||
}, [searchParams, setSearchParams]);
|
||||
}, [searchParams, setSearchParams, stockIndex, stockIndexLoading, agentStatusChecking]);
|
||||
|
||||
const handleSend = useCallback(
|
||||
async (
|
||||
@@ -679,7 +750,7 @@ const ChatPage: React.FC = () => {
|
||||
overrideStockContext?: ActiveStockContext,
|
||||
) => {
|
||||
const msgText = (overrideMessage ?? input).trim();
|
||||
if (!msgText || loading || !agentAvailable || !agentStatus) return;
|
||||
if (!msgText || loading || stockIndexLoading || !agentAvailable || !agentStatus) return;
|
||||
if (overrideMessage !== undefined) {
|
||||
setInput(msgText);
|
||||
}
|
||||
@@ -696,7 +767,7 @@ const ChatPage: React.FC = () => {
|
||||
let useActiveContextForThisSend = Boolean(codexStockContext);
|
||||
const stockResolution = codexStockContext
|
||||
? null
|
||||
: resolveActiveStockContextFromMessage(msgText, activeStockContext);
|
||||
: resolveActiveStockContextFromMessage(msgText, activeStockContext, stockIndex);
|
||||
if (stockResolution) {
|
||||
nextActiveStockContext = stockResolution.context;
|
||||
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>) => {
|
||||
@@ -1281,7 +1352,7 @@ const ChatPage: React.FC = () => {
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => handleQuickQuestion(q)}
|
||||
disabled={!agentAvailable}
|
||||
disabled={!agentAvailable || stockIndexLoading}
|
||||
className="quick-question-btn disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{q.label}
|
||||
@@ -1619,7 +1690,7 @@ const ChatPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeStockCode && (
|
||||
{activeStockCode && !isRegisteredIndexCanonicalCode(activeStockCode, stockIndex) && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-muted-text font-mono">{activeStockCode}</span>
|
||||
<Button
|
||||
@@ -1666,8 +1737,8 @@ const ChatPage: React.FC = () => {
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => handleSend()}
|
||||
disabled={!input.trim() || loading || !agentAvailable}
|
||||
isLoading={loading}
|
||||
disabled={!input.trim() || loading || stockIndexLoading || !agentAvailable}
|
||||
isLoading={loading || stockIndexLoading}
|
||||
className="btn-primary flex-shrink-0"
|
||||
>
|
||||
发送
|
||||
|
||||
@@ -38,7 +38,8 @@ import type {
|
||||
} from '../types/analysis';
|
||||
import type { RunFlowSnapshotSource } from '../types/runFlow';
|
||||
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 = {
|
||||
variant: 'success' | 'warning' | 'danger';
|
||||
@@ -142,9 +143,10 @@ function shiftDateKey(dateKey: string, days: number): string {
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function getStockCodeKey(code?: string | null): string {
|
||||
const trimmed = (code ?? '').trim();
|
||||
return trimmed ? normalizeStockCode(trimmed).toUpperCase() : '';
|
||||
function getStockCodeKey(code?: string | null, assetType?: AssetAwareAssetType | null): string {
|
||||
// Direct delegation: `toAssetAwareCodeKey` already treats unknown/stock codes
|
||||
// with the existing stock normalization, so no extra branching is needed.
|
||||
return toAssetAwareCodeKey(code, assetType);
|
||||
}
|
||||
|
||||
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) {
|
||||
return {
|
||||
accepted: result.accepted.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 {
|
||||
@@ -204,6 +207,7 @@ function toStockBarItemFromHistoryItem(item: HistoryItem): StockBarItem {
|
||||
lastAnalysisTime: item.createdAt,
|
||||
modelUsed: item.modelUsed,
|
||||
marketPhaseSummary: item.marketPhaseSummary ?? null,
|
||||
assetType: item.assetType,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -248,6 +252,40 @@ const HomePage: React.FC = () => {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
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 [isSubmittingMarketReview, setIsSubmittingMarketReview] = useState(false);
|
||||
const [marketReviewNotice, setMarketReviewNotice] = useState<MarketReviewNotice>(null);
|
||||
@@ -603,7 +641,7 @@ const HomePage: React.FC = () => {
|
||||
if (task.reportType === 'market_review') {
|
||||
return;
|
||||
}
|
||||
const key = getStockCodeKey(task.stockCode);
|
||||
const key = getStockCodeKey(task.stockCode, task.assetType);
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
@@ -618,7 +656,7 @@ const HomePage: React.FC = () => {
|
||||
if (task.reportType === 'market_review') {
|
||||
return;
|
||||
}
|
||||
const key = getStockCodeKey(task.stockCode);
|
||||
const key = getStockCodeKey(task.stockCode, task.assetType);
|
||||
if (key) {
|
||||
setCompletedTaskRefreshPendingCounts((current) => {
|
||||
const pendingCount = current.get(key) ?? 0;
|
||||
@@ -674,14 +712,14 @@ const HomePage: React.FC = () => {
|
||||
const watchlistCodesByNormalized = useMemo(() => {
|
||||
const codesByNormalized = new Map<string, string>();
|
||||
for (const code of watchlistState.watchlistCodes) {
|
||||
const key = getStockCodeKey(code);
|
||||
const key = getWatchlistCodeKey(code);
|
||||
if (!key || key === 'MARKET' || codesByNormalized.has(key)) {
|
||||
continue;
|
||||
}
|
||||
codesByNormalized.set(key, code);
|
||||
}
|
||||
return Array.from(codesByNormalized.entries());
|
||||
}, [watchlistState.watchlistCodes]);
|
||||
}, [getWatchlistCodeKey, watchlistState.watchlistCodes]);
|
||||
|
||||
const stockBarItemByCode = useMemo(() => {
|
||||
const itemsByCode = new Map<string, StockBarItem>();
|
||||
@@ -689,7 +727,7 @@ const HomePage: React.FC = () => {
|
||||
if (item.stockCode === 'MARKET') {
|
||||
continue;
|
||||
}
|
||||
const key = getStockCodeKey(item.stockCode);
|
||||
const key = getStockCodeKey(item.stockCode, item.assetType);
|
||||
if (key) {
|
||||
itemsByCode.set(key, item);
|
||||
}
|
||||
@@ -697,7 +735,12 @@ const HomePage: React.FC = () => {
|
||||
return itemsByCode;
|
||||
}, [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(
|
||||
() => (
|
||||
@@ -748,7 +791,7 @@ const HomePage: React.FC = () => {
|
||||
const next = new Map<string, StockBarItem>();
|
||||
const failedKeys = new Set<string>();
|
||||
for (const entry of results) {
|
||||
const key = getStockCodeKey(entry.code);
|
||||
const key = getWatchlistCodeKey(entry.code);
|
||||
if (!key) {
|
||||
continue;
|
||||
}
|
||||
@@ -782,7 +825,7 @@ const HomePage: React.FC = () => {
|
||||
isCanceled = true;
|
||||
abortController.abort();
|
||||
};
|
||||
}, [canLookupWatchlistHistory, watchlistHistoryRetryVersion, watchlistMissingHistoryEntries, watchlistMissingHistorySignature]);
|
||||
}, [canLookupWatchlistHistory, getWatchlistCodeKey, watchlistHistoryRetryVersion, watchlistMissingHistoryEntries, watchlistMissingHistorySignature]);
|
||||
|
||||
const clearMarketReviewState = useCallback(() => {
|
||||
stopMarketReviewPolling();
|
||||
@@ -1101,7 +1144,7 @@ const HomePage: React.FC = () => {
|
||||
if (task.reportType === 'market_review') {
|
||||
continue;
|
||||
}
|
||||
const key = getStockCodeKey(task.stockCode);
|
||||
const key = getStockCodeKey(task.stockCode, task.assetType);
|
||||
if (key) {
|
||||
tasksByCode.set(key, task);
|
||||
}
|
||||
@@ -1111,7 +1154,8 @@ const HomePage: React.FC = () => {
|
||||
|
||||
const watchlistRows = useMemo<HomeWatchlistRow[]>(() => (
|
||||
watchlistState.watchlistCodes.map((code) => {
|
||||
const key = getStockCodeKey(code);
|
||||
const identity = getWatchlistCodeIdentity(code);
|
||||
const key = identity.key;
|
||||
const latestItemCandidate = key
|
||||
? stockBarItemByCode.get(key) ?? watchlistHistoryItemsByCode.get(key)
|
||||
: undefined;
|
||||
@@ -1145,6 +1189,11 @@ const HomePage: React.FC = () => {
|
||||
: latestItemCandidate;
|
||||
return {
|
||||
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,
|
||||
analyzedToday: !isTodayStatusLoading && !isTodayStatusUnknown && getShanghaiDateKey(latestItem?.lastAnalysisTime) === todayDateKey,
|
||||
isTodayStatusLoading,
|
||||
@@ -1158,6 +1207,7 @@ const HomePage: React.FC = () => {
|
||||
completedTaskRefreshPendingCounts,
|
||||
isLoadingStockBar,
|
||||
stockBarRefreshFailed,
|
||||
getWatchlistCodeIdentity,
|
||||
stockBarItemByCode,
|
||||
todayDateKey,
|
||||
watchlistHistoryItemsByCode,
|
||||
@@ -1213,6 +1263,12 @@ const HomePage: React.FC = () => {
|
||||
}, [todayDateKey, todayHistoryItems]);
|
||||
|
||||
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) {
|
||||
setBatchAnalyzeStatus({
|
||||
variant: 'warning',
|
||||
@@ -1224,7 +1280,7 @@ const HomePage: React.FC = () => {
|
||||
const sourceCodes = mode === 'pending' ? pendingWatchlistCodes : watchlistState.watchlistCodes;
|
||||
const seen = new Set<string>();
|
||||
const targetCodes = sourceCodes.filter((code) => {
|
||||
const key = getStockCodeKey(code);
|
||||
const key = getWatchlistCodeKey(code);
|
||||
if (!key || seen.has(key)) {
|
||||
return false;
|
||||
}
|
||||
@@ -1244,6 +1300,8 @@ const HomePage: React.FC = () => {
|
||||
setBatchAnalyzeStatus(null);
|
||||
let acceptedCount = 0;
|
||||
let duplicateCount = 0;
|
||||
let rejectedCount = 0;
|
||||
let rejectedReasons: string[] = [];
|
||||
let confirmedCodeCount = 0;
|
||||
let submissionError: ParsedApiError | null = null;
|
||||
try {
|
||||
@@ -1258,7 +1316,17 @@ const HomePage: React.FC = () => {
|
||||
const counts = countBatchAccepted(result);
|
||||
acceptedCount += counts.accepted;
|
||||
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);
|
||||
if (confirmedInChunk !== chunk.length) {
|
||||
submissionError = getParsedApiError(new Error(t('watchlist.batchIncompleteResponse', {
|
||||
@@ -1284,12 +1352,16 @@ const HomePage: React.FC = () => {
|
||||
setSidebarWorkspaceTab('watchlist');
|
||||
|
||||
if (submissionError) {
|
||||
if (acceptedCount > 0 || duplicateCount > 0) {
|
||||
if (acceptedCount > 0 || duplicateCount > 0 || rejectedCount > 0) {
|
||||
const partialKey = rejectedCount > 0
|
||||
? 'watchlist.batchPartiallySubmittedWithRejected'
|
||||
: 'watchlist.batchPartiallySubmitted';
|
||||
setBatchAnalyzeStatus({
|
||||
variant: 'warning',
|
||||
message: t('watchlist.batchPartiallySubmitted', {
|
||||
message: t(partialKey, {
|
||||
accepted: acceptedCount,
|
||||
duplicates: duplicateCount,
|
||||
rejected: rejectedCount,
|
||||
unconfirmed: targetCodes.length - confirmedCodeCount,
|
||||
error: submissionError.message || t('watchlist.batchFailed'),
|
||||
}),
|
||||
@@ -1303,6 +1375,19 @@ const HomePage: React.FC = () => {
|
||||
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({
|
||||
variant: acceptedCount > 0 ? 'success' : 'warning',
|
||||
message: t('watchlist.batchSubmitted', {
|
||||
@@ -1320,6 +1405,8 @@ const HomePage: React.FC = () => {
|
||||
setIsBatchAnalyzingWatchlist(false);
|
||||
}
|
||||
}, [
|
||||
getWatchlistCodeKey,
|
||||
isStockIndexReady,
|
||||
notify,
|
||||
pendingWatchlistCodes,
|
||||
refreshActiveTasks,
|
||||
@@ -1369,7 +1456,7 @@ const HomePage: React.FC = () => {
|
||||
activeTab={sidebarWorkspaceTab}
|
||||
onTabChange={setSidebarWorkspaceTab}
|
||||
watchlistRows={watchlistRows}
|
||||
watchlistLoading={watchlistState.isLoading}
|
||||
watchlistLoading={watchlistState.isLoading || !isStockIndexReady}
|
||||
watchlistActioning={watchlistState.isActioning}
|
||||
watchlistMessage={watchlistState.actionMessage}
|
||||
onAddToWatchlist={watchlistState.addToWatchlist}
|
||||
@@ -1385,6 +1472,7 @@ const HomePage: React.FC = () => {
|
||||
historyItems={mergedStockBarItems}
|
||||
isLoadingHistory={isLoadingStockBar}
|
||||
selectedStockCode={selectedReport?.meta.stockCode}
|
||||
selectedAssetType={selectedReport?.meta.assetType ?? null}
|
||||
selectedRecordId={selectedReport?.meta.id}
|
||||
onHistoryItemClick={handleHistoryItemClick}
|
||||
onDeleteStock={handleDeleteStock}
|
||||
@@ -1405,11 +1493,13 @@ const HomePage: React.FC = () => {
|
||||
isDeletingStock,
|
||||
isLoadingStockBar,
|
||||
isLoadingTodayAnalysisItems,
|
||||
isStockIndexReady,
|
||||
isTaskPanelCollapsed,
|
||||
todayAnalysisLoadFailed,
|
||||
mergedStockBarItems,
|
||||
openTaskRunFlow,
|
||||
selectedReport?.meta.id,
|
||||
selectedReport?.meta.assetType,
|
||||
selectedReport?.meta.stockCode,
|
||||
sidebarWorkspaceTab,
|
||||
todayAnalysisItems,
|
||||
|
||||
@@ -6,6 +6,7 @@ import { createParsedApiError } from '../../api/error';
|
||||
import { UiLanguageProvider } from '../../contexts/UiLanguageContext';
|
||||
import { historyApi } from '../../api/history';
|
||||
import type { Message, ProgressStep } from '../../stores/agentChatStore';
|
||||
import type { StockIndexItem } from '../../types/stockIndex';
|
||||
import { UI_LANGUAGE_STORAGE_KEY } from '../../utils/uiLanguage';
|
||||
import ChatPage from '../ChatPage';
|
||||
import { extractStockCodeFromMessage, extractStockCodesFromMessage } from '../../utils/chatStockCode';
|
||||
@@ -33,25 +34,43 @@ const {
|
||||
mockDownloadSession,
|
||||
mockFormatSessionAsMarkdown,
|
||||
mockStockIndex,
|
||||
} = vi.hoisted(() => ({
|
||||
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: [
|
||||
mockStockIndexState,
|
||||
} = vi.hoisted(() => {
|
||||
const mockStockIndex = [
|
||||
{ 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: '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: '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 mockLoadInitialSession = vi.fn();
|
||||
@@ -121,11 +140,11 @@ vi.mock('../../api/history', () => ({
|
||||
|
||||
vi.mock('../../hooks/useStockIndex', () => ({
|
||||
useStockIndex: () => ({
|
||||
index: mockStockIndex,
|
||||
loading: false,
|
||||
error: null,
|
||||
fallback: false,
|
||||
loaded: true,
|
||||
index: mockStockIndexState.index,
|
||||
loading: mockStockIndexState.loading,
|
||||
error: mockStockIndexState.error,
|
||||
fallback: mockStockIndexState.fallback,
|
||||
loaded: mockStockIndexState.loaded,
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -257,6 +276,11 @@ beforeEach(() => {
|
||||
});
|
||||
mockDownloadSession.mockImplementation(() => {});
|
||||
mockFormatSessionAsMarkdown.mockReturnValue('# exported session');
|
||||
mockStockIndexState.index = mockStockIndex;
|
||||
mockStockIndexState.loading = false;
|
||||
mockStockIndexState.error = null;
|
||||
mockStockIndexState.fallback = false;
|
||||
mockStockIndexState.loaded = true;
|
||||
});
|
||||
|
||||
describe('ChatPage', () => {
|
||||
@@ -445,7 +469,11 @@ describe('ChatPage', () => {
|
||||
expect(input).toBeDisabled();
|
||||
fireEvent.click(screen.getByRole('button', { name: '前往 Agent 设置' }));
|
||||
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 () => {
|
||||
@@ -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 () => {
|
||||
window.localStorage.setItem(UI_LANGUAGE_STORAGE_KEY, 'en');
|
||||
mockGetStatus.mockResolvedValueOnce({
|
||||
@@ -2044,7 +2424,9 @@ describe('ChatPage', () => {
|
||||
expect(await screen.findByDisplayValue('请深入分析 贵州茅台(600519)')).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();
|
||||
|
||||
@@ -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 () => {
|
||||
mockStoreState.messages = [
|
||||
{ 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', () => {
|
||||
it('shows "从自选删除" when canonical code is in watchlist and user inputs variant', async () => {
|
||||
mockGetWatchlist.mockResolvedValue(['600519', 'HK01810']);
|
||||
|
||||
@@ -70,6 +70,26 @@ vi.mock('../../hooks/useTaskStream', () => ({
|
||||
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 = {
|
||||
id: 1,
|
||||
queryId: 'q-1',
|
||||
@@ -210,6 +230,10 @@ describe('HomePage', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
navigateMock.mockReset();
|
||||
stockIndexItems = [];
|
||||
stockIndexLoading = false;
|
||||
stockIndexFallback = false;
|
||||
stockIndexLoaded = true;
|
||||
window.localStorage.clear();
|
||||
window.sessionStorage.clear();
|
||||
window.localStorage.setItem(UI_LANGUAGE_STORAGE_KEY, 'zh');
|
||||
@@ -533,6 +557,196 @@ describe('HomePage', () => {
|
||||
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 () => {
|
||||
vi.mocked(systemConfigApi.getWatchlist).mockResolvedValue(['600519']);
|
||||
vi.mocked(historyApi.getStockBarList).mockResolvedValue({
|
||||
@@ -2022,6 +2236,70 @@ describe('HomePage', () => {
|
||||
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 () => {
|
||||
configureWatchlistBatch(51);
|
||||
vi.mocked(analysisApi.analyzeAsync)
|
||||
@@ -2124,6 +2402,53 @@ describe('HomePage', () => {
|
||||
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 () => {
|
||||
let isMarketReviewDeleted = false;
|
||||
vi.mocked(historyApi.getStockBarList).mockResolvedValue({
|
||||
|
||||
@@ -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 () => {
|
||||
useStockPoolStore.setState({
|
||||
historyItems: [historyItem],
|
||||
@@ -736,6 +792,92 @@ describe('stockPoolStore', () => {
|
||||
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 () => {
|
||||
const otherReport = {
|
||||
...historyReport,
|
||||
|
||||
@@ -5,7 +5,7 @@ import { getParsedApiError } from '../api/error';
|
||||
import { historyApi } from '../api/history';
|
||||
import type { AnalysisReport, HistoryItem, HistoryListResponse, ReportLanguage, StockBarItem, StockHistoryFilters, StockHistoryRange, TaskInfo } from '../types/analysis';
|
||||
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';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
@@ -20,6 +20,7 @@ type FetchHistoryOptions = {
|
||||
reset?: boolean;
|
||||
silent?: boolean;
|
||||
selectLatestForStockCode?: string;
|
||||
selectLatestForStockAssetType?: AssetAwareAssetType | null;
|
||||
};
|
||||
|
||||
type SubmitAnalysisOptions = {
|
||||
@@ -235,6 +236,7 @@ function reportToHistoryItem(report: AnalysisReport): HistoryItem | null {
|
||||
currentPrice: report.meta.currentPrice,
|
||||
changePct: report.meta.changePct,
|
||||
modelUsed: report.meta.modelUsed,
|
||||
assetType: report.meta.assetType,
|
||||
createdAt: report.meta.createdAt,
|
||||
};
|
||||
}
|
||||
@@ -252,17 +254,22 @@ function normalizeSelectedReport(report: AnalysisReport): AnalysisReport {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeStockCodeKey(stockCode: string | undefined): string {
|
||||
const trimmed = (stockCode ?? '').trim();
|
||||
return trimmed ? normalizeStockCode(trimmed).toUpperCase() : '';
|
||||
function normalizeStockCodeKey(
|
||||
stockCode: string | undefined,
|
||||
assetType?: AssetAwareAssetType | null,
|
||||
): string {
|
||||
return toAssetAwareCodeKey(stockCode, assetType);
|
||||
}
|
||||
|
||||
function queueCompletedTaskSelection(
|
||||
stockCode: string | undefined,
|
||||
selectedReport: AnalysisReport | null,
|
||||
assetType?: AssetAwareAssetType | null,
|
||||
): void {
|
||||
const key = normalizeStockCodeKey(stockCode);
|
||||
const key = normalizeStockCodeKey(stockCode, assetType);
|
||||
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, {
|
||||
manualSelectionSeq: manualSelectionRequestSeq,
|
||||
selectedReportId: selectedReport?.meta.id,
|
||||
@@ -285,7 +292,10 @@ function consumeCompletedTaskSelection(items: HistoryItem[], selectedReport: Ana
|
||||
}
|
||||
|
||||
if (selectedReport) {
|
||||
const selectedStockCode = normalizeStockCodeKey(selectedReport.meta.stockCode);
|
||||
const selectedStockCode = normalizeStockCodeKey(
|
||||
selectedReport.meta.stockCode,
|
||||
selectedReport.meta.assetType,
|
||||
);
|
||||
const pendingSelectionIntent = selectedStockCode
|
||||
? pendingCompletedTaskSelectionKeys.get(selectedStockCode)
|
||||
: undefined;
|
||||
@@ -311,7 +321,7 @@ function consumeCompletedTaskSelection(items: HistoryItem[], selectedReport: Ana
|
||||
const latestItem = items.find(
|
||||
(item) =>
|
||||
item.reportType !== 'market_review' &&
|
||||
normalizeStockCodeKey(item.stockCode) === selectedStockCode,
|
||||
normalizeStockCodeKey(item.stockCode, item.assetType) === selectedStockCode,
|
||||
);
|
||||
if (latestItem) {
|
||||
pendingCompletedTaskSelectionKeys.delete(selectedStockCode);
|
||||
@@ -323,7 +333,7 @@ function consumeCompletedTaskSelection(items: HistoryItem[], selectedReport: Ana
|
||||
if (item.reportType === 'market_review') {
|
||||
return false;
|
||||
}
|
||||
const stockCode = normalizeStockCodeKey(item.stockCode);
|
||||
const stockCode = normalizeStockCodeKey(item.stockCode, item.assetType);
|
||||
const pendingSelectionIntent = pendingCompletedTaskSelectionKeys.get(stockCode);
|
||||
return stockCode.length > 0 && pendingSelectionIntent?.manualSelectionSeq === manualSelectionRequestSeq;
|
||||
});
|
||||
@@ -453,11 +463,16 @@ async function fetchHistory(
|
||||
reset = true,
|
||||
silent = false,
|
||||
selectLatestForStockCode,
|
||||
selectLatestForStockAssetType,
|
||||
} = options;
|
||||
const currentState = get();
|
||||
const page = reset ? 1 : currentState.currentPage + 1;
|
||||
if (reset) {
|
||||
queueCompletedTaskSelection(selectLatestForStockCode, currentState.selectedReport);
|
||||
queueCompletedTaskSelection(
|
||||
selectLatestForStockCode,
|
||||
currentState.selectedReport,
|
||||
selectLatestForStockAssetType,
|
||||
);
|
||||
}
|
||||
const requestId = ++historyRequestSeq;
|
||||
|
||||
@@ -668,6 +683,7 @@ export const useStockPoolStore = create<StockPoolState>((set, get) => ({
|
||||
reset: true,
|
||||
silent: true,
|
||||
selectLatestForStockCode: task.reportType === 'market_review' ? undefined : task.stockCode,
|
||||
selectLatestForStockAssetType: task.reportType === 'market_review' ? null : (task.assetType ?? null),
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -84,6 +84,7 @@ export interface ReportMeta {
|
||||
changePct?: number;
|
||||
modelUsed?: string; // 历史元数据快照,仅用于展示,不用于运行时模型选择
|
||||
marketPhaseSummary?: MarketPhaseSummary | null;
|
||||
assetType?: 'stock' | 'index'; // 后端权威资产类型;index 用于隐藏 stock-only 自选操作
|
||||
}
|
||||
|
||||
/** Sentiment label */
|
||||
@@ -439,6 +440,7 @@ export interface BatchTaskAcceptedItem {
|
||||
status: 'pending' | 'processing';
|
||||
message?: string;
|
||||
analysisPhase?: AnalysisPhase;
|
||||
assetType?: 'stock' | 'index';
|
||||
}
|
||||
|
||||
export interface BatchDuplicateTaskItem {
|
||||
@@ -447,9 +449,15 @@ export interface BatchDuplicateTaskItem {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface BatchRejectedTaskItem {
|
||||
stockCode: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface BatchTaskAcceptedResponse {
|
||||
accepted: BatchTaskAcceptedItem[];
|
||||
duplicates: BatchDuplicateTaskItem[];
|
||||
rejected?: BatchRejectedTaskItem[];
|
||||
message: string;
|
||||
}
|
||||
|
||||
@@ -494,6 +502,7 @@ export interface TaskInfo {
|
||||
analysisPhase?: AnalysisPhase;
|
||||
skills?: string[];
|
||||
region?: string;
|
||||
assetType?: 'stock' | 'index';
|
||||
}
|
||||
|
||||
/** Task list response */
|
||||
@@ -534,6 +543,7 @@ export interface HistoryItem {
|
||||
turnoverRate?: number;
|
||||
modelUsed?: string; // 历史元数据快照,仅用于列表展示,不影响运行时调用与路由
|
||||
marketPhaseSummary?: MarketPhaseSummary | null;
|
||||
assetType?: 'stock' | 'index';
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
@@ -595,6 +605,7 @@ export interface StockBarItem {
|
||||
lastAnalysisTime?: string;
|
||||
modelUsed?: string;
|
||||
marketPhaseSummary?: MarketPhaseSummary | null;
|
||||
assetType?: 'stock' | 'index';
|
||||
}
|
||||
|
||||
export interface StockBarResponse {
|
||||
|
||||
@@ -485,4 +485,58 @@ describe('searchStocks', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
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', () => {
|
||||
it('keeps clean A-share codes as-is', () => {
|
||||
@@ -110,3 +118,90 @@ describe('normalizeStockCode', () => {
|
||||
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;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -132,6 +132,36 @@ describe('stockIndexLoader', () => {
|
||||
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 () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
@@ -188,7 +218,7 @@ describe('stockIndexLoader', () => {
|
||||
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 = [
|
||||
...mockIndexData,
|
||||
{
|
||||
@@ -214,13 +244,16 @@ describe('stockIndexLoader', () => {
|
||||
|
||||
expect(result.loaded).toBe(true);
|
||||
expect(result.fallback).toBe(false);
|
||||
// Index rows are hidden from the current consumers.
|
||||
expect(result.data.some(item => item.assetType === 'index')).toBe(false);
|
||||
// Registered index rows flow to autocomplete/search/group consumers.
|
||||
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.
|
||||
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 = [
|
||||
['600519.SH', '600519', '贵州茅台', 'guizhoumaotai', 'gzmt', ['茅台'], 'CN', 'stock', true, 100],
|
||||
['sh000300', 'sh000300', '沪深300', 'hushen300', 'hs300', ['000300.SH'], 'CN', 'index', true, 100],
|
||||
@@ -234,8 +267,10 @@ describe('stockIndexLoader', () => {
|
||||
const result = await loadStockIndex();
|
||||
|
||||
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[1].canonicalCode).toBe('sh000300');
|
||||
expect(result.data[1].nameZh).toBe('沪深300');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -27,4 +27,38 @@ describe('stock code validation', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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 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 {
|
||||
return extractStockCodesFromMessage(message)[0] ?? null;
|
||||
export function extractStockCodeFromMessage(
|
||||
message: string,
|
||||
index?: ReadonlyArray<StockIndexItem>,
|
||||
): string | null {
|
||||
return extractStockCodesFromMessage(message, index)[0] ?? null;
|
||||
}
|
||||
|
||||
export function extractStockCodesFromMessage(message: string): string[] {
|
||||
// More specific patterns first to avoid greedy \d{6} capturing inside .SH/.SZ codes
|
||||
export function extractStockCodesFromMessage(
|
||||
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 = [
|
||||
/\b(\d{6}\.(?:CSI|SH|SZ))\b/gi,
|
||||
/\b(CSI\d{6})\b/gi,
|
||||
/\b(30\d{4}\.SZ)\b/gi,
|
||||
/\b(68\d{4}\.SH)\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);
|
||||
}
|
||||
|
||||
const matches: Array<{ value: string; index: number; priority: number }> = [];
|
||||
const matches: Array<{ value: string; index: number; priority: number; end: number }> = [];
|
||||
patterns.forEach((pattern, priority) => {
|
||||
pattern.lastIndex = 0;
|
||||
for (const match of message.matchAll(pattern)) {
|
||||
@@ -84,6 +95,7 @@ export function extractStockCodesFromMessage(message: string): string[] {
|
||||
value,
|
||||
index: start,
|
||||
priority,
|
||||
end,
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -92,17 +104,43 @@ export function extractStockCodesFromMessage(message: string): string[] {
|
||||
|
||||
const stockCodes: 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) {
|
||||
if (acceptedSpans.some((span) => match.index >= span.start && match.end <= span.end)) {
|
||||
continue;
|
||||
}
|
||||
if (EXCHANGE_PREFIXES.has(match.value.toUpperCase())) {
|
||||
continue;
|
||||
}
|
||||
if (isDeniedTickerCandidate(match.value, message)) {
|
||||
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);
|
||||
if (!valid) {
|
||||
continue;
|
||||
}
|
||||
acceptedSpans.push({ start: match.index, end: match.end });
|
||||
const stockCode = normalizeStockCode(normalized);
|
||||
if (!seen.has(stockCode)) {
|
||||
seen.add(stockCode);
|
||||
|
||||
@@ -112,3 +112,106 @@ export function findMatchingStockCode(codes: string[], stockCode: string): strin
|
||||
export function includesStockCode(codes: string[], stockCode: string): boolean {
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
import type { StockIndexData, StockIndexItem, StockIndexTuple } from '../types/stockIndex';
|
||||
import { INDEX_FIELD } from './stockIndexFields';
|
||||
|
||||
const STOCK_INDEX_LOAD_TIMEOUT_MS = 10_000;
|
||||
|
||||
export interface IndexLoadResult {
|
||||
/** Index data */
|
||||
data: StockIndexItem[];
|
||||
@@ -24,9 +26,17 @@ export interface IndexLoadResult {
|
||||
* @returns Index load result
|
||||
*/
|
||||
export async function loadStockIndex(): Promise<IndexLoadResult> {
|
||||
const abortController = new AbortController();
|
||||
const timeoutId = globalThis.setTimeout(
|
||||
() => abortController.abort(),
|
||||
STOCK_INDEX_LOAD_TIMEOUT_MS,
|
||||
);
|
||||
try {
|
||||
// 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) {
|
||||
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[])
|
||||
: data as StockIndexItem[];
|
||||
|
||||
// The shared payload may now carry ``assetType=index`` rows, but the
|
||||
// current autocomplete/popular/group consumers must not see them. Filter
|
||||
// index rows out before constructing the successful result so stock/ETF
|
||||
// behaviour is unchanged.
|
||||
const visibleItems = items.filter(item => item.assetType !== 'index');
|
||||
// Registered index rows flow to autocomplete/search/group consumers. The
|
||||
// per-consumer gates (popular keeps stock-only) are enforced by each
|
||||
// consumer, not by a global filter here.
|
||||
const visibleItems = items;
|
||||
|
||||
return {
|
||||
data: visibleItems,
|
||||
@@ -58,6 +67,8 @@ export async function loadStockIndex(): Promise<IndexLoadResult> {
|
||||
error: error as Error,
|
||||
fallback: true, // Load failed, fallback to old mode
|
||||
};
|
||||
} finally {
|
||||
globalThis.clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ const SUPPORTED_QUERY_CHARACTERS = /^[A-Z0-9.\u3400-\u9FFF\s]+$/;
|
||||
|
||||
const STOCK_CODE_PATTERNS = [
|
||||
/^\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{5}$/, // HK code without prefix
|
||||
/^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{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
|
||||
/^CSI\d{6}$/, // Registered CSI index canonical (e.g. csi930955)
|
||||
/^\d{6}\.CSI$/, // Registered CSI index display form (e.g. 930955.CSI)
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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。
|
||||
- [新功能] 桌面端全局右上角增加更新入口,与设置页共用更新状态;普通浏览器 WebUI 不展示,且不会在挂载时重复触发后台检查。
|
||||
- [修复] 桌面端右上角更新入口与设置页共用检查中状态,避免一侧检查时另一侧仍可重复触发 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 与单任务 202,duplicate+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
|
||||
|
||||
|
||||
@@ -738,7 +738,24 @@ python main.py --stocks sh000016 --dry-run
|
||||
|
||||
指数实时行情使用独立固定链:腾讯 → 新浪 → 东财单股接口 → TickFlow;SH/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 真实持仓作为分析列表
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
> **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
|
||||
|
||||
|
||||
@@ -40,6 +40,62 @@ _INDICATOR_CONTEXT_PATTERN = re.compile(
|
||||
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)
|
||||
class StockScope:
|
||||
@@ -65,8 +121,8 @@ class StockScopeResolution:
|
||||
stock_scope: Optional[StockScope]
|
||||
|
||||
|
||||
def _normalize_stock_code(value: Any) -> str:
|
||||
"""Normalize a code with the runner's canonical stock-code rules."""
|
||||
def _normalize_stock_code(value: Any, registry: Optional[Any] = None) -> str:
|
||||
"""Normalize a code, preserving exact registered index canonicals."""
|
||||
if not isinstance(value, str):
|
||||
return ""
|
||||
text = value.strip()
|
||||
@@ -75,7 +131,7 @@ def _normalize_stock_code(value: Any) -> str:
|
||||
try:
|
||||
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:
|
||||
normalized = text.strip().upper()
|
||||
return normalized if isinstance(normalized, str) else str(normalized)
|
||||
@@ -95,20 +151,31 @@ def _is_denied_candidate(candidate: str, text: str = "") -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _append_candidate(candidates: List[str], candidate: str, text: str = "") -> None:
|
||||
normalized = _normalize_stock_code(candidate)
|
||||
def _append_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):
|
||||
return
|
||||
if normalized not in candidates:
|
||||
candidates.append(normalized)
|
||||
|
||||
|
||||
def extract_stock_codes(text: str) -> List[str]:
|
||||
"""Extract all explicit stock-code candidates from free text."""
|
||||
def extract_stock_codes(text: str, registry: Optional[Any] = None) -> List[str]:
|
||||
"""Extract candidates; no registry preserves the legacy stock-only path."""
|
||||
if not text:
|
||||
return []
|
||||
|
||||
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 (
|
||||
(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),
|
||||
):
|
||||
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)
|
||||
_append_candidate(candidates, raw, text)
|
||||
_append_candidate(candidates, raw, text, registry)
|
||||
|
||||
if (
|
||||
_SWITCH_PATTERN.search(text)
|
||||
@@ -129,12 +201,22 @@ def extract_stock_codes(text: str) -> List[str]:
|
||||
or _CHOICE_COMPARE_PATTERN.search(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
|
||||
|
||||
|
||||
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):
|
||||
return True
|
||||
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
|
||||
|
||||
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:
|
||||
return True
|
||||
return False
|
||||
@@ -182,11 +264,22 @@ def resolve_stock_scope(
|
||||
*,
|
||||
skills: Optional[Iterable[str]] = None,
|
||||
strict_initial_scope: bool = False,
|
||||
registry: Optional[Any] = None,
|
||||
) -> 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 {})
|
||||
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))
|
||||
original_context.pop("allowed_stock_codes", None)
|
||||
if invalid_context_code:
|
||||
@@ -196,7 +289,7 @@ def resolve_stock_scope(
|
||||
|
||||
if not current_code:
|
||||
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:
|
||||
return StockScopeResolution(
|
||||
effective_context=_with_skills(original_context, skills),
|
||||
@@ -222,14 +315,14 @@ def resolve_stock_scope(
|
||||
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]
|
||||
mode = "maintain"
|
||||
effective_context = dict(original_context)
|
||||
expected = 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"
|
||||
allowed.update(candidates)
|
||||
elif _SWITCH_PATTERN.search(message_text) and len(new_candidates) == 1:
|
||||
|
||||
@@ -169,45 +169,75 @@ def serialize_tool_result(result: Any) -> str:
|
||||
return str(result)
|
||||
|
||||
|
||||
def _normalize_tool_stock_code(value: Any) -> Any:
|
||||
"""Canonicalize stock code arguments so equivalent HK variants share one cache key."""
|
||||
def _normalize_tool_stock_code(value: Any, registry: Optional[Any] = None) -> Any:
|
||||
"""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):
|
||||
return value
|
||||
|
||||
text = value.strip().upper()
|
||||
text = value.strip()
|
||||
if not text:
|
||||
return text
|
||||
|
||||
if text.endswith(".HK"):
|
||||
base = text[:-3]
|
||||
if registry is not None:
|
||||
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:
|
||||
return f"HK{base.zfill(5)}"
|
||||
|
||||
if text.startswith("HK"):
|
||||
base = text[2:]
|
||||
if upper.startswith("HK"):
|
||||
base = upper[2:]
|
||||
if base.isdigit() and 1 <= len(base) <= 5:
|
||||
return f"HK{base.zfill(5)}"
|
||||
|
||||
if text.isdigit() and len(text) == 5:
|
||||
return f"HK{text}"
|
||||
if upper.isdigit() and len(upper) == 5:
|
||||
return f"HK{upper}"
|
||||
|
||||
try:
|
||||
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:
|
||||
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]:
|
||||
"""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):
|
||||
return None
|
||||
|
||||
registry = None
|
||||
if "stock_code" in arguments:
|
||||
registry = _default_index_registry_or_none()
|
||||
|
||||
normalized_args: Dict[str, Any] = {}
|
||||
for key, value in arguments.items():
|
||||
if key == "stock_code":
|
||||
normalized_args[key] = _normalize_tool_stock_code(value)
|
||||
normalized_args[key] = _normalize_tool_stock_code(value, registry)
|
||||
else:
|
||||
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)
|
||||
|
||||
|
||||
def _normalize_guard_stock_code(value: Any) -> str:
|
||||
def _normalize_guard_stock_code(value: Any, registry: Optional[Any] = None) -> str:
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, float) and value.is_integer():
|
||||
value = int(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)
|
||||
|
||||
|
||||
@@ -253,7 +283,9 @@ def _guard_tool_stock_scope(
|
||||
tool_name: str,
|
||||
arguments: Dict[str, Any],
|
||||
stock_scope: Any,
|
||||
index_registry: Optional[Any] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Enforce one tool stock argument against the active canonical scope."""
|
||||
if stock_scope is None or not isinstance(arguments, dict):
|
||||
return None
|
||||
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:
|
||||
return None
|
||||
|
||||
requested = _normalize_guard_stock_code(arguments.get("stock_code"))
|
||||
expected = _normalize_guard_stock_code(getattr(stock_scope, "expected_stock_code", ""))
|
||||
if index_registry is None:
|
||||
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 = {
|
||||
normalized
|
||||
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 requested and (requested == expected or requested in allowed):
|
||||
|
||||
@@ -4051,7 +4051,13 @@ class SearchService:
|
||||
# 如果提供了关键词,直接使用关键词作为查询
|
||||
query = " ".join(focus_keywords)
|
||||
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:
|
||||
# 港股/美股使用英文搜索关键词;优先使用英文公司名(issue #2026)
|
||||
if english_aliases and short_name and short_name != effective_name:
|
||||
@@ -4064,13 +4070,13 @@ class SearchService:
|
||||
# 默认主查询:股票名称 + 核心关键词
|
||||
query = f"{stock_name} {stock_code} 股票 最新消息"
|
||||
|
||||
subject_label = f"{stock_name}({stock_code})" if stock_code else stock_name
|
||||
logger.info(
|
||||
(
|
||||
"搜索股票新闻: %s(%s), query='%s', 时间范围: 近%s天 "
|
||||
"搜索股票新闻: %s, query='%s', 时间范围: 近%s天 "
|
||||
"(profile=%s, NEWS_MAX_AGE_DAYS=%s, prefer_chinese=%s), 目标条数=%s, provider请求条数=%s"
|
||||
),
|
||||
stock_name,
|
||||
stock_code,
|
||||
subject_label,
|
||||
query,
|
||||
search_days,
|
||||
self.news_strategy_profile,
|
||||
|
||||
@@ -36,6 +36,32 @@ from src.services.empty_news import empty_news_disclosure
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def asset_type_from_canonical_code(code: Any) -> Optional[str]:
|
||||
"""Derive the authoritative ``asset_type`` for a canonical stock/index code.
|
||||
|
||||
Uses :func:`parse_analysis_target` — the single asset-type authority — on
|
||||
the *canonical* code, never the display code, so ``sh000016`` (index) and
|
||||
bare ``000016`` (stock) are distinguished by the parser rather than by
|
||||
display normalization. Returns ``None`` for market review / empty /
|
||||
unsupported codes, so legacy clients and market reviews simply omit the
|
||||
optional field.
|
||||
"""
|
||||
text = str(code or "").strip()
|
||||
if not text:
|
||||
return None
|
||||
if text.upper() == "MARKET":
|
||||
return None
|
||||
|
||||
from src.services.stock_list_parser import ParseStatus, parse_analysis_target
|
||||
|
||||
target = parse_analysis_target(text)
|
||||
if target.asset_type == ParseStatus.INDEX:
|
||||
return "index"
|
||||
if target.asset_type == ParseStatus.STOCK:
|
||||
return "stock"
|
||||
return None
|
||||
|
||||
|
||||
class AnalysisService:
|
||||
"""
|
||||
分析服务
|
||||
@@ -62,6 +88,7 @@ class AnalysisService:
|
||||
query_source: str = "api",
|
||||
portfolio_context: Optional[Dict[str, Any]] = None,
|
||||
report_language: Optional[str] = None,
|
||||
analysis_target: Optional[Any] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
执行股票分析
|
||||
@@ -73,6 +100,8 @@ class AnalysisService:
|
||||
query_id: 查询 ID(可选)
|
||||
send_notification: 是否发送通知(API 触发默认发送)
|
||||
analysis_phase: 请求的分析阶段覆盖(auto/premarket/intraday/postmarket)
|
||||
analysis_target: 可选的结构化分析目标(指数目标贯穿到 pipeline,
|
||||
否则指数会退化为股票语义)
|
||||
|
||||
Returns:
|
||||
分析结果字典,包含:
|
||||
@@ -128,6 +157,7 @@ class AnalysisService:
|
||||
skip_analysis=False,
|
||||
single_stock_notify=send_notification,
|
||||
report_type=rt,
|
||||
analysis_target=analysis_target,
|
||||
)
|
||||
|
||||
if result is None:
|
||||
@@ -219,6 +249,7 @@ class AnalysisService:
|
||||
"change_pct": result.change_pct,
|
||||
"model_used": getattr(result, "model_used", None),
|
||||
"market_phase_summary": market_phase_summary,
|
||||
"asset_type": asset_type_from_canonical_code(result.code),
|
||||
},
|
||||
"summary": {
|
||||
"analysis_summary": result.analysis_summary,
|
||||
|
||||
@@ -141,36 +141,50 @@ class HistoryService:
|
||||
is_bse_code,
|
||||
normalize_stock_code,
|
||||
)
|
||||
from src.services.stock_code_utils import (
|
||||
_converge_registered_csi_identity,
|
||||
)
|
||||
from src.services.stock_list_parser import ParseStatus, parse_analysis_target
|
||||
|
||||
# PR #2267 review remediation: converge registered CSI aliases
|
||||
# (``csi930955`` / ``930955.CSI`` / ``CSI930955``) so a record persisted under any
|
||||
# equivalent form is reachable from every equivalent query input.
|
||||
# This is a *persisted-read* filter path, so the candidate set must
|
||||
# include:
|
||||
# 1. the parser canonical (``csi930955`` — current storage form),
|
||||
# 2. the old resolver's uppercase canonical (``CSI930955`` — how
|
||||
# pre-fix records were saved), and
|
||||
# 3. the IndexEntry's explicit aliases (``930955.CSI``).
|
||||
converged_csi = _converge_registered_csi_identity(raw_code)
|
||||
if converged_csi is not None:
|
||||
from src.data.stock_index_loader import _load_active_index_rows
|
||||
active_rows = _load_active_index_rows()
|
||||
alias_keys: List[str] = []
|
||||
display_keys: List[str] = []
|
||||
for row in active_rows:
|
||||
if row and str(row[0] or "").strip() == converged_csi:
|
||||
display_keys = [str(row[1] or "").strip()]
|
||||
alias_keys = [
|
||||
str(a) for a in (row[5] if isinstance(row[5], list) else [])
|
||||
if str(a).strip()
|
||||
]
|
||||
break
|
||||
canonical_upper = canonical_stock_code(converged_csi) or converged_csi.upper()
|
||||
add_keys = [converged_csi, canonical_upper] + display_keys + alias_keys
|
||||
for key in add_keys:
|
||||
# PR #2312: parser-aware index branch. ``parse_analysis_target``
|
||||
# (with the index registry) is the single asset-type authority. When
|
||||
# the query token is a registered INDEX, the persisted-read candidate
|
||||
# set must be exactly:
|
||||
# 1. the lowercase parser canonical (``sh000016`` / ``csi930955`` —
|
||||
# the current storage form),
|
||||
# 2. the legacy uppercase canonical (``SH000016`` / ``CSI930955`` —
|
||||
# how pre-fix records were saved), and
|
||||
# 3. every explicit alias/display form (``000016.SH`` /
|
||||
# ``930955.CSI`` — ``IndexEntry.aliases``).
|
||||
# The bare same-code stock (``000016`` / ``930955``) is deliberately
|
||||
# excluded so an index record is never reachable through a stock
|
||||
# query and vice versa. This unifies the previous CSI-only convergence
|
||||
# (PR #2267) with the SH/SZ index identity so all three namespaces
|
||||
# share one branch.
|
||||
target = parse_analysis_target(raw_code)
|
||||
if target.asset_type == ParseStatus.INDEX:
|
||||
index_keys = [target.canonical_id]
|
||||
legacy_upper = (
|
||||
canonical_stock_code(target.canonical_id)
|
||||
or target.canonical_id.upper()
|
||||
)
|
||||
if legacy_upper and legacy_upper not in index_keys:
|
||||
index_keys.append(legacy_upper)
|
||||
entry = target.matched_index
|
||||
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:
|
||||
candidates.append(key)
|
||||
return candidates
|
||||
@@ -373,6 +387,21 @@ class HistoryService:
|
||||
code = str(raw_code or "").strip()
|
||||
if not 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
|
||||
|
||||
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),
|
||||
"created_at": self._serialize_created_at(record.created_at),
|
||||
"market_phase_summary": market_phase_summary,
|
||||
"asset_type": self._asset_type_for_record(record),
|
||||
**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(
|
||||
self,
|
||||
record_id: str,
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
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):
|
||||
"""Task status enumeration"""
|
||||
PENDING = "pending" # Waiting for execution
|
||||
@@ -88,6 +129,13 @@ class TaskInfo:
|
||||
trace_id: Optional[str] = None
|
||||
region: Optional[str] = None
|
||||
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]:
|
||||
"""Convert task info into an API-friendly dictionary."""
|
||||
@@ -111,6 +159,8 @@ class TaskInfo:
|
||||
}
|
||||
if self.region is not None:
|
||||
payload["region"] = self.region
|
||||
if self.asset_type is not None:
|
||||
payload["asset_type"] = self.asset_type
|
||||
return payload
|
||||
|
||||
def copy(self) -> 'TaskInfo':
|
||||
@@ -138,6 +188,9 @@ class TaskInfo:
|
||||
trace_id=self.trace_id or self.task_id,
|
||||
region=self.region,
|
||||
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,
|
||||
skills: Optional[List[str]] = None,
|
||||
report_language: Optional[str] = None,
|
||||
analysis_target: Optional[Any] = None,
|
||||
) -> TaskInfo:
|
||||
"""
|
||||
Submit a single analysis task.
|
||||
@@ -349,6 +403,8 @@ class AnalysisTaskQueue:
|
||||
report_type: Report type
|
||||
analysis_phase: Requested analysis phase override
|
||||
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:
|
||||
TaskInfo: Accepted task information
|
||||
@@ -372,6 +428,7 @@ class AnalysisTaskQueue:
|
||||
force_refresh=force_refresh,
|
||||
skills=skills,
|
||||
report_language=report_language,
|
||||
analysis_targets=([analysis_target] if analysis_target is not None else None),
|
||||
)
|
||||
if duplicates:
|
||||
raise duplicates[0]
|
||||
@@ -391,12 +448,16 @@ class AnalysisTaskQueue:
|
||||
notify: bool = True,
|
||||
skills: Optional[List[str]] = None,
|
||||
report_language: Optional[str] = None,
|
||||
analysis_targets: Optional[List[Any]] = None,
|
||||
) -> Tuple[List[TaskInfo], List[DuplicateTaskError]]:
|
||||
"""
|
||||
Submit analysis tasks in batch.
|
||||
|
||||
- Duplicate stocks are skipped and recorded in duplicates.
|
||||
- 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)
|
||||
|
||||
@@ -404,14 +465,34 @@ class AnalysisTaskQueue:
|
||||
duplicates: List[DuplicateTaskError] = []
|
||||
created_task_ids: List[str] = []
|
||||
|
||||
canonical_codes = [
|
||||
normalized for normalized in (resolve_index_stock_code_for_analysis(code) for code in stock_codes)
|
||||
if normalized
|
||||
]
|
||||
# Align per-code targets with the canonicalized code list. Index targets
|
||||
# keep their parser canonical id (lowercase ``sh000016`` / ``csi930955``)
|
||||
# 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:
|
||||
for stock_code in canonical_codes:
|
||||
dedupe_key = _dedupe_stock_code_key(stock_code)
|
||||
for idx, stock_code in enumerate(canonical_codes):
|
||||
analysis_target = targets_by_index.get(idx)
|
||||
dedupe_key = _dedupe_task_key(stock_code, analysis_target)
|
||||
if dedupe_key in self._analyzing_stocks:
|
||||
existing_task_id = self._analyzing_stocks[dedupe_key]
|
||||
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,
|
||||
skills=task_skills,
|
||||
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._analyzing_stocks[dedupe_key] = task_id
|
||||
@@ -448,6 +532,7 @@ class AnalysisTaskQueue:
|
||||
notify,
|
||||
task_skills,
|
||||
report_language,
|
||||
analysis_target,
|
||||
)
|
||||
except Exception:
|
||||
# Roll back the current batch to avoid partial submission.
|
||||
@@ -521,7 +606,12 @@ class AnalysisTaskQueue:
|
||||
|
||||
task = self._tasks.pop(task_id, None)
|
||||
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:
|
||||
del self._analyzing_stocks[dedupe_key]
|
||||
|
||||
@@ -674,6 +764,7 @@ class AnalysisTaskQueue:
|
||||
notify: bool = True,
|
||||
skills: Optional[List[str]] = None,
|
||||
report_language: Optional[str] = None,
|
||||
analysis_target: Optional[Any] = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
执行分析任务(在线程池中运行)
|
||||
@@ -683,6 +774,7 @@ class AnalysisTaskQueue:
|
||||
stock_code: 股票代码
|
||||
report_type: 报告类型
|
||||
force_refresh: 是否强制刷新
|
||||
analysis_target: 可选的结构化分析目标(指数目标透传到 pipeline)
|
||||
|
||||
Returns:
|
||||
分析结果字典
|
||||
@@ -736,6 +828,7 @@ class AnalysisTaskQueue:
|
||||
query_source=query_source,
|
||||
portfolio_context=portfolio_context,
|
||||
report_language=report_language,
|
||||
analysis_target=analysis_target,
|
||||
)
|
||||
reset_run_diagnostic_context(diag_token)
|
||||
diag_token = None
|
||||
@@ -752,8 +845,11 @@ class AnalysisTaskQueue:
|
||||
task.message = "分析完成"
|
||||
task.stock_name = result.get("stock_name", task.stock_name)
|
||||
|
||||
# 从分析中集合移除
|
||||
dedupe_key = _dedupe_stock_code_key(task.stock_code)
|
||||
# 从分析中集合移除(使用 submit 时固化的 key,避免不一致残留)
|
||||
dedupe_key = task.dedupe_key or _dedupe_task_key(
|
||||
task.stock_code,
|
||||
getattr(task, "analysis_target", None),
|
||||
)
|
||||
if dedupe_key in self._analyzing_stocks:
|
||||
del self._analyzing_stocks[dedupe_key]
|
||||
|
||||
@@ -782,8 +878,11 @@ class AnalysisTaskQueue:
|
||||
task.error = error_msg[:200] # 限制错误信息长度
|
||||
task.message = f"分析失败: {error_msg[:50]}"
|
||||
|
||||
# 从分析中集合移除
|
||||
dedupe_key = _dedupe_stock_code_key(task.stock_code)
|
||||
# 从分析中集合移除(使用 submit 时固化的 key,避免不一致残留)
|
||||
dedupe_key = task.dedupe_key or _dedupe_task_key(
|
||||
task.stock_code,
|
||||
getattr(task, "analysis_target", None),
|
||||
)
|
||||
if dedupe_key in self._analyzing_stocks:
|
||||
del self._analyzing_stocks[dedupe_key]
|
||||
|
||||
|
||||
@@ -113,6 +113,44 @@ def _make_mock_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(
|
||||
*,
|
||||
realtime_quote=None,
|
||||
@@ -422,6 +460,183 @@ class TestAgentExecutor(unittest.TestCase):
|
||||
self.assertEqual(result.effective_context["stock_code"], "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):
|
||||
registry = _make_registry_with_echo()
|
||||
adapter = _make_mock_adapter()
|
||||
|
||||
@@ -7,6 +7,7 @@ import json
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from src.agent.stock_scope import StockScope
|
||||
from src.agent.tool_surface import ToolSurface
|
||||
@@ -415,6 +416,154 @@ def test_declared_stock_scope_requires_explicit_stock_context_before_handler() -
|
||||
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 _fail():
|
||||
raise RuntimeError("secret stack")
|
||||
|
||||
@@ -49,7 +49,7 @@ from src.enums import ReportType
|
||||
from src.config import Config
|
||||
from src.services.analysis_service import AnalysisService
|
||||
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:
|
||||
@@ -3119,6 +3119,460 @@ class AnalysisApiContractTestCase(unittest.TestCase):
|
||||
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:
|
||||
if trigger_analysis is None or analysis_endpoint_module is None:
|
||||
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].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):
|
||||
def setUp(self) -> None:
|
||||
@@ -3853,6 +4617,196 @@ class BatchTaskQueueContractTestCase(unittest.TestCase):
|
||||
self.assertEqual(updated.message, "LLM 正在生成分析结果")
|
||||
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):
|
||||
def test_litellm_completion_patch_target_remains_available(self) -> None:
|
||||
|
||||
@@ -141,6 +141,95 @@ class TestHistoryCsiCandidateConvergence(unittest.TestCase):
|
||||
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:
|
||||
return {
|
||||
"pack_version": "1.0",
|
||||
@@ -1248,6 +1337,67 @@ class AnalysisHistoryTestCase(unittest.TestCase):
|
||||
self.assertEqual(report.meta.current_price, 200.0)
|
||||
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)
|
||||
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."""
|
||||
@@ -2528,6 +2678,311 @@ class AnalysisHistoryTestCase(unittest.TestCase):
|
||||
report = get_history_detail(str(record_id), db_manager=self.db)
|
||||
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):
|
||||
"""Regression: HistoryItem / ReportSummary must accept out-of-range sentiment_score from DB rows."""
|
||||
|
||||
@@ -67,6 +67,9 @@ class TestAnalysisIntegration:
|
||||
data = response.json()
|
||||
assert data["task_id"] == "test_task_123"
|
||||
assert data["status"] == "pending"
|
||||
# 单股 202(TaskAccepted schema)不包含可选的 asset_type 字段;legacy
|
||||
# MagicMock 任务即使 asset_type 是 MagicMock 子对象也必须保持该契约。
|
||||
assert "asset_type" not in data
|
||||
|
||||
# Verify task queue received the correct resolved code and metadata.
|
||||
# Use call_args so this integration test stays focused on analysis flow
|
||||
|
||||
@@ -845,6 +845,69 @@ class PipelineMarketPhaseContextTestCase(unittest.TestCase):
|
||||
self.assertEqual(kwargs["query_source"], "api")
|
||||
self.assertEqual(kwargs["report_type"], ReportType.SIMPLE.value)
|
||||
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):
|
||||
pipeline = _make_pipeline(agent_mode=False, save_context_snapshot=True)
|
||||
|
||||
Reference in New Issue
Block a user