35 KiB
决议 O 卡片墓园聚合语义 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: 卡片死活=卡下因子聚合(全死才埋)+人工判卡死陪葬制+部分死角标/死因子行态+终态卡禁分解——修 D5「任一因子死埋整卡」在一卡多因子语境下的错位。
Architecture: 后端 4 件(writeback_card_death 聚合改造/hypotheses verdict 新端点/factors[] 透传 cause/decompose 422)+前端 4 件(角标☠n/N/死因子划线不可点/判死按钮/陪葬弹窗),原子性靠「全部 transition 在内存 reg 完成后才 save_registry」保证。spec=docs/superpowers/specs/2026-09-22-research-to-trading-pipeline-design.md §4.1 决议 O 块(0093c0c)。
Tech Stack: FastAPI+sqlite(sanguo_portfolio.pipeline_store)+yaml registry(sanguo_factor.version_registry)+Vue3+Element Plus。
Global Constraints
- 后端测试跑法:
venv310/bin/python -m pytest <file> -q(项目根目录) - 前端测试跑法:
cd frontend && npx vitest run <file>;类型检查npx vue-tsc --noEmit - 凡测试触及 factor registry 必须
monkeypatch.setenv("SANGUO_FACTOR_REGISTRY", str(tmp_path/…))(ensure_runtime_registry 的种子 merge 会向 data/ 运行副本写真数据) - commit 标签 [vps] 必须放 subject 行尾(enforce-label 只扫
%s首行;三犯在案) - sanguo_factor/ 改动 → 同 push 更新
docs/factor_research/factor-system-design.md(design-doc CI 门) - 卡片状态机:queued/data_check/building/evaluating→graveyard 合法;promoted/graveyard 终态(pipeline_store._VALID_HYP_TRANSITIONS)
- 因子状态机:incubating/assessable/promoted/decaying/retired→graveyard 全合法;graveyard 不可迁出(version_registry._VALID_TRANSITIONS)
- 共享 master 禁 amend/force-push
Task 1: writeback_card_death 聚合改造(全死才埋)
Files:
- Modify:
sanguo_factor/version_registry.py:174-226(writeback_card_death)+:295(CLI 调用方) - Modify:
sanguo_api/routes_pipeline.py:785(factor_verdict 调用方) - Test:
tests/factor/test_version_registry.py(追加)
Interfaces:
-
Produces:
writeback_card_death(pipeline_db: str | None, registry: dict[str, Any], entry: dict[str, Any], cause: str | None, events_path: str = "config/registry_events.jsonl") -> bool——新增第二位置参数registry(全量注册表 dict,调用方均在手);部分死=stderr 提示+return False(不附记 death_writeback_missed——部分死是正常路径非 miss);全死才埋,death_reason=「全灭:触发因子 {name} 入墓园: {cause or 未注死因}」 -
Step 1: 写失败测试(追加到 tests/factor/test_version_registry.py,沿用文件既有 import 风格;若文件缺 tmp_path 风格先例,用如下自包含写法)
class TestWritebackAggregation:
"""决议O:卡片死亡回写=全死才埋(任一因子死不再埋整卡)."""
def _card_db(self, tmp_path):
from sanguo_portfolio import pipeline_store as ps
db = str(tmp_path / "pipeline.db")
ps.insert_hypothesis(db, {
"title": "高管增持后跑赢", "logic": "x",
"expected_sign": "positive", "falsifiable": "y"})
with __import__("sqlite3").connect(db) as conn:
conn.execute(
"UPDATE hypotheses SET id='hyp-agg1' WHERE title='高管增持后跑赢'")
return db
def test_partial_death_keeps_card_alive(self, tmp_path, capsys):
db = self._card_db(tmp_path)
reg = {"factors": {
"fa_a": {"name": "fa_a", "hypothesis": "hyp-agg1",
"status": "graveyard"}, # 死的那个(触发者)
"fa_b": {"name": "fa_b", "hypothesis": "hyp-agg1",
"status": "incubating"},}} # 活着 → 不埋卡
ok = writeback_card_death(db, reg, reg["factors"]["fa_a"], "IC 两窗反号")
assert ok is False
out = capsys.readouterr().err
assert "部分因子存活" in out and "fa_b" in out
from sanguo_portfolio import pipeline_store as ps
card = [c for c in ps.list_hypotheses(db) if c["id"] == "hyp-agg1"][0]
assert card["state"] == "queued" # 卡留活跃池
def test_all_dead_buries_card_with_new_reason(self, tmp_path):
db = self._card_db(tmp_path)
reg = {"factors": {
"fa_a": {"name": "fa_a", "hypothesis": "hyp-agg1",
"status": "graveyard"},
"fa_b": {"name": "fa_b", "hypothesis": "hyp-agg1",
"status": "graveyard"},}} # 全灭 → 埋
ok = writeback_card_death(db, reg, reg["factors"]["fa_a"], "IC 两窗反号")
assert ok is True
from sanguo_portfolio import pipeline_store as ps
card = [c for c in ps.list_hypotheses(db) if c["id"] == "hyp-agg1"][0]
assert card["state"] == "graveyard"
assert card["deathReason"] == "全灭:触发因子 fa_a 入墓园: IC 两窗反号"
def test_zero_sibling_factor_buries(self, tmp_path):
"""卡下无其他因子(一卡一因子旧语境)→该因子死即全灭,照埋."""
db = self._card_db(tmp_path)
reg = {"factors": {
"fa_a": {"name": "fa_a", "hypothesis": "hyp-agg1",
"status": "graveyard"},}}
assert writeback_card_death(db, reg, reg["factors"]["fa_a"], "x") is True
(若 tests/factor/test_version_registry.py 顶部尚未 from sanguo_factor.version_registry import writeback_card_death,在 class 前补 import 或改用 from sanguo_factor import version_registry as vr 后 vr.writeback_card_death。)
- Step 2: 跑测试确认失败
Run: venv310/bin/python -m pytest tests/factor/test_version_registry.py::TestWritebackAggregation -q
Expected: FAIL(TypeError: writeback_card_death() takes … positional args——registry 参数不存在)
- Step 3: 实现——version_registry.py 三处
3a. 签名+聚合段(writeback_card_death 内,hyp-id 校验之后、pipeline_db 绝对化之前插入):
def writeback_card_death(pipeline_db: str | None,
registry: dict[str, Any],
entry: dict[str, Any], cause: str | None,
events_path: str = "config/registry_events.jsonl") -> bool:
"""决议O:因子入墓园→卡片聚合回写(全死才埋).
卡 id=entry.hypothesis 以 hyp- 开头才回写(旧 H- 编号非卡片,静默跳过);
registry=全量注册表(调用方在手),反查卡下全部因子——部分死=卡留活跃池
(stderr 提示,非 miss 不附记 events);全 graveyard 才回写,death_reason=
「全灭:触发因子 {name} 入墓园:{cause}」.其余(P2-10 miss 附记/失败不阻断
转移/相对路径绝对化)语义不变.
"""
hyp_id = str(entry.get("hypothesis") or "")
if not hyp_id.startswith("hyp-"):
return False
# 决议O 聚合:反查卡下全部因子,全死才埋
siblings = [e for e in registry["factors"].values()
if e.get("hypothesis") == hyp_id]
alive = [str(e.get("name")) for e in siblings
if e.get("status") != "graveyard"]
if alive:
print(f"[registry] ℹ️ 卡片 {hyp_id} 部分因子存活({len(alive)} 条),"
f"不埋卡(决议O 全死才埋): {', '.join(alive[:5])}",
file=sys.stderr)
return False
3b. death_reason 行(update_hypothesis_state 调用处):
result = pipeline_store.update_hypothesis_state(
pipeline_db, hyp_id, "graveyard",
death_reason=f"全灭:触发因子 {name} 入墓园: {cause or '未注死因'}",
factor_id=name)
3c. CLI 调用方(main 内 graveyard 分支):
if args.status == "graveyard":
writeback_card_death(args.pipeline_db, reg, entry, args.cause,
events_path=args.events)
- Step 4: 改 routes_pipeline.py factor_verdict 调用方(:785)
vr.writeback_card_death(None, reg, entry, note, events_path=events_path)
- Step 5: 跑全量确认
Run: venv310/bin/python -m pytest tests/factor/test_version_registry.py -q && venv310/bin/python -m pytest tests/api -q
Expected: 全 PASS(若 tests/api 或 tests/factor 旧用例按旧签名调 writeback_card_death 报 TypeError,同班把测试调用改为新签名补第二参 registry——旧用例语义若断言「任一死即埋」,按决议 O 改断言为全死才埋)
- Step 6: Commit
git add sanguo_factor/version_registry.py sanguo_api/routes_pipeline.py tests/factor/test_version_registry.py
git commit -m "feat(factor): 决议O①卡片死亡回写聚合化——全死才埋(反查卡下全部因子,部分死=卡留活跃池只影响展示;death_reason=全灭:触发因子x入墓园:死因;修D5任一死埋整卡在一卡多因子语境错位) [vps]"
Task 2: POST /pipeline/hypotheses/{id}/verdict 判卡死+陪葬端点
Files:
- Modify:
sanguo_api/routes_pipeline.py(factor_verdict 端点后新增,约 :795) - Test:
tests/api/test_routes_pipeline_hypotheses.py(追加 class)
Interfaces:
-
Consumes: Task 1 的
vr.transition/save_registry/append_event(既有);pipeline_store.update_hypothesis_state(既有) -
Produces:
POST /pipeline/hypotheses/{hyp_id}/verdictbody{reason: str}→ 200{ok: true, buried: string[], card: "graveyard"};422=reason 空/卡已 graveyard/卡 promoted(毕业语义挂账)/因子状态机拒绝;404=卡不存在。陪葬 cause={reason}(随卡判死)。 -
Step 1: 写失败测试(追加到 tests/api/test_routes_pipeline_hypotheses.py;复用该文件既有 client fixture 的 SANGUO_PIPELINE_DB/tmp_path 环境,registry 隔离用 setenv)
class TestCardVerdict:
"""决议O②:人工判卡片死(强断言想法死)——活因子陪葬,cause 带(随卡判死)."""
@pytest.fixture()
def env2(self, client, monkeypatch, tmp_path):
"""registry 指 tmp(绕种子 merge)+预置一卡两因子(一活一死)."""
reg = tmp_path / "reg.yaml"
reg.write_text(
"factors:\n"
" fa_live:\n"
" name: fa_live\n status: incubating\n"
" hypothesis: hyp-kill1\n origin: decomposer\n"
" fa_dead:\n"
" name: fa_dead\n status: graveyard\n"
" hypothesis: hyp-kill1\n cause: 旧死\n"
" origin: decomposer\n", encoding="utf-8")
monkeypatch.setenv("SANGUO_FACTOR_REGISTRY", str(reg))
return reg
def _mk_card(self, state="building"):
from sanguo_portfolio import pipeline_store as ps
import os
db = os.environ["SANGUO_PIPELINE_DB"]
ps.insert_hypothesis(db, {
"title": "判死测试卡", "logic": "x",
"expected_sign": "positive", "falsifiable": "y"})
import sqlite3
with sqlite3.connect(db) as conn:
conn.execute("UPDATE hypotheses SET id='hyp-kill1', "
f"state='{state}' WHERE title='判死测试卡'")
return "hyp-kill1"
def test_kill_card_buries_live_siblings(self, client, env2):
import yaml
hyp = self._mk_card("building")
r = client.post(f"/api/v1/pipeline/hypotheses/{hyp}/verdict",
json={"reason": "逻辑前提失效:增持数据口径变更"})
assert r.status_code == 200
doc = r.json()
assert doc["buried"] == ["fa_live"] # 死的不重复陪葬
with open(env2, encoding="utf-8") as f:
reg = yaml.safe_load(f)
assert reg["factors"]["fa_live"]["status"] == "graveyard"
assert reg["factors"]["fa_live"]["cause"] == \
"逻辑前提失效:增持数据口径变更(随卡判死)"
from sanguo_portfolio import pipeline_store as ps
import os
card = [c for c in ps.list_hypotheses(os.environ["SANGUO_PIPELINE_DB"])
if c["id"] == hyp][0]
assert card["state"] == "graveyard"
assert card["deathReason"] == "逻辑前提失效:增持数据口径变更"
def test_kill_requires_reason(self, client, env2):
self._mk_card()
r = client.post("/api/v1/pipeline/hypotheses/hyp-kill1/verdict",
json={"reason": " "})
assert r.status_code == 422
def test_graveyard_card_reject_422(self, client, env2):
import sqlite3, os
self._mk_card("graveyard")
r = client.post("/api/v1/pipeline/hypotheses/hyp-kill1/verdict",
json={"reason": "再死一次"})
assert r.status_code == 422 and "墓园" in r.json()["detail"]
def test_promoted_card_reject_422(self, client, env2):
self._mk_card("promoted")
r = client.post("/api/v1/pipeline/hypotheses/hyp-kill1/verdict",
json={"reason": "毕业卡判死"})
assert r.status_code == 422 and "promoted" in r.json()["detail"]
def test_zero_factor_card_pure_idea_kill(self, client, monkeypatch, tmp_path):
"""0 因子卡=纯想法毙掉,无陪葬也 200."""
reg = tmp_path / "empty.yaml"
reg.write_text("factors: {}\n", encoding="utf-8")
monkeypatch.setenv("SANGUO_FACTOR_REGISTRY", str(reg))
hyp = self._mk_card("queued")
r = client.post(f"/api/v1/pipeline/hypotheses/{hyp}/verdict",
json={"reason": "想法被新文献证伪"})
assert r.status_code == 200 and r.json()["buried"] == []
- Step 2: 跑测试确认失败
Run: venv310/bin/python -m pytest tests/api/test_routes_pipeline_hypotheses.py::TestCardVerdict -q
Expected: FAIL(404 detail 不符——路由不存在,FastAPI 返回 404 {"detail":"Not Found"})
- Step 3: 实现端点(routes_pipeline.py,factor_verdict 函数之后插入)
@router.post("/pipeline/hypotheses/{hyp_id}/verdict")
def hypothesis_verdict(hyp_id: str, body: dict) -> dict:
"""决议O②:人工判卡片死(强断言「想法死了」,危险区操作)——卡下活因子陪葬.
原子性:全部 transition 在内存 reg 完成后才 save_registry(中途 ValueError
零副作用);陪葬 cause=卡片死因+「(随卡判死)」后缀;incubating→graveyard
状态机合法.0 因子卡=纯想法毙掉无陪葬.promoted 卡不可判死(毕业语义挂账,
决议O 只动墓园).
"""
from sanguo_factor import version_registry as vr
reason = str(body.get("reason") or "").strip()
if not reason:
raise HTTPException(422, "判死必须带 reason(死因,可检索防重复造轮)")
items = pipeline_store.list_hypotheses(_pipeline_db())
card = next((i for i in items if i["id"] == hyp_id), None)
if card is None:
raise HTTPException(404, "卡片不存在")
if card["state"] == "graveyard":
raise HTTPException(422, "卡片已在墓园(终态不可再判)")
if card["state"] == "promoted":
raise HTTPException(422, "promoted 卡不可判死(决议O 挂账:毕业语义待定)")
reg_path = _ensure_factor_runtime_registry()
events_path = os.environ.get("SANGUO_FACTOR_EVENTS",
os.path.join("data", "registry_events.jsonl"))
reg = vr.load_registry(reg_path)
buried: list[tuple[str, str, dict[str, Any]]] = [] # (name, old, entry)
try:
for name, e in sorted(reg["factors"].items()):
if e.get("hypothesis") != hyp_id or e.get("status") == "graveyard":
continue
old = str(e.get("status"))
entry = vr.transition(reg, name, "graveyard",
cause=f"{reason}(随卡判死)",
reviewed_via="console")
buried.append((name, old, entry))
vr.save_registry(reg_path, reg)
except ValueError as exc:
raise HTTPException(422, str(exc)) from exc # 零副作用(未落盘)
for name, old, entry in buried:
vr.append_event(events_path, entry, old, "graveyard",
cause=f"{reason}(随卡判死)", reviewed_via="console")
pipeline_store.update_hypothesis_state(
_pipeline_db(), hyp_id, "graveyard", death_reason=reason)
return {"ok": True, "buried": [n for n, _, _ in buried],
"card": "graveyard"}
- Step 4: 跑测试确认通过
Run: venv310/bin/python -m pytest tests/api/test_routes_pipeline_hypotheses.py -q
Expected: 全 PASS
- Step 5: Commit
git add sanguo_api/routes_pipeline.py tests/api/test_routes_pipeline_hypotheses.py
git commit -m "feat(api): 决议O②人工判卡片死端点——POST hypotheses/{id}/verdict 活因子陪葬(cause=死因+随卡判死;内存全转后落盘=原子;promoted 卡422挂账;0因子卡纯想法毙掉) [vps]"
Task 3: factors[] 透传 cause + decompose 终态 422
Files:
- Modify:
sanguo_api/routes_pipeline.py:1131-1137(by_hyp 组装)+:1228(decompose 前置) - Test:
tests/api/test_routes_pipeline_hypotheses.py(追加)
Interfaces:
-
Produces: GET /pipeline/hypotheses 的 factors[] 每项新增
cause: string | null(graveyard 因子=死因,其余 null);POST decompose 对 graveyard 卡返回 422「终态卡(墓园)不可分解——重推想法=开新卡(决议O)」 -
Step 1: 写失败测试(追加 class)
class TestFactorsCauseAndDecompose422:
def test_factors_carry_cause(self, client, monkeypatch, tmp_path):
reg = tmp_path / "reg.yaml"
reg.write_text(
"factors:\n"
" fa_d:\n name: fa_d\n status: graveyard\n"
" hypothesis: hyp-c1\n cause: IC 两窗反号\n"
" origin: decomposer\n"
" fa_l:\n name: fa_l\n status: incubating\n"
" hypothesis: hyp-c1\n origin: decomposer\n",
encoding="utf-8")
monkeypatch.setenv("SANGUO_FACTOR_REGISTRY", str(reg))
from sanguo_portfolio import pipeline_store as ps
import os, sqlite3
db = os.environ["SANGUO_PIPELINE_DB"]
ps.insert_hypothesis(db, {
"title": "透传测试卡", "logic": "x",
"expected_sign": "positive", "falsifiable": "y"})
with sqlite3.connect(db) as conn:
conn.execute("UPDATE hypotheses SET id='hyp-c1' "
"WHERE title='透传测试卡'")
items = client.get("/api/v1/pipeline/hypotheses").json()["items"]
fs = next(i for i in items if i["id"] == "hyp-c1")["factors"]
cmap = {f["name"]: f.get("cause") for f in fs}
assert cmap["fa_d"] == "IC 两窗反号"
assert cmap["fa_l"] is None
def test_decompose_graveyard_card_422(self, client, monkeypatch, tmp_path):
from sanguo_portfolio import pipeline_store as ps
import os, sqlite3
db = os.environ["SANGUO_PIPELINE_DB"]
ps.insert_hypothesis(db, {
"title": "终态分解卡", "logic": "x",
"expected_sign": "positive", "falsifiable": "y"})
with sqlite3.connect(db) as conn:
conn.execute("UPDATE hypotheses SET id='hyp-g1', state='graveyard', "
"death_reason='全灭' WHERE title='终态分解卡'")
monkeypatch.setenv("SANGUO_LLM_API_KEY", "sk-test")
r = client.post("/api/v1/pipeline/hypotheses/hyp-g1/decompose")
assert r.status_code == 422
assert "终态" in r.json()["detail"] or "墓园" in r.json()["detail"]
- Step 2: 跑测试确认失败
Run: venv310/bin/python -m pytest tests/api/test_routes_pipeline_hypotheses.py::TestFactorsCauseAndDecompose422 -q
Expected: FAIL(cause 键缺失 KeyError / decompose 返回 409 非 422)
- Step 3: 实现
3a. by_hyp 组装 dict(routes_pipeline.py:1131-1137,"status": e.get("status") 后加一行):
by_hyp.setdefault(hyp, []).append({
"name": name, "expression": expr,
"origin": e.get("origin") or "manual",
"createdAt": (versions[0].get("effective_from")
if versions else None),
"status": e.get("status"),
"cause": e.get("cause")}) # 决议O③死因透传(死因子行tooltip)
3b. decompose 前置(:1228 通用 409 之前插入):
if card["state"] == "graveyard":
raise HTTPException(422, "终态卡(墓园)不可分解——重推想法=开新卡(决议O)")
if card["state"] not in _DECOMPOSE_ALLOWED_STATES:
raise HTTPException(409, f"卡片状态 {card['state']} 不可分解")
- Step 4: 跑测试确认通过
Run: venv310/bin/python -m pytest tests/api/test_routes_pipeline_hypotheses.py -q
Expected: 全 PASS
- Step 5: Commit
git add sanguo_api/routes_pipeline.py tests/api/test_routes_pipeline_hypotheses.py
git commit -m "feat(api): 决议O③④hypotheses端点factors[]透传cause(死因子行tooltip数据源)+decompose终态卡前置422(墓园不可分解,重推=开新卡;原409通用态细化) [vps]"
Task 4: 前端类型+卡角标☠n/N+死因子行态
Files:
- Modify:
frontend/src/api/pipeline.ts:31-38(HypothesisFactor) - Modify:
frontend/src/views/pipeline/HypothesisPool.vue(hcard-head 角标+fac-row 行态+样式) - Modify:
frontend/src/mock/pipelineData.ts(hypothesesMock 补 factors 死例) - Test:
frontend/src/views/pipeline/HypothesisPool.spec.ts(追加用例)
Interfaces:
-
Consumes: Task 3 的 factors[].cause
-
Produces:
HypothesisFactor.cause?: string | null;组件函数deadFactorCount(h: Hypothesis): number -
Step 1: 类型(pipeline.ts HypothesisFactor 加字段)
export interface HypothesisFactor {
name: string
expression: string | null
origin: string
createdAt: string | null
status: string
cause?: string | null // 决议O③:graveyard 死因(死因子行 tooltip)
batch?: { jobId: string; startedAt: string } | null
}
- Step 2: mock 补死例(pipelineData.ts hypothesesMock 里「高管增持」卡补 factors 字段——找到该 mock 对象加)
factors: [
{ name: 'fa高管增持超额', expression: 'ts_rank(close, 60)', origin: 'decomposer',
createdAt: '2026-10-09', status: 'graveyard', cause: 'IC 两窗反号(2026-10 日报)', batch: null },
{ name: 'fa高管增持量比', expression: 'volume / ts_mean(volume, 20)', origin: 'decomposer',
createdAt: '2026-10-09', status: 'incubating', batch: null },
],
- Step 3: 组件(HypothesisPool.vue)
3a. script 段加(canDecompose 附近):
// 决议O:部分死=卡留活跃池+角标☠n/N(n=死因子数,0 不显示)
const deadFactorCount = (h: Hypothesis): number =>
(h.factors || []).filter((f) => f.status === 'graveyard').length
3b. hcard-head 内 StatusChip 之后插角标(:225-227 附近):
<StatusChip :label="HYPOTHESIS_LABEL[h.state]" :tone="HYPOTHESIS_TONE[h.state]" />
<span v-if="deadFactorCount(h)" class="skull-badge"
:title="`卡下 ${deadFactorCount(h)}/${h.factors!.length} 因子已判死(卡留活跃池)`">
☠{{ deadFactorCount(h) }}/{{ h.factors!.length }}
</span>
3c. fac-row 死因子行态(:266-270 改造——fac-name 分死活两分支):
<div class="fac-row">
<span v-if="f.status === 'graveyard'" class="fac-name dead"
:title="`☠ ${f.cause ?? '未注死因'}`">☠ {{ f.name }}</span>
<span v-else class="fac-name" title="看出生档案"
@click="router.push(`/pipeline/factors/${f.name}`)">{{ f.name }}</span>
<code class="fac-expr">{{ f.expression }}</code>
<span class="fac-date">{{ f.createdAt }}</span>
</div>
3d. 样式(style 段 fac-name 相关附近加):
.fac-name.dead { text-decoration: line-through; color: var(--text-3); cursor: default; }
.skull-badge { font-family: var(--mono); font-size: 10px; color: #ff5f6d;
border: 1px solid rgba(255, 95, 109, 0.4); border-radius: var(--r-sm);
padding: 0 5px; line-height: 16px; flex-shrink: 0; }
- Step 4: 写前端测试(HypothesisPool.spec.ts 追加)
it('决议O:部分死角标☠n/N+死因子划线不可点+死因tooltip', async () => {
vi.mocked(api.getHypotheses).mockResolvedValue(hypothesesMock) // mock 已含 1 死 1 活
const w = mount(HypothesisPool, { global: { plugins: [ElementPlus] } })
await flushPromises()
const badge = w.find('.skull-badge')
expect(badge.exists()).toBe(true)
expect(badge.text()).toContain('☠1/2')
const dead = w.findAll('.fac-name.dead')
expect(dead.length).toBe(1)
expect(dead[0].text()).toContain('fa高管增持超额')
expect(dead[0].attributes('title')).toContain('IC 两窗反号')
})
- Step 5: 跑测试+类型检查
Run: cd frontend && npx vitest run src/views/pipeline/HypothesisPool.spec.ts && npx vue-tsc --noEmit
Expected: 全 PASS+零类型错
- Step 6: Commit
git add frontend/src/api/pipeline.ts frontend/src/views/pipeline/HypothesisPool.vue frontend/src/mock/pipelineData.ts frontend/src/views/pipeline/HypothesisPool.spec.ts
git commit -m "feat(frontend): 决议O③卡角标☠n/N(部分死=卡留活跃池)+死因子行态(划线/☠前缀/死因tooltip/不可点修404) [vps]"
Task 5: 判死卡片按钮+陪葬确认弹窗
Files:
- Modify:
frontend/src/api/pipeline.ts(postHypothesisVerdict) - Modify:
frontend/src/views/pipeline/HypothesisPool.vue(危险区按钮+el-dialog+逻辑) - Test:
frontend/src/views/pipeline/HypothesisPool.spec.ts(追加用例)
Interfaces:
-
Consumes: Task 2 的 POST /pipeline/hypotheses/{id}/verdict
-
Produces:
postHypothesisVerdict(id: string, reason: string): Promise<{ ok: boolean; buried: string[] }> -
Step 1: api 函数(pipeline.ts,saveHypothesis 附近加)
// 决议O②:人工判卡片死(危险区)——卡下活因子陪葬,cause=reason+(随卡判死)
export async function postHypothesisVerdict(
id: string, reason: string,
): Promise<{ ok: boolean; buried: string[] }> {
const { data } = await apiClient.post<{ ok: boolean; buried: string[] }>(
`/pipeline/hypotheses/${id}/verdict`, { reason })
return data
}
- Step 2: 组件逻辑+按钮+弹窗(HypothesisPool.vue)
2a. script 段(import 补 postHypothesisVerdict、ElMessage/ElMessageBox 若未引入则从 element-plus 引;状态段加):
// 决议O②:判死卡片(危险区)——强断言想法死,活因子陪葬(终态不可逆)
const kill = ref<{ card: Hypothesis; reason: string; loading: boolean } | null>(null)
const killBuriable = computed(() =>
(kill.value?.card.factors || []).filter((f) => f.status !== 'graveyard'))
const openKill = (h: Hypothesis) => {
kill.value = { card: h, reason: '', loading: false }
}
const confirmKill = async () => {
if (!kill.value) return
const reason = kill.value.reason.trim()
if (!reason) { ElMessage.warning('死因必填(可检索防重复造轮)') ; return }
kill.value.loading = true
try {
const r = await postHypothesisVerdict(kill.value.card.id, reason)
ElMessage.success(
r.buried.length ? `卡片已判死,陪葬 ${r.buried.length} 条因子` : '卡片已判死(纯想法,无陪葬)')
kill.value = null
await load() // 复用组件既有刷新函数(以实际名为准)
} catch (e: unknown) {
ElMessage.error(e instanceof Error ? e.message : '判死失败')
if (kill.value) kill.value.loading = false
}
}
(load() 若组件内刷新函数名不同——如 refresh()/fetchAll()——以实际为准替换。)
2b. 卡片操作区(hcard-ops 处,与分解按钮同区,分危险按钮):
<div v-if="canDecompose(h) || isDecomposing(h) || killable(h)" class="hcard-ops">
<button v-if="canDecompose(h) || isDecomposing(h)" class="wiz-btn"
:disabled="isDecomposing(h)"
@click="runDecompose(h)">
{{ isDecomposing(h) ? '分解中(后台执行,可离开本页)…' : '⚡ AI 分解' }}
</button>
<button v-if="killable(h)" class="wiz-btn danger"
@click="openKill(h)">☠ 判死卡片</button>
</div>
配套 helper(deadFactorCount 旁):
const killable = (h: Hypothesis): boolean =>
h.state !== 'graveyard' && h.state !== 'promoted'
2c. 弹窗(template 根部列表之后追加):
<el-dialog :model-value="kill !== null" title="☠ 判死卡片(危险区)"
width="480px" @update:model-value="kill = null">
<template v-if="kill">
<div class="kill-warn">
强断言「想法死了」——卡下活因子将全部陪葬入墓园(终态不可逆,重推想法=开新卡)。
</div>
<div v-if="killBuriable.length" class="kill-list">
<div class="kill-list-h">陪葬清单({{ killBuriable.length }} 条):</div>
<div v-for="f in killBuriable" :key="f.name" class="kill-item">
<span class="mono">{{ f.name }}</span>
<span class="dim">{{ f.status }}</span>
</div>
</div>
<div v-else class="kill-list dim">本卡无因子——纯想法毙掉,无陪葬。</div>
<el-input v-model="kill.reason" type="textarea" :rows="2"
placeholder="死因(必填,可检索防重复造轮)" style="margin-top: 10px" />
</template>
<template #footer>
<el-button @click="kill = null">取消</el-button>
<el-button type="danger" :loading="kill?.loading"
@click="confirmKill">确认判死</el-button>
</template>
</el-dialog>
2d. 样式:
.wiz-btn.danger { color: #ff5f6d; border-color: rgba(255, 95, 109, 0.5); }
.wiz-btn.danger:hover { background: rgba(255, 95, 109, 0.12); }
.kill-warn { font-size: 12px; color: #ffb84d; padding: 8px 10px;
border: 1px solid rgba(255, 184, 77, 0.4); border-radius: var(--r-sm); }
.kill-list { margin-top: 10px; font-size: 12px; }
.kill-list-h { color: var(--text-2); margin-bottom: 4px; }
.kill-item { display: flex; justify-content: space-between;
padding: 3px 8px; border-bottom: 1px dashed var(--border); }
.kill-item .dim { color: var(--text-3); font-size: 11px; }
- Step 3: 写前端测试(HypothesisPool.spec.ts 追加;mock 顶部 vi.mock 工厂补
postHypothesisVerdict: vi.fn())
it('决议O②:判死卡片弹窗列陪葬清单+确认后调端点', async () => {
vi.mocked(api.getHypotheses).mockResolvedValue(hypothesesMock)
vi.mocked(api.postHypothesisVerdict).mockResolvedValue(
{ ok: true, buried: ['fa高管增持量比'] })
const w = mount(HypothesisPool, { global: { plugins: [ElementPlus] } })
await flushPromises()
await w.findAll('.wiz-btn.danger')[0].trigger('click')
await flushPromises()
const dlg = document.querySelector('.el-dialog')
expect(dlg?.textContent).toContain('判死卡片')
expect(dlg?.textContent).toContain('陪葬清单(1 条)') // mock 1 死 1 活,只列活
expect(dlg?.textContent).toContain('fa高管增持量比')
// 填死因+确认
const ta = dlg?.querySelector('textarea') as HTMLTextAreaElement
ta.value = '逻辑前提失效'
ta.dispatchEvent(new Event('input', { bubbles: true }))
await flushPromises()
const btns = Array.from(document.querySelectorAll('.el-dialog button'))
.find((b) => b.textContent?.includes('确认判死'))
btns?.click()
await flushPromises()
expect(api.postHypothesisVerdict).toHaveBeenCalledWith(
expect.any(String), '逻辑前提失效')
})
(el-dialog 挂 body 需从 document 查——与 Element Plus 测试惯用法一致;若 mount attachTo 不够,加 attachTo: document.body。)
- Step 4: 跑测试+类型检查
Run: cd frontend && npx vitest run src/views/pipeline/HypothesisPool.spec.ts && npx vue-tsc --noEmit
Expected: 全 PASS+零类型错
- Step 5: Commit
git add frontend/src/api/pipeline.ts frontend/src/views/pipeline/HypothesisPool.vue frontend/src/views/pipeline/HypothesisPool.spec.ts
git commit -m "feat(frontend): 决议O②判死卡片危险区按钮+陪葬确认弹窗(列活因子清单+死因必填;强断言想法死,实现陪葬) [vps]"
Task 6: 全量回归+双 spec 落账
Files:
-
Modify:
docs/superpowers/specs/2026-09-22-research-to-trading-pipeline-design.md(决议 O 块尾加实现状态行) -
Modify:
docs/factor_research/factor-system-design.md(writeback 语义变更锚点,design-doc 门) -
Modify:
docs/superpowers/specs/2026-10-08-pipeline-console-ux-redesign.md(变更记录一笔) -
Step 1: 全量回归
Run: venv310/bin/python -m pytest tests/factor tests/api -q && cd frontend && npx vitest run && npx vue-tsc --noEmit
Expected: 全 PASS
- Step 2: spec 落账(三处各一小笔)
2026-09-22 spec §4.1 决议 O 块「挂账」行前加:
- **✅ 实现(2026-10-10)**:Task 链=writeback 聚合改造/`POST /pipeline/hypotheses/{id}/verdict` 陪葬端点/factors[] 透传 cause+decompose 终态 422/前端角标☠n/N+死因子行态+判死危险区+陪葬弹窗;commit 见 registry_events 同班车。
factor-system-design.md(§7 因子域档案中死亡回写小节,无则 §7 末尾追加):
- **卡片死亡回写聚合化(决议 O,2026-10-10)**:`writeback_card_death` 新增 registry 参数,反查卡下全部因子——全 graveyard 才回写卡片(death_reason=「全灭:触发因子 {name} 入墓园:{cause}」);部分死=卡留活跃池(只影响卡上展示,角标☠n/N)。CLI 与 factor_verdict 端点两调用方同步改签名。设计全貌归流水线 spec §4.1。
UX spec 变更记录追加:
- 2026-10-10:**决议 O 前端落地**——卡角标☠n/N(部分死=卡留活跃池)+死因子行态(划线/☠/死因 tooltip/不可点,修卡上死因子点击 404)+判死卡片危险区(陪葬清单弹窗+死因必填,对齐因子详情页危险区交互)
- Step 3: Commit+push
git add docs/superpowers/specs/2026-09-22-research-to-trading-pipeline-design.md docs/factor_research/factor-system-design.md docs/superpowers/specs/2026-10-08-pipeline-console-ux-redesign.md
git commit -m "docs: 决议O实现落账三处——流水线spec状态行+因子域writeback聚合语义锚点(design-doc门)+UX变更记录 [nas]"
git push origin master
- Step 4: 等 CI 绿(
http://192.168.2.154:3000/sanguo/sanguo_vnpy_v2/actions)→ nas-verify 绿后报用户「待推 VPS」清单,等「推vps」口令 dispatch(sha 用git rev-parse输出原样粘贴)
Self-Review 结论
- Spec 覆盖:决议 O 五要点→①Task1 ②Task2/Task5 ③Task3/Task4 ④Task3b ⑤不动项零改✓;挂账(promoted 触发点)明示不动✓
- 占位符扫描:唯一「以实际为准」点=Task5 的
load()刷新函数名与 ElMessage import——执行时 30 秒可确认,非设计缺口 - 类型一致:
cause?: string | null(TS)/"cause": e.get("cause")(PY dict)对齐;buried: string[]端点↔前端一致