feat: 支持 Bot 指数分析入口与在线验收 (#2327)

* feat: add bot index analysis entry

* test: add Bot index online smoke

* fix: guard Windows-only spawn flag in smoke tests for Linux CI

* fix: clean up worker tree on parent interrupt in smoke runner

* fix: surface interrupt cleanup failures in smoke runner

---------

Co-authored-by: zhulinsen <42829555+ZhuLinsen@users.noreply.github.com>
This commit is contained in:
Elvis Wang
2026-09-04 18:04:14 +08:00
committed by GitHub
parent b6f9a61671
commit e866cfa48d
14 changed files with 2549 additions and 56 deletions

View File

@@ -4,16 +4,24 @@
股票分析命令
===================================
分析指定股票,调用 AI 生成分析报告。
分析指定股票或已登记指数,调用 AI 生成分析报告。
"""
import re
import logging
from typing import List, Optional
import re
import unicodedata
from dataclasses import replace
from typing import List, Optional, Tuple, Union
from bot.commands.base import BotCommand
from bot.models import BotMessage, BotResponse
from src.services.stock_code_utils import resolve_index_stock_code_for_analysis
from src.services.stock_code_utils import is_code_like, resolve_index_stock_code_for_analysis
from src.services.stock_list_parser import (
AnalysisTarget,
ParseStatus,
default_index_registry,
parse_analysis_target,
)
logger = logging.getLogger(__name__)
@@ -22,11 +30,14 @@ class AnalyzeCommand(BotCommand):
"""
股票分析命令
分析指定股票代码,生成 AI 分析报告并推送。
分析指定股票代码或已登记指数,生成 AI 分析报告并推送。
用法:
/analyze 600519 - 分析贵州茅台(精简报告)
/analyze 600519 full - 分析并生成完整报告
/analyze sh000016 - 分析上证50指数
/analyze 930955.CSI - 分析红利低波100指数alias 收敛)
/analyze 上证50 - 按注册名称分析上证50指数
"""
@property
@@ -39,43 +50,41 @@ class AnalyzeCommand(BotCommand):
@property
def description(self) -> str:
return "分析指定股票"
return "分析指定股票或指数"
@property
def usage(self) -> str:
return "/analyze <股票代码> [full]"
return "/analyze <股票代码/指数代码/指数名称> [full]"
def validate_args(self, args: List[str]) -> Optional[str]:
"""验证参数"""
"""验证参数(仅结构检查;语义校验在 execute 中完成)"""
if not args:
return "请输入股票代码"
code = args[0].upper()
# 验证股票代码格式
# A股6位数字
# 港股HK+5位数字
# 美股1-5个大写字母+.+2个后缀字母
is_a_stock = re.match(r'^\d{6}$', code)
is_hk_stock = re.match(r'^HK\d{5}$', code)
is_us_stock = re.match(r'^[A-Z]{1,5}(\.[A-Z]{1,2})?$', code)
if not (is_a_stock or is_hk_stock or is_us_stock):
return f"无效的股票代码: {code}A股6位数字 / 港股HK+5位数字 / 美股1-5个字母"
return "请输入股票代码或指数名称"
return None
def execute(self, message: BotMessage, args: List[str]) -> BotResponse:
"""执行分析命令"""
code = resolve_index_stock_code_for_analysis(args[0])
raw = (args[0] or "").strip()
if not raw:
return BotResponse.error_response("请输入股票代码或指数名称")
# 检查是否需要完整报告(默认精简,传 full/完整/详细 切换)
report_type = "simple"
if len(args) > 1 and args[1].lower() in ["full", "完整", "详细"]:
report_type = "full"
logger.info(f"[AnalyzeCommand] 分析股票: {code}, 报告类型: {report_type}")
try:
code, analysis_target = self._resolve_analysis_input(raw)
if code is None:
error_msg = analysis_target
if not isinstance(error_msg, str):
error_msg = "无法识别标的"
return BotResponse.error_response(error_msg)
if isinstance(analysis_target, str):
return BotResponse.error_response(analysis_target)
logger.info(f"[AnalyzeCommand] 分析标的: {code}, 报告类型: {report_type}")
# 调用分析服务
from src.services.task_service import get_task_service
from src.enums import ReportType
@@ -86,22 +95,150 @@ class AnalyzeCommand(BotCommand):
result = service.submit_analysis(
code=code,
report_type=ReportType.from_str(report_type),
source_message=message
source_message=message,
analysis_target=analysis_target,
)
if result.get("success"):
task_id = result.get("task_id", "")
return BotResponse.markdown_response(
# ``extra`` 仅供内部任务 identity 透传transport-independent
# 消费者,如 E2E smoke文本保持既有契约不变平台适配器可忽略。
response = BotResponse.markdown_response(
f"✅ **分析任务已提交**\n\n"
f"股票代码: `{code}`\n"
f"标的: `{code}`\n"
f"• 报告类型: {ReportType.from_str(report_type).display_name}\n"
f"• 任务 ID: `{task_id[:20]}...`\n\n"
f"分析完成后将自动推送结果。"
)
else:
response.extra = {
"task_id": task_id,
"stock_code": code,
}
return response
error = result.get("error", "未知错误")
return BotResponse.error_response(f"提交分析任务失败: {error}")
except Exception as e:
logger.error(f"[AnalyzeCommand] 执行失败: {e}")
return BotResponse.error_response(f"分析失败: {str(e)[:100]}")
def _resolve_analysis_input(
self, raw: str
) -> Tuple[Optional[str], Union[AnalysisTarget, str, None]]:
"""Resolve one ``/analyze`` argument into ``(code, analysis_target)``.
Registered index first, existing stock-name resolution as fallback.
1. Explicit index identity/alias (``sh000016`` / ``930955.CSI`` /
``csi930955``) via
:meth:`IndexRegistry.find_by_explicit_key`.
2. Exact registered display name (``上证50``) via
:meth:`IndexRegistry.find_by_display_name` — before any stock-name
fallback; ambiguous names fail with an explicit error.
3. Parser INDEX acceptance: when :func:`parse_analysis_target`
classifies the raw input as INDEX (e.g. dotted-prefix aliases such
as ``SH.000016`` the registry key lookup misses), submit the
registry canonical with the matching structured target. Parser
STOCK results are deliberately ignored here so the legacy stock
gate stays authoritative for stock shapes.
4. Explicit CSI forms (``csi`` + one or more digits, or one or more
digits + ``.csi``) that no registry entry claimed — they surface
the parser's ``unsupported`` details (never a stock-name fallback
or a US-ticker guess).
5. Legacy stock-code gate, preserved exactly as before this change
(case-insensitive): A-share six digits, ``HK`` + five digits, and
US 1-5 letters with optional ``.XX`` suffix. Matches keep the
legacy code path (no structured target) so lowercase real tickers
like ``usfd`` still resolve to ``USFD``.
6. Any remaining parser-UNSUPPORTED input (e.g. ``us1``,
``600519.BJ``, ``1234567.SH``) is an explicit error — never sent
into stock-name resolution.
7. Anything code-like the legacy gate rejected (``12345``, bare
``00700``, ``600519.SH``, unregistered ``sh999999``) is an
explicit error — never silently submitted.
8. Non-code names through :func:`resolve_name_to_code` (stock only).
Only INDEX targets are carried downstream; stock inputs keep the
legacy code path so ``600519`` is never rewritten into a parser
canonical. On failure returns ``(None, error_message)``.
"""
from src.services.name_to_code_resolver import resolve_name_to_code
registry = default_index_registry()
# 1) Explicit index identity/alias — registered index wins over any
# stock-name fallback and is submitted with its lowercase
# canonical_id verbatim.
if registry.find_by_explicit_key(raw) is not None:
target = parse_analysis_target(raw, registry=registry)
return target.canonical_id, target
# 2) Exact registered display name — independent of identity aliases;
# a Chinese name must never become a parser identity alias.
if registry.is_ambiguous_display_name(raw):
return None, (
f"指数名称 `{raw}` 存在歧义,请改用显式代码(如 `sh000016`"
)
entry = registry.find_by_display_name(raw)
if entry is not None:
target = replace(
parse_analysis_target(entry.canonical_id, registry=registry),
raw_input=raw,
)
return target.canonical_id, target
# 3) Parser INDEX acceptance — the shared parser recognizes forms the
# registry key lookup above misses (e.g. dotted-prefix aliases
# ``SH.000016`` / ``SZ.399001``). Only an INDEX result short-circuits
# here; STOCK and UNSUPPORTED results continue to the legacy gates
# below so the legacy stock contract stays authoritative.
target = parse_analysis_target(raw, registry=registry)
if target.asset_type == ParseStatus.INDEX:
return target.canonical_id, target
# 4) Explicit CSI forms — registered ones were claimed in step 1, so
# anything reaching here is unregistered and must surface the
# parser's UNSUPPORTED details. The full numeric explicit CSI
# family (``csi`` + one or more digits, or one or more digits +
# ``.csi``) is covered, not only six-digit forms.
normalized = unicodedata.normalize("NFKC", raw).strip().casefold()
if re.fullmatch(r"(?:csi\d+|\d+\.csi)", normalized):
reason = target.unsupported_reason or f"无法识别标的: {raw}"
return None, f"无法分析 `{raw}`{reason}"
# 5) Legacy stock-code gate — case-insensitive, exactly the shapes the
# old ``validate_args`` accepted. Matches keep the legacy code
# path (no structured target), including lowercase real tickers
# like ``usfd`` -> ``USFD``.
upper = raw.upper()
if (
re.fullmatch(r"\d{6}", upper)
or re.fullmatch(r"HK\d{5}", upper)
or re.fullmatch(r"[A-Z]{1,5}(?:\.[A-Z]{1,2})?", upper)
):
return resolve_index_stock_code_for_analysis(raw), None
# 6) Any remaining parser-UNSUPPORTED input is an explicit error —
# never sent into stock-name resolution. This covers malformed
# code shapes the parser rejected (``us1``, ``600519.BJ``,
# ``1234567.SH``) that the legacy gate above did not claim.
if target.asset_type == ParseStatus.UNSUPPORTED:
reason = target.unsupported_reason or f"无法识别标的: {raw}"
return None, f"无法分析 `{raw}`{reason}"
# 7) Code-like inputs the legacy gate rejected are explicit errors —
# never submitted, never routed into stock-name resolution. This
# includes ``sh``/``sz`` prefixed six-digit forms (``sh999999``)
# that ``is_code_like`` misses because the bare digits classify as
# a different exchange.
if is_code_like(raw) or re.fullmatch(r"(?:sh|sz)\d{6}", normalized):
return None, (
f"无效的标的代码: `{raw}`"
f"A股6位数字 / HK+5位数字 / 美股1-5个字母 / 已登记指数代码或名称)"
)
# 8) Name input → stock-name fallback.
code = resolve_name_to_code(raw)
if not code:
return None, f"无法识别标的: {raw}"
return code, None

View File

@@ -8,6 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/).
> For user-friendly release highlights, see the [GitHub Releases](https://github.com/ZhuLinsen/daily_stock_analysis/releases) page.
## [Unreleased]
- [测试] 新增 Bot 指数入口 transport-independent 在线 E2E smoke`scripts/smoke_bot_index_entry.py`worker 子进程经真实 `CommandDispatcher.dispatch_async` 提交并在同进程轮询 `TaskService` 贯穿到 `StockAnalysisPipeline`,父进程只负责 deadline、进程树清理Windows `taskkill /T /F`、POSIX 杀进程组与退出码0=成功/1=失败/124=超时),输出单行 `E2E_EVENT {json}` 事件(`phase=submitted|completed|failed|timeout`smoke 覆盖 `SH.000016`/`上证50`/`930955.CSI` 矩阵,期望 code/name 取脚本内置权威映射(不信任响应/结果自报身份,提交 code mismatch 输出含期望值与实际值的显式错误并携带结构化实际 `stock_code`dispatcher 路由错误也失败),矩阵外 target 与非正 `--timeout` 在提交与 spawn 之前即被拒绝(退出码 2completed 须 exact canonical code、exact 注册名称且 `analysis_summary`/`operation_advice`/`trend_prediction` 非空仅空白视为空失败或结果不完整即非零退出超时清理进程树Windows `taskkill /T /F`、POSIX 杀进程组),清理成功输出 `timeout` 事件并退出 124、清理失败输出含清理错误的 `failed` 事件并退出 1不回滚 DB/报告/通知副作用;用户 Ctrl-C 中止时父进程同样先清理进程树,清理成功透传中断、清理失败输出含清理错误的 `failed` 事件并退出 1绝不静默吞掉清理失败worker 意外异常输出 `failed` 事件并退出 1stderr 保留异常证据,`KeyboardInterrupt` 不按普通失败处理),父进程将 worker 任意其他退出码归一化为 1运行时契约只暴露 0/1/124父进程以内部 `--worker` flag 显式拉起子进程(不依赖环境变量,防外部预置绕过硬超时);不 mock 在线依赖、不 dry-run、不修 transport不加入离线 gate。
- [新功能] Bot `/analyze` 支持已登记指数入口:显式代码(`sh000016`、CSI alias`930955.CSI` 收敛为 `csi930955`)与注册中文名(`上证50`)均可提交,指数以结构化 `AnalysisTarget``TaskService` 贯穿到 Pipeline `process_single_stock``sh000016` 不再被改写为 `SH000016`);注册名称查询独立于 parser identity alias中文名不进入 `find_by_explicit_key`/`parse_analysis_target`),同名歧义返回明确错误并要求显式代码,未登记 CSI 与未知名称返回明确错误且不提交任务个股代码A/HK/US保持既有 legacy code 路径不变,股票名称输入(如 `贵州茅台`)由本次 Bot 入口新暴露——复用既有名称解析器(`resolve_name_to_code`)解析后提交 legacy code不携带结构化 target提交成功响应在 `BotResponse.extra` 暴露内部任务 identity`task_id`/`stock_code`)供在线验收等内部流程使用,文本与错误路径不变、平台适配器可忽略 `extra`
- [新功能] 新增最小 Agent 轨迹评估入口 `evals/agent_trajectory/`(Refs #1956):纯函数指标层只消费真实 `tool_calls_log + AgentResult`,冻结最小指标契约(工具命中、冗余/缓存、失败/重试、总步数/max_steps),`run_eval.py``build_agent_executor` 真实执行并输出文本摘要 + 结构化 JSON 报告;评估为 reporter 非 gate,零 `src/` 改动
- [修复] 将 litellm 依赖窗口上界收敛到 `<1.99.0`1.99.0 起把 `prompt_cache_key` 透传给 OpenAI provider破坏 provider 缓存测试对不透传行为的既有断言CI backend-tests 3/3 与 backend-gate 失败);保留历史最低版本与 `!=1.82.7`/`!=1.82.8` 事故排除,同时同步更新各 LLM 兼容文档中写死的依赖约束表述,避免文档与 requirements.txt 漂移

View File

@@ -52,7 +52,7 @@ bot/
├── commands/ # 命令处理器
│ ├── __init__.py
│ ├── base.py # 命令抽象基类
│ ├── analyze.py # /analyze 股票分析
│ ├── analyze.py # /analyze 股票/指数分析
│ ├── market.py # /market 大盘复盘
│ ├── help.py # /help 帮助信息
│ └── status.py # /status 系统状态
@@ -185,7 +185,7 @@ class CommandDispatcher:
|------|------|------|------|
| /analyze | /a, 分析 | 分析指定股票 | `/analyze 600519` |
| /analyze | /a, 分析 | 分析指定股票或已登记指数 | `/analyze 600519``/analyze sh000016``/analyze 上证50` |
| /market | /m, 大盘 | 大盘复盘 | `/market` |
@@ -195,6 +195,31 @@ class CommandDispatcher:
| /status | /s, 状态 | 系统状态 | `/status` |
### `/analyze` 支持边界
- 股票A 股 6 位数字(`600519`)、港股 `HK+5 位数字``hk00700`)、美股 1-5 个字母(`AAPL``usfd``USFD`)。股票名称(`贵州茅台`)由本次 Bot 入口新暴露:复用既有名称解析器(`resolve_name_to_code`)解析后提交 legacy code不携带结构化 target。
- 已登记指数:显式代码(`sh000016`、CSI alias`930955.CSI` 收敛为 `csi930955`)、注册中文名(`上证50`)均可提交;指数以结构化 `AnalysisTarget` 贯穿到分析 Pipeline`sh000016` 不会被改写为 `SH000016`
- 未登记 CSI`930956.CSI`)、旧闸门拒绝的代码形态(如 `12345`、裸 `00700``600519.SH`、未登记 `sh999999`)或无法识别的名称:返回明确错误,不提交任务。
- 注册表内存在等价名称歧义时:要求改用显式代码,不猜测、不进入股票名称兜底。
### Bot 指数入口在线 E2E smoke`scripts/smoke_bot_index_entry.py`
用于从真实 `CommandDispatcher.dispatch_async -> AnalyzeCommand -> TaskService -> StockAnalysisPipeline` 在线贯穿单标的分析,覆盖 `SH.000016` / `上证50` / `930955.CSI` 三条矩阵场景,不经过任何 webhook transport不 mock 在线依赖、不 dry-run
运行(单 target 参数,`--timeout` 默认 900 秒):
```powershell
.venv\Scripts\python.exe scripts/smoke_bot_index_entry.py "SH.000016"
.venv\Scripts\python.exe scripts/smoke_bot_index_entry.py "上证50"
.venv\Scripts\python.exe scripts/smoke_bot_index_entry.py "930955.CSI"
```
进程职责worker 独立子进程提交并轮询(同进程 `TaskService`),输出单行 `E2E_EVENT {json}` 事件(`phase=submitted|completed|failed|timeout`,含 `target`,按阶段附加 `task_id`/`stock_code`/`result`/`error`);父进程只负责 deadline、进程树清理和退出码0=成功 / 1=失败 / 124=超时)。
失败语义任务失败、completed 但结果不完整canonical code 或注册名称与矩阵期望不符,或 `analysis_summary`/`operation_advice`/`trend_prediction` 任一为空或仅空白)即输出 `failed` 事件并不为零退出超时清理进程树Windows `taskkill /T /F`POSIX 杀进程组),清理成功输出 `timeout` 事件并退出 124清理失败则输出含清理错误的 `failed` 事件并退出 1不回滚 DB / 报告 / 通知等既有副作用。用户 Ctrl-C 中止时父进程同样先清理进程树:清理成功透传中断,清理失败输出含清理错误的 `failed` 事件并退出 1绝不静默吞掉清理失败。worker 的期望 code/name 来自脚本内置的矩阵权威映射(`SH.000016`/`上证50``sh000016`/`上证50``930955.CSI``csi930955`/`红利低波100`),不信任响应或任务结果自报身份:提交响应的 `stock_code` 与期望不符时输出含期望值与实际值的显式 mismatch 错误failed 事件同时携带结构化 `stock_code` 实际值);矩阵之外的 target 与非正 `--timeout` 在提交与子进程 spawn 之前即被拒绝(参数/输入错误,退出码 2。worker 意外异常dispatch/轮询/事件序列化)输出 `failed` 事件并退出 1异常证据保留在 stderr`KeyboardInterrupt` 不按普通失败处理;父进程将 worker 的任意其他退出码归一化为 1运行时契约只暴露 0 / 1 / 124。父进程以内部 `--worker` flag 显式拉起子进程,不依赖环境变量。
前置条件:需要网络与数据源/AI 凭据配置齐全(在线链路)。本脚本不属于离线 gate不进入 CI。
## 五、`/status` 与模型配置诊断说明
### 可配置层级与可用性判断依据

View File

@@ -57,7 +57,7 @@ bot/
├── commands/ # Command handlers
│ ├── __init__.py
│ ├── base.py # Abstract base class for commands
│ ├── analyze.py # /analyze — stock analysis
│ ├── analyze.py # /analyze — stock/index analysis
│ ├── ask.py # /ask — single-turn question
│ ├── batch.py # /batch — batch watchlist analysis
│ ├── chat.py # /chat — multi-turn strategy chat
@@ -152,7 +152,7 @@ class BotCommand(ABC):
| Command | Description | Example |
|---------|-------------|---------|
| `/analyze` | Analyze a specific stock | `/analyze AAPL` or `/analyze 600519` |
| `/analyze` | Analyze a specific stock or a registered index | `/analyze AAPL`, `/analyze 600519`, `/analyze sh000016`, `/analyze 上证50` |
| `/ask` | Single-turn question about a stock or the market | `/ask what is RSI for AAPL` |
| `/batch` | Batch-analyze your configured watchlist | `/batch` |
| `/chat` | Multi-turn strategy chat (maintains conversation context) | `/chat` |
@@ -162,6 +162,26 @@ class BotCommand(ABC):
> **Stock code formats:** A-shares use 6-digit codes (e.g. `600519`); HK stocks prefix `hk` (e.g. `hk00700`); US stocks use ticker symbols (e.g. `AAPL`, `TSLA`).
> **Registered index inputs:** explicit codes (`sh000016`), CSI aliases (`930955.CSI` converges to `csi930955`) and registered Chinese names (`上证50`) are all accepted. Index inputs are submitted as a structured `AnalysisTarget` that flows through to the analysis pipeline, so `sh000016` is never rewritten to `SH000016`. Unregistered CSI forms (e.g. `930956.CSI`), code shapes the legacy gate rejected (`12345`, bare `00700`, `600519.SH`, unregistered `sh999999`) and unrecognized names return an explicit error without submitting a task; ambiguous registered names require an explicit code instead of guessing. Stock inputs (A-share 6-digit, `HK`+5-digit, US 1-5 letters, e.g. `usfd`→`USFD`) keep the legacy code path. Stock-name inputs (e.g. `贵州茅台`) are newly exposed by this Bot entry: they reuse the existing name resolver (`resolve_name_to_code`) and then submit the legacy code without a structured target.
### Bot index entry online E2E smoke (`scripts/smoke_bot_index_entry.py`)
Walks the real `CommandDispatcher.dispatch_async -> AnalyzeCommand -> TaskService -> StockAnalysisPipeline` online for a single target, covering the `SH.000016` / `上证50` / `930955.CSI` matrix scenarios without any webhook transport (no mocked online dependencies, no dry-run).
Run with a single target argument (`--timeout` defaults to 900 seconds):
```powershell
.venv\Scripts\python.exe scripts/smoke_bot_index_entry.py "SH.000016"
.venv\Scripts\python.exe scripts/smoke_bot_index_entry.py "上证50"
.venv\Scripts\python.exe scripts/smoke_bot_index_entry.py "930955.CSI"
```
Process responsibilities: a worker subprocess submits and polls the in-process `TaskService` and prints single-line `E2E_EVENT {json}` events (`phase=submitted|completed|failed|timeout`, with `target`, plus `task_id`/`stock_code`/`result`/`error` per phase); the parent only enforces the deadline, cleans up the process tree and maps exit codes (0=success / 1=failure / 124=timeout).
Failure semantics: a failed task or a completed-but-incomplete result (canonical code or registered name not matching the matrix expectation, or any of `analysis_summary`/`operation_advice`/`trend_prediction` empty or whitespace-only) emits a `failed` event and exits non-zero; on timeout the process tree is killed (Windows `taskkill /T /F`, POSIX process group) — a successful cleanup emits a `timeout` event and exits 124, a failed cleanup emits a `failed` event carrying the cleanup error and exits 1 — never rolling back DB / reports / notifications side effects. On a user Ctrl-C the parent also cleans up the process tree first: a successful cleanup propagates the interrupt, a failed cleanup emits a `failed` event carrying the cleanup error and exits 1 — a cleanup failure is never silently swallowed. The worker's expected code/name come from the script's built-in authoritative matrix map (`SH.000016`/`上证50``sh000016`/`上证50`, `930955.CSI``csi930955`/`红利低波100`) — never from the response or the result's self-reported identity: a submission whose `extra.stock_code` does not match the expectation fails with an explicit mismatch error carrying the expected and actual values (the failed event also carries the structured actual `stock_code`), and targets outside the matrix as well as non-positive `--timeout` are rejected before any submission or subprocess spawn (argument/input error, exit code 2). Unexpected worker exceptions (dispatch / poll / event serialization) emit a `failed` event and exit 1 with the exception evidence kept on stderr; `KeyboardInterrupt` is not treated as an ordinary failure; the parent normalizes any other worker exit code to 1 so the runtime contract only exposes 0 / 1 / 124. The parent spawns the worker with an internal `--worker` flag, not via environment variables.
Prerequisites: network plus configured data-source and AI credentials (online chain). This script is not part of the offline gate and does not run in CI.
---
## 5. `/status` and LLM configuration diagnostics

View File

@@ -73,19 +73,21 @@ DISCORD_BOT_STATUS=A股智能分析 | /help
Discord机器人支持以下Slash命令
1. `/analyze <stock_code> [full_report]` - 分析指定股票代码
- `stock_code`: 股票代码如 600519
1. `/analyze <target> [full_report]` - 分析指定股票、已登记指数或股票名称
- `target`: 分析标的,可为股票代码`600519`)、已登记指数(如 `sh000016``930955.CSI``上证50`)或股票名称(如 `贵州茅台`
- `full_report`: 可选,是否生成完整报告(包含大盘)
2. `/market_review` - 获取大盘复盘报告
3. `/help` - 查看帮助信息
> 边界:`/analyze` 支持已登记指数入口与股票名称输入(股票名称复用既有名称解析器并提交 legacy code`/ask` 与 `/batch` 暂不支持指数输入。
## 测试机器人
1. 确保机器人已成功添加到你的服务器
2. 在频道中输入`/help`,机器人会返回帮助信息
3. 输入`/analyze 600519`测试股票分析功能
3. 输入`/analyze 600519`测试股票分析功能(已登记指数如 `/analyze sh000016`、股票名称如 `/analyze 贵州茅台` 同样可用)
4. 输入`/market_review`测试大盘复盘功能
## 注意事项

View File

@@ -744,7 +744,9 @@ Web 自动补全与搜索已放行已登记指数:搜索注册中文名(如
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
Bot `/analyze` 已支持已登记指数的显式代码(`sh000016`、CSI alias`930955.CSI`)和注册中文名(`上证50`)。指数以 registry canonical 和结构化 `AnalysisTarget` 进入与 CLI/API 相同的 Pipeline未登记 CSI、未知名称或歧义注册名称会明确报错且不提交任务。普通 A/HK/US 代码与股票名称继续沿用 legacy code 路径
> **Phase 2 边界**:默认 `STOCK_LIST`、`--schedule`、Bot `/ask`、Bot `/batch` 与 GitHub Actions 每日工作流仍未开放指数入口Web/API、Bot `/analyze` 与一次性 `--stocks` 已支持指数,其余定时/每日工作流入口留待后续 PR。
### 指数与个股 Dashboard canonical 隔离PR #2312

View File

@@ -683,7 +683,9 @@ Web autocomplete and search now expose registered indices: searching a registry
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.
Bot `/analyze` now accepts registered-index explicit codes (`sh000016`), CSI aliases (`930955.CSI`), and registered Chinese names (`上证50`). The registry canonical and structured `AnalysisTarget` flow into the same Pipeline used by CLI/API; unregistered CSI forms, unknown names, and ambiguous registered names return an explicit error without submitting a task. Existing A/HK/US codes and stock names keep the legacy-code path.
> **Phase 2 boundary**: default `STOCK_LIST`, `--schedule`, Bot `/ask`, Bot `/batch`, and the GitHub Actions daily workflow still do not expose index entrypoints. Web/API, Bot `/analyze`, and the one-shot `--stocks` entry support indices; the remaining scheduled/daily-workflow entry lands in a later PR.
### Index vs stock Dashboard canonical isolation (PR #2312)

View File

@@ -0,0 +1,506 @@
# -*- coding: utf-8 -*-
"""
=============================================
Bot 指数入口 transport-independent 在线 E2E smoke
=============================================
从真实 ``CommandDispatcher.dispatch_async -> AnalyzeCommand ->
TaskService -> StockAnalysisPipeline`` 在线贯穿单标的分析,覆盖
SH_ALIAS / REGISTERED_NAME / CSI_ALIAS 三条矩阵场景,不经过任何 webhook
transport不修 transport不 mock 在线依赖,不 dry-run
运行方式(单 target 参数,--timeout 默认 900 秒)::
.venv\\Scripts\\python.exe scripts/smoke_bot_index_entry.py "SH.000016"
.venv\\Scripts\\python.exe scripts/smoke_bot_index_entry.py "上证50"
.venv\\Scripts\\python.exe scripts/smoke_bot_index_entry.py "930955.CSI"
.venv\\Scripts\\python.exe scripts/smoke_bot_index_entry.py "SH.000016" --timeout 1800
进程职责:
- 父进程: 只负责 deadline、进程树清理和退出码worker 继承控制台。
- worker: 独立子进程(由父进程以内部 ``--worker`` flag 显式拉起,不依赖
任何环境变量),经真实 dispatcher 提交并在同一进程内轮询
``TaskService``,输出单行 ``E2E_EVENT {json}`` 事件。
事件契约(单行 ``E2E_EVENT {json}``phase=submitted|completed|failed|timeout
含 target按阶段附加 task_id / stock_code / result / error:
- submitted : 任务已提交,附带 ``task_id`` / ``stock_code``。
- completed : 任务完成且结构断言通过,附带 ``result``。
- failed : 任务失败或 completed 但结果不完整,附带 ``error``。
- timeout : worker 超过 deadline 被清理(父进程输出)。
退出码: 0 = 成功1 = 失败(结构断言或 failed 事件124 = 超时。
超时清理: Windows 使用 ``taskkill /T /F`` 结束整个进程树POSIX 向进程组
发送 SIGKILL。超时只清理进程树不回滚 DB / 报告 / 通知等既有副作用。
Ctrl-C 中止: 父进程在轮询窗口收到 Ctrl-C 时同样先清理进程树——清理成功
透传中断,清理失败输出含清理错误的 ``failed`` 事件并退出 1绝不静默
吞掉清理失败。
"""
from __future__ import annotations
import argparse
import json
import os
import signal
import subprocess
import sys
import time
from pathlib import Path
from typing import Optional
REPO_ROOT = Path(__file__).resolve().parents[1]
if str(REPO_ROOT) not in sys.path:
sys.path.insert(0, str(REPO_ROOT))
from scripts.check_env import _reconfigure_output_stream # noqa: E402
EVENT_PREFIX = "E2E_EVENT "
# 矩阵输入 -> (canonical code, registered display name) 的权威映射。
# worker 的期望 code/name 只从这里取值,绝不信任响应或任务结果自报的身份,
# 防止 dispatcher 路由错误时仍假通过。
EXPECTED_OUTCOMES = {
"SH.000016": ("sh000016", "上证50"),
"上证50": ("sh000016", "上证50"),
"930955.CSI": ("csi930955", "红利低波100"),
}
def _resolve_expected(target: str):
"""Return ``(expected_code, expected_name)`` for a matrix target.
Raises ``ValueError`` for targets outside the required matrix.
"""
try:
return EXPECTED_OUTCOMES[target]
except KeyError:
raise ValueError(
f"unsupported smoke target: {target!r} (must be one of "
f"{sorted(EXPECTED_OUTCOMES)!r})"
)
def _emit_event(phase: str, target: str, **fields) -> None:
"""Print one single-line ``E2E_EVENT {json}`` event to stdout."""
payload = {"phase": phase, "target": target}
payload.update(fields)
print(f"{EVENT_PREFIX}{json.dumps(payload, ensure_ascii=False)}", flush=True)
def _spawn_kwargs(platform_name: str) -> dict:
"""Worker spawn kwargs: inherit the console (never ``PIPE``) and detach
the child into its own session / new process group so the parent can
kill the whole tree on timeout."""
if platform_name == "nt":
creationflags = getattr(subprocess, "CREATE_NEW_PROCESS_GROUP", 0)
return {"creationflags": creationflags} if creationflags else {}
return {"start_new_session": True}
def _terminate_process_tree(proc, platform_name: str) -> Optional[str]:
"""Kill the whole worker process tree.
Returns ``None`` only when tree cleanup is confirmed: a successful
``taskkill /T /F`` (Windows) or process-group kill (POSIX), or a
``ProcessLookupError`` for the tree/group (already gone). A failed tree
cleanup returns an error string even when the direct ``proc.kill()``
best-effort fallback succeeds — direct kill only proves the worker
process itself was killed, not its descendants, so it never erases the
original tree-cleanup error.
"""
if platform_name == "nt":
try:
result = subprocess.run(
["taskkill", "/T", "/F", "/PID", str(proc.pid)],
timeout=30,
)
if result.returncode == 0:
return None
taskkill_error = f"taskkill exited {result.returncode}"
except Exception as exc:
taskkill_error = f"taskkill failed: {exc}"
# Best-effort damage control: kill the worker process itself, but
# never report a confirmed tree cleanup.
try:
proc.kill()
return (
f"{taskkill_error}; direct worker kill succeeded but "
f"tree cleanup is unconfirmed"
)
except ProcessLookupError:
return (
f"{taskkill_error}; worker process already gone but "
f"tree cleanup is unconfirmed"
)
except Exception as exc:
return f"{taskkill_error}; direct worker kill also failed: {exc}"
kill_signal = getattr(signal, "SIGKILL", signal.SIGTERM)
killpg = getattr(os, "killpg", None)
try:
if killpg is not None:
killpg(proc.pid, kill_signal)
else:
# 无进程组原语:直接 kill 只能证明 worker 本身被杀,不能证明
# 后代已清理,不得返回 None 声称树清理已确认。
proc.kill()
return (
"process-group kill unavailable; direct worker kill "
"succeeded but tree cleanup is unconfirmed"
)
return None
except ProcessLookupError:
# The process group is already gone — confirmed success.
return None
except OSError as exc:
try:
proc.kill()
return (
f"process-group kill failed: {exc}; "
f"direct worker kill succeeded but tree cleanup is unconfirmed"
)
except ProcessLookupError:
return (
f"process-group kill failed: {exc}; "
f"worker process already gone but tree cleanup is unconfirmed"
)
except Exception as kill_exc:
return (
f"process-group kill failed: {exc}; "
f"direct worker kill also failed: {kill_exc}"
)
def _cleanup_process_tree(proc, platform_name: str) -> Optional[str]:
"""Terminate the worker tree and wait for the worker to exit.
Returns ``None`` only when tree cleanup is confirmed; any terminate /
post-cleanup wait / kill failure is merged into the returned error
string so callers never silently claim a confirmed cleanup.
"""
cleanup_error = _terminate_process_tree(proc, platform_name)
# Post-cleanup handling must never escape as an uncaught exception:
# merge any failure into ``cleanup_error``.
try:
proc.wait(timeout=10)
except subprocess.TimeoutExpired:
try:
proc.kill()
except Exception as exc:
post_error = f"post-cleanup kill failed: {exc}"
cleanup_error = (
f"{cleanup_error}; {post_error}"
if cleanup_error is not None
else post_error
)
except Exception as exc:
post_error = f"post-cleanup wait failed: {exc}"
cleanup_error = (
f"{cleanup_error}; {post_error}"
if cleanup_error is not None
else post_error
)
return cleanup_error
def _assert_complete_result(result: dict, expected_code: str, expected_name: str):
"""Validate a completed task result.
Returns ``None`` on success, or a human-readable failure reason string.
A completed task is only a success when the canonical code equals the
authoritative expected code, the name equals the registered display
name, and the three non-empty result fields (analysis_summary /
operation_advice / trend_prediction) are present.
"""
if not isinstance(result, dict):
return "completed result is not a dict"
if result.get("code") != expected_code:
return (
f"result code mismatch: expected {expected_code!r}, "
f"got {result.get('code')!r}"
)
if result.get("name") != expected_name:
return (
f"result name mismatch: expected {expected_name!r}, "
f"got {result.get('name')!r}"
)
missing = [
key
for key in ("analysis_summary", "operation_advice", "trend_prediction")
if not (result.get(key) or "").strip()
]
if missing:
return f"completed result missing non-empty fields: {missing}"
return None
def _poll_once(service, task_id: str, target: str, stock_code: str):
"""One poll of the in-process ``TaskService``.
Empty initial state (``None`` / ``running``) returns ``None`` so the
caller keeps waiting. Terminal states return exactly one event dict:
``completed`` (structure assertions passed against the authoritative
matrix expectation) or ``failed`` (task failed or completed but
incomplete).
"""
expected_code, expected_name = _resolve_expected(target)
status = service.get_task_status(task_id)
if status is None or status.get("status") in ("running", "pending"):
return None
if status.get("status") == "completed":
result = status.get("result")
reason = _assert_complete_result(result, expected_code, expected_name)
if reason is not None:
return {
"phase": "failed",
"target": target,
"task_id": task_id,
"stock_code": stock_code,
"error": reason,
}
return {
"phase": "completed",
"target": target,
"task_id": task_id,
"stock_code": stock_code,
"result": result,
}
return {
"phase": "failed",
"target": target,
"task_id": task_id,
"stock_code": stock_code,
"error": status.get("error") or f"task status is {status.get('status')!r}",
}
# ---------------------------------------------------------------------------
# Worker
# ---------------------------------------------------------------------------
def _run_worker(target: str) -> int:
"""Worker entrypoint: submit via the real dispatcher in this process and
poll the same process's TaskService until a terminal state.
Only exits 0 when a completed event with passing structure assertions
was produced. Authoritative target validation runs BEFORE any dispatcher
construction or submission, so an unsupported target can never submit.
Unexpected exceptions from dispatch / poll / event serialization are
converted into a ``failed`` E2E event with the exception evidence on
stderr and exit 1 — never an uncaught traceback. ``KeyboardInterrupt``
is not treated as an ordinary failure.
"""
expected_code = _resolve_expected(target)[0]
try:
return _run_worker_inner(target, expected_code)
except KeyboardInterrupt:
raise
except Exception as exc:
_emit_event("failed", target, error=f"worker exception: {exc}")
print(f"ERROR: worker exception: {exc}", file=sys.stderr)
return 1
def _run_worker_inner(target: str, expected_code: str) -> int:
"""Dispatch + poll loop of the worker (see :func:`_run_worker`)."""
from datetime import datetime
from bot.commands.analyze import AnalyzeCommand
from bot.dispatcher import CommandDispatcher
from bot.models import BotMessage, ChatType
from src.services.task_service import get_task_service
dispatcher = CommandDispatcher()
dispatcher.register(AnalyzeCommand())
message = BotMessage(
platform="smoke",
message_id="smoke-e2e",
user_id="smoke",
user_name="smoke",
chat_id="smoke",
chat_type=ChatType.PRIVATE,
content=f"/analyze {target}",
raw_content=f"/analyze {target}",
mentioned=False,
timestamp=datetime.now(),
)
import asyncio
response = asyncio.run(dispatcher.dispatch_async(message))
# 权威期望只来自矩阵映射,绝不信任 ``extra.stock_code`` 或任务结果自报
# 身份dispatcher 路由错误(提交了错误 code/name时仍会失败。
response_code = response.extra.get("stock_code")
response_task_id = response.extra.get("task_id")
if response_task_id and response_code == expected_code:
task_id = response_task_id
_emit_event(
"submitted",
target,
task_id=task_id,
stock_code=expected_code,
)
elif response_task_id:
# 有任务 identity 但 code 与矩阵期望不符:提交了错误的标的,输出
# 明确的 mismatch 错误(含期望值与实际值)与结构化实际 code
# 而不是成功响应文本。
_emit_event(
"failed",
target,
task_id=response_task_id,
stock_code=response_code,
error=(
f"submitted code mismatch: expected {expected_code!r}, "
f"got {response_code!r}"
),
)
return 1
else:
_emit_event("failed", target, error=response.text)
return 1
service = get_task_service()
while True:
event = _poll_once(service, task_id, target, expected_code)
if event is not None:
_emit_event(event["phase"], target, **{
k: v
for k, v in event.items()
if k not in ("phase", "target")
})
return 0 if event["phase"] == "completed" else 1
time.sleep(5)
# ---------------------------------------------------------------------------
# Parent
# ---------------------------------------------------------------------------
def _run_parent(target: str, timeout: int) -> int:
"""Parent entrypoint: spawn the worker (inheriting the console), enforce
the deadline, clean up the process tree on timeout and on Ctrl-C, and
map to exit codes 0 / 1 / 124. Authoritative target validation runs
before the subprocess spawn, so an unsupported target never reaches
``Popen``; a non-positive timeout is rejected the same way. On Ctrl-C
the parent cleans up the worker tree first: a confirmed cleanup
propagates the interrupt, a failed cleanup emits a structured ``failed``
event and returns 1."""
_resolve_expected(target) # raises ValueError for unsupported targets
if timeout <= 0:
raise ValueError(f"timeout must be positive, got {timeout}")
# ``proc.wait`` without ``timeout`` would block SIGINT handling on
# Windows, so poll with the deadline instead.
child_args = [sys.executable, str(Path(__file__).resolve()), "--worker", target]
if os.name == "nt":
proc = subprocess.Popen(
child_args,
cwd=str(REPO_ROOT),
**_spawn_kwargs("nt"),
)
else:
proc = subprocess.Popen(
child_args,
cwd=str(REPO_ROOT),
**_spawn_kwargs("posix"),
)
deadline = time.monotonic() + timeout
while True:
try:
exit_code = proc.wait(timeout=1)
except subprocess.TimeoutExpired:
exit_code = None
except KeyboardInterrupt:
# 用户主动中止:清理 worker 进程树。清理失败不得静默吞掉——
# 与 timeout 分支一致,输出结构化 failed 事件并返回 1避免
# 用户误以为进程树已清理而 worker 后代仍在跑真实副作用。
if proc.poll() is not None:
# worker 已自行退出:无需清理,直接透传中断。
raise
cleanup_error = _cleanup_process_tree(
proc, "nt" if os.name == "nt" else "posix"
)
if cleanup_error is not None:
_emit_event(
"failed",
target,
error=f"interrupt cleanup failed: {cleanup_error}",
)
return 1
raise
if exit_code is not None:
# 文档化运行时契约只暴露 0 / 1 / 124worker 的任意其他退出码
# 归一化为 1避免泄漏任意崩溃码。
if exit_code in (0, 1, 124):
return exit_code
return 1
if time.monotonic() >= deadline:
cleanup_error = _cleanup_process_tree(
proc, "nt" if os.name == "nt" else "posix"
)
if cleanup_error is not None:
# 清理失败不得静默声称成功:输出结构化 failed 事件并返回 1。
_emit_event(
"failed",
target,
error=f"timeout cleanup failed: {cleanup_error}",
)
return 1
_emit_event("timeout", target)
return 124
def main(argv=None) -> int:
parser = argparse.ArgumentParser(
description=(
"Bot 指数入口 transport-independent 在线 E2E smoke"
"从真实 dispatcher 贯穿 Pipeline单标的在线分析。"
)
)
parser.add_argument("target", help="矩阵输入SH.000016 / 上证50 / 930955.CSI")
parser.add_argument(
"--timeout",
type=int,
default=900,
help="worker 超时秒数(默认 900 秒;超时清理进程树,不回滚副作用)",
)
parser.add_argument(
"--worker",
action="store_true",
help=argparse.SUPPRESS,
)
args = parser.parse_args(argv)
target = (args.target or "").strip()
if not target:
print("ERROR: target 不能为空", file=sys.stderr)
return 2
# 权威 target 校验必须先于任何角色分支:不支持的 target 不得构造
# dispatcher、不得提交、不得 spawn 子进程(参数/输入错误,非零返回)。
try:
_resolve_expected(target)
except ValueError as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 2
# ``--timeout`` 必须为正:非正超时在 spawn/提交之前即拒绝(输入错误)。
if args.timeout <= 0:
print(f"ERROR: --timeout 必须为正整数got {args.timeout}", file=sys.stderr)
return 2
_reconfigure_output_stream(sys.stdout)
_reconfigure_output_stream(sys.stderr)
# 角色选择只由显式 ``--worker`` flag 决定(父进程拉起子进程时显式传入),
# 不依赖任何环境变量,避免外部预置环境变量绕过父进程的硬超时。
if args.worker:
return _run_worker(target)
return _run_parent(target, args.timeout)
if __name__ == "__main__":
sys.exit(main())

View File

@@ -87,6 +87,20 @@ def _normalize_index_key(value: str) -> str:
return normalized
def _normalize_display_name(value: str) -> str:
"""Normalize a display name for exact name lookup only.
Exactly NFKC + trim + casefold. Deliberately **not** the identity
normalization of :func:`_normalize_index_key`, which rewrites exchange
variants (``000016.SH`` collapses to ``sh000016``): code-shaped display
names that are distinct under exact text equality must stay distinct here,
otherwise a name lookup would falsely report them as one key or as
ambiguous. Display-name equality is exact-text equality after this
normalization — no fuzzy search.
"""
return unicodedata.normalize("NFKC", str(value or "")).strip().casefold()
# ---------------------------------------------------------------------------
# Legacy helpers (unchanged).
# ---------------------------------------------------------------------------
@@ -392,6 +406,47 @@ class IndexRegistry:
"""
return self._by_resolver_key.get(_normalize_index_key(key))
def find_by_display_name(self, name: str) -> Optional[IndexEntry]:
"""Look up an index by its exact registered display name.
Only NFKC + trim + casefold exact matches are considered — no fuzzy
search. Display names are deliberately NOT identity aliases: this
lookup is independent of :meth:`find_by_explicit_key` and never feeds
``_by_resolver_key`` / :func:`parse_analysis_target`, so a Chinese
name can never become a parser identity alias. When two registered
entries have NFKC/casefold-equivalent display names the lookup is
ambiguous and returns ``None``; callers should detect that via
:meth:`is_ambiguous_display_name` and ask the user for an explicit
code instead of falling back to stock-name resolution.
"""
key = _normalize_display_name(name)
if not key:
return None
matches = [
entry
for entry in self._entries
if _normalize_display_name(entry.display_name) == key
]
if len(matches) == 1:
return matches[0]
return None
def is_ambiguous_display_name(self, name: str) -> bool:
"""True when two or more registered entries share an NFKC/casefold-
equivalent display name, so :meth:`find_by_display_name` cannot pick
a single entry."""
key = _normalize_display_name(name)
if not key:
return False
return (
sum(
1
for entry in self._entries
if _normalize_display_name(entry.display_name) == key
)
> 1
)
def find_by_bare_conflict(self, bare_code: str) -> Optional[IndexEntry]:
"""Return the index whose explicit alias base equals ``bare_code``."""
return self._by_bare_conflict.get(_normalize_index_key(bare_code))

View File

@@ -24,6 +24,7 @@ from src.enums import ReportType
from src.storage import get_db
from bot.models import BotMessage
from src.services.stock_code_utils import resolve_index_stock_code_for_analysis
from src.services.stock_list_parser import AnalysisTarget, ParseStatus
logger = logging.getLogger(__name__)
@@ -72,7 +73,8 @@ class TaskService:
report_type: Union[ReportType, str] = ReportType.SIMPLE,
source_message: Optional[BotMessage] = None,
save_context_snapshot: Optional[bool] = None,
query_source: str = "bot"
query_source: str = "bot",
analysis_target: Optional[AnalysisTarget] = None
) -> Dict[str, Any]:
"""
提交异步分析任务
@@ -83,6 +85,9 @@ class TaskService:
source_message: 来源消息(用于回复)
save_context_snapshot: 是否保存上下文快照
query_source: 任务来源标识bot/api/cli/system
analysis_target: 可选结构化分析目标INDEX 时原样使用
``target.canonical_id`` 并跳过股票代码规范化,防止
``sh000016`` 被改写为 ``SH000016``
Returns:
任务信息字典
@@ -91,6 +96,16 @@ class TaskService:
if isinstance(report_type, str):
report_type = ReportType.from_str(report_type)
if (
analysis_target is not None
and analysis_target.asset_type != ParseStatus.INDEX
):
raise ValueError("analysis_target must be an INDEX target")
if analysis_target is not None:
# INDEX target 携带时原样使用 canonical_id不再走股票代码
# 规范化路径(避免 ``sh000016`` 被改写为 ``SH000016``)。
normalized_code = analysis_target.canonical_id
else:
normalized_code = resolve_index_stock_code_for_analysis(code)
if not normalized_code:
raise ValueError("股票代码不能为空或仅包含空白字符")
@@ -105,7 +120,8 @@ class TaskService:
report_type,
source_message,
save_context_snapshot,
query_source
query_source,
analysis_target
)
logger.info(
@@ -153,7 +169,8 @@ class TaskService:
report_type: ReportType = ReportType.SIMPLE,
source_message: Optional[BotMessage] = None,
save_context_snapshot: Optional[bool] = None,
query_source: str = "bot"
query_source: str = "bot",
analysis_target: Optional[AnalysisTarget] = None
) -> Dict[str, Any]:
"""
执行单只股票分析
@@ -191,12 +208,15 @@ class TaskService:
)
# 执行单只股票分析(启用单股推送)
result = pipeline.process_single_stock(
code=code,
skip_analysis=False,
single_stock_notify=True,
report_type=report_type
)
process_kwargs = {
"code": code,
"skip_analysis": False,
"single_stock_notify": True,
"report_type": report_type,
}
if analysis_target is not None:
process_kwargs["analysis_target"] = analysis_target
result = pipeline.process_single_stock(**process_kwargs)
if result and result.success:
result_data = {

View File

@@ -0,0 +1,363 @@
# -*- coding: utf-8 -*-
"""Tests for the shared ``/analyze`` command (Bot 指数入口, PR2).
Covers the real ``CommandDispatcher`` gate (``validate_args`` before
``execute``) with a stubbed ``TaskService`` so the tests assert the
code/target handed to ``submit_analysis`` without touching the network or
the real pipeline.
"""
import unittest
from datetime import datetime
from unittest.mock import patch
# Keep tests runnable when optional deps are missing.
try:
import litellm # noqa: F401
except ModuleNotFoundError:
from tests.litellm_stub import ensure_litellm_stub
ensure_litellm_stub()
from bot.commands.analyze import AnalyzeCommand
from bot.dispatcher import CommandDispatcher
from bot.models import BotMessage, BotResponse, ChatType
from src.services.stock_list_parser import ParseStatus
class _StubTaskService:
"""Records the last ``submit_analysis`` call; never touches the network.
Supports two canned outcomes (success by default, or failure) so tests
can assert both the success ``extra`` contract and the no-identity error
contract without touching the network or the real pipeline.
"""
def __init__(self, fail: bool = False):
self.calls = []
self._fail = fail
def submit_analysis(self, **kwargs):
self.calls.append(kwargs)
if self._fail:
return {"success": False, "task_id": "", "error": "boom"}
return {
"success": True,
"task_id": "task-1234567890abcdef",
"code": kwargs.get("code", ""),
}
def _make_message(content: str) -> BotMessage:
return BotMessage(
platform="feishu",
message_id="m1",
user_id="u1",
user_name="tester",
chat_id="c1",
chat_type=ChatType.PRIVATE,
content=content,
raw_content=content,
mentioned=False,
timestamp=datetime.now(),
)
class TestAnalyzeCommandDispatcherGate(unittest.TestCase):
"""Real dispatcher gate: ``validate_args`` runs before ``execute`` and the
command submits through the shared ``AnalyzeCommand``."""
def _dispatch(self, content: str):
dispatcher = CommandDispatcher()
command = AnalyzeCommand()
dispatcher.register(command)
stub = _StubTaskService()
with patch("src.services.task_service.get_task_service", return_value=stub):
response = dispatcher.dispatch(_make_message(content))
return response, stub
def test_registered_code_submits_index_target(self):
response, stub = self._dispatch("/analyze sh000016")
self.assertIsInstance(response, BotResponse)
self.assertIn("分析任务已提交", response.text)
self.assertEqual(len(stub.calls), 1)
call = stub.calls[0]
self.assertEqual(call["code"], "sh000016")
target = call["analysis_target"]
self.assertIsNotNone(target)
self.assertEqual(target.asset_type, ParseStatus.INDEX)
self.assertEqual(target.canonical_id, "sh000016")
def test_csi_alias_converges_to_canonical_index_target(self):
response, stub = self._dispatch("/analyze 930955.CSI")
self.assertIn("分析任务已提交", response.text)
call = stub.calls[0]
self.assertEqual(call["code"], "csi930955")
target = call["analysis_target"]
self.assertEqual(target.asset_type, ParseStatus.INDEX)
self.assertEqual(target.canonical_id, "csi930955")
def test_registered_prefix_alias_submits_index_target(self):
"""``csi930955`` is the registered canonical CSI prefix form; it must
submit with a matching INDEX target."""
response, stub = self._dispatch("/analyze csi930955")
self.assertIn("分析任务已提交", response.text)
call = stub.calls[0]
self.assertEqual(call["code"], "csi930955")
target = call["analysis_target"]
self.assertIsNotNone(target)
self.assertEqual(target.asset_type, ParseStatus.INDEX)
self.assertEqual(target.canonical_id, "csi930955")
def test_registered_suffix_alias_submits_index_target(self):
"""``000016.SH`` is the registered suffix alias of sh000016; it must
submit the registry canonical with a matching INDEX target."""
response, stub = self._dispatch("/analyze 000016.SH")
self.assertIn("分析任务已提交", response.text)
call = stub.calls[0]
self.assertEqual(call["code"], "sh000016")
target = call["analysis_target"]
self.assertIsNotNone(target)
self.assertEqual(target.asset_type, ParseStatus.INDEX)
self.assertEqual(target.canonical_id, "sh000016")
def test_dotted_us_ticker_keeps_legacy_path_without_target(self):
"""``BRK.B`` is a dotted US ticker the legacy gate accepts; it must
resolve to ``BRK.B`` with no structured target."""
response, stub = self._dispatch("/analyze BRK.B")
self.assertIn("分析任务已提交", response.text)
call = stub.calls[0]
self.assertEqual(call["code"], "BRK.B")
self.assertIsNone(call["analysis_target"])
def test_parser_index_alias_ss_dotted_prefix_submits_index_target(self):
"""``SS.000300`` is a parser-recognized dotted-prefix alias of the
registered sh000300 index (SS = Shanghai alias); the Bot must submit
the registry canonical with a matching INDEX target."""
response, stub = self._dispatch("/analyze SS.000300")
self.assertIn("分析任务已提交", response.text)
call = stub.calls[0]
self.assertEqual(call["code"], "sh000300")
target = call["analysis_target"]
self.assertIsNotNone(target)
self.assertEqual(target.asset_type, ParseStatus.INDEX)
self.assertEqual(target.canonical_id, "sh000300")
def test_dotted_prefix_sh_alias_submits_index_target(self):
"""``SH.000016`` is a parser-recognized dotted-prefix alias of the
registered sh000016 index; the Bot must submit the registry canonical
with a matching INDEX target."""
response, stub = self._dispatch("/analyze SH.000016")
self.assertIn("分析任务已提交", response.text)
call = stub.calls[0]
self.assertEqual(call["code"], "sh000016")
target = call["analysis_target"]
self.assertIsNotNone(target)
self.assertEqual(target.asset_type, ParseStatus.INDEX)
self.assertEqual(target.canonical_id, "sh000016")
def test_dotted_prefix_sz_alias_submits_index_target(self):
"""``SZ.399001`` is a parser-recognized dotted-prefix alias of the
registered sz399001 index; the Bot must submit the registry canonical
with a matching INDEX target."""
response, stub = self._dispatch("/analyze SZ.399001")
self.assertIn("分析任务已提交", response.text)
call = stub.calls[0]
self.assertEqual(call["code"], "sz399001")
target = call["analysis_target"]
self.assertIsNotNone(target)
self.assertEqual(target.asset_type, ParseStatus.INDEX)
self.assertEqual(target.canonical_id, "sz399001")
def test_registered_name_wins_over_stock_name_fallback(self):
with patch(
"src.services.name_to_code_resolver.resolve_name_to_code"
) as mock_resolve:
response, stub = self._dispatch("/analyze 上证50")
self.assertIn("分析任务已提交", response.text)
call = stub.calls[0]
self.assertEqual(call["code"], "sh000016")
target = call["analysis_target"]
self.assertEqual(target.asset_type, ParseStatus.INDEX)
self.assertEqual(target.canonical_id, "sh000016")
self.assertEqual(target.raw_input, "上证50")
mock_resolve.assert_not_called()
def test_stock_code_keeps_legacy_code_and_no_target(self):
response, stub = self._dispatch("/analyze 600519")
self.assertIn("分析任务已提交", response.text)
call = stub.calls[0]
self.assertEqual(call["code"], "600519")
self.assertIsNone(call["analysis_target"])
def test_stock_name_fallback_keeps_legacy_code(self):
"""Deterministic: the AkShare/network online map is stubbed empty so
the real top-level ``resolve_name_to_code`` runs its local tables
only and still resolves 贵州茅台 to its legacy code."""
response, stub = None, None
with patch(
"src.services.name_to_code_resolver._get_akshare_name_to_code",
return_value={},
):
response, stub = self._dispatch("/analyze 贵州茅台")
self.assertIn("分析任务已提交", response.text)
call = stub.calls[0]
self.assertEqual(call["code"], "600519")
self.assertIsNone(call["analysis_target"])
def test_hk_and_us_stock_keep_legacy_code(self):
# ``hk00700`` is uppercased to ``HK00700`` by the legacy resolver —
# the same behavior the pre-PR command already had.
for content, expected in (("/analyze hk00700", "HK00700"), ("/analyze AAPL", "AAPL")):
response, stub = self._dispatch(content)
self.assertIn("分析任务已提交", response.text)
call = stub.calls[0]
self.assertEqual(call["code"], expected)
self.assertIsNone(call["analysis_target"])
def test_lowercase_us_ticker_keeps_legacy_uppercase_path(self):
"""``usfd`` is a real US ticker the old gate accepted (case-insensitive
1-5 letters); it must resolve to ``USFD`` with no target, exactly as
before this change."""
response, stub = self._dispatch("/analyze usfd")
self.assertIn("分析任务已提交", response.text)
call = stub.calls[0]
self.assertEqual(call["code"], "USFD")
self.assertIsNone(call["analysis_target"])
def test_legacy_invalid_shapes_return_error_without_submission(self):
"""Shapes the old validate_args gate rejected must stay rejected —
explicit error and no TaskService call."""
for code in ("12345", "00700", "600519.SH", "sh999999"):
with self.subTest(code=code):
response, stub = self._dispatch(f"/analyze {code}")
self.assertIn("无效的标的代码", response.text)
self.assertEqual(len(stub.calls), 0)
def test_unregistered_csi_returns_error_and_does_not_submit(self):
response, stub = self._dispatch("/analyze 930956.CSI")
self.assertIn("无法分析", response.text)
self.assertIn("CSI", response.text)
self.assertEqual(len(stub.calls), 0)
def test_malformed_csi_numeric_forms_return_csi_error_without_submission(
self,
):
"""The full numeric explicit CSI family (``csi`` + digits, or digits +
``.csi``) is rejected with the CSI-specific error — never submitted,
never routed into stock-name resolution."""
for code in ("csi930956", "csi123", "12345.csi"):
with self.subTest(code=code):
response, stub = self._dispatch(f"/analyze {code}")
self.assertIn("无法分析", response.text)
self.assertIn("CSI", response.text)
self.assertEqual(len(stub.calls), 0)
def test_parser_unsupported_malformed_code_returns_error_without_submission(
self,
):
"""A non-CSI parser-UNSUPPORTED malformed code shape (``us1``) must
return an explicit error and never submit a task."""
response, stub = self._dispatch("/analyze us1")
self.assertIn("无法分析", response.text)
self.assertEqual(len(stub.calls), 0)
def test_unknown_name_returns_error_and_does_not_submit(self):
"""Deterministic: the AkShare/network online map is stubbed empty so
the real ``resolve_name_to_code`` runs its local tables only."""
response, stub = None, None
with patch(
"src.services.name_to_code_resolver._get_akshare_name_to_code",
return_value={},
):
response, stub = self._dispatch("/analyze 不存在标的")
self.assertIn("无法识别标的", response.text)
self.assertEqual(len(stub.calls), 0)
def test_empty_args_rejected_by_validate_args_gate(self):
response, stub = self._dispatch("/analyze")
self.assertIn("请输入", response.text)
self.assertEqual(len(stub.calls), 0)
def test_full_report_flag_still_works(self):
response, stub = self._dispatch("/analyze sh000016 full")
self.assertIn("分析任务已提交", response.text)
call = stub.calls[0]
self.assertEqual(call["code"], "sh000016")
self.assertEqual(call["report_type"].value, "full")
def test_success_text_is_unchanged_and_carries_task_identity_extra(self):
"""On a successful submission the user-visible text must stay exactly
as before, while ``extra`` carries the internal task identity
(``task_id`` + ``stock_code``) for transport-independent consumers."""
response, stub = self._dispatch("/analyze sh000016")
expected_text = (
"✅ **分析任务已提交**\n\n"
"• 标的: `sh000016`\n"
"• 报告类型: 精简报告\n"
"• 任务 ID: `task-1234567890abcde...`\n\n"
"分析完成后将自动推送结果。"
)
self.assertEqual(response.text, expected_text)
self.assertEqual(response.extra, {
"task_id": "task-1234567890abcdef",
"stock_code": "sh000016",
})
def test_success_extra_uses_normalized_code(self):
"""``extra.stock_code`` carries the normalized code the task was
submitted under (the registry canonical for an index alias)."""
response, stub = self._dispatch("/analyze 930955.CSI")
self.assertIn("分析任务已提交", response.text)
self.assertEqual(response.extra["task_id"], "task-1234567890abcdef")
self.assertEqual(response.extra["stock_code"], "csi930955")
def test_error_response_has_no_task_identity_extra(self):
"""On a submission failure there is no task identity: no task was
created, so ``extra`` must stay empty."""
dispatcher = CommandDispatcher()
dispatcher.register(AnalyzeCommand())
stub = _StubTaskService(fail=True)
with patch("src.services.task_service.get_task_service", return_value=stub):
response = dispatcher.dispatch(_make_message("/analyze sh000016"))
self.assertIn("提交分析任务失败", response.text)
self.assertEqual(response.extra, {})
class TestAnalyzeCommandAmbiguousName(unittest.TestCase):
"""Ambiguous registered display names must fail with an explicit error and
never fall back to stock-name resolution."""
def test_ambiguous_display_name_returns_error(self):
from src.services.stock_list_parser import IndexEntry, IndexRegistry
registry = IndexRegistry((
IndexEntry(
bare_code="000300",
exchange="SH",
canonical_id="sh000300",
display_name="沪深300",
),
IndexEntry(
bare_code="000999",
exchange="SH",
canonical_id="sh000999",
display_name="沪深300",
),
))
dispatcher = CommandDispatcher()
dispatcher.register(AnalyzeCommand())
stub = _StubTaskService()
with patch(
"bot.commands.analyze.default_index_registry", return_value=registry
), patch(
"src.services.name_to_code_resolver.resolve_name_to_code"
) as mock_resolve, patch(
"src.services.task_service.get_task_service", return_value=stub
):
response = dispatcher.dispatch(_make_message("/analyze 沪深300"))
self.assertIn("歧义", response.text)
self.assertEqual(stub.calls, [])
mock_resolve.assert_not_called()
if __name__ == "__main__":
unittest.main()

File diff suppressed because it is too large Load Diff

View File

@@ -429,6 +429,60 @@ class TestDefaultIndexRegistry:
target = parse_analysis_target("沪深300")
assert target.asset_type != ParseStatus.INDEX
def test_find_by_display_name_resolves_registered_name(self) -> None:
"""PR2: exact registered display names resolve through the dedicated
name lookup — independent of ``find_by_explicit_key``."""
registry = default_index_registry()
entry = registry.find_by_display_name("上证50")
assert entry is not None
assert entry.canonical_id == "sh000016"
entry2 = registry.find_by_display_name("沪深300")
assert entry2 is not None
assert entry2.canonical_id == "sh000300"
def test_find_by_display_name_is_nfkc_trim_casefold_exact(self) -> None:
"""PR2: name lookup normalizes NFKC + trim + casefold before the exact
match, but never falls back to fuzzy matching."""
registry = default_index_registry()
# Full-width / whitespace variants of a registered name still match.
assert registry.find_by_display_name(" 上证50 ") is not None
assert registry.find_by_display_name("上证50") is not None
# Unknown names return None (no fuzzy fallback).
assert registry.find_by_display_name("不存在标的") is None
assert registry.find_by_display_name("上证5") is None
def test_find_by_display_name_does_not_leak_into_identity_alias(self) -> None:
"""PR2: a display-name hit must not make the name resolvable as an
identity alias — the parser contract stays untouched."""
registry = default_index_registry()
assert registry.find_by_display_name("上证50") is not None
assert registry.find_by_explicit_key("上证50") is None
target = parse_analysis_target("上证50")
assert target.asset_type != ParseStatus.INDEX
def test_ambiguous_display_name_is_reported(self) -> None:
"""PR2: two entries with NFKC/casefold-equivalent display names make
the name lookup ambiguous — no guessing, no single winner."""
registry = IndexRegistry((
IndexEntry(
bare_code="000300",
exchange="SH",
canonical_id="sh000300",
display_name="沪深300",
),
IndexEntry(
bare_code="000999",
exchange="SH",
canonical_id="sh000999",
display_name="沪深300",
),
))
assert registry.is_ambiguous_display_name("沪深300") is True
assert registry.find_by_display_name("沪深300") is None
# Explicit identity still resolves deterministically.
assert registry.find_by_explicit_key("sh000300") is not None
assert registry.find_by_explicit_key("sh000999") is not None
def test_custom_registry_rejects_text_identity_alias(self) -> None:
with pytest.raises(ValueError, match="explicit code form"):
IndexRegistry((
@@ -441,6 +495,40 @@ class TestDefaultIndexRegistry:
),
))
def test_code_shaped_display_names_stay_distinct_under_exact_name_lookup(
self,
) -> None:
"""PR2 review fix: display-name equality is exactly NFKC + trim +
casefold. Code-shaped names (``000016.SH`` vs ``sh000016``) that
identity normalization would falsely collapse (``_normalize_index_key``
treats them as the same resolver key ``sh000016``) must stay distinct
for the name lookup, so each exact query resolves its own entry and
neither is reported ambiguous."""
registry = IndexRegistry((
IndexEntry(
bare_code="000016",
exchange="SH",
canonical_id="sh000016",
display_name="000016.SH",
),
IndexEntry(
bare_code="000999",
exchange="SH",
canonical_id="sh000999",
display_name="sh000016",
),
))
# Identity normalization collapses both names to ``sh000016``, but the
# name lookup must NOT reuse it — each display name resolves exactly.
entry = registry.find_by_display_name("000016.SH")
assert entry is not None
assert entry.display_name == "000016.SH"
entry = registry.find_by_display_name("sh000016")
assert entry is not None
assert entry.display_name == "sh000016"
assert registry.is_ambiguous_display_name("000016.SH") is False
assert registry.is_ambiguous_display_name("sh000016") is False
# ---------------------------------------------------------------------------
# Batch parsing helper.

