docs(plans): 流水线待办三矛盾修复实施计划——触点②补源(assessable+t≥2 上待办)+评审页落选原因行+工厂页可判 tooltip+一问文案对齐,四编码任务+spec 注记+factor 域转单,10-08 窗口后派发 [nas]
CI/CD / test (push) Failing after 12m47s
CI/CD / nas-deploy (push) Has been skipped
CI/CD / nas-verify (push) Has been skipped

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-10-08 09:45:52 +08:00
parent 508ead3b45
commit 196e046187
@@ -0,0 +1,387 @@
# 流水线待办三矛盾修复 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 修复 VPS 流水线控制台三处矛盾——待办板补「因子晋级评审候选」源(真 bug)、评审页显示落选原因、工厂页可判徽标语义说明、一问文案对齐数据实况。
**Architecture:** 纯展示层+聚合层修补,判定语义(五问内容、t≥2 门槛、状态机)零改动。后端在 `routes_pipeline.py` 的 `todos()` 加第五聚合源(复用 `factors()` 已有的注册表+eval 库读取路径);前端抽共享过滤模块 `promotionFilter.ts` 供评审页下拉与落选行共用,工厂页加 tooltip。
**Tech Stack:** FastAPI + pytest(后端);Vue3 + vitest + @vue/test-utils(前端)。
## Global Constraints
- **commit 标签**:每个代码 commit 末尾带 `[vps]`(VPS 常驻 sanguo-api 中招)+ `[no-doc]`(spec 更新由 Task 5 同 push 承担);Task 5 的 docs commit 带 `[vps]` 不带 `[no-doc]`。commit 用 `git commit -F -` heredoc 式。
- **共享树纪律**:`git add` 必须 pathspec 精确列出文件,禁 `git add -A`/`git add .`。
- **判定语义不动**:`REVIEW_T_GATE = 2` 只是显式常量化现有门槛(前后端各持同值,注释互相引用),不是改门槛。
- **测试命令**:后端 `./venv310/bin/python -m pytest tests/portfolio/test_routes_pipeline.py -q`(工作目录=repo 根);前端 `cd frontend && npx vitest run <文件>`。
- **范围外**(勿顺手做):sources 健康度块/as_of 新鲜度/前端 catch 错误态分流(未拍板);fa_gross_margin 状态机自查(转 factor 域,见 Task 7);登录页 L1。
- **背景速览**(为何改):因子工厂显示 3 个「可判」因子(composite_quant12 t=10.2 / fa_gross_margin t=None / fund_gross_margin t=-0.21),评审页下拉只收 t≥2 的 1 个(`PromotionReview.vue:13` 过滤),待办板却说「0 件等你决策」——三页各说各话。
---
### Task 1: 后端 todos 补「因子晋级评审候选」源
**Files:**
- Modify: `sanguo_api/routes_pipeline.py:270-349`(`todos()` 函数)
- Test: `tests/portfolio/test_routes_pipeline.py`(文件末尾追加)
**Interfaces:**
- Consumes: `_factor_registry_path()`(:352 已有)、`_eval_latest_points()`(:357 已有,返回 `{factor_name: {"t": float|None}}`)
- Produces: todos items 新形状 `{id: "review-{name}", touchpoint: "promotion", title, detail, path: "/pipeline/review", severity: "warn", updatedAt}`;常量 `REVIEW_T_GATE = 2.0`
- [ ] **Step 1: 写失败测试**——在 `tests/portfolio/test_routes_pipeline.py` 末尾追加:
```python
def test_todos_factor_review_candidates(client, token, env, tmp_path, monkeypatch):
# 三矛盾②:触点②补源——assessable 且近窗 t≥2 → 待办「待晋级评审」;
# 无 IC 数据/t 不显著/非 assessable 一律不上板(与评审页下拉同门)
monkeypatch.setenv("SANGUO_FACTOR_REGISTRY",
str(tmp_path / "factor_registry.yaml"))
with open(tmp_path / "factor_registry.yaml", "w", encoding="utf-8") as f:
yaml.safe_dump({"factors": {
"fa_hot": {"name": "fa_hot", "hypothesis": "H-1",
"status": "assessable", "versions": []},
"fa_nodata": {"name": "fa_nodata", "hypothesis": "H-2",
"status": "assessable", "versions": []},
"fa_weak": {"name": "fa_weak", "hypothesis": "H-3",
"status": "assessable", "versions": []},
"fa_prom": {"name": "fa_prom", "hypothesis": "H-4",
"status": "promoted", "promotion_t": 3.0,
"versions": []},
}}, f, allow_unicode=True)
monkeypatch.setenv("SANGUO_FACTOR_EVAL_DB",
str(tmp_path / "no_such_eval.db"))
from datetime import date
from sanguo_api.routes_pipeline import set_pipeline_eval_db_path
from sanguo_factor import eval_store
inj_db = str(tmp_path / "factor_eval.db")
eval_store.init_db(inj_db)
end = date.today().isoformat()
run_id = eval_store.create_run(inj_db, "monthly_2026-09", "sz", 10, 1,
"2025-10-01", end, {})
eval_store.save_results(inj_db, run_id, [
{"factor": "fa_hot", "category": "fundamental", "expression": "x",
"metrics": {"1": {"t_stat": 3.4, "ic_mean": 0.03, "count": 240}}},
{"factor": "fa_weak", "category": "fundamental", "expression": "y",
"metrics": {"1": {"t_stat": -0.2, "ic_mean": 0.001, "count": 240}}},
])
eval_store.finish_run(inj_db, run_id, "done", 1)
set_pipeline_eval_db_path(inj_db)
try:
r = client.get("/api/v1/pipeline/todos", headers=_h(token))
assert r.status_code == 200
items = r.json()["items"]
rev = [i for i in items if i["id"] == "review-fa_hot"]
assert len(rev) == 1
assert rev[0]["touchpoint"] == "promotion"
assert rev[0]["path"] == "/pipeline/review"
assert rev[0]["severity"] == "warn"
assert "3.40" in rev[0]["detail"] or "3.4" in rev[0]["detail"]
ids = [i["id"] for i in items]
assert "review-fa_nodata" not in ids # 无 IC 数据不上板
assert "review-fa_weak" not in ids # t<2 不上板
assert "review-fa_prom" not in ids # 非 assessable 不上板
finally:
set_pipeline_eval_db_path("")
```
- [ ] **Step 2: 跑测试确认失败**:`./venv310/bin/python -m pytest tests/portfolio/test_routes_pipeline.py::test_todos_factor_review_candidates -q` → 预期 FAIL(`review-fa_hot` 不存在,`rev` 为空)
- [ ] **Step 3: 实现**——`sanguo_api/routes_pipeline.py` 两处改动:
(a) 模块级常量(放在 `_monthly_dir()` 之后):
```python
# 因子晋级评审门:近窗 t≥2 才值得人评五问(与前端 PromotionReview 的
# promotionFilter.REVIEW_T_GATE 同值镜像,改门须两处同改+spec 注记)
REVIEW_T_GATE = 2.0
```
(b) `todos()` 内,在「# ② 因子衰减告警」注释行之前插入:
```python
# ①′ 因子晋级评审候选(触点②另一半,10-08 补源):可判且近窗 t≥2
# = 评审页下拉同门——待办板与评审页从此同口径,「N 件等你决策」说真话
from sanguo_factor import version_registry as vr
from sanguo_portfolio.strategy_registry import ensure_runtime_registry
try:
freg = vr.load_registry(ensure_runtime_registry(
_factor_registry_path(),
os.path.join("config", "factor_registry.yaml")))
points, _ = _eval_latest_points()
for fname, fe in freg["factors"].items():
if fe.get("status") != "assessable":
continue
t = (points.get(fname) or {}).get("t")
if t is None or t < REVIEW_T_GATE:
continue
items.append({"id": f"review-{fname}", "touchpoint": "promotion",
"title": f"{fname} 待晋级评审(五问)",
"detail": f"可判且近窗 t={t:.2f}≥{REVIEW_T_GATE:.0f},"
"评审页等你判定",
"path": "/pipeline/review", "severity": "warn",
"updatedAt": now})
except Exception: # noqa: BLE001 注册表/eval 库异常不拖垮待办聚合
pass
```
- [ ] **Step 4: 跑测试确认通过**:同 Step 2 命令 → PASS;再跑全文件 `./venv310/bin/python -m pytest tests/portfolio/test_routes_pipeline.py -q` 全绿(不碰旧测试)
- [ ] **Step 5: Commit**:
```bash
git add sanguo_api/routes_pipeline.py tests/portfolio/test_routes_pipeline.py
git commit -F - <<'EOF'
feat(pipeline): todos 补「因子晋级评审候选」源——触点②另一半上板(assessable 且近窗 t≥REVIEW_T_GATE=2,与评审页下拉同门);待办板「N 件等你决策」与评审页待办同口径,修三矛盾②(评审页等五问/待办却 0 件) [vps] [no-doc]
EOF
```
---
### Task 2: 前端共享过滤模块 + 评审页落选原因行
**Files:**
- Create: `frontend/src/views/pipeline/promotionFilter.ts`
- Create: `frontend/src/views/pipeline/promotionFilter.spec.ts`
- Modify: `frontend/src/views/pipeline/PromotionReview.vue`
- Test: `frontend/src/views/pipeline/PromotionReview.spec.ts`(追加一测)
**Interfaces:**
- Consumes: `PipelineFactor`(`@/api/pipeline` 已有,含 `status: string`、`icRecentT: number | null`)
- Produces: `REVIEW_T_GATE = 2`、`isReviewCandidate(f): boolean`、`exclusionReason(f): string`(非 assessable 返回 `''`)
- [ ] **Step 1: 写失败测试**——新建 `frontend/src/views/pipeline/promotionFilter.spec.ts`:
```typescript
import { describe, expect, it } from 'vitest'
import { isReviewCandidate, exclusionReason, REVIEW_T_GATE } from './promotionFilter'
import type { PipelineFactor } from '@/api/pipeline'
const f = (over: Partial<PipelineFactor>): PipelineFactor => ({
id: 'x', category: 'fundamental', version: '1', status: 'assessable',
icRecentT: null, icFullT: null, promotedAtT: null, decayMonths: 0,
lastEvalDate: '', ...over,
})
describe('promotionFilter', () => {
it('候选门=assessable 且近窗 t≥2', () => {
expect(REVIEW_T_GATE).toBe(2)
expect(isReviewCandidate(f({ icRecentT: 10.2 }))).toBe(true)
expect(isReviewCandidate(f({ icRecentT: 2.0 }))).toBe(true)
})
it('无数据/不显著/非可判都不是候选', () => {
expect(isReviewCandidate(f({ icRecentT: null }))).toBe(false)
expect(isReviewCandidate(f({ icRecentT: -0.21 }))).toBe(false)
expect(isReviewCandidate(f({ status: 'promoted', icRecentT: 9 }))).toBe(false)
})
it('落选原因可读:无数据 vs 未达门', () => {
expect(exclusionReason(f({ icRecentT: null }))).toContain('无近窗 IC 数据')
expect(exclusionReason(f({ icRecentT: -0.21 }))).toContain('未达 +2')
expect(exclusionReason(f({ status: 'incubating' }))).toBe('')
})
})
```
- [ ] **Step 2: 跑测试确认失败**:`cd frontend && npx vitest run src/views/pipeline/promotionFilter.spec.ts` → FAIL(模块不存在)
- [ ] **Step 3: 实现模块**——新建 `frontend/src/views/pipeline/promotionFilter.ts`:
```typescript
import type { PipelineFactor } from '@/api/pipeline'
// 评审门:与后端 routes_pipeline.REVIEW_T_GATE 同值镜像,改门须两处同改+spec 注记
export const REVIEW_T_GATE = 2
export function isReviewCandidate(f: PipelineFactor): boolean {
return f.status === 'assessable' && (f.icRecentT ?? 0) >= REVIEW_T_GATE
}
/** 可判却进不了评审页的原因(三矛盾①:鸿沟可见);非可判返回空串 */
export function exclusionReason(f: PipelineFactor): string {
if (f.status !== 'assessable') return ''
if (f.icRecentT == null) return '无近窗 IC 数据'
return `近窗 t=${f.icRecentT.toFixed(2)} 未达 +${REVIEW_T_GATE}`
}
```
- [ ] **Step 4: 跑模块测试确认通过**:同 Step 2 → PASS
- [ ] **Step 5: 改 PromotionReview.vue**——三处:
(a) script 区(`const factors = ref...` 到 `reload` 段)替换为:
```typescript
import { isReviewCandidate, exclusionReason } from './promotionFilter'
const all = ref<PipelineFactor[]>([])
const factors = computed(() => all.value.filter(isReviewCandidate))
const excluded = computed(() =>
all.value.filter((f) => f.status === 'assessable' && !isReviewCandidate(f)))
const picked = ref('')
onMounted(async () => {
all.value = await getFactors().catch(() => [])
picked.value = factors.value[0]?.id ?? ''
})
```
(`reload` 同步改 `all.value = ...` 两行;`current` 的 computed 不动。)
(b) template 区,在 `<div v-if="!factors.length" class="empty">` 之前插一行:
```html
<div v-if="excluded.length" class="excluded">
另有 {{ excluded.length }} 个可判因子未达评审门:{{
excluded.map((f) => `${f.id}(${exclusionReason(f)})`).join('、')
}}
</div>
```
(c) style 区追加:
```css
.excluded { color: var(--text-3); font-size: 11px; margin-bottom: 8px; font-family: var(--mono); }
```
- [ ] **Step 6: 页面测试**——`PromotionReview.spec.ts` 追加:
```typescript
it('落选可见:可判但未达 t 门的因子显示原因行', async () => {
const mock = [
...factorsMock,
{ id: 'fa_nodata', category: 'fundamental', version: '1',
status: 'assessable', icRecentT: null, icFullT: null,
promotedAtT: null, decayMonths: 0, lastEvalDate: '' } as never,
]
vi.mocked(api.getFactors).mockResolvedValue(mock)
const w = mount(PromotionReview, { global: { plugins: [ElementPlus] } })
await flushPromises()
expect(w.text()).toContain('另有 1 个可判因子未达评审门')
expect(w.text()).toContain('fa_nodata(无近窗 IC 数据)')
})
```
(若 `factorsMock` 里已有 assessable 低 t 项导致计数≠1,按实际 mock 调整断言数字——先读 `frontend/src/mock/pipelineData.ts` 的 `factorsMock` 再定。)
- [ ] **Step 7: 跑全部相关前端测试**:`cd frontend && npx vitest run src/views/pipeline/` → 全绿
- [ ] **Step 8: Commit**:
```bash
git add frontend/src/views/pipeline/promotionFilter.ts frontend/src/views/pipeline/promotionFilter.spec.ts frontend/src/views/pipeline/PromotionReview.vue frontend/src/views/pipeline/PromotionReview.spec.ts
git commit -F - <<'EOF'
feat(pipeline): 评审页落选原因行+过滤逻辑抽共享模块——可判却未达 t 门(无数据/t 不显著)的因子在下拉下方列明原因,3 可判 vs 1 可评的鸿沟可见;isReviewCandidate/exclusionReason 供下拉与落选行共用单一口径,修三矛盾① [vps] [no-doc]
EOF
```
---
### Task 3: 工厂页可判徽标语义说明
**Files:**
- Modify: `frontend/src/views/pipeline/FactorFactory.vue:54-56`(状态列)
- [ ] **Step 1: 改状态列模板**——把:
```html
<el-table-column label="状态" width="90">
<template #default="{ row }"><StatusChip :label="FACTOR_LABEL[row.status as PipelineFactor['status']]" :tone="FACTOR_TONE[row.status as PipelineFactor['status']]" /></template>
</el-table-column>
```
改为:
```html
<el-table-column label="状态" width="90">
<template #default="{ row }">
<el-tooltip v-if="row.status === 'assessable'" content="可判=够格被评审;近窗 t≥2 才会出现在晋级评审页" placement="top">
<span><StatusChip :label="FACTOR_LABEL[row.status as PipelineFactor['status']]" :tone="FACTOR_TONE[row.status as PipelineFactor['status']]" /></span>
</el-tooltip>
<StatusChip v-else :label="FACTOR_LABEL[row.status as PipelineFactor['status']]" :tone="FACTOR_TONE[row.status as PipelineFactor['status']]" />
</template>
</el-table-column>
```
(`el-tooltip` 是 ElementPlus 全局注册组件,FactorFactory 无需新增 import。)
- [ ] **Step 2: 跑工厂页现有测试**:`cd frontend && npx vitest run src/views/pipeline/FactorFactory.spec.ts` → 全绿(tooltip 不破坏现有断言;若断言 StatusChip 结构则按需微调 spec)
- [ ] **Step 3: Commit**:
```bash
git add frontend/src/views/pipeline/FactorFactory.vue
git commit -F - <<'EOF'
feat(pipeline): 工厂页可判徽标悬停说明——「可判=够格被评审;近窗 t≥2 才进评审页」,两页标准差异摊开在徽标上,修三矛盾①另一面 [vps] [no-doc]
EOF
```
---
### Task 4: 一问文案对齐数据实况
**Files:**
- Modify: `frontend/src/views/pipeline/PromotionReview.vue:20`(FIVE_QUESTIONS 第一问 hint)
- [ ] **Step 1: 改文案**——把:
```typescript
{ q: '一问:IC 两窗都显著吗?', hint: '近窗 t≥2 且全史 t≥2,任一反号即否决(res_fwd_ep 先例)' },
```
改为:
```typescript
{ q: '一问:IC 两窗都显著吗?', hint: '近窗 t≥2(全史 t 暂无数据源,以月度批评全史表现为辅);任一反号即否决(res_fwd_ep 先例)' },
```
- [ ] **Step 2: 跑评审页测试**:`cd frontend && npx vitest run src/views/pipeline/PromotionReview.spec.ts` → 全绿
- [ ] **Step 3: Commit**:
```bash
git add frontend/src/views/pipeline/PromotionReview.vue
git commit -F - <<'EOF'
fix(pipeline): 一问提示文案对齐数据实况——icFullT 无全史数据源(routes_pipeline 硬 None),文案不再承诺页面给不了的「全史 t≥2」,改以月度批评全史表现为辅,修三矛盾③ [vps] [no-doc]
EOF
```
---
### Task 5: 流水线 spec 补源注记(档随码走)
**Files:**
- Modify: `docs/superpowers/specs/2026-09-22-research-to-trading-pipeline-design.md`(§4.5「P2 实现注记」段末)
- [ ] **Step 1: 定位**——找 `SANGUO_FACTOR_MONTHLY_DIR`(todos 聚合源)所在段落(「P2 实现注记(2026-09-24 计划落档)」),在段末追加一句:
```
**驾驶舱 todos 聚合源清单(10-08 补源定案)**:①策略毕业候选(shadow 满+gate 过)+**①′因子晋级评审候选(assessable 且近窗 t≥REVIEW_T_GATE=2,与评审页下拉同门,前后端常量镜像)**②衰减告警 ③集体水位 ④数据缺口——触点②两半齐;评审页对可判未达门者列落选原因行(鸿沟可见原则)。
```
- [ ] **Step 2: Commit**:
```bash
git add docs/superpowers/specs/2026-09-22-research-to-trading-pipeline-design.md
git commit -F - <<'EOF'
docs(pipeline): 驾驶舱 todos 聚合源清单入档——①′因子晋级评审候选补源定案(门=评审页同门常量镜像)+评审页落选原因行(鸿沟可见),档随码走随本批同 push [vps]
EOF
```
---
### Task 6: push + CI 三绿 + issue 勾单
- [ ] **Step 1: 全量回归**:`./venv310/bin/python -m pytest tests/portfolio/ -q` + `cd frontend && npx vitest run` → 双绿
- [ ] **Step 2: push**:`git push origin master` → CI 跑 test→nas-deploy→nas-verify
- [ ] **Step 3: 盯终态**:三绿后在 issue 勾任务清单+留 `@main-agent ✅ EXECUTE_DONE`
### Task 7: 转单 factor 域(不在本批编码)
在 Gitea issue 留 comment(或独立开单):fa_gross_margin 无任何近窗 IC 数据却处于 assessable 态——状态推进与数据验收脱节,请 factor 域自查孵化→可判的转移条件是否该含「至少一批 eval 数据」。
---
## Self-Review 结论
- 覆盖:三矛盾②=Task 1;①=Task 2+3;③=Task 4;根子=Task 7 转单。已批准范围无遗漏、无超范围。
- 占位符:无 TBD;Step 2 的 mock 计数断言留了「先读 factorsMock 再定」的实读指令(数据依赖,非占位)。
- 类型一致:`REVIEW_T_GATE` 后端 `2.0`/前端 `2`,`exclusionReason` 返回 `string`,`PipelineFactor` 复用既有类型。