diff --git a/.scratch/ppac-candidate/issues/01-fixed-tab.md b/.scratch/ppac-candidate/issues/01-fixed-tab.md new file mode 100644 index 0000000..9a3398c --- /dev/null +++ b/.scratch/ppac-candidate/issues/01-fixed-tab.md @@ -0,0 +1,24 @@ +# Alpha 管理候选PPAC +Type: task +Status: ready-for-agent + +用户要求新增固定候选清单,并将对应检查结果显示为黄色“候选PPAC”Tag。 + +## 实现范围 + +- 仅有一项 Alpha FAIL 且名称为 PURE_POWER_POOL_THEME 时,检查结果派生为 PPAC_CANDIDATE;REGULAR_SUBMISSION 沿用既有排除口径,WARNING/PENDING 不计失败。 +- 固定“候选PPAC”Tab 通过 ppac_candidate=true 在数据库分页前筛选未提交候选;叠加筛选、分页、导出、保存/恢复视图及 AI 页面上下文保持一致。 +- 列表和详情显示黄色候选状态,保留原始 FAIL 检查证据与本地研究记录。候选身份不表示当前可正式提交。 +- 迁移 0022 复用已有 check_type 索引,仅重分类匹配的历史缓存。同步和主动检查沿用 snapshot_columns 自动刷新分类。 + +## 验证 + +分类边界、组合筛选/分页/导出、同步与主动检查后的状态迁移、隔离历史迁移、后端静态检查、前端构建和浏览器固定 Tab/黄色 Tag/保存恢复/窄屏验证。 + +## Comments + +已完成本地实现与验证:95 项后端回归通过,包含分类边界、真实业务执行器对模拟 /check 的状态刷新、筛选/分页/导出/保存视图、503 条历史缓存的升级/降级/再升级及迁移 schema check;Ruff、TypeScript 与生产构建通过。 + +原有 Alpha 管理、提交受阻和本地相关性筛选 3 项浏览器回归通过。另在隔离临时库使用 56 条合成 PPAC Alpha 验证:固定 Tab、列表/详情黄色 Tag、原始 FAIL 保留、跨页勾选、搜索后回第一页及清空选择、保存/恢复视图、空结果和重置保持候选范围。1920px 桌面与 720px 窄屏完成截图检查,行高为 40px,窄屏分页底部 887px 位于 900px 视口内,页面未横向溢出,表体可独立横向滚动。 + +截图位于忽略目录 output/playwright/ppac-candidate-{desktop,detail,narrow}.png。构建保留已有 lottie eval 和大包提示;浏览器仅有登录前 auth/me 的 401 与 favicon 404。未部署、未迁移实际业务数据库、未请求真实平台。 diff --git a/backend/app/alphas.py b/backend/app/alphas.py index b330f95..62e5f54 100644 --- a/backend/app/alphas.py +++ b/backend/app/alphas.py @@ -31,16 +31,20 @@ def snapshot_columns(settings, metrics, checks, *, checked=False): Sync snapshots with no failures are PRE_CHECK; a completed explicit /check with no failures is PASS. WARNING/PENDING do not count as failures, matching the legacy workflow. Empty, malformed or unknown results remain PENDING. - No submission eligibility or activity eligibility is inferred here. + A sole PURE_POWER_POOL_THEME failure is a PPAC candidate, not confirmation + of current submission or activity eligibility. Raw failures stay available. """ settings = settings if isinstance(settings, dict) else {} metrics = metrics if isinstance(metrics, dict) else {} blocked = submission_limits(checks)["status"] == "blocked" checks, _ = split_checks(checks) valid = [check for check in checks if isinstance(check, dict)] - failures = len(failed_checks(checks)) + failed_names = failed_checks(checks) + failures = len(failed_names) by_name = {check["name"]: check for check in valid if isinstance(check.get("name"), str)} - if failures: + if failed_names == ["PURE_POWER_POOL_THEME"]: + check_type = "PPAC_CANDIDATE" + elif failures: check_type = "FAIL_1" if failures == 1 else "FAIL_2" elif not checks or len(valid) != len(checks) or any(check_result(check) not in ("PASS", "WARNING", "PENDING") for check in valid): check_type = "PENDING" @@ -75,7 +79,7 @@ def check_summary(checks, *, check_type): "check_type": check_type, "failed_checks": failed_checks(checks), "submission_limits": submission_limits(checks), - "meaning": "PRE_CHECK 为同步无失败项;PASS 为主动检查完成且无失败项。PENDING/WARNING 不算失败,不代表全部检查项 PASS 或当前可提交", + "meaning": "PRE_CHECK 为同步无失败项;PASS 为主动检查完成且无失败项;PPAC_CANDIDATE 为唯一失败项是 PURE_POWER_POOL_THEME 的候选。PENDING/WARNING 不算失败,不代表全部检查项 PASS 或当前可提交", } @@ -199,6 +203,11 @@ def list_statement(filters): query = query.where(Alpha.submission_blocked == filters.submission_blocked) if filters.submission_blocked: query = query.where(submission_condition("UNSUBMITTED")) + if filters.ppac_candidate is not None: + candidate = Alpha.check_type == "PPAC_CANDIDATE" + query = query.where(candidate if filters.ppac_candidate else ~candidate) + if filters.ppac_candidate: + query = query.where(submission_condition("UNSUBMITTED")) if (filters.local_correlation_status is not None or filters.local_correlation_min is not None or filters.local_correlation_max is not None): # One cache row per Alpha keeps totals/export stable; stale overrides the displayed status. diff --git a/backend/app/schemas.py b/backend/app/schemas.py index bf39e18..b867725 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -9,7 +9,7 @@ from pydantic import BaseModel, ConfigDict, Field, field_validator, model_valida ResearchState = Literal["inbox", "candidate", "optimizing", "archived"] Submission = Literal["UNSUBMITTED", "SUBMITTED"] -CheckType = Literal["PENDING", "PRE_CHECK", "PASS", "FAIL_1", "FAIL_2"] +CheckType = Literal["PENDING", "PRE_CHECK", "PASS", "PPAC_CANDIDATE", "FAIL_1", "FAIL_2"] SortField = Literal[ "id", "name", @@ -74,6 +74,7 @@ class AlphaFilters(Contract): local_correlation_min: float | None = Field(default=None, ge=-1, le=1) local_correlation_max: float | None = Field(default=None, ge=-1, le=1) submission_blocked: bool | None = None + ppac_candidate: bool | None = None submission: Submission | None = None source: str | None = Field(default=None, max_length=100) source_reference: str | None = Field(default=None, max_length=200) diff --git a/backend/migrations/versions/0022_ppac_candidate.py b/backend/migrations/versions/0022_ppac_candidate.py new file mode 100644 index 0000000..f3f1f94 --- /dev/null +++ b/backend/migrations/versions/0022_ppac_candidate.py @@ -0,0 +1,41 @@ +"""Classify snapshots whose sole Alpha failure is the unopened PPAC theme.""" + +import sqlalchemy as sa +from alembic import op + +revision = "0022" +down_revision = "0021" +branch_labels = None +depends_on = None + + +def upgrade(): + # Reuse the indexed check_type column; preserve all raw evidence and check stages. + table = sa.table("alphas", sa.column("id", sa.String()), sa.column("checks", sa.JSON()), + sa.column("check_type", sa.String(20))) + connection = op.get_bind() + last_id = None + while True: + query = sa.select(table.c.id, table.c.checks).order_by(table.c.id).limit(500) + if last_id is not None: + query = query.where(table.c.id > last_id) + rows = connection.execute(query).mappings().all() + if not rows: + break + candidates = [] + for row in rows: + # Freeze the current interpretation instead of importing application code. + checks = row["checks"] if isinstance(row["checks"], list) else [] + failures = [check for check in checks if isinstance(check, dict) + and check.get("name") != "REGULAR_SUBMISSION" + and isinstance(check.get("result"), str) and check["result"].upper() == "FAIL"] + if len(failures) == 1 and failures[0].get("name") == "PURE_POWER_POOL_THEME": + candidates.append(row["id"]) + if candidates: + connection.execute(table.update().where(table.c.id.in_(candidates)).values(check_type="PPAC_CANDIDATE")) + last_id = rows[-1]["id"] + + +def downgrade(): + table = sa.table("alphas", sa.column("check_type", sa.String(20))) + op.execute(table.update().where(table.c.check_type == "PPAC_CANDIDATE").values(check_type="FAIL_1")) diff --git a/backend/tests/test_ppac_candidate.py b/backend/tests/test_ppac_candidate.py new file mode 100644 index 0000000..57c9a35 --- /dev/null +++ b/backend/tests/test_ppac_candidate.py @@ -0,0 +1,108 @@ +"""PPAC candidates retain their theme failure and wait for platform eligibility.""" + +import copy +import csv +import io + +import pytest + +from app.alphas import check_summary, snapshot_columns, upsert_alpha +from tests import test_submission +from tests.conftest import alpha + +THEME = {"name": "PURE_POWER_POOL_THEME", "result": "FAIL"} +LIMIT = {"name": "REGULAR_SUBMISSION", "result": "FAIL"} +OTHER = {"name": "LOW_SHARPE", "result": "FAIL"} + + +@pytest.mark.parametrize("checked", [False, True]) +@pytest.mark.parametrize("checks,expected", [ + ([THEME], "PPAC_CANDIDATE"), + ([{**THEME, "result": "fail"}], "PPAC_CANDIDATE"), + ([THEME, LIMIT, {"name": "MATCHES_THEMES", "result": "WARNING"}, + {"name": "PROD_CORRELATION", "result": "PENDING"}], "PPAC_CANDIDATE"), + ([THEME, OTHER], "FAIL_2"), + ([THEME, THEME], "FAIL_2"), + ([THEME, {"result": "FAIL"}], "FAIL_2"), + ([OTHER], "FAIL_1"), + ([{**THEME, "result": "PASS"}, OTHER], "FAIL_1"), + ([{**THEME, "result": False}], "PENDING"), + ([{**THEME, "value": False}], "PPAC_CANDIDATE"), + ([], "PENDING"), + ([{}], "PENDING"), +]) +def test_only_a_single_explicit_theme_failure_is_a_candidate(checks, expected, checked): + original = copy.deepcopy(checks) + columns = snapshot_columns({}, {}, checks, checked=checked) + assert columns["check_type"] == expected + assert checks == original + if expected == "PPAC_CANDIDATE": + assert check_summary(checks, check_type=expected)["failed_checks"] == ["PURE_POWER_POOL_THEME"] + + +async def test_ppac_scope_filters_before_pagination_and_export(app, logged_in): + async with app.state.sessions.begin() as db: + for name, checks, status, region, hidden in [ + ("candidate1", [THEME], "UNSUBMITTED", "USA", False), + ("candidate2", [THEME, LIMIT], "UNSUBMITTED", "USA", True), + ("other_region", [THEME], "UNSUBMITTED", "CHN", False), + ("submitted", [THEME], "ACTIVE", "USA", False), + ("missing_status", [THEME], None, "USA", False), + ("two_failures", [THEME, OTHER], "UNSUBMITTED", "USA", False), + ("other_failure", [OTHER], "UNSUBMITTED", "USA", False), + ("passed", [{**THEME, "result": "PASS"}], "UNSUBMITTED", "USA", False), + ("missing", [], "UNSUBMITTED", "USA", False), + ]: + await upsert_alpha(db, alpha(name, status=status, hidden=hidden, settings={"region": region}, + **{"is": {"checks": checks}})) + query = "ppac_candidate=true®ion=USA&sort=id&direction=asc&limit=1&offset=1" + response = await logged_in.get(f"/api/v1/alphas?{query}") + assert response.status_code == 200, response.text + result = response.json() + assert result["total"] == 2 + assert [row["id"] for row in result["items"]] == ["candidate2"] + assert result["items"][0]["check_type"] == "PPAC_CANDIDATE" + assert result["items"][0]["failed_checks"] == ["PURE_POWER_POOL_THEME"] + export = await logged_in.get(f"/api/v1/alphas/export?{query}") + rows = list(csv.DictReader(io.StringIO(export.text.lstrip("\ufeff")))) + assert [row["id"] for row in rows] == ["candidate1", "candidate2"] + assert {row["check_type"] for row in rows} == {"PPAC_CANDIDATE"} + assert (await logged_in.get("/api/v1/alphas?ppac_candidate=true&submission=SUBMITTED")).json()["total"] == 0 + assert (await logged_in.get("/api/v1/alphas?ppac_candidate=true&check_type=FAIL_1")).json()["total"] == 0 + assert (await logged_in.get("/api/v1/alphas?ppac_candidate=true&submission_blocked=true")).json()["total"] == 1 + assert (await logged_in.get("/api/v1/alphas?ppac_candidate=true&q=candidate1")).json()["total"] == 1 + assert (await logged_in.get("/api/v1/alphas?check_type=PPAC_CANDIDATE&submission=UNSUBMITTED")).json()["total"] == 3 + assert (await logged_in.get("/api/v1/alphas?ppac_candidate=false")).json()["total"] == 4 + + saved = await logged_in.post("/api/v1/research/assets", json={ + "kind": "view", "content": {"name": "候选PPAC", "filters": { + "submission": "UNSUBMITTED", "ppac_candidate": True, "region": "USA", + }, "columns": ["name", "check_type"]}, + }) + assert saved.status_code in (200, 201), saved.text + assert saved.json()["content"]["filters"]["ppac_candidate"] is True + + +async def test_check_and_sync_refresh_candidate_status_without_losing_evidence(app, logged_in, monkeypatch): + await test_submission.setup(app, alpha(**{"is": {"checks": [OTHER]}})) + for checks, expected in [([THEME, LIMIT], "PPAC_CANDIDATE"), ([{**THEME, "result": "PASS"}], "PASS")]: + monkeypatch.setattr(test_submission, "CHECKS", checks) + response = await test_submission.enqueue(logged_in) + assert response.status_code == 202, response.text + await app.state.runner.execute(response.json()["id"]) + state = (await logged_in.get("/api/v1/alphas/alpha1/submission")).json() + assert state["job"]["status"] == "completed" + assert state["check_summary"]["check_type"] == expected + detail = (await logged_in.get("/api/v1/alphas/alpha1")).json() + assert detail["checks"] == checks + assert detail["check_type"] == expected + assert detail["research"]["note"] == "preserve local research" + assert (await logged_in.get("/api/v1/alphas?ppac_candidate=true")).json()["total"] == (expected == "PPAC_CANDIDATE") + for checks, expected in [([THEME], "PPAC_CANDIDATE"), ([THEME, OTHER], "FAIL_2")]: + async with app.state.sessions.begin() as db: + await upsert_alpha(db, alpha(**{"is": {"checks": checks}})) + detail = (await logged_in.get("/api/v1/alphas/alpha1")).json() + assert detail["check_type"] == expected + assert detail["checks"] == checks + assert detail["research"]["note"] == "preserve local research" + assert (await logged_in.get("/api/v1/alphas?ppac_candidate=true")).json()["total"] == (expected == "PPAC_CANDIDATE") diff --git a/backend/tests/test_ppac_candidate_migration.py b/backend/tests/test_ppac_candidate_migration.py new file mode 100644 index 0000000..0da2790 --- /dev/null +++ b/backend/tests/test_ppac_candidate_migration.py @@ -0,0 +1,55 @@ +"""Historical PPAC classification changes only matching cached check types.""" + +from datetime import datetime, timezone +from pathlib import Path + +import sqlalchemy as sa +from alembic import command +from alembic.config import Config +from cryptography.fernet import Fernet + + +def test_ppac_backfill_preserves_evidence_across_batches_and_downgrade(tmp_path, monkeypatch): + database = tmp_path / "ppac.db" + monkeypatch.setenv("DATABASE_URL", f"sqlite+aiosqlite:///{database}") + monkeypatch.setenv("ADMIN_PASSWORD", "migration-test-only") + monkeypatch.setenv("ENCRYPTION_KEY", Fernet.generate_key().decode()) + monkeypatch.setenv("WQ_EMAIL", "") + monkeypatch.setenv("WQ_PASSWORD", "") + root = Path(__file__).resolve().parents[1] + config = Config(str(root / "alembic.ini")) + config.set_main_option("script_location", str(root / "migrations")) + command.upgrade(config, "0021") + engine = sa.create_engine(f"sqlite:///{database}") + alphas = sa.Table("alphas", sa.MetaData(), autoload_with=engine) + theme = {"name": "PURE_POWER_POOL_THEME", "result": "FAIL"} + other = {"name": "LOW_SHARPE", "result": "FAIL"} + patterns = [ + ([theme], "FAIL_1", "PPAC_CANDIDATE"), + ([{**theme, "result": "fail"}, {"name": "REGULAR_SUBMISSION", "result": "FAIL"}], "FAIL_1", "PPAC_CANDIDATE"), + ([theme, other], "FAIL_2", "FAIL_2"), + ([theme, theme], "FAIL_2", "FAIL_2"), + ([other], "FAIL_1", "FAIL_1"), + ([{**theme, "result": "PASS"}], "PASS", "PASS"), + ([{**theme, "result": "WARNING"}], "PRE_CHECK", "PRE_CHECK"), + ([{}], "PENDING", "PENDING"), + (None, "PENDING", "PENDING"), + ] + with engine.begin() as db: + db.execute(alphas.insert(), [ + {"id": f"ppac{i:04}", "status": "UNSUBMITTED", "hidden": False, "settings": {}, "os_metrics": {}, + "is_metrics": {"checks": patterns[i % len(patterns)][0]}, "checks": patterns[i % len(patterns)][0], + "check_type": patterns[i % len(patterns)][1], "synced_at": datetime(2026, 9, 13, tzinfo=timezone.utc), + "raw": {"is": {"checks": patterns[i % len(patterns)][0]}}} + for i in range(503) + ]) + original = db.execute(sa.select(alphas).order_by(alphas.c.id)).mappings().all() + for target, position in [("0022", 2), ("0021", 1), ("0022", 2)]: + (command.upgrade if target == "0022" else command.downgrade)(config, target) + with engine.connect() as db: + rows = db.execute(sa.select(alphas).order_by(alphas.c.id)).mappings().all() + assert len(rows) == 503 + for i, row in enumerate(rows): + assert dict(row) == {**original[i], "check_type": patterns[i % len(patterns)][position]} + command.check(config) + engine.dispose() diff --git a/frontend/src/components/AlphaDetail.tsx b/frontend/src/components/AlphaDetail.tsx index c7775c3..5c974ca 100644 --- a/frontend/src/components/AlphaDetail.tsx +++ b/frontend/src/components/AlphaDetail.tsx @@ -230,6 +230,11 @@ export function AlphaDetail({ {detail.alpha_type ?? "类型未提供"} {detail.language ?? "语言未提供"} {detail.status ?? "状态未提供"} + {detail.check_type === "PPAC_CANDIDATE" && ( + + 候选PPAC + + )} 在 BRAIN 中打开 ↗ + {detail.check_type === "PPAC_CANDIDATE" && ( +

+ 唯一失败项为 PURE_POWER_POOL_THEME,等待平台开放 PPAC + 主题后可重新检查提交资格。 +

+ )} {detail.alpha_type === "SUPER" && (