View File

@@ -43,6 +43,19 @@ class _FakePipeline:
return _make_failed_result(kwargs["code"])
class _CapturingPipeline:
"""Records the final ``process_single_stock`` kwargs for target assertions."""
calls = [] # class-level so tests can assert via the fake module attribute
def __init__(self, *args, **kwargs):
pass
def process_single_stock(self, *args, **kwargs):
type(self).calls.append(kwargs)
return _make_failed_result(kwargs["code"])
class TestTaskService(unittest.TestCase):
def test_run_analysis_marks_failed_for_unsuccessful_result(self):
service = TaskService()
@@ -116,6 +129,94 @@ class TestTaskService(unittest.TestCase):
# executor.submit(self._run_analysis, code, task_id, ...) — code is arg[1]
self.assertEqual(captured["args"][1], "csi930955")
def test_submit_analysis_with_index_target_skips_resolver_and_keeps_canonical(self):
"""PR2: when an INDEX ``AnalysisTarget`` is supplied, ``submit_analysis``
must use ``target.canonical_id`` verbatim and skip the stock-code
resolver — otherwise ``sh000016`` would be rewritten to ``SH000016``."""
from src.services.stock_list_parser import AnalysisTarget, ParseStatus
service = TaskService()
service._tasks = {}
service._tasks_lock = threading.Lock()
captured = {}
executor = MagicMock()
def capture_submit(*args, **kwargs):
captured["args"] = args
return "future"
executor.submit.side_effect = capture_submit
service._executor = executor
target = AnalysisTarget(
raw_input="sh000016",
asset_type=ParseStatus.INDEX,
canonical_id="sh000016",
display_code="上证50",
exchange="SH",
)
with patch("src.services.task_service.resolve_index_stock_code_for_analysis") as mock_resolve:
result = service.submit_analysis(
"sh000016", report_type="simple", query_source="cli",
analysis_target=target,
)
mock_resolve.assert_not_called()
self.assertEqual(result["code"], "sh000016")
self.assertIn("args", captured)
# executor.submit(self._run_analysis, code, task_id, ...) — code is arg[1]
self.assertEqual(captured["args"][1], "sh000016")
# analysis_target is the last positional arg handed to _run_analysis
self.assertIs(captured["args"][-1], target)
def test_submit_analysis_rejects_non_index_target(self):
from src.services.stock_list_parser import parse_analysis_target
service = TaskService()
service._executor = MagicMock()
target = parse_analysis_target("600519")
with self.assertRaisesRegex(ValueError, "must be an INDEX target"):
service.submit_analysis("600519", analysis_target=target)
service._executor.submit.assert_not_called()
def test_run_analysis_passes_index_target_to_pipeline(self):
"""PR2: the background task must forward the same ``analysis_target``
to ``process_single_stock`` so the pipeline receives canonical code and
the structured INDEX target together."""
from src.services.stock_list_parser import AnalysisTarget, ParseStatus
service = TaskService()
service._tasks = {}
service._tasks_lock = threading.Lock()
fake_main = ModuleType("main")
fake_main.StockAnalysisPipeline = _CapturingPipeline
target = AnalysisTarget(
raw_input="930955.CSI",
asset_type=ParseStatus.INDEX,
canonical_id="csi930955",
display_code="红利低波100",
exchange="CSI",
)
_CapturingPipeline.calls = []
with patch.dict("sys.modules", {"main": fake_main}), patch(
"src.config.get_config", return_value=SimpleNamespace()
):
result = service._run_analysis(
code="csi930955", task_id="task-index-1", analysis_target=target
)
self.assertFalse(result["success"]) # _CapturingPipeline returns failure
self.assertEqual(len(_CapturingPipeline.calls), 1)
call = _CapturingPipeline.calls[0]
self.assertEqual(call["code"], "csi930955")
self.assertIs(call["analysis_target"], target)
if __name__ == "__main__":
import unittest