Compare commits

...

5 Commits

Author SHA1 Message Date
yuxuanhui f3eb239e1a fix: allow repeated platform checks and unify Alpha metric formatting
Deploy production / deploy (push) Successful in 54s
2026-09-12 22:42:37 +08:00
yuxuanhui c18960946b fix: restore stage-based Alpha check classification 2026-09-12 22:34:14 +08:00
yuxuanhui 45eb4c3a17 feat: 分离 Alpha 检查与提交限制,优化检查统计与前端展示 2026-09-12 22:11:11 +08:00
yuxuanhui 5eb6008ef7 feat: expose platform production correlation for submitted alphas in displayed columns 2026-09-12 20:16:02 +08:00
yuxuanhui ef24ace748 style: reduce grid gaps to 6px 2026-09-12 14:44:35 +08:00
30 changed files with 756 additions and 62 deletions
@@ -0,0 +1,17 @@
# 分离 Alpha 检查和提交限制
Type: task
Status: ready-for-agent
用户已批准实现。保留 REGULAR_SUBMISSION 原始证据,将其从 Alpha 失败统计及待定判定中分离;HTTP/MCP 提供分类摘要,页面单独展示缓存限制;迁移仅重算历史 check_type。未知检查保持保守,不推断实时额度、恢复时间或正式提交资格。
验证:分类边界、模拟平台检查到持久化和 MCP/HTTP 返回、研究筛选、历史迁移、后端检查及前端构建。
## 完成记录
已实现并完成本地验证:后端 471 项全量测试通过;最终调整后 23 项专项测试通过;Ruff 与 git diff --check 通过;前端构建通过;Alpha 管理和工作空间 3 项浏览器回归通过。迁移 0018 在隔离 SQLite 中验证 503 条记录的升级、降级、再升级和原始证据保留。尚未部署或对业务数据库执行迁移;Docker 后端部署启动时按既有流程执行 alembic upgrade head。
## Comments
用户后续确认恢复旧项目阶段语义:同步非空有效检查无 FAIL 为 PRE_CHECK,主动 /check 完成无 FAIL 为 PASS,PENDING/WARNING 不算失败;异常、空结果保留待定。同步刷新采用新的同步快照重新分类,不沿用旧检查阶段。MCP/HTTP 使用数据库派生状态;迁移 0019 使用晚于 synced_at 的已保存 checked 检查点识别历史主动检查,其余归同步阶段。保留 0018 历史迁移和所有原始 checks。
阶段逻辑调整已完成:后端 499 项测试通过,含同步→主动检查→再同步、MCP PENDING/WARNING 返回和 503 条历史记录迁移;Ruff、git diff --check、前端构建通过。预检通过使用蓝色 Tag。未部署,未迁移实际业务数据库。
+21 -8
View File
@@ -7,6 +7,7 @@ from datetime import datetime
from sqlalchemy import or_, select, update
from .models import Alpha, Research, ResearchTag, SelfCorrelation, now
from .platform_checks import check_result, split_checks, submission_limits
from .research.provenance import source_alpha_ids
METRIC_FIELDS = (
@@ -16,32 +17,34 @@ METRIC_FIELDS = (
def failed_checks(checks):
"""Return failed platform check names; local correlation never changes this list."""
"""Return failed Alpha check names, excluding submission limits and local correlation."""
return [
check.get("name") if isinstance(check.get("name"), str) else "未命名检查"
for check in checks if isinstance(check, dict) and check.get("result") == "FAIL"
for check in split_checks(checks)[0] if isinstance(check, dict) and check_result(check) == "FAIL"
] if isinstance(checks, list) else []
def snapshot_columns(settings, metrics, checks):
def snapshot_columns(settings, metrics, checks, *, checked=False):
"""Derive list fields from a platform snapshot, preserving missing metrics as null.
Only explicit FAIL results count. Empty, malformed and unfinished checks are
pending; all known checks passing without PROD_CORRELATION is only a pre-check.
Submission limits are excluded. Only explicit Alpha FAIL results count.
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.
"""
settings = settings if isinstance(settings, dict) else {}
metrics = metrics if isinstance(metrics, dict) else {}
checks = checks if isinstance(checks, list) else []
checks, _ = split_checks(checks)
valid = [check for check in checks if isinstance(check, dict)]
failures = len(failed_checks(checks))
by_name = {check["name"]: check for check in valid if isinstance(check.get("name"), str)}
if failures:
check_type = "FAIL_1" if failures == 1 else "FAIL_2"
elif not checks or len(valid) != len(checks) or any(check.get("result") != "PASS" for check in valid):
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"
else:
check_type = "PASS" if "PROD_CORRELATION" in by_name else "PRE_CHECK"
check_type = "PASS" if checked else "PRE_CHECK"
# /check values are freshest; submitted snapshots also expose a scalar in IS.
prod_correlation = number(by_name.get("PROD_CORRELATION", {}).get("value"))
if prod_correlation is None:
@@ -64,6 +67,16 @@ def snapshot_columns(settings, metrics, checks):
}
def check_summary(checks, *, check_type):
"""Separate cached Alpha findings from submission limits; infer no live eligibility."""
return {
"check_type": check_type,
"failed_checks": failed_checks(checks),
"submission_limits": submission_limits(checks),
"meaning": "PRE_CHECK 为同步无失败项;PASS 为主动检查完成且无失败项。PENDING/WARNING 不算失败,不代表全部检查项 PASS 或当前可提交",
}
def submission_condition(submission):
"""Match the platform list contract; a missing status is never assumed submitted."""
return Alpha.status == "UNSUBMITTED" if submission == "UNSUBMITTED" else Alpha.status != "UNSUBMITTED"
+2 -2
View File
@@ -24,7 +24,7 @@ TOOLS = {
"search_data_preparations": (c.PreparationSearch, "preparations", "research:read", "分页查询数据准备集合,返回固定范围、字段数及版本。研究可使用多个集合,各集合范围独立。"),
"get_data_preparation": (c.PreparationRead, "preparation", "research:read", "按集合 ID 与版本分页预览字段、类型、描述和数据集归属。提交回测时携带 preparation_refs,由服务端核对版本并固定独立输入快照;空集合不能用于研究。"),
"create_research_template": (c.CreateTemplate, "create_template", "research:write", "将调用方大模型研究后自行总结的参数化模板保存到模板工坊,供用户后续批量回测。先用 get_backtest_results 阅读实际指标和检查,选择 1–20 个已完成采集的 source_item_ids,并说明 hypothesis;不要把 completed 当作检查通过。template 使用 {name} 占位符及逐一对应的 variables,字段变量须声明 MATRIX/VECTOR/GROUP,VECTOR 聚合须明确写入表达式。提供唯一名称和 idempotency_key,可附 reference。返回模板 ID、版本和理论组合数;仅核验结构及来源,不验证所有参数组合,不再次调用模型、不执行回测、不覆盖已有模板。"),
"get_submission_check": (c.SelfCorrelationReference, "submission_check_context", "research:read", "读取已导入 Alpha 的表达式、Description、snapshot 和缓存检查结果;不发起检查。先核对或生成三段 Description,再调用 check_submission。"),
"get_submission_check": (c.SelfCorrelationReference, "submission_check_context", "research:read", "读取已导入 Alpha 的表达式、Description、snapshot 和缓存检查结果;check_summary 分离 Alpha 检查和 REGULAR_SUBMISSION 提交限制,原始 checks 保留;限制不代表当前额度。不发起检查。先核对或生成三段 Description,再调用 check_submission。"),
"check_submission": (c.SubmissionCheck, "check_submission", "research:refresh", "对单个待提交 Alpha 写回已获用户授权的 Description 并调用平台 GET /check,返回 job_id。须先用 get_submission_check 获取 snapshot;保留本地自相关门槛和冲突保护。通过 get_refresh_job 查进度、get_submission_check 读结果。无论检查结果如何,都不会调用 /submit 或正式提交 Alpha。"),
"get_worldquant_connection": (c.ConnectionReference, "connection", "research:read", "读取 WorldQuant 连接状态及可选认证 job_id 的进度,不发起认证;人工验证在网页完成。"),
"authenticate_worldquant": (c.Authentication, "authenticate", "research:refresh", "使用服务端已保存凭据连接或重新认证 WorldQuant,返回 job_id;action=connect(默认)或人工验证后 verify。用 get_worldquant_connection 查询,不接收密码,不修改账户配置。"),
@@ -38,7 +38,7 @@ TOOLS = {
"search_backtests": (c.History, "history", "research:read", "分页查历史候选与固定设置;candidates 按完整输入精确匹配,不推断数学等价。"),
"submit_backtests": (c.Submit, "submit", "backtests:execute", "执行用户已授权的固定批次,自动留痕并立即返回运行 ID。可携带 preparation_refs 选择集合,版本变化须重新读取;每项必须完整设置;重复默认拒绝,rerun 明确重跑。不需要研究资产。"),
"get_backtest": (c.RunReference, "run", "research:read", "读取真实运行进度、提交数量和可选增量事件;受理不等于成功。"),
"get_backtest_results": (c.Results, "results", "research:read", "分页读取固定快照指标、全部非通过检查及三层状态;缺失指标不补零。"),
"get_backtest_results": (c.Results, "results", "research:read", "分页读取固定快照指标、Alpha 非通过检查及三层状态;REGULAR_SUBMISSION 单列 submission_limits,不计入 Alpha 失败统计。缺失指标不补零。"),
"get_backtest_artifact": (c.Artifact, "artifact", "research:read", "分页读取候选脱敏快照的顶层键值或独立采集的 PnL;缺缓存不自动刷新。"),
"control_backtest": (c.Control, "control", "backtests:control", "对已授权运行暂停、继续、停止或恢复采集;不远程取消、不重提未知模拟。需要版本和幂等键。"),
}
+29
View File
@@ -0,0 +1,29 @@
"""Classify platform evidence without discarding unknown checks or inferring eligibility."""
def check_result(check):
"""Normalize known upstream result casing without rewriting the raw evidence."""
value = check.get("result") if isinstance(check, dict) else None
return value.upper() if isinstance(value, str) else None
def is_submission_limit(check):
"""Recognize only the confirmed account-limit check; unknown names remain Alpha checks."""
return isinstance(check, dict) and check.get("name") == "REGULAR_SUBMISSION"
def split_checks(checks):
"""Return Alpha checks and submission limits, retaining malformed Alpha evidence."""
items = checks if isinstance(checks, list) else []
return ([c for c in items if not is_submission_limit(c)],
[c for c in items if is_submission_limit(c)])
def submission_limits(checks):
"""Summarize the observed limit, never the account's current allowance or reset time."""
_, limits = split_checks(checks)
status = "blocked" if any(check_result(c) == "FAIL" for c in limits) else (
"not_blocked" if limits and all(check_result(c) == "PASS" for c in limits) else "unknown"
)
return {"status": status, "checks": limits,
"meaning": "仅反映缓存观测时的提交限制,不代表当前额度或正式提交资格"}
+7 -4
View File
@@ -7,6 +7,7 @@ from sqlalchemy import select
from ..backtests.service import Backtests, uid
from ..models import Alpha, ResearchEvaluation, ResearchExperiment, SelfCorrelation
from ..platform_checks import split_checks, submission_limits
from .experiments import Experiments
from .serialization import encode_snapshot as jsonable_encoder
@@ -30,13 +31,14 @@ def assess(snapshot, rules):
evidence.append(
{"metric": key, "value": value, "bound": bound, "direction": direction, "status": status}
)
checks = metrics.get("checks") or []
raw_checks = metrics.get("checks") or []
checks, _ = split_checks(raw_checks)
if not checks:
missing.append("platform_checks")
for check in checks:
if check.get("result") == "FAIL":
if isinstance(check, dict) and check.get("result") == "FAIL":
failed.append(f"platform:{check.get('name', 'unknown')}")
unknown_checks = [check for check in checks if check.get("result") not in ("PASS", "FAIL")]
unknown_checks = [check for check in checks if not isinstance(check, dict) or check.get("result") not in ("PASS", "FAIL")]
if unknown_checks:
missing.append("unresolved_platform_checks")
return {
@@ -44,7 +46,8 @@ def assess(snapshot, rules):
"evidence": evidence,
"failed": failed,
"missing": missing,
"existing_platform_checks": checks,
"existing_platform_checks": raw_checks,
"submission_limits": submission_limits(raw_checks),
"meaning": "本地研究筛选结果,不是官方提交资格",
}
+6 -1
View File
@@ -7,6 +7,7 @@ from sqlalchemy import func, select
from ..alphas import number, sanitize
from ..backtests.contracts import fingerprint
from ..models import BacktestItem, BacktestResult, BacktestRun, Pnl
from ..platform_checks import is_submission_limit
from ..research.serialization import encode_snapshot
@@ -26,6 +27,8 @@ def checks_summary(snapshot):
if "checks" in snapshot:
raw = snapshot["checks"]
checks.extend({"section": "root", "raw": c} for c in (raw if isinstance(raw, list) else [raw]))
submission_checks = [c for c in checks if is_submission_limit(c["raw"])]
checks = [c for c in checks if not is_submission_limit(c["raw"])]
counts = Counter({key: 0 for key in ("PASS", "FAIL", "PENDING", "WARNING", "UNKNOWN")})
non_pass = []
for check in checks:
@@ -36,7 +39,9 @@ def checks_summary(snapshot):
if state != "PASS":
non_pass.append({**check, "status": state})
return {"status": "unknown" if not checks else "reported", "counts": dict(counts),
"total": len(checks), "non_pass": non_pass}
"total": len(checks), "non_pass": non_pass,
"submission_limits": submission_checks,
"meaning": "Alpha 检查统计不含提交限制;限制为快照观测,不代表实时提交资格"}
def item_summary(item, result):
+2 -1
View File
@@ -9,6 +9,7 @@ from uuid import uuid4
from sqlalchemy import func, select
from ..alphas import check_summary
from ..backtests.contracts import ControlInput, DraftInput, PreviewInput, Source, StartInput, fingerprint
from ..backtests.service import Backtests
from ..business import Business
@@ -221,7 +222,7 @@ class ResearchAccess:
return {"alpha_id": args.alpha_id, "snapshot": submission_fingerprint(context),
**context, "descriptions": {key: item["description"] for key, item in context["sections"].items()},
"can_check": alpha.status == "UNSUBMITTED" and await correlation_allows_check(self.db, args.alpha_id),
"checks": alpha.checks, "source": "local_cache", "production_submission": False,
"checks": alpha.checks, "check_summary": check_summary(alpha.checks, check_type=alpha.check_type), "source": "local_cache", "production_submission": False,
"job_id": job.id if job else None, "job_status": job.status if job else None,
"checked_at": job.checkpoint.get("checked_at") if job else None}
+8 -3
View File
@@ -15,7 +15,7 @@ from pydantic_ai.usage import UsageLimits
from sqlalchemy import select
from .ai.provider import public_error
from .alphas import code, sanitize, snapshot_columns, submission_condition
from .alphas import check_summary, code, sanitize, snapshot_columns, submission_condition
from .jobs import ACTIVE
from .models import Account, AISettings, Alpha, Job, JobItem, SelfCorrelation, now
from .schemas import Contract, JobOutput, valid_ids
@@ -206,6 +206,8 @@ def router(runner, ai):
)
return {
"snapshot": fingerprint(context),
"checks": alpha.checks,
"check_summary": check_summary(alpha.checks, check_type=alpha.check_type),
"sections": context["sections"],
"descriptions": {key: item["description"] for key, item in context["sections"].items()},
"model": config.description_model,
@@ -353,8 +355,11 @@ async def run_check(runner, job_id, payload):
alpha.checks = sanitize(checks)
alpha.is_metrics = {**alpha.is_metrics, "checks": alpha.checks}
alpha.raw = {**alpha.raw, "is": {**(alpha.raw.get("is") or {}), "checks": alpha.checks}}
for key, value in snapshot_columns(alpha.settings, alpha.is_metrics, alpha.checks).items():
for key, value in snapshot_columns(alpha.settings, alpha.is_metrics, alpha.checks, checked=True).items():
setattr(alpha, key, value)
db.add(JobItem(job_id=job_id, alpha_id=alpha_id))
job.processed = 1
job.checkpoint = {"alpha_id": alpha_id, "phase": "checked", "checked_at": now().isoformat()}
job.checkpoint = {
"alpha_id": alpha_id, "phase": "checked", "checked_at": now().isoformat(),
"review_snapshot": fingerprint(source(alpha.raw)),
}
@@ -0,0 +1,55 @@
"""Reclassify cached Alpha checks without changing upstream evidence."""
import sqlalchemy as sa
from alembic import op
revision = "0018"
down_revision = "0017"
branch_labels = None
depends_on = None
def check_type(checks, separate_limits):
"""Frozen classification for reversible data migration; never import mutable app code."""
checks = checks if isinstance(checks, list) else []
if separate_limits:
checks = [c for c in checks if not (isinstance(c, dict) and c.get("name") == "REGULAR_SUBMISSION")]
valid = [c for c in checks if isinstance(c, dict)]
failures = sum(c.get("result") == "FAIL" for c in valid)
if failures:
return "FAIL_1" if failures == 1 else "FAIL_2"
if not checks or len(valid) != len(checks) or any(c.get("result") != "PASS" for c in valid):
return "PENDING"
return "PASS" if any(c.get("name") == "PROD_CORRELATION" for c in valid) else "PRE_CHECK"
def reclassify(separate_limits):
"""Update only affected derived columns, in bounded batches; raw snapshots stay intact."""
table = sa.table("alphas", sa.column("id", sa.String()), sa.column("checks", sa.JSON()),
sa.column("check_type", sa.String()))
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
updates = [
{"snapshot_id": row["id"], "classification": check_type(row["checks"], separate_limits)}
for row in rows if isinstance(row["checks"], list) and any(
isinstance(c, dict) and c.get("name") == "REGULAR_SUBMISSION" for c in row["checks"])
]
if updates:
connection.execute(table.update().where(table.c.id == sa.bindparam("snapshot_id"))
.values(check_type=sa.bindparam("classification")), updates)
last_id = rows[-1]["id"]
def upgrade():
reclassify(True)
def downgrade():
reclassify(False)
@@ -0,0 +1,91 @@
"""Restore stage-based check classification, preserving raw platform snapshots."""
from datetime import datetime, timezone
import sqlalchemy as sa
from alembic import op
revision = "0019"
down_revision = "0018"
branch_labels = None
depends_on = None
def timestamp(value):
"""Read historical timestamps; missing or invalid evidence cannot prove a /check."""
try:
value = datetime.fromisoformat(value) if isinstance(value, str) else value
return value.replace(tzinfo=timezone.utc) if value.tzinfo is None else value
except (ValueError, TypeError, AttributeError):
return None
def classify(checks, checked, legacy):
"""Frozen migration rules; legacy means the pre-0019 correlation-presence rule."""
checks = checks if isinstance(checks, list) else []
checks = [c for c in checks if not (isinstance(c, dict) and c.get("name") == "REGULAR_SUBMISSION")]
valid = [c for c in checks if isinstance(c, dict)]
results = [c.get("result") for c in valid]
if not legacy:
results = [v.upper() if isinstance(v, str) else None for v in results]
failures = sum(v == "FAIL" for v in results)
if failures:
return "FAIL_1" if failures == 1 else "FAIL_2"
allowed = ("PASS",) if legacy else ("PASS", "PENDING", "WARNING")
if not checks or len(valid) != len(checks) or any(v not in allowed for v in results):
return "PENDING"
passed = any(c.get("name") == "PROD_CORRELATION" for c in valid) if legacy else checked
return "PASS" if passed else "PRE_CHECK"
def reclassify(legacy=False):
"""Recompute in batches; only a check checkpoint newer than the sync proves its stage.
A prior PASS is not evidence because the old rule inferred it from a check name.
A subsequent sync replaces the snapshot and is classified as pre-check again.
"""
alphas = sa.table("alphas", sa.column("id", sa.String()), sa.column("checks", sa.JSON()),
sa.column("synced_at", sa.DateTime(timezone=True)), sa.column("check_type", sa.String()))
jobs = sa.table("sync_jobs", sa.column("kind", sa.String()), sa.column("payload", sa.JSON()),
sa.column("checkpoint", sa.JSON()))
connection = op.get_bind()
last_id = None
while True:
query = sa.select(alphas.c.id, alphas.c.checks, alphas.c.synced_at).order_by(alphas.c.id).limit(500)
if last_id is not None:
query = query.where(alphas.c.id > last_id)
rows = connection.execute(query).mappings().all()
if not rows:
break
checked_at = {}
if not legacy:
observations = connection.execute(sa.select(jobs.c.checkpoint).where(
jobs.c.kind == "submission_check",
jobs.c.payload["alpha_ids"][0].as_string().in_([r["id"] for r in rows]),
)).scalars()
for checkpoint in observations:
if not isinstance(checkpoint, dict) or checkpoint.get("phase") != "checked":
continue
alpha_id = checkpoint.get("alpha_id")
observed = timestamp(checkpoint.get("checked_at"))
if isinstance(alpha_id, str) and observed and (
alpha_id not in checked_at or observed > checked_at[alpha_id]
):
checked_at[alpha_id] = observed
updates = []
for row in rows:
synced = timestamp(row["synced_at"])
observed = checked_at.get(row["id"])
updates.append({"snapshot_id": row["id"], "classification": classify(
row["checks"], bool(synced and observed and observed > synced), legacy)})
connection.execute(alphas.update().where(alphas.c.id == sa.bindparam("snapshot_id"))
.values(check_type=sa.bindparam("classification")), updates)
last_id = rows[-1]["id"]
def upgrade():
reclassify()
def downgrade():
reclassify(legacy=True)
+5 -5
View File
@@ -37,10 +37,10 @@ def checks(failures):
(None, "PENDING"),
([None], "PENDING"),
([{}], "PENDING"),
([{"name": "LOW_SHARPE", "result": "WARNING"}], "PENDING"),
([{"name": "LOW_SHARPE", "result": "WARNING"}], "PRE_CHECK"),
([{"name": "LOW_SHARPE", "result": "PASS"}], "PRE_CHECK"),
([{"name": "PROD_CORRELATION", "result": "PENDING"}], "PENDING"),
(checks(0), "PASS"),
([{"name": "PROD_CORRELATION", "result": "PENDING"}], "PRE_CHECK"),
(checks(0), "PRE_CHECK"),
(checks(1), "FAIL_1"),
(checks(2), "FAIL_2"),
(checks(3), "FAIL_2"),
@@ -58,7 +58,7 @@ async def test_checks_filter_before_pagination_and_share_export_scope(app, logge
for check_type, expected in [
("FAIL_1", ["failed1"]),
("FAIL_2", ["failed2", "failed3"]),
("PASS", ["failed0"]),
("PRE_CHECK", ["failed0"]),
("PENDING", ["unknown"]),
]:
response = await logged_in.get(
@@ -218,7 +218,7 @@ def test_migration_backfills_multiple_batches_and_preserves_research(tmp_path, m
rows = db.execute(sa.select(alphas).order_by(alphas.c.id)).mappings().all()
assert len(rows) == 503
for i, row in enumerate(rows):
expected = snapshot_columns(row["settings"], row["is_metrics"], checks(i % 4))
expected = snapshot_columns(row["settings"], row["is_metrics"], checks(i % 4), checked=True)
assert {key: row[key] for key in expected} == expected
record = db.execute(sa.select(research)).mappings().one()
assert record["tags"] == ["PPAC"] and record["note"] == "keep" and record["version"] == 7
@@ -0,0 +1,64 @@
"""Historical stage recovery requires a persisted /check newer than the latest sync."""
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_stage_backfill_preserves_evidence_and_uses_checkpoints(tmp_path, monkeypatch):
path = tmp_path / "stages.db"
monkeypatch.setenv("DATABASE_URL", f"sqlite+aiosqlite:///{path}")
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, "0018")
engine = sa.create_engine(f"sqlite:///{path}")
alphas = sa.Table("alphas", sa.MetaData(), autoload_with=engine)
jobs = sa.Table("sync_jobs", sa.MetaData(), autoload_with=engine)
synced = datetime(2026, 9, 12, 0, tzinfo=timezone.utc)
pending = [{"name": "PROD_CORRELATION", "result": "PENDING"},
{"name": "REGULAR_SUBMISSION", "result": "FAIL"}]
passed = [{"name": "PROD_CORRELATION", "result": "PASS"}]
patterns = [
(pending, "PENDING", "PRE_CHECK", None),
(pending, "PENDING", "PASS", {"phase": "checked", "checked_at": "2026-09-12T01:00:00+00:00"}),
(pending, "PENDING", "PRE_CHECK", {"phase": "checked", "checked_at": "2026-09-11T23:00:00+00:00"}),
(pending, "PENDING", "PRE_CHECK", {"phase": "check"}),
(passed, "PASS", "PRE_CHECK", None),
([{"name": "LOW_SHARPE", "result": "FAIL"}], "FAIL_1", "FAIL_1", None),
([{}], "PENDING", "PENDING", {"phase": "checked", "checked_at": "2026-09-12T01:00:00Z"}),
]
with engine.begin() as db:
db.execute(alphas.insert(), [
{"id": f"stage{i:04}", "hidden": False, "settings": {}, "os_metrics": {},
"is_metrics": {"checks": patterns[i % 7][0]}, "checks": patterns[i % 7][0],
"check_type": patterns[i % 7][1], "synced_at": synced,
"raw": {"is": {"checks": patterns[i % 7][0]}}}
for i in range(503)
])
db.execute(jobs.insert(), [
{"id": f"job{i}", "kind": "submission_check", "status": "completed",
"payload": {"alpha_ids": [f"stage{i:04}"]},
"checkpoint": {**patterns[i % 7][3], "alpha_id": f"stage{i:04}"},
"processed": 1, "failed": 0, "total": 1, "cancel_requested": False,
"created_at": synced, "updated_at": synced}
for i in range(503) if patterns[i % 7][3]
])
for target, expected_index in [("0019", 2), ("0018", 1), ("0019", 2)]:
(command.upgrade if target == "0019" 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 row["check_type"] == patterns[i % 7][expected_index]
assert row["checks"] == row["raw"]["is"]["checks"] == row["is_metrics"]["checks"] == patterns[i % 7][0]
command.check(config)
engine.dispose()
+56
View File
@@ -0,0 +1,56 @@
"""The same checks have different meanings at sync and explicit /check stages."""
import pytest
from app.alphas import snapshot_columns
@pytest.mark.parametrize("checks", [
[{"name": "LOW_SHARPE", "result": "PASS"}],
[{"name": "PROD_CORRELATION", "result": "PASS"}],
[{"name": "PROD_CORRELATION", "result": "PENDING"}],
[{"name": "MATCHES_THEMES", "result": "WARNING"}],
])
def test_stage_not_correlation_presence_decides_pass(checks):
assert snapshot_columns({}, {}, checks)["check_type"] == "PRE_CHECK"
assert snapshot_columns({}, {}, checks, checked=True)["check_type"] == "PASS"
@pytest.mark.parametrize("checked", [False, True])
@pytest.mark.parametrize("checks,expected", [
([], "PENDING"),
([None], "PENDING"),
([{}], "PENDING"),
([{"name": "UNKNOWN", "result": "OTHER"}], "PENDING"),
([{"name": "REGULAR_SUBMISSION", "result": "FAIL"}], "PENDING"),
([{"name": "LOW_SHARPE", "result": "fail"}, {"name": "REGULAR_SUBMISSION", "result": "FAIL"}], "FAIL_1"),
([{"name": "LOW_SHARPE", "result": "FAIL"}, {"name": "LOW_FITNESS", "result": "Fail"}], "FAIL_2"),
])
def test_stage_preserves_failures_and_missing_evidence(checked, checks, expected):
assert snapshot_columns({}, {}, checks, checked=checked)["check_type"] == expected
async def test_sync_check_and_resync_use_distinct_stages(app, logged_in, monkeypatch):
from app.alphas import upsert_alpha
from tests import test_submission
from tests.conftest import alpha
checks = [{"name": "LOW_SHARPE", "result": "PASS"},
{"name": "PROD_CORRELATION", "result": "PENDING"},
{"name": "MATCHES_THEMES", "result": "WARNING"},
{"name": "REGULAR_SUBMISSION", "result": "FAIL"}]
monkeypatch.setattr(test_submission, "CHECKS", checks)
await test_submission.setup(app, alpha(**{"is": {"checks": checks}}))
endpoint = "/api/v1/alphas/alpha1/submission"
assert (await logged_in.get(endpoint)).json()["check_summary"]["check_type"] == "PRE_CHECK"
response = await test_submission.enqueue(logged_in)
assert response.status_code == 202
await app.state.runner.execute(response.json()["id"])
state = (await logged_in.get(endpoint)).json()
assert state["job"]["status"] == "completed"
assert state["check_summary"]["check_type"] == "PASS"
assert state["check_summary"]["submission_limits"]["status"] == "blocked"
assert (await logged_in.get("/api/v1/alphas/alpha1")).json()["check_type"] == "PASS"
async with app.state.sessions.begin() as db:
await upsert_alpha(db, alpha(**{"is": {"checks": checks}}))
assert (await logged_in.get(endpoint)).json()["check_summary"]["check_type"] == "PRE_CHECK"
+7 -2
View File
@@ -11,11 +11,14 @@ from tests.test_submission import FIELDS, Description, setup
@pytest.mark.parametrize("kind", ["REGULAR", "SUPER"])
@pytest.mark.parametrize("result", ["PASS", "FAIL"])
async def test_mcp_check_never_submits(mcp_app, kind, result, monkeypatch):
@pytest.mark.parametrize("result", ["PASS", "FAIL", "PENDING", "WARNING"])
@pytest.mark.parametrize("limited", [False, True])
async def test_mcp_check_never_submits(mcp_app, kind, result, limited, monkeypatch):
from tests import test_submission
checks = [{"name": "PROD_CORRELATION", "result": result}]
if limited:
checks.append({"name": "REGULAR_SUBMISSION", "result": "FAIL"})
monkeypatch.setattr(test_submission, "CHECKS", checks)
sections = ["regular"] if kind == "REGULAR" else ["selection", "combo"]
platform = await setup(mcp_app, alpha(type=kind, **{s: {"code": "rank(close)"} for s in sections}))
@@ -35,6 +38,8 @@ async def test_mcp_check_never_submits(mcp_app, kind, result, monkeypatch):
assert job["status"] == "completed", job
data = await invoke(mcp_app, principal, "get_submission_check", {"alpha_id": "alpha1"})
assert data["checks"] == checks and data["checked_at"]
assert data["check_summary"]["check_type"] == ("FAIL_1" if result == "FAIL" else "PASS")
assert data["check_summary"]["submission_limits"]["status"] == ("blocked" if limited else "unknown")
assert data["production_submission"] is False
assert platform.patches == [{s: {"description": args["descriptions"][s]} for s in sections}]
assert platform.calls.count(("GET", "/alphas/alpha1/check")) == 2
@@ -0,0 +1,52 @@
"""Upgrade cached classifications across batches while preserving platform evidence."""
from pathlib import Path
import sqlalchemy as sa
from alembic import command
from alembic.config import Config
from cryptography.fernet import Fernet
from app.models import now
def test_limit_migration_is_reversible_and_preserves_snapshots(tmp_path, monkeypatch):
path = tmp_path / "limits.db"
monkeypatch.setenv("DATABASE_URL", f"sqlite+aiosqlite:///{path}")
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, "0017")
engine = sa.create_engine(f"sqlite:///{path}")
table = sa.Table("alphas", sa.MetaData(), autoload_with=engine)
limit = {"name": "REGULAR_SUBMISSION", "result": "FAIL"}
patterns = [
([{"name": "PROD_CORRELATION", "result": "PASS"}, limit], "FAIL_1", "PASS"),
([{"name": "LOW_SHARPE", "result": "FAIL"}, limit], "FAIL_2", "FAIL_1"),
([limit], "FAIL_1", "PENDING"),
([{"name": "LOW_SHARPE", "result": "PASS"}, limit], "FAIL_1", "PRE_CHECK"),
([{"name": "UNKNOWN", "result": "FAIL"}], "FAIL_1", "FAIL_1"),
]
with engine.begin() as db:
db.execute(table.insert(), [
{"id": f"old{i:04}", "hidden": False, "settings": {}, "os_metrics": {},
"is_metrics": {"checks": patterns[i % 5][0]}, "checks": patterns[i % 5][0],
"check_type": patterns[i % 5][1], "synced_at": now(),
"raw": {"is": {"checks": patterns[i % 5][0]}}}
for i in range(503)
])
for version, position in [("0018", 2), ("0017", 1), ("0018", 2)]:
(command.upgrade if version == "0018" else command.downgrade)(config, version)
with engine.connect() as db:
rows = db.execute(sa.select(table).order_by(table.c.id)).mappings().all()
assert len(rows) == 503
for i, row in enumerate(rows):
assert row["check_type"] == patterns[i % 5][position]
assert row["checks"] == row["is_metrics"]["checks"] == row["raw"]["is"]["checks"] == patterns[i % 5][0]
command.upgrade(config, "head")
command.check(config)
engine.dispose()
+76
View File
@@ -0,0 +1,76 @@
"""Submission limits must not disqualify an otherwise passing Alpha."""
import pytest
from app.alphas import failed_checks, snapshot_columns
LIMIT = {"name": "REGULAR_SUBMISSION", "result": "FAIL", "value": 4, "limit": 4}
@pytest.mark.parametrize("checks,expected", [
([{"name": "PROD_CORRELATION", "result": "PASS"}], "PRE_CHECK"),
([{"name": "LOW_SHARPE", "result": "PASS"}], "PRE_CHECK"),
([{"name": "LOW_SHARPE", "result": "FAIL"}], "FAIL_1"),
([], "PENDING"),
([None], "PENDING"),
([{"name": "UNKNOWN_CHECK", "result": "FAIL"}], "FAIL_1"),
([{"name": "PROD_CORRELATION", "result": "PENDING"}], "PRE_CHECK"),
])
def test_limit_does_not_change_alpha_verdict(checks, expected):
original = [*checks, LIMIT]
assert snapshot_columns({}, {}, original)["check_type"] == expected
assert "REGULAR_SUBMISSION" not in failed_checks(original)
assert original[-1] == LIMIT
@pytest.mark.parametrize("result", ["PASS", "PENDING", "WARNING", "UNRECOGNIZED"])
def test_all_limit_states_are_separate(result):
from app.alphas import check_summary
checks = [{"name": "PROD_CORRELATION", "result": "PASS"}, {**LIMIT, "result": result}]
summary = check_summary(checks, check_type="PASS")
assert summary["check_type"] == "PASS"
assert summary["submission_limits"]["status"] == ("not_blocked" if result == "PASS" else "unknown")
def test_research_and_mcp_evidence_keep_limits_separate():
from types import SimpleNamespace
from app.research.evaluations import assess
from app.research_access.queries import checks_summary
snapshot = {"is": {"sharpe": 2, "fitness": 2, "turnover": 0.1,
"checks": [{"name": "PROD_CORRELATION", "result": "PASS"}, LIMIT]}}
rules = SimpleNamespace(sharpe_min=1, fitness_min=1, turnover_max=0.5)
finding = assess(snapshot, rules)
assert finding["verdict"] == "pass" and finding["failed"] == []
assert finding["existing_platform_checks"] == snapshot["is"]["checks"]
assert finding["submission_limits"]["status"] == "blocked"
summary = checks_summary(snapshot)
assert summary["counts"]["FAIL"] == 0 and summary["total"] == 1
assert summary["non_pass"] == []
assert summary["submission_limits"] == [{"section": "is", "raw": LIMIT}]
snapshot["is"]["checks"] = [LIMIT]
assert assess(snapshot, rules)["verdict"] == "review"
assert checks_summary(snapshot)["status"] == "unknown"
async def test_http_check_persists_limit_without_failing_alpha(app, logged_in, monkeypatch):
from app.models import Alpha
from tests import test_submission
checks = [{"name": "PROD_CORRELATION", "result": "PASS"}, LIMIT]
monkeypatch.setattr(test_submission, "CHECKS", checks)
await test_submission.setup(app)
response = await test_submission.enqueue(logged_in)
assert response.status_code == 202
await app.state.runner.execute(response.json()["id"])
async with app.state.sessions() as db:
item = await db.get(Alpha, "alpha1")
assert item.check_type == "PASS"
assert item.checks == item.is_metrics["checks"] == item.raw["is"]["checks"] == checks
state = (await logged_in.get("/api/v1/alphas/alpha1/submission")).json()
assert state["job"]["status"] == "completed"
assert state["check_summary"]["submission_limits"]["status"] == "blocked"
rows = (await logged_in.get("/api/v1/alphas?check_type=PASS")).json()["items"]
assert rows[0]["id"] == "alpha1" and rows[0]["failed_checks"] == []
+23
View File
@@ -0,0 +1,23 @@
"""A completed check can be repeated using its resulting review snapshot."""
from tests.test_submission import FIELDS, Description, setup
async def test_repeat_check_uses_resulting_snapshot_without_repatch(app, logged_in):
platform = await setup(app)
url = "/api/v1/alphas/alpha1/submission"
state = (await logged_in.get(url)).json()
job_ids = []
for _ in range(2):
response = await logged_in.post("/api/v1/alphas/alpha1/submission-check", json={
"snapshot": state["snapshot"], "descriptions": {"regular": Description(**FIELDS).text()},
})
assert response.status_code == 202
job_ids.append(response.json()["id"])
await app.state.runner.execute(job_ids[-1])
state = (await logged_in.get(url)).json()
assert state["job"]["status"] == "completed"
assert state["job"]["checkpoint"]["review_snapshot"] == state["snapshot"]
assert job_ids[0] != job_ids[1]
assert len(platform.patches) == 1
assert platform.calls.count(("GET", "/alphas/alpha1/check")) == 2
+12
View File
@@ -0,0 +1,12 @@
/** Format the five headline Alpha metrics for display; stored/filter values stay raw.
* Unsupported fields return undefined so callers can retain their existing format.
*/
export function formatAlphaMetric(
key: string,
value: unknown,
): string | undefined {
const percent = key === "turnover" || key === "returns" || key === "drawdown";
if (!percent && key !== "sharpe" && key !== "fitness") return undefined;
if (typeof value !== "number" || !Number.isFinite(value)) return "—";
return percent ? `${(value * 100).toFixed(2)}%` : value.toFixed(2);
}
+1 -1
View File
@@ -28,7 +28,7 @@
.backtest-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px 16px;
gap: 6px;
}
.backtest-form-grid .semi-input-number {
width: 100%;
+48 -8
View File
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from "react";
import { formatAlphaMetric } from "../alphaMetrics";
import {
Banner,
Button,
@@ -464,7 +465,10 @@ function MetricTable({
<h3>{title}</h3>
{entries.length ? (
<DetailFieldGrid
data={entries.map(([key, value]) => ({ key, value }))}
data={entries.map(([key, value]) => ({
key,
value: formatAlphaMetric(key, value) ?? value,
}))}
/>
) : (
<p className="muted">未提供</p>
@@ -474,16 +478,46 @@ function MetricTable({
}
/** IS and OS checks are separate observations; missing values never imply a pass. */
function PlatformChecks({ title, checks }: { title: string; checks: unknown }) {
function PlatformChecks({
title,
checks,
submissionLimit = false,
}: {
title: string;
checks: unknown;
submissionLimit?: boolean;
}) {
const rows = Array.isArray(checks)
? checks.filter(
(item): item is Record<string, unknown> =>
item !== null && typeof item === "object" && !Array.isArray(item),
)
: [];
const limits = rows.filter((check) => check.name === "REGULAR_SUBMISSION");
const alphaChecks = rows.filter(
(check) => check.name !== "REGULAR_SUBMISSION",
);
if (!submissionLimit && limits.length) {
return (
<>
<PlatformChecks title={title} checks={alphaChecks} />
<PlatformChecks
title={`${title} · 提交限制`}
checks={limits}
submissionLimit
/>
</>
);
}
return (
<section aria-label={title}>
<h4>{title}</h4>
{submissionLimit && (
<p className="muted">
以下为缓存观测时的提交限制,不计入 Alpha
失败项;当前额度需重新检查确认。
</p>
)}
{rows.length ? (
rows.map((check, i) => (
<div className="check-row" key={i}>
@@ -496,17 +530,23 @@ function PlatformChecks({ title, checks }: { title: string; checks: unknown }) {
check.result === "PASS"
? "green"
: check.result === "FAIL"
? "red"
? submissionLimit
? "orange"
: "red"
: check.result === "WARNING"
? "orange"
: "grey"
}
>
{check.result === "WARNING"
? "警告(WARNING)"
: check.result === "PENDING"
? "待定(PENDING)"
: displayValue(check.result)}
{submissionLimit && check.result === "FAIL"
? "提交受限(FAIL)"
: submissionLimit && check.result === "PASS"
? "当时未受限(PASS)"
: check.result === "WARNING"
? "警告(WARNING)"
: check.result === "PENDING"
? "待定(PENDING)"
: displayValue(check.result)}
</Tag>
</div>
))
+26 -14
View File
@@ -34,6 +34,7 @@ export function SubmissionPanel({
const [busy, setBusy] = useState("");
const [error, setError] = useState("");
const dirty = useRef(false);
const submittedJob = useRef<string | null>(null);
const mounted = useRef(true);
useEffect(() => {
mounted.current = true;
@@ -47,6 +48,16 @@ export function SubmissionPanel({
.then((value) => {
if (!active) return;
setData(value);
// Rebase only our completed writeback and its exact resulting snapshot.
// A different expression/settings/description must still require review.
if (
value.job?.id === submittedJob.current &&
value.job?.status === "completed" &&
value.job.checkpoint.review_snapshot === value.snapshot
) {
setSnapshot(value.snapshot);
submittedJob.current = null;
}
if (!dirty.current) {
setDraft(value.descriptions);
setSnapshot(value.snapshot);
@@ -89,6 +100,7 @@ export function SubmissionPanel({
if (!mounted.current) return;
// Keep the reviewed draft visible while the durable task runs.
dirty.current = true;
submittedJob.current = job.id;
setData((current) => (current ? { ...current, job } : current));
onTask();
} catch (e) {
@@ -105,14 +117,14 @@ export function SubmissionPanel({
<div className="detail-section">
<h3>Description 与提交检查</h3>
<p className="muted">
AI 一次生成完整 Description,可在下方统一修改。写回并检查会更新 BRAIN 的
AI 一次生成完整 Description,可在下方统一修改。平台检查会更新 BRAIN 的
Description,随后获取平台提交检查结果,不会正式提交 Alpha。
</p>
{error && <Banner type="danger" description={error} />}
{!data.can_check && (
<Banner
type="info"
description="写回并检查需要待提交 Alpha。有本地比较基准时,需取得有效、完整且低于阈值的自相关结果;没有本地基准时可直接继续平台检查。"
description="平台检查需要待提交 Alpha。有本地比较基准时,需取得有效、完整且低于阈值的自相关结果;没有本地基准时可直接继续平台检查。"
/>
)}
{!data.can_generate && (
@@ -121,6 +133,17 @@ export function SubmissionPanel({
</p>
)}
<div className="inline-actions">
<Button
disabled={!!busy || running}
onClick={() => {
dirty.current = false;
setDraft(data.descriptions);
setSnapshot(data.snapshot);
setError("");
}}
>
载入最新描述
</Button>
<Button
onClick={() => void generate()}
loading={busy === "generate"}
@@ -181,18 +204,7 @@ export function SubmissionPanel({
disabled={!!busy || running || conflict || !data.can_check}
onClick={() => void check()}
>
写回 Description 并检查
</Button>
<Button
disabled={!!busy || running}
onClick={() => {
dirty.current = false;
setDraft(data.descriptions);
setSnapshot(data.snapshot);
setError("");
}}
>
载入最新描述
平台检查
</Button>
</div>
{data.job && (
@@ -67,7 +67,7 @@
.catalog-filter-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
gap: 6px;
}
.catalog-filter-grid > label {
display: flex;
+1 -1
View File
@@ -92,7 +92,7 @@
.alpha-filter-fields {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
gap: 6px;
}
.alpha-filter-fields > label {
margin: 0;
+19 -6
View File
@@ -27,6 +27,7 @@ import {
IconRefresh,
} from "@douyinfe/semi-icons";
import "./AlphaPage.css";
import { formatAlphaMetric } from "../alphaMetrics";
import type { ColumnProps } from "@douyinfe/semi-ui-19/lib/es/table/interface";
import {
api,
@@ -84,8 +85,10 @@ function metricColor(key: string, value: unknown): string | undefined {
return "var(--semi-color-danger)";
}
/** Convert Margin only for display; filtering and sorting still use raw values. */
/** Format metrics only for display; filtering and sorting still use raw values. */
function formatMetric(key: string, value: unknown): string {
const headline = formatAlphaMetric(key, value);
if (headline !== undefined) return headline;
if (key !== "margin") return formatNumber(value, 3);
return typeof value === "number" && Number.isFinite(value)
? `${formatNumber(value * 10000, 2)}bps`
@@ -523,7 +526,9 @@ export function AlphaPage({
? "red"
: row!.check_type === "PASS"
? "green"
: "grey"
: row!.check_type === "PRE_CHECK"
? "blue"
: "grey"
}
>
{checkLabels[row!.check_type] || "待检查"}
@@ -646,8 +651,13 @@ export function AlphaPage({
render: (_, row) => formatTime(row!.date_created, account?.timezone),
},
];
// Submitted alphas always expose the platform production correlation, including saved views.
const displayedColumns =
submission === "SUBMITTED"
? [...new Set([...visibleColumns, "prod_correlation"])]
: visibleColumns;
const columns = columnDefinitions
.filter((column) => visibleColumns.includes(String(column.key)))
.filter((column) => displayedColumns.includes(String(column.key)))
.map((column) => (compact ? { ...column, fixed: undefined } : column));
const activeFilterCount = Object.values(filters).filter(Boolean).length;
const options = (key: string) =>
@@ -909,7 +919,7 @@ export function AlphaPage({
suspended={overlaySuspended || !active}
onOverlay={onOverlay}
filters={{ ...filters, submission, sort, direction, limit: pageSize }}
columns={visibleColumns}
columns={displayedColumns}
onRestore={(view) => {
setFilterOpen(false);
const {
@@ -984,12 +994,15 @@ export function AlphaPage({
<strong>显示列</strong>
<CheckboxGroup
direction="vertical"
value={visibleColumns}
value={displayedColumns}
options={Object.entries(columnLabels).map(
([value, label]) => ({
value,
label,
disabled: value === "name",
disabled:
value === "name" ||
(submission === "SUBMITTED" &&
value === "prod_correlation"),
}),
)}
onChange={(values) => {
+2 -2
View File
@@ -44,7 +44,7 @@
.home-metrics {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
gap: 6px;
margin-bottom: 20px;
}
.home-metric.semi-card,
@@ -142,7 +142,7 @@
.home-panel-error {
display: grid;
justify-items: start;
gap: 16px;
gap: 6px;
min-height: 180px;
}
.home-calendar-summary {
+1 -1
View File
@@ -62,7 +62,7 @@
.mcp-key-permissions {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 16px;
gap: 6px;
}
.mcp-key-permissions label {
display: flex;
+1 -1
View File
@@ -48,7 +48,7 @@
.research-form-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
gap: 16px;
gap: 6px;
}
.research-toolbar,
.research-methods,
+1 -1
View File
@@ -38,7 +38,7 @@
.settings-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
gap: 6px;
}
.settings-field {
display: flex;
+1
View File
@@ -138,6 +138,7 @@ export type Job = {
alpha_ids?: string[];
};
checkpoint: {
review_snapshot?: string;
date?: string;
dataset_id?: string;
datasets_completed?: number;
+121
View File
@@ -0,0 +1,121 @@
import { expect, test } from "@playwright/test";
import type { Job } from "../src/types";
test("platform check can repeat after its own description writeback", async ({
page,
}) => {
let snapshot = "a".repeat(64);
let descriptions = { regular: "" };
let job: Job | null = null;
const submitted: string[] = [];
await page.route(/\/api\/v1\/sync-jobs$/, (route) =>
route.fulfill({ json: job ? [job] : [] }),
);
await page.route(/\/api\/v1\/alphas\/[^/]+\/submission$/, (route) =>
route.fulfill({
json: {
snapshot,
descriptions,
sections: {
regular: { code: "rank(close)", description: descriptions.regular },
},
model: "test-model",
can_generate: true,
can_check: true,
job,
},
}),
);
await page.route(
/\/api\/v1\/alphas\/[^/]+\/submission-check$/,
async (route) => {
const body = route.request().postDataJSON();
expect(body.snapshot).toBe(snapshot);
submitted.push(body.snapshot);
descriptions = body.descriptions;
snapshot = String(submitted.length).repeat(64);
job = {
id: `repeat-${submitted.length}`,
kind: "submission_check",
status: "completed",
processed: 1,
failed: 0,
total: 1,
error: null,
next_retry_at: null,
created_at: new Date().toISOString(),
updated_at: new Date().toISOString(),
payload: {},
checkpoint: { phase: "checked", review_snapshot: snapshot },
};
await route.fulfill({ status: 202, json: { ...job, status: "queued" } });
},
);
await page.goto("/#alphas");
await page.getByLabel("密码", { exact: true }).fill("browser-test-password");
await page.getByRole("button", { name: "进入工作空间" }).click();
await expect(
page.getByRole("tab", { name: "待提交", exact: true }),
).toBeVisible();
const headers = { "X-WQ-Request": "1" };
await page.request.put("/api/v1/account/credentials", {
headers,
data: { email: "test@example.com", password: "synthetic-password" },
});
await page.request.post("/api/v1/account/connect", { headers });
await expect
.poll(
async () =>
(await (await page.request.get("/api/v1/account")).json())
.connection_status,
)
.toBe("connected");
const imported = await (
await page.request.post("/api/v1/sync-jobs", {
headers,
data: { kind: "alpha_refresh", alpha_ids: ["TEST0004"] },
})
).json();
await expect
.poll(
async () =>
(
await (
await page.request.get(`/api/v1/sync-jobs/${imported.id}`)
).json()
).status,
)
.toBe("completed");
await page.reload();
await page.locator(".alpha-link").first().click();
const sheet = page.locator(".alpha-detail");
await sheet.getByRole("tab", { name: "相关性检查", exact: true }).click();
const load = sheet.getByRole("button", { name: "载入最新描述", exact: true });
const generate = sheet.getByRole("button", {
name: "AI 生成 Description",
exact: true,
});
await expect(load).toBeVisible();
expect((await load.boundingBox())!.x).toBeLessThan(
(await generate.boundingBox())!.x,
);
const draft = sheet.getByRole("textbox", {
name: "regular Description",
exact: true,
});
await draft.fill("A reviewed description for repeat checking.");
const check = sheet.getByRole("button", { name: "平台检查", exact: true });
for (let i = 1; i <= 2; i++) {
await expect(check).toBeEnabled();
await check.click();
await expect.poll(() => submitted.length).toBe(i);
await expect(page.locator(".job-panel")).toBeVisible();
await page.keyboard.press("Escape");
await expect(page.locator(".job-panel")).not.toBeVisible();
await expect(check).toBeEnabled();
await expect(draft).toHaveValue(
"A reviewed description for repeat checking.",
);
}
expect(submitted).toEqual(["a".repeat(64), "1".repeat(64)]);
});