Files
sanguo_vnpy_v2/docs/superpowers/plans/2026-10-10-resolution-o-card-graveyard.md
T
claude_dev cdeea605a3
CI/CD / test (push) Successful in 46s
CI/CD / nas-deploy (push) Successful in 0s
CI/CD / nas-verify (push) Successful in 17s
docs(factor): 工程坑清单#10月数学1-based减法归零(window_start产2026-00-01案,20a3b89根治)+B班plan落档 [nas]
2026-10-11 10:01:39 +08:00

35 KiB
Raw Blame History

决议 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}/verdict body {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[] 端点↔前端一致