diff --git a/.env.example b/.env.example index 9c6e396ee..65cf33caf 100644 --- a/.env.example +++ b/.env.example @@ -31,7 +31,7 @@ ALPHASIFT_ENABLED=false # - ALPHASIFT_ENABLED 只影响 AlphaSift 选股流程,不会改写/迁移/清理现有 LITELLM_*/LLM_* 配置。 # - 关闭/回退只需将 ALPHASIFT_ENABLED 置 false;现有 provider/base_url/custom headers 与 fallback 语义保持原状。 # - 兼容依据(运维核验):LiteLLM providers 与 OpenAI-compatible 语义说明见下方注释;运行时默认使用 requirements.txt 中固定 litellm 版本。 -ALPHASIFT_INSTALL_SPEC=git+https://github.com/ZhuLinsen/alphasift.git@de54ea0da367be85770d9589a5bf7ded4f62d386 +ALPHASIFT_INSTALL_SPEC=git+https://github.com/ZhuLinsen/alphasift.git@d038c52c468543726fc1fd830b53c27d3f09d6da # AlphaSift 全市场快照源优先级;未配置时 DSA 调用 AlphaSift 会优先使用更稳的东方财富数据中心源。 # SNAPSHOT_SOURCE_PRIORITY=em_datacenter,tushare,efinance,akshare_em # AlphaSift 最新版支持 last-good 快照、日线历史和行业/概念 provider 缓存;DSA 运行时默认使用 data/alphasift 下的隔离缓存目录。 diff --git a/apps/dsa-web/src/api/alphasift.ts b/apps/dsa-web/src/api/alphasift.ts index dc48acbe3..a5692458e 100644 --- a/apps/dsa-web/src/api/alphasift.ts +++ b/apps/dsa-web/src/api/alphasift.ts @@ -132,6 +132,9 @@ export type AlphaSiftHotspotStock = { volumeRatio?: number | null; role?: string; hotStockScore?: number | null; + source?: string; + sourceConfidence?: number | null; + fallbackUsed?: boolean; }; export type AlphaSiftHotspotDetail = { @@ -139,11 +142,21 @@ export type AlphaSiftHotspotDetail = { provider: string; topic: string; name?: string; + canonicalTopic?: string; + aliases?: string[]; summary?: string; + summaryDetail?: Record; route: AlphaSiftHotspotRouteItem[]; + timeline?: AlphaSiftHotspotRouteItem[]; stocks: AlphaSiftHotspotStock[]; stockCount: number; sourceErrors?: string[]; + qualityStatus?: 'available' | 'partial' | 'stale' | 'failed' | string; + missingFields?: string[]; + fallbackUsed?: boolean; + stale?: boolean; + staleAgeHours?: number | null; + resolverCandidates?: Record[]; }; export type AlphaSiftHotspotsResponse = { diff --git a/apps/dsa-web/src/pages/StockScreeningPage.tsx b/apps/dsa-web/src/pages/StockScreeningPage.tsx index e7cc04911..8810c1edc 100644 --- a/apps/dsa-web/src/pages/StockScreeningPage.tsx +++ b/apps/dsa-web/src/pages/StockScreeningPage.tsx @@ -734,12 +734,27 @@ const StockScreeningPage: React.FC = () => {

{loadingHotspotDetail ? '正在读取发酵路线与概念股...' : hotspotDetail?.summary || '点击题材查看发酵路线与概念股。'}

+ {hotspotDetail?.canonicalTopic && hotspotDetail.canonicalTopic !== selectedHotspotTopic ? ( +

标准题材:{hotspotDetail.canonicalTopic}

+ ) : null} + +
+ {hotspotDetail?.qualityStatus ? ( + + 质量 {hotspotDetail.qualityStatus} + + ) : null} + {hotspotDetail?.fallbackUsed || hotspotDetail?.stale ? ( + + {hotspotDetail.staleAgeHours != null ? `缓存回退 ${formatNumber(hotspotDetail.staleAgeHours, 1)}h` : '缓存回退'} + + ) : null} + {hotspotDetail?.stockCount != null ? ( + + 概念股 {hotspotDetail.stockCount} + + ) : null}
- {hotspotDetail?.stockCount != null ? ( - - 概念股 {hotspotDetail.stockCount} - - ) : null} {hotspotDetailError ? ( @@ -748,6 +763,14 @@ const StockScreeningPage: React.FC = () => {

) : null} + {hotspotDetail && ((hotspotDetail.missingFields || []).length > 0 || (hotspotDetail.sourceErrors || []).length > 0) ? ( +

+ 详情降级: + {(hotspotDetail.missingFields || []).length > 0 ? `缺失 ${(hotspotDetail.missingFields || []).join('、')}` : ''} + {(hotspotDetail.sourceErrors || []).length > 0 ? ` ${(hotspotDetail.sourceErrors || []).slice(0, 2).join(';')}` : ''} +

+ ) : null} + {hotspotDetail ? (
@@ -778,6 +801,13 @@ const StockScreeningPage: React.FC = () => {

涨跌幅 {formatNumber(stock.changePct)}% · 热度 {formatNumber(stock.hotStockScore, 0)}

+ {stock.source || stock.sourceConfidence != null || stock.fallbackUsed ? ( +

+ 来源 {stock.source || '-'} + {stock.sourceConfidence != null ? ` · 置信 ${formatPercent(stock.sourceConfidence)}` : ''} + {stock.fallbackUsed ? ' · 回退' : ''} +

+ ) : null}
))}
diff --git a/apps/dsa-web/src/pages/__tests__/StockScreeningPage.test.tsx b/apps/dsa-web/src/pages/__tests__/StockScreeningPage.test.tsx index 5254fb2fd..0272de4fa 100644 --- a/apps/dsa-web/src/pages/__tests__/StockScreeningPage.test.tsx +++ b/apps/dsa-web/src/pages/__tests__/StockScreeningPage.test.tsx @@ -110,9 +110,24 @@ describe('StockScreeningPage', () => { provider: 'akshare', topic: 'AI算力', name: 'AI算力', + canonicalTopic: '算力', summary: 'AI算力 盘中发酵。', + qualityStatus: 'stale', + missingFields: ['live_stocks'], + fallbackUsed: true, + stale: true, + staleAgeHours: 2.5, + sourceErrors: ['akshare timeout'], route: [{ title: '盘中发酵', description: '出现大笔买入。', source: 'eastmoney_board_change' }], - stocks: [{ code: '300000', name: '中际旭创', role: '核心龙头', hotStockScore: 88 }], + stocks: [{ + code: '300000', + name: '中际旭创', + role: '核心龙头', + hotStockScore: 88, + source: 'last_good_cache.leader_stocks', + sourceConfidence: 0.65, + fallbackUsed: true, + }], stockCount: 1, }); getHotspots.mockResolvedValue({ enabled: true, provider: 'akshare', hotspots: [], hotspotCount: 0 }); @@ -195,9 +210,15 @@ describe('StockScreeningPage', () => { expect(screen.getByText('加速主升')).toBeInTheDocument(); expect(screen.getByText(/中际旭创、工业富联/)).toBeInTheDocument(); expect(await screen.findByText('发酵路线')).toBeInTheDocument(); + expect(screen.getByText('标准题材:算力')).toBeInTheDocument(); + expect(screen.getByText('质量 stale')).toBeInTheDocument(); + expect(screen.getByText('缓存回退 2.5h')).toBeInTheDocument(); + expect(screen.getByText(/详情降级:/)).toBeInTheDocument(); + expect(screen.getByText(/缺失 live_stocks/)).toBeInTheDocument(); expect(screen.getByText('盘中发酵')).toBeInTheDocument(); expect(screen.getByText('概念股')).toBeInTheDocument(); expect(screen.getByText('中际旭创')).toBeInTheDocument(); + expect(screen.getByText(/来源 last_good_cache\.leader_stocks · 置信 65% · 回退/)).toBeInTheDocument(); }); it('loads selected hotspot detail once when switching themes', async () => { diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 599d12ced..f89ebd7ed 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -23,7 +23,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/). - [修复] 修复运行流 live SSE 事件未复用快照层递归脱敏规则的问题,避免本地路径、prompt/raw response、代理头等敏感诊断字段在 refetch 前短暂暴露。 -- [改进] AlphaSift 依赖锁定更新到 `de54ea0da367be85770d9589a5bf7ded4f62d386`,并为新版 last-good snapshot、日线历史、行业/概念 provider cache、hotspot 具体题材榜单、题材发酵路线、概念股详情、上次成功热点缓存与 post-analysis 元信息补齐 DSA 运行期和 Web 选股页适配;默认不启用 DSA deep-analysis 回调。 +- [改进] AlphaSift 依赖锁定更新到 `d038c52c468543726fc1fd830b53c27d3f09d6da`,并为新版 last-good snapshot、日线历史、行业/概念 provider cache、hotspot 具体题材榜单、题材发酵路线、概念股详情、上次成功热点缓存与 post-analysis 元信息补齐 DSA 运行期和 Web 选股页适配;默认不启用 DSA deep-analysis 回调。 - [修复] 桌面发布打包改用冻结可执行文件运行时探针校验 `alphasift.dsa_adapter`,避免 macOS PyInstaller 将模块内嵌进可执行文件时被文件系统/zip 扫描误判为缺失。 - [改进] #1381 个股分析新增按当日/市场复用的大盘环境摘要,普通 Pipeline 与 Agent 分析 Prompt 可读取低敏大盘背景,并在高风险/退潮环境下软化激进买入建议。 - [改进] #1381 新增默认开启的 `DAILY_MARKET_CONTEXT_ENABLED` 配置,默认把大盘摘要注入个股分析并启用保守护栏,仍允许用户显式关闭且保留大盘复盘报告独立运行。 diff --git a/docs/alphasift-integration.md b/docs/alphasift-integration.md index 8f6829e08..12484c702 100644 --- a/docs/alphasift-integration.md +++ b/docs/alphasift-integration.md @@ -6,16 +6,33 @@ AlphaSift 作为独立仓库维护的选股引擎接入 DSA。DSA 默认不启 - 默认关闭:`ALPHASIFT_ENABLED=false`。 - 启用入口:设置页或选股页点击开启,或在 `.env` 中配置 `ALPHASIFT_ENABLED=true`。 -- 依赖来源:`requirements.txt` 固定到已验证的 AlphaSift 适配层 commit:`git+https://github.com/ZhuLinsen/alphasift.git@de54ea0da367be85770d9589a5bf7ded4f62d386#egg=alphasift`(对应提交 `https://github.com/ZhuLinsen/alphasift/commit/de54ea0da367be85770d9589a5bf7ded4f62d386`)。该来源覆盖 `alphasift.dsa_adapter` 契约与 `screen/list_strategies/get_status` 调用。 +- 依赖来源:`requirements.txt` 固定到已验证的 AlphaSift 适配层 commit:`git+https://github.com/ZhuLinsen/alphasift.git@d038c52c468543726fc1fd830b53c27d3f09d6da#egg=alphasift`(对应提交 `https://github.com/ZhuLinsen/alphasift/commit/d038c52c468543726fc1fd830b53c27d3f09d6da`,来源 PR `https://github.com/ZhuLinsen/alphasift/pull/11`)。该来源覆盖 `alphasift.dsa_adapter` 契约与 `screen/list_strategies/get_status` 调用。 - 修复安装来源:`ALPHASIFT_INSTALL_SPEC` 仍保留,默认等于同一个受信任 commit。它不再是策略列表或选股接口的运行时安装主路径,只用于显式调用 `/api/v1/alphasift/install` 时做修复安装和来源校验。 +- 旧配置迁移:如果现有 `.env` 明确保留旧的 `ALPHASIFT_INSTALL_SPEC=git+https://github.com/ZhuLinsen/alphasift.git@de54ea0da367be85770d9589a5bf7ded4f62d386`,该显式环境变量会覆盖代码默认值;升级热点详情缓存契约时应删除该行以使用内置默认值,或同步改为 `d038c52c468543726fc1fd830b53c27d3f09d6da` 后重新安装依赖/重建后端。 +- 回退方式:如新版 AlphaSift contract 运行异常,最小回退是将 `ALPHASIFT_ENABLED=false` 并重启后端;如需回退到旧 alphasift commit,请使用“完整回滚”路径,因为 `/api/v1/alphasift/install` 的 allow-list 与当前代码中的 `DEFAULT_ALPHASIFT_INSTALL_SPEC` 强绑定,单独改 `.env` 中的 `ALPHASIFT_INSTALL_SPEC` 不会通过允许源校验(会返回 `alphasift_install_spec_not_allowed`)。 - 缺失依赖边界:如果运行环境缺少 `alphasift.dsa_adapter`,`status` 返回 `available=false + diagnostics.reason=missing_module`;`strategies` 和 `screen` 返回 `424` 并提示执行 `pip install -r requirements.txt` 或重建 Docker/桌面后端产物,不会在业务请求中自动 `pip install`。 - 运行异常边界:若适配层可导入但 `get_status()` 报错或返回 `available=false`,DSA 返回 `424 + diagnostics`,保留故障诊断,防止用重装掩盖真实运行时错误。 - 策略归属:策略列表、策略参数、全市场快照、初筛、因子评分和 LLM 重排由 AlphaSift 负责;DSA 负责开关、API 壳、数据 provider、展示和错误提示。 + +## 外部契约来源与迁移边界 + +- 外部契约依据:本次 AlphaSift 运行契约(含 `schema_version=2` 的热点缓存与题材详情字段)对应 GitHub 提交 `https://github.com/ZhuLinsen/alphasift/commit/d038c52c468543726fc1fd830b53c27d3f09d6da`。 + 该 commit 在 DSA 中通过以下链路生效:`requirements.txt` 安装 pin、`src/config.py` 默认 `DEFAULT_ALPHASIFT_INSTALL_SPEC`、`.env.example` 默认示例值。 +- 升级路径: + - 仅需 `git pull` 后按部署方式重建依赖(`pip install -r requirements.txt`)并重启服务; + - 在 Web 端/设置页保持 `ALPHASIFT_ENABLED=true` 或按需开启即可接入新 `schema_version=2` 行为; + - 对于已经手动配置的 `ALPHASIFT_INSTALL_SPEC`,DSA 不做静默替换,只影响 `/api/v1/alphasift/install` 的来源校验展示,不会覆盖原配置。 +- 回滚边界(两条路径): + - 路径 A(业务临时回退,5 分钟内可执行):将 `ALPHASIFT_ENABLED=false` 并重启服务/进程。核心分析、日报报表与原有 LLM 调用链路不受该开关影响;此路径不影响依赖版本。 + - 路径 B(适配层版本回滚):恢复到上一个版本的 `requirements.txt` 与 `src/config.py`(`DEFAULT_ALPHASIFT_INSTALL_SPEC`)到旧值,并同步回退 `.env.example` 的默认示例,重建后端镜像/桌面后端产物(等价于完整 revert 本次 PR)后重启。仅改 `.env` 回退 `ALPHASIFT_INSTALL_SPEC` 会被当前 allow-list 拒绝,必须与 `requirements.txt` 与代码 allow-list 一起回退。 + - 安装入口说明:`/api/v1/alphasift/install` 仅允许当前代码 `ALLOWED_ALPHASIFT_INSTALL_SPECS`(目前为单值集合)中的来源。若确有需要临时接入其他来源,先在环境中手动安装并确认适配层可导入,再重启服务。 +- 兼容说明:`ALPHASIFT_INSTALL_SPEC` 与旧配置同名变量保持“展示可见但不再默认生效”的兼容语义;DSA 只在调用时读取当前值并做 allow-list 校验,不会在运行期自动重写 `.env`。 + - DSA 增强:AlphaSift 通过 DSA provider context 在 LLM 重排前只补充 Top 候选的轻量实时行情和基本面上下文,不在初筛阶段抓新闻;DSA API 返回阶段会对最终 Top 候选补新闻和辅助摘要,并通过 `dsa_enrichment` 记录复用或补全情况。 - 日 K 线补特征:DSA 调用 AlphaSift 时会优先复用 DSA 历史行情加载链路(数据库缓存、Tushare、Efinance、Akshare、Pytdx、Baostock、Yfinance 等 fallback),仅在 DSA 链路无可用数据时回退到 AlphaSift 原始日线数据源,减少单一上游超时拖垮选股。 - LLM 环境:DSA 调用 AlphaSift 时会桥接 DSA 已解析的 `LITELLM_MODEL`、`LITELLM_FALLBACK_MODELS`、`LLM_CHANNELS`、`LLM__*`、`LITELLM_CONFIG`、渠道额外请求头和各模型密钥;AlphaSift 独立运行时仍使用自己的 `.env`/环境变量。 - 快照源:DSA 调用 AlphaSift 时,未显式配置 `SNAPSHOT_SOURCE_PRIORITY` 会默认优先使用 `em_datacenter`,减少 Tushare/东方财富行情接口在夜间或网络抖动时逐个失败造成的等待;显式配置的源顺序会原样保留。 -- 最新 AlphaSift 能力:锁定 commit `de54ea0da367be85770d9589a5bf7ded4f62d386` 包含选股 pipeline 性能优化、last-good snapshot fallback、日线历史缓存、行业/概念 provider cache、热点/行业热度因子、hotspot 热点题材榜单与本地 scorecard/post-analysis 元信息。DSA 调用时会注入隔离缓存默认路径 `data/alphasift`、`data/alphasift/snapshot.last_good.json`、`data/alphasift/daily_history`、`data/alphasift/industry_provider_cache`;Web 选股页提供“热点题材”手动刷新入口,请求 `/api/v1/alphasift/hotspots` 时会显式使用 `akshare` provider 优先拉取具体概念/题材异动(例如玻璃基板、机器人执行器、减速器),行业板块仅作为兜底;默认打开页面时优先读取上一次成功的热点题材缓存,点击刷新才实时拉取并覆盖缓存,实时拉取失败时会尽量回退旧缓存;点击题材会请求 `/api/v1/alphasift/hotspots/{topic}` 展示发酵路线与概念股;不会默认触发 AlphaSift 的 DSA deep-analysis 回调,避免无提示扩大递归调用面。 +- 最新 AlphaSift 能力:锁定 commit `d038c52c468543726fc1fd830b53c27d3f09d6da` 包含选股 pipeline 性能优化、last-good snapshot fallback、日线历史缓存、行业/概念 provider cache、热点/行业热度因子、hotspot 热点题材榜单与本地 scorecard/post-analysis 元信息。DSA 调用时会注入隔离缓存默认路径 `data/alphasift`、`data/alphasift/snapshot.last_good.json`、`data/alphasift/daily_history`、`data/alphasift/industry_provider_cache`;Web 选股页提供“热点题材”手动刷新入口,请求 `/api/v1/alphasift/hotspots` 时会显式使用 `akshare` provider 优先拉取具体概念/题材异动(例如玻璃基板、机器人执行器、减速器),行业板块仅作为兜底;默认打开页面时优先读取上一次成功的热点题材缓存,点击刷新才实时拉取并覆盖缓存,实时拉取失败时会尽量回退旧缓存;点击题材会请求 `/api/v1/alphasift/hotspots/{topic}` 展示发酵路线与概念股;不会默认触发 AlphaSift 的 DSA deep-analysis 回调,避免无提示扩大递归调用面。 - 风险提示:前端设置页和选股页展示第三方来源与投资风险说明;不会弹窗打断用户。 ## AlphaSift 适配层要求 @@ -95,7 +112,7 @@ context = { AlphaSift 会在 L1 初筛后、LLM 重排前调用 `context["dsa"]` 中的 provider,为有限 Top 候选补充 DSA 行情和基本面轻量上下文,并把 `dsa_context` 随候选返回。新闻搜索、完整摘要和缺失字段补全由 DSA API 在最终 Top 候选阶段执行;若候选已经携带完整新闻上下文,DSA API 返回阶段会复用这些字段,避免重复请求。 -AlphaSift 侧已在 `ZhuLinsen/alphasift@de54ea0da367be85770d9589a5bf7ded4f62d386` 提供 DSA provider context 支持、DSA adapter contract,并支持复用 DSA 的 `LLM_TIMEOUT_SEC`。 +AlphaSift 侧已在 `ZhuLinsen/alphasift@d038c52c468543726fc1fd830b53c27d3f09d6da` 提供 DSA provider context 支持、DSA adapter contract,并支持复用 DSA 的 `LLM_TIMEOUT_SEC`。 ## DSA 后端行为 @@ -109,7 +126,7 @@ AlphaSift 侧已在 `ZhuLinsen/alphasift@de54ea0da367be85770d9589a5bf7ded4f62d38 ## 配置兼容边界(LLM / LiteLLM / Base URL) - 兼容语义与版本证据(可追溯): - - 运行依赖约束:`requirements.txt` 中将 LiteLLM 固定到 `litellm>=1.80.10,!=1.82.7,!=1.82.8,<2.0.0`,并通过 `git+https://github.com/ZhuLinsen/alphasift.git@de54ea0da367be85770d9589a5bf7ded4f62d386` 安装 AlphaSift 适配层。 + - 运行依赖约束:`requirements.txt` 中将 LiteLLM 固定到 `litellm>=1.80.10,!=1.82.7,!=1.82.8,<2.0.0`,并通过 `git+https://github.com/ZhuLinsen/alphasift.git@d038c52c468543726fc1fd830b53c27d3f09d6da` 安装 AlphaSift 适配层。 - 文档依据: - LiteLLM Providers: https://docs.litellm.ai/docs/providers - LiteLLM OpenAI-compatible: https://docs.litellm.ai/docs/providers/openai_compatible @@ -142,7 +159,7 @@ AlphaSift 侧已在 `ZhuLinsen/alphasift@de54ea0da367be85770d9589a5bf7ded4f62d38 - 依赖与源码约束核验:`requirements.txt` 中的 `litellm` 约束与 `src/config.py`/`requirements.txt` 一致。 - Hotspot 契约兼容核验:`docs/alphasift-integration.md` 与 `api/v1/endpoints/alphasift.py`、`src/services/alphasift_service.py` 保持 `hotspots`/`hotspots/{topic}` 字段与 `tests/test_alphasift_api.py` 一致,调用前后默认使用 `snapshot.last_good` 缓存兜底。 -- 外部版本来源:本次集成依赖来源为 `https://github.com/ZhuLinsen/alphasift/commit/de54ea0da367be85770d9589a5bf7ded4f62d386`,需在复验时按该 commit pin 回放导入与接口契约。 +- 外部版本来源:本次集成依赖来源为 `https://github.com/ZhuLinsen/alphasift/commit/d038c52c468543726fc1fd830b53c27d3f09d6da`,需在复验时按该 commit pin 回放导入与接口契约。 - 行为核验:`src/services/alphasift_service.py` 的 `_build_alphasift_runtime_env` 与 `_build_alphasift_context` 仅在调用期写入进程环境;`/api/v1/alphasift/screen`、`strategies`、`status` 在运行期不回写 `.env`。 - 回退核验:关闭 `ALPHASIFT_ENABLED` 并重启配置链路后,系统恢复原始 `LITELLM_MODEL/FALLBACK_MODELS`、`LLM_CHANNELS` 与 `LLM_*` 运行语义,不执行迁移清理脚本。 - 语义来源核验:LiteLLM 文档(https://docs.litellm.ai/docs/providers)、OpenAI-compatible 文档(https://docs.litellm.ai/docs/providers/openai_compatible)与 LiteLLM 配置文档(https://docs.litellm.ai/docs/proxy/configs)用于核对 provider/model/base_url/extra_headers 映射链路。 @@ -189,5 +206,6 @@ Docker 镜像与桌面发布包保持一致:`docker/Dockerfile` 会通过 `req ## 回滚 - 关闭功能:设置页关闭 AlphaSift,或配置 `ALPHASIFT_ENABLED=false`。 -- 禁止启用:保持 `ALPHASIFT_ENABLED=false`;如需使用默认来源之外的 AlphaSift 安装包,先在后端 Python 环境完成手动安装并确认 `alphasift.dsa_adapter` 可导入。 +- 版本回退:如需降级 alphasift 适配层,必须同时回退仓库 `requirements.txt` 与 `src/config.py` 中受信任的 pin,否则仅改 `.env` 的 `ALPHASIFT_INSTALL_SPEC` 会被 `alphasift_install_spec_not_allowed` 拒绝;确认后重建依赖与重启服务。 +- 特殊来源:如需使用默认来源之外的 AlphaSift 安装包,先在后端 Python 环境完成手动安装并确认 `alphasift.dsa_adapter` 可导入,随后再重启服务(安装前不要触发 `/api/v1/alphasift/install` 的 allow-list 校验路径)。 - 回滚代码:移除 AlphaSift API 注册、Web 选股入口和相关配置项即可恢复到集成前流程;默认关闭状态下不会影响原有股票分析、报告生成和通知流程。 diff --git a/requirements.txt b/requirements.txt index f354584e8..8c5b3135e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -19,7 +19,7 @@ yfinance>=0.2.0 # Priority 4: Yahoo Finance (Fallback) longbridge>=0.2.77 # Priority 5: Longbridge OpenAPI fallback for US/HK stocks; OAuth capability checked at runtime tickflow>=0.1.0 # TickFlow official SDK (Issue #632, market review enhancement) # Built-in optional AlphaSift screening engine -git+https://github.com/ZhuLinsen/alphasift.git@de54ea0da367be85770d9589a5bf7ded4f62d386#egg=alphasift +git+https://github.com/ZhuLinsen/alphasift.git@d038c52c468543726fc1fd830b53c27d3f09d6da#egg=alphasift # Feishu lark-oapi>=1.0.0 # Feishu API diff --git a/src/config.py b/src/config.py index 2e150b845..aa36ee814 100644 --- a/src/config.py +++ b/src/config.py @@ -40,7 +40,7 @@ from src.llm import generation_params as llm_generation_params logger = logging.getLogger(__name__) DEFAULT_ALPHASIFT_INSTALL_SPEC = ( - "git+https://github.com/ZhuLinsen/alphasift.git@de54ea0da367be85770d9589a5bf7ded4f62d386" + "git+https://github.com/ZhuLinsen/alphasift.git@d038c52c468543726fc1fd830b53c27d3f09d6da" ) diff --git a/src/services/alphasift_service.py b/src/services/alphasift_service.py index ea011ecec..3339c30d5 100644 --- a/src/services/alphasift_service.py +++ b/src/services/alphasift_service.py @@ -98,7 +98,7 @@ def _load_alphasift_hotspot_cache(*, provider: str, top: int) -> Optional[Dict[s logger.warning("Failed to read AlphaSift hotspot cache from %s: %s", cache_path, exc) return None - payload = raw.get("payload") if isinstance(raw, dict) else None + payload = _normalize_alphasift_hotspot_cache_payload(raw) if not isinstance(payload, dict): return None hotspots = payload.get("hotspots") @@ -119,6 +119,34 @@ def _load_alphasift_hotspot_cache(*, provider: str, top: int) -> Optional[Dict[s return _remove_non_finite_json_values(cached) +def _normalize_alphasift_hotspot_cache_payload(raw: Any) -> Optional[Dict[str, Any]]: + if not isinstance(raw, dict): + return None + payload = raw.get("payload") + if isinstance(payload, dict): + return payload + hotspots = raw.get("hotspots") + if not isinstance(hotspots, list): + return None + metadata_raw = raw.get("metadata") + metadata: Dict[str, Any] = metadata_raw if isinstance(metadata_raw, dict) else {} + cached_at = raw.get("cached_at") or raw.get("generated_at") or metadata.get("generated_at") + return { + "enabled": True, + "provider": _env_text(metadata.get("provider")) or "akshare", + "provider_used": _env_text(metadata.get("provider_used")), + "fallback_used": False, + "cache_used": False, + "cached_at": cached_at, + "schema_version": raw.get("schema_version") or metadata.get("schema_version"), + "source_errors": _list_text_values(raw.get("source_errors") or metadata.get("source_errors")), + "stale": bool(raw.get("stale") or metadata.get("stale") or False), + "stale_age_hours": raw.get("stale_age_hours") or metadata.get("stale_age_hours"), + "hotspots": hotspots, + "hotspot_count": len(hotspots), + } + + def _write_alphasift_hotspot_cache(payload: Dict[str, Any]) -> None: cache_path = _alphasift_hotspot_cache_path() try: @@ -128,7 +156,25 @@ def _write_alphasift_hotspot_cache(payload: Dict[str, Any]) -> None: cache_payload["cache_used"] = False cache_payload["cached_at"] = cached_at cache_path.write_text( - json.dumps({"cached_at": cached_at, "payload": cache_payload}, ensure_ascii=False, indent=2), + json.dumps( + { + "schema_version": 2, + "generated_at": cached_at, + "cached_at": cached_at, + "metadata": { + "schema_version": 2, + "asset_type": "hotspot_cache", + "provider": cache_payload.get("provider"), + "provider_used": cache_payload.get("provider_used"), + "row_count": len(cache_payload.get("hotspots") or []), + "source_errors": _list_text_values(cache_payload.get("source_errors")), + }, + "hotspots": cache_payload.get("hotspots") or [], + "payload": cache_payload, + }, + ensure_ascii=False, + indent=2, + ), encoding="utf-8", ) except Exception as exc: @@ -281,17 +327,59 @@ class AlphaSiftService: provider_name, provider_arg = _resolve_hotspot_provider(provider) if not isinstance(provider_arg, DsaEastMoneyHotspotProvider): provider_arg = DsaEastMoneyHotspotProvider() + normalized: Dict[str, Any] = {} + hotspot_helper_error: str = "" try: + try: + hotspot_module = _import_alphasift_hotspot() + get_hotspot_detail = getattr(hotspot_module, "get_hotspot_detail", None) + except Exception: + get_hotspot_detail = None with _alphasift_runtime_env(self.config): - detail = provider_arg.hotspot_detail(topic_text) + if callable(get_hotspot_detail) and type(provider_arg) is DsaEastMoneyHotspotProvider: + try: + detail = get_hotspot_detail( + topic_text, + provider=provider_arg, + top_stocks=30, + history_path=_alphasift_hotspot_history_path(), + fallback_cache_path=_alphasift_hotspot_cache_path(), + ) + normalized = _normalize_alphasift_hotspot_detail( + detail, + provider=provider_name, + requested_topic=topic_text, + ) + normalized = _merge_provider_hotspot_route_fallback( + normalized, + provider=provider_arg, + topic=topic_text, + ) + except Exception as exc: + hotspot_helper_error = f"{exc}" + logger.warning( + "AlphaSift contract hotspot detail fallback to provider for topic=%s: %s", + topic_text, + hotspot_helper_error, + ) + else: + normalized = provider_arg.hotspot_detail(topic_text) + if not normalized: + normalized = provider_arg.hotspot_detail(topic_text) except Exception as exc: raise HTTPException( status_code=424, detail={"error": "alphasift_hotspot_detail_failed", "message": f"AlphaSift hotspot detail failed: {exc}"}, ) from exc - detail["enabled"] = True - detail["provider"] = provider_name - return _remove_non_finite_json_values(detail) + if hotspot_helper_error: + source_errors = _list_text_values(normalized.get("source_errors")) + source_errors.append(f"alphasift_hotspot_detail_fallback: {hotspot_helper_error}") + normalized["source_errors"] = source_errors + normalized["fallback_used"] = True + normalized["provider"] = provider_name + normalized["enabled"] = True + normalized["provider"] = provider_name + return _remove_non_finite_json_values(normalized) def screen(self, *, strategy: str, market: str, max_results: int) -> Dict[str, Any]: _ensure_alphasift_enabled(self.config) @@ -358,6 +446,168 @@ class AlphaSiftService: } +def _normalize_alphasift_hotspot_detail(detail: Any, *, provider: str, requested_topic: str) -> Dict[str, Any]: + raw_value = _remove_non_finite_json_values(_to_plain(detail)) + raw: Dict[str, Any] = raw_value if isinstance(raw_value, dict) else {} + summary_value = raw.get("summary") + summary: Dict[str, Any] = summary_value if isinstance(summary_value, dict) else {} + stocks_value = raw.get("stocks") + stocks: List[Any] = stocks_value if isinstance(stocks_value, list) else [] + timeline_value = raw.get("timeline") + timeline: List[Any] = timeline_value if isinstance(timeline_value, list) else [] + route_value = raw.get("route") + route: List[Any] = route_value if isinstance(route_value, list) and route_value else _hotspot_timeline_to_route(timeline) + source_errors = _list_text_values(raw.get("source_errors") or summary.get("source_errors")) + topic = _env_text(summary.get("topic") or raw.get("topic") or requested_topic) + canonical_topic = _env_text(summary.get("canonical_topic") or raw.get("canonical_topic")) + name = _env_text(summary.get("name") or raw.get("name") or canonical_topic or topic) + quality_status = _env_text(summary.get("quality_status") or raw.get("quality_status")) + missing_fields = _list_text_values(summary.get("missing_fields") or raw.get("missing_fields")) + summary_text_value = raw.get("summary") + summary_text = ( + summary_text_value + if isinstance(summary_text_value, str) + else _build_alphasift_hotspot_summary_text(summary, topic=topic, canonical_topic=canonical_topic) + ) + return { + "enabled": True, + "provider": provider, + "topic": topic, + "name": name, + "canonical_topic": canonical_topic, + "aliases": _list_text_values(summary.get("aliases") or raw.get("aliases")), + "summary": summary_text, + "summary_detail": summary, + "route": route, + "timeline": timeline, + "stocks": stocks, + "stock_count": len(stocks), + "source_errors": source_errors, + "quality_status": quality_status, + "missing_fields": missing_fields, + "fallback_used": bool(summary.get("fallback_used") or raw.get("fallback_used") or False), + "stale": bool(summary.get("stale") or raw.get("stale") or False), + "stale_age_hours": summary.get("stale_age_hours") or raw.get("stale_age_hours"), + "resolver_candidates": _list_dict_values(summary.get("resolver_candidates") or raw.get("resolver_candidates")), + } + + +def _list_text_values(value: Any) -> List[str]: + if value is None: + return [] + if isinstance(value, str): + text = _env_text(value) + return [text] if text else [] + if not isinstance(value, list): + text = _env_text(value) + return [text] if text else [] + return [text for item in value if (text := _env_text(item))] + + +def _list_dict_values(value: Any) -> List[Dict[str, Any]]: + if not isinstance(value, list): + return [] + return [item for item in value if isinstance(item, dict)] + + +def _hotspot_timeline_to_route(timeline: List[Any]) -> List[Dict[str, Any]]: + route: List[Dict[str, Any]] = [] + for item in timeline: + if not isinstance(item, dict): + continue + title = _env_text(item.get("title")) + if not title: + continue + date = _env_text(item.get("date") or item.get("published_at")) + source = _env_text(item.get("source")) or "alphasift_timeline" + route.append({ + "title": title, + "description": f"{date}:{title}" if date else title, + "source": source, + "url": _env_text(item.get("url")), + "published_at": date, + }) + if route: + return route + return [{ + "title": "等待发酵", + "description": "暂未获取到明确催化事件,可继续观察涨跌幅、成交额和核心个股联动。", + "source": "fallback", + }] + + +def _merge_provider_hotspot_route_fallback( + normalized: Dict[str, Any], + *, + provider: "DsaEastMoneyHotspotProvider", + topic: str, +) -> Dict[str, Any]: + if _has_meaningful_hotspot_route(normalized.get("route")): + return normalized + try: + provider_detail = provider.hotspot_detail(topic) + except Exception as exc: + logger.warning( + "AlphaSift provider route fallback failed for %s; keeping contract detail route: %s", + topic, + exc, + ) + return normalized + + raw_value = _remove_non_finite_json_values(_to_plain(provider_detail)) + raw: Dict[str, Any] = raw_value if isinstance(raw_value, dict) else {} + provider_route = raw.get("route") + if _has_meaningful_hotspot_route(provider_route): + normalized["route"] = provider_route + provider_timeline = raw.get("timeline") + if not normalized.get("timeline") and isinstance(provider_timeline, list): + normalized["timeline"] = provider_timeline + return normalized + + provider_timeline = raw.get("timeline") + if isinstance(provider_timeline, list) and provider_timeline: + provider_timeline_route = _hotspot_timeline_to_route(provider_timeline) + if _has_meaningful_hotspot_route(provider_timeline_route): + normalized["route"] = provider_timeline_route + normalized["timeline"] = provider_timeline + return normalized + + +def _has_meaningful_hotspot_route(route: Any) -> bool: + if not isinstance(route, list): + return False + for item in route: + if not isinstance(item, dict): + continue + title = _env_text(item.get("title")) + description = _env_text(item.get("description")) + source = _env_text(item.get("source")) + if not title and not description: + continue + if source == "fallback" and title == "等待发酵": + continue + return True + return False + + +def _build_alphasift_hotspot_summary_text(summary: Dict[str, Any], *, topic: str, canonical_topic: str) -> str: + display_topic = canonical_topic or topic + quality = _env_text(summary.get("quality_status")) + heat = _safe_float(summary.get("heat_score")) + stage = _env_text(summary.get("stage")) + leaders = summary.get("leaders") if isinstance(summary.get("leaders"), list) else [] + parts = [f"{display_topic} 当前热点详情"] + if heat is not None: + parts.append(f"热度 {heat:.1f}") + if stage: + parts.append(f"阶段 {stage}") + if leaders: + parts.append("核心股 " + "、".join(_env_text(item) for item in leaders[:3] if _env_text(item))) + if quality: + parts.append(f"质量状态 {quality}") + return ",".join(part for part in parts if part) + "。" + + def _install_alphasift(config: Config) -> Dict[str, Any]: with _ALPHASIFT_INSTALL_LOCK: install_spec_is_default = _is_default_alphasift_install_spec(config.alphasift_install_spec) diff --git a/tests/test_alphasift_api.py b/tests/test_alphasift_api.py index c4b2657a7..980dde52f 100644 --- a/tests/test_alphasift_api.py +++ b/tests/test_alphasift_api.py @@ -463,9 +463,229 @@ class AlphaSiftOpportunitiesApiTestCase(unittest.TestCase): ): cached = self._hotspots(config=config, provider="akshare", top=1, refresh=False) + cache_payload = json.loads(cache_path.read_text(encoding="utf-8")) + self.assertEqual(cached["cache_used"], True) self.assertEqual(cached["hotspots"][0]["topic"], "机器人执行器") discover_again.assert_not_called() + self.assertEqual(cache_payload["schema_version"], 2) + self.assertEqual(cache_payload["hotspots"][0]["topic"], "机器人执行器") + + def test_hotspots_reads_alphasift_v2_hotspot_cache(self) -> None: + config = self._config(enabled=True) + + with tempfile.TemporaryDirectory() as tmpdir: + cache_path = Path(tmpdir) / "hotspots.json" + cache_path.write_text( + json.dumps({ + "schema_version": 2, + "generated_at": "2026-06-13T02:55:00Z", + "source_errors": "provider timeout", + "metadata": {"schema_version": 2, "provider_used": "last_good_cache"}, + "hotspots": [ + { + "topic": "算力", + "canonical_topic": "算力", + "aliases": ["AI算力"], + "heat_score": 88.0, + "quality_status": "available", + } + ], + }), + encoding="utf-8", + ) + discover = MagicMock() + with ( + patch("src.services.alphasift_service.DSA_ALPHASIFT_HOTSPOT_CACHE_PATH", cache_path), + patch("src.services.alphasift_service._get_alphasift_status_snapshot", return_value=({}, True, {})), + patch("src.services.alphasift_service._import_alphasift_hotspot", return_value=SimpleNamespace(discover_hotspots=discover)), + ): + cached = self._hotspots(config=config, provider="akshare", top=1, refresh=False) + + self.assertEqual(cached["cache_used"], True) + self.assertEqual(cached["cached_at"], "2026-06-13T02:55:00Z") + self.assertEqual(cached["schema_version"], 2) + self.assertEqual(cached["source_errors"], ["provider timeout"]) + self.assertEqual(cached["hotspots"][0]["canonical_topic"], "算力") + discover.assert_not_called() + + def test_hotspot_detail_uses_alphasift_contract_detail_cache(self) -> None: + config = self._config(enabled=True) + captured: Dict[str, Any] = {} + + def get_hotspot_detail(topic: str, **kwargs: Any) -> Dict[str, Any]: + captured.update({"topic": topic, **kwargs}) + return { + "summary": { + "topic": topic, + "name": "算力", + "canonical_topic": "算力", + "aliases": "AI算力", + "heat_score": 88.0, + "stage": "加速主升", + "leaders": ["算力龙头"], + "quality_status": "stale", + "missing_fields": "live_stocks", + "source_errors": "none: no live detail rows", + "fallback_used": True, + "stale": True, + "stale_age_hours": 1.5, + "resolver_candidates": [{"topic": "算力", "confidence": 1.0}], + }, + "stocks": [{ + "code": "300001", + "name": "算力龙头", + "role": "核心龙头", + "source": "last_good_cache.leader_stocks", + "source_confidence": 0.65, + "fallback_used": True, + }], + "timeline": [{"date": "2026-06-13", "source": "新闻", "title": "AI算力催化"}], + } + + with tempfile.TemporaryDirectory() as tmpdir: + data_dir = Path(tmpdir) / "alphasift" + provider = alphasift_service.DsaEastMoneyHotspotProvider() + with ( + patch.dict(os.environ, {"ALPHASIFT_DATA_DIR": str(data_dir)}, clear=False), + patch("src.services.alphasift_service._get_alphasift_status_snapshot", return_value=({}, True, {})), + patch("src.services.alphasift_service._resolve_hotspot_provider", return_value=("akshare", provider)), + patch( + "src.services.alphasift_service._import_alphasift_hotspot", + return_value=SimpleNamespace(get_hotspot_detail=get_hotspot_detail), + ), + ): + payload = self._hotspot_detail(config=config, provider="akshare", topic="AI算力") + + self.assertEqual(captured["topic"], "AI算力") + self.assertIs(captured["provider"], provider) + self.assertEqual(captured["fallback_cache_path"], data_dir / "hotspots.json") + self.assertEqual(captured["history_path"], data_dir / "hotspot.history.jsonl") + self.assertEqual(payload["enabled"], True) + self.assertEqual(payload["provider"], "akshare") + self.assertEqual(payload["topic"], "AI算力") + self.assertEqual(payload["canonical_topic"], "算力") + self.assertEqual(payload["quality_status"], "stale") + self.assertEqual(payload["aliases"], ["AI算力"]) + self.assertEqual(payload["missing_fields"], ["live_stocks"]) + self.assertEqual(payload["source_errors"], ["none: no live detail rows"]) + self.assertEqual(payload["stocks"][0]["source"], "last_good_cache.leader_stocks") + self.assertEqual(payload["route"][0]["title"], "AI算力催化") + + def test_hotspot_detail_prefers_timeline_when_contract_route_is_empty(self) -> None: + config = self._config(enabled=True) + provider = alphasift_service.DsaEastMoneyHotspotProvider() + provider.hotspot_detail = MagicMock(side_effect=RuntimeError("provider fallback should not be used")) + + def get_hotspot_detail(topic: str, **_kwargs: Any) -> Dict[str, Any]: + return { + "summary": { + "topic": topic, + "name": "算力", + "canonical_topic": "算力", + "quality_status": "available", + }, + "stocks": [{ + "code": "300001", + "name": "算力龙头", + }], + "timeline": [{"date": "2026-06-13", "source": "新闻", "title": "AI算力催化"}], + "route": [], + } + + with ( + patch("src.services.alphasift_service._get_alphasift_status_snapshot", return_value=({}, True, {})), + patch("src.services.alphasift_service._resolve_hotspot_provider", return_value=("akshare", provider)), + patch( + "src.services.alphasift_service._import_alphasift_hotspot", + return_value=SimpleNamespace(get_hotspot_detail=get_hotspot_detail), + ), + ): + payload = self._hotspot_detail(config=config, provider="akshare", topic="AI算力") + + self.assertEqual(payload["route"][0]["title"], "AI算力催化") + self.assertEqual(payload["route"][0]["source"], "新闻") + provider.hotspot_detail.assert_not_called() + + def test_hotspot_detail_falls_back_to_provider_when_contract_helper_fails(self) -> None: + config = self._config(enabled=True) + provider = alphasift_service.DsaEastMoneyHotspotProvider() + provider.hotspot_detail = MagicMock(return_value={ + "topic": "机器人执行器", + "summary": "机器人执行器 盘中发酵。", + "route": [{"title": "盘中发酵", "description": "provider fallback route.", "source": "eastmoney_board_change"}], + "stocks": [{"code": "002000", "name": "旧路径个股"}], + "stock_count": 1, + "source_errors": [], + }) + + def get_hotspot_detail(topic: str, **_kwargs: Any) -> Dict[str, Any]: + raise RuntimeError("contract parser broken") + + with ( + patch("src.services.alphasift_service._get_alphasift_status_snapshot", return_value=({}, True, {})), + patch("src.services.alphasift_service._resolve_hotspot_provider", return_value=("akshare", provider)), + patch( + "src.services.alphasift_service._import_alphasift_hotspot", + return_value=SimpleNamespace(get_hotspot_detail=get_hotspot_detail), + ), + ): + payload = self._hotspot_detail(config=config, provider="akshare", topic="机器人执行器") + + self.assertEqual(payload["route"][0]["title"], "盘中发酵") + self.assertEqual(payload["route"][0]["source"], "eastmoney_board_change") + provider.hotspot_detail.assert_called_once_with("机器人执行器") + self.assertEqual( + payload["source_errors"][0], + "alphasift_hotspot_detail_fallback: contract parser broken", + ) + self.assertTrue(payload["fallback_used"]) + + def test_hotspot_detail_preserves_provider_route_when_contract_detail_has_no_timeline(self) -> None: + config = self._config(enabled=True) + provider = alphasift_service.DsaEastMoneyHotspotProvider() + provider.hotspot_detail = MagicMock(return_value={ + "topic": "机器人执行器", + "summary": "机器人执行器 盘中发酵。", + "route": [{ + "title": "盘中发酵", + "description": "机器人执行器 当前有异动记录。", + "source": "eastmoney_board_change", + }], + "stocks": [{"code": "002000", "name": "旧路径个股"}], + "stock_count": 1, + "source_errors": [], + }) + + def get_hotspot_detail(topic: str, **_kwargs: Any) -> Dict[str, Any]: + return { + "summary": { + "topic": topic, + "name": "机器人执行器", + "canonical_topic": "机器人执行器", + "quality_status": "available", + }, + "stocks": [{ + "code": "300000", + "name": "合约路径个股", + "source": "alphasift_contract", + }], + } + + with ( + patch("src.services.alphasift_service._get_alphasift_status_snapshot", return_value=({}, True, {})), + patch("src.services.alphasift_service._resolve_hotspot_provider", return_value=("akshare", provider)), + patch( + "src.services.alphasift_service._import_alphasift_hotspot", + return_value=SimpleNamespace(get_hotspot_detail=get_hotspot_detail), + ), + ): + payload = self._hotspot_detail(config=config, provider="akshare", topic="机器人执行器") + + self.assertEqual(payload["route"][0]["title"], "盘中发酵") + self.assertEqual(payload["route"][0]["source"], "eastmoney_board_change") + self.assertEqual(payload["stocks"][0]["name"], "合约路径个股") + provider.hotspot_detail.assert_called_once_with("机器人执行器") def test_hotspot_detail_returns_route_and_concept_stocks(self) -> None: config = self._config(enabled=True) diff --git a/tests/test_docker_entrypoint.py b/tests/test_docker_entrypoint.py index c3b576dd5..58b3e21bf 100644 --- a/tests/test_docker_entrypoint.py +++ b/tests/test_docker_entrypoint.py @@ -29,7 +29,7 @@ def test_dockerfile_bundles_default_alphasift_adapter() -> None: requirements = (REPO_ROOT / "requirements.txt").read_text(encoding="utf-8") assert "git \\" in dockerfile - assert "git+https://github.com/ZhuLinsen/alphasift.git@de54ea0da367be85770d9589a5bf7ded4f62d386#egg=alphasift" in requirements + assert "git+https://github.com/ZhuLinsen/alphasift.git@d038c52c468543726fc1fd830b53c27d3f09d6da#egg=alphasift" in requirements assert "pip install --no-cache-dir -r requirements.txt" in dockerfile assert "import alphasift.dsa_adapter" in dockerfile diff --git a/tests/test_system_config_service.py b/tests/test_system_config_service.py index 3060dc396..af56a483c 100644 --- a/tests/test_system_config_service.py +++ b/tests/test_system_config_service.py @@ -732,7 +732,7 @@ class SystemConfigServiceTestCase(unittest.TestCase): "LLM_OPENAI_MODELS=openai/gpt-4o-mini,openai/gpt-4o", "LITELLM_FALLBACK_MODELS=openai/gpt-4o-mini,openai/gpt-4o", "ALPHASIFT_ENABLED=false", - "ALPHASIFT_INSTALL_SPEC=git+https://github.com/ZhuLinsen/alphasift.git@de54ea0da367be85770d9589a5bf7ded4f62d386", + "ALPHASIFT_INSTALL_SPEC=git+https://github.com/ZhuLinsen/alphasift.git@d038c52c468543726fc1fd830b53c27d3f09d6da", "GEMINI_API_KEY=legacy-secret", ) @@ -756,7 +756,7 @@ class SystemConfigServiceTestCase(unittest.TestCase): self.assertEqual(current_map["ALPHASIFT_ENABLED"], "true") self.assertEqual( current_map["ALPHASIFT_INSTALL_SPEC"], - "git+https://github.com/ZhuLinsen/alphasift.git@de54ea0da367be85770d9589a5bf7ded4f62d386", + "git+https://github.com/ZhuLinsen/alphasift.git@d038c52c468543726fc1fd830b53c27d3f09d6da", ) self.assertEqual(current_map["GEMINI_API_KEY"], "legacy-secret") self.assertEqual(current_map["LITELLM_MODEL"], "openai/gpt-4o-mini")