diff --git a/.scratch/research-deletion/issues/01-delete-research.md b/.scratch/research-deletion/issues/01-delete-research.md new file mode 100644 index 0000000..7e0ef01 --- /dev/null +++ b/.scratch/research-deletion/issues/01-delete-research.md @@ -0,0 +1,14 @@ +# 研究素材和 Alpha 变体删除 +Type: task +Status: resolved + +为模板工坊、特征工程、Alpha 变体的列表和详情补齐删除操作。沿用素材软删除,生成记录新增软删除标记;移出列表时保留不可变候选、历史版本、回测和来源引用。删除需要页面确认,素材删除使用当前版本防止覆盖并发修改;删除后刷新列表并修正空页。 + +验证:删除/取消/冲突、历史引用保留、列表总数与分页、后端测试及静态检查、前端构建和浏览器交互。只使用隔离测试数据,不删除实际研究数据、不部署。 + +## Answer +已实现素材与生成记录的列表、详情删除和确认,删除后清理当前选择并刷新/修正分页。素材删除与保存均拒绝已删除状态及过期版本;生成记录不可变,重复删除幂等。删除仅移出日常列表,明确引用的历史版本仍可供来源追溯、复用和已授权运行使用,不取消已有回测。 + +验证:`uv run ruff check app tests` 通过;研究删除、工作空间、结果、流水线、QuantFlow 相关后端测试 82 passed;前端 `pnpm build` 通过(已有 lottie-web eval 和大包提示)。现有 research-outcomes / research-flows / quantflow 浏览器回归 3 passed。另用 Playwright CLI 和隔离合成库实测三处列表/详情删除、取消、26→25→24 总数、最后一页自动回退、模板并发删除 409 与未保存草稿保留;已查看 `output/playwright/research-delete-confirm.png`。 + +新增迁移 `0014`:隔离 SQLite 上升级、降级、再升级及旧记录保留已验证。Docker 服务未运行,未做 PostgreSQL 实例迁移实测。未部署、未迁移正式数据库、未删除真实数据。 diff --git a/.scratch/semi-tables/issues/01-migrate.md b/.scratch/semi-tables/issues/01-migrate.md new file mode 100644 index 0000000..b9b19c9 --- /dev/null +++ b/.scratch/semi-tables/issues/01-migrate.md @@ -0,0 +1,23 @@ +# 统一业务表格为 Semi Table + +Type: task +Status: ready-for-agent +Progress: completed + +## Scope + +将 11 个文件内的 13 处原生业务表格替换为项目已安装的 Semi 2.103.0 Table。保留筛选、分页、操作、候选选择和详情联动;删除按钮继续仅显示“删除”。AI Markdown 内容的渲染器保持原样。 + +## Validation + +运行前端构建与现有浏览器回归,并补充关键表格、空状态和滚动的浏览器核验。 + +## Result + +13 处业务表格全部改用 Semi Table,移除原生表格的样式覆盖和外置空提示。保留外部分页、稳定行标识和单元格操作;研究列表及首页使用原生 sticky 表头。 + +验证:`pnpm build` 通过,`pnpm test` 28 项全部通过,`git diff --check` 通过。静态检索 `frontend/src` 未发现手写 table/thead/tbody/tr/td/th。 + +隔离浏览器补充核验通过:模板表头滚动保持位置;26 条跨页显示,取消删除保留数据,删除第 2 页唯一条目后回到第 1 页显示 25 条;筛选空状态无重复;390px 横向滚动和页面宽度正常。首页 6 条 Pyramid 数据及筛选、算子详情联动、AI Alpha 结果 5 个 Semi Table 和窄屏宽度均通过。 + +构建保留第三方 lottie-web eval 与大 bundle 提示,未影响构建成功。未部署或提交。截图位于 `output/playwright/semi-*.png`。 diff --git a/backend/app/models.py b/backend/app/models.py index af4adfe..9fed18b 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -13,6 +13,7 @@ from sqlalchemy import ( String, Text, UniqueConstraint, + false, ) from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column @@ -443,6 +444,7 @@ class ResearchExperiment(Base): id: Mapped[str] = mapped_column(String(36), primary_key=True) name: Mapped[str] = mapped_column(String(200)) kind: Mapped[str] = mapped_column(String(30), index=True) + archived: Mapped[bool] = mapped_column(Boolean, default=False, server_default=false()) hypothesis: Mapped[str] = mapped_column(Text) inputs: Mapped[list] = mapped_column(JSON) parents: Mapped[list] = mapped_column(JSON) diff --git a/backend/app/research/assets.py b/backend/app/research/assets.py index 6fa4b24..66b0bfe 100644 --- a/backend/app/research/assets.py +++ b/backend/app/research/assets.py @@ -76,11 +76,12 @@ class Assets: ResearchAsset.id == asset_id, ResearchAsset.version == body.version, ResearchAsset.kind == body.kind, + ResearchAsset.archived.is_(False), ) .values(version=body.version + 1, name=content["name"], updated_at=now()) ) if changed.rowcount != 1: - raise HTTPException(409, "素材已变化,保留草稿并读取最新版本") + raise HTTPException(409, "素材已变化或已删除,保留草稿并读取最新版本") version = body.version + 1 else: asset_id, version = uid(), 1 @@ -100,11 +101,15 @@ class Assets: async def archive(self, asset_id, version): result = await self.db.execute( update(ResearchAsset) - .where(ResearchAsset.id == asset_id, ResearchAsset.version == version) + .where( + ResearchAsset.id == asset_id, + ResearchAsset.version == version, + ResearchAsset.archived.is_(False), + ) .values(archived=True, version=ResearchAsset.version + 1, updated_at=now()) ) if result.rowcount != 1: - raise HTTPException(409, "素材已变化或不存在") + raise HTTPException(409, "素材已变化、已删除或不存在,请刷新后重试") # Archiving is itself a revision; old references remain resolvable. previous = await self.db.get(ResearchRevision, (asset_id, version)) self.db.add( diff --git a/backend/app/research/experiments.py b/backend/app/research/experiments.py index 526802c..7431570 100644 --- a/backend/app/research/experiments.py +++ b/backend/app/research/experiments.py @@ -5,7 +5,7 @@ import json from collections import defaultdict from fastapi import HTTPException -from sqlalchemy import func, select +from sqlalchemy import func, select, update from ..backtests.contracts import Candidate, DraftInput, PreviewInput, SimulationSettings, Source from ..backtests.service import Backtests, uid @@ -243,6 +243,7 @@ class Experiments: "id", "name", "kind", + "archived", "hypothesis", "inputs", "parents", @@ -256,7 +257,7 @@ class Experiments: ) async def list(self, kind=None, limit=25, offset=0): - query = select(ResearchExperiment) + query = select(ResearchExperiment).where(ResearchExperiment.archived.is_(False)) if kind: query = query.where(ResearchExperiment.kind == kind) total = await self.db.scalar(select(func.count()).select_from(query.subquery())) @@ -281,6 +282,19 @@ class Experiments: } ) + async def archive(self, experiment_id): + """Hide an immutable experiment; backtests and lineage must still resolve it. + + Returns an acknowledgement, or raises HTTP 404 for an unknown ID. Repeated + deletion is idempotent because candidate contents cannot change. + """ + result = await self.db.execute( + update(ResearchExperiment).where(ResearchExperiment.id == experiment_id).values(archived=True) + ) + if result.rowcount != 1: + raise HTTPException(404, "研究实验不存在") + return {"ok": True} + async def preview(self, experiment_id, candidate_ids=None, source_kind=None, reference=None): experiment = await self.get(experiment_id) candidates = experiment["candidates"] diff --git a/backend/app/research/routes.py b/backend/app/research/routes.py index 3ac0ba5..983b5b2 100644 --- a/backend/app/research/routes.py +++ b/backend/app/research/routes.py @@ -143,6 +143,12 @@ async def experiment(experiment_id: str, request: Request): return await Experiments(db).get(experiment_id) +@router.delete("/experiments/{experiment_id}") +async def archive_experiment(experiment_id: str, request: Request): + async with request.app.state.sessions.begin() as db: + return await Experiments(db).archive(experiment_id) + + @router.post("/experiments/{experiment_id}/preview", status_code=201) async def preview(experiment_id: str, body: ExperimentPreview, request: Request): async with request.app.state.sessions.begin() as db: diff --git a/backend/migrations/versions/0014_research_experiment_archive.py b/backend/migrations/versions/0014_research_experiment_archive.py new file mode 100644 index 0000000..b52dc76 --- /dev/null +++ b/backend/migrations/versions/0014_research_experiment_archive.py @@ -0,0 +1,20 @@ +"""Hide deleted research experiments while preserving candidate and source history.""" + +import sqlalchemy as sa +from alembic import op + +revision = "0014" +down_revision = "0013" +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column( + "research_experiments", + sa.Column("archived", sa.Boolean(), nullable=False, server_default=sa.false()), + ) + + +def downgrade(): + op.drop_column("research_experiments", "archived") diff --git a/backend/tests/test_research_deletion.py b/backend/tests/test_research_deletion.py new file mode 100644 index 0000000..3dba6c7 --- /dev/null +++ b/backend/tests/test_research_deletion.py @@ -0,0 +1,121 @@ +"""Deleting workspace items hides them without severing research provenance.""" + +import importlib.util +from pathlib import Path + +import pytest +import sqlalchemy as sa +from alembic.migration import MigrationContext +from alembic.operations import Operations + +from tests.test_backtests import execute, setup, start +from tests.test_catalog import catalog as catalog_fixture +from tests.test_research_workspace import expansion, template +from tests.test_research_workspace import research_input as research_input_fixture + +catalog = catalog_fixture +research_input = research_input_fixture + + +@pytest.mark.parametrize("kind", ["template", "feature"]) +async def test_asset_deletion_checks_version_and_preserves_revisions(logged_in, research_input, kind): + content = template() if kind == "template" else { + "name": "待删除特征", + "hypothesis": "排名比较", + "input_ids": [research_input["id"]], + "template": template(), + } + response = await logged_in.post("/api/v1/research/assets", json={"kind": kind, "content": content}) + assert response.status_code == 201, response.text + original = response.json() + url = f"/api/v1/research/assets/{original['id']}" + updated = await logged_in.put(url, json={"kind": kind, "version": 1, "content": {**content, "name": "新版"}}) + assert updated.status_code == 200 + assert (await logged_in.delete(f"{url}?version=1")).status_code == 409 + assert not (await logged_in.get(url)).json()["archived"] + assert (await logged_in.delete(f"{url}?version=2")).status_code == 200 + listing = (await logged_in.get(f"/api/v1/research/assets?kind={kind}")).json() + assert listing["total"] == 0 and listing["items"] == [] + historical = (await logged_in.get(f"{url}?version=1")).json() + assert historical["archived"] is True + assert historical["content"] == original["content"] + assert historical["provenance"] == original["provenance"] + latest = (await logged_in.get(url)).json() + assert latest["version"] == 3 + assert (await logged_in.delete(f"{url}?version=3")).status_code == 409 + assert (await logged_in.put(url, json={"kind": kind, "version": 3, "content": content})).status_code == 409 + assert (await logged_in.get(url)).json()["version"] == 3 + + +async def test_experiment_deletion_preserves_backtest_and_child_sources(app, logged_in, research_input): + created = await logged_in.post("/api/v1/research/experiments", json=expansion(research_input["id"])) + assert created.status_code == 201, created.text + parent = created.json() + generated = await logged_in.post( + "/api/v1/research/experiments", + json=expansion(research_input["id"], parent_experiment_ids=[parent["id"]]), + ) + assert generated.status_code == 201, generated.text + variant = generated.json() + assert variant["kind"] == "variant" + child = await logged_in.post( + "/api/v1/research/experiments", + json=expansion(research_input["id"], parent_experiment_ids=[variant["id"]]), + ) + assert child.status_code == 201 + _, lane = await setup(app) + url = f"/api/v1/research/experiments/{variant['id']}" + preview = await logged_in.post(f"{url}/preview", json={}) + assert preview.status_code == 201, preview.text + run = await start(logged_in, preview.json(), "delete-variant-provenance") + await execute(app, lane, run["backtest_run_id"]) + before = (await logged_in.get(url)).json() + assert before["backtest_run_ids"] == [run["backtest_run_id"]] + + assert (await logged_in.delete(url)).status_code == 200 + assert (await logged_in.delete(url)).status_code == 200 + all_items = (await logged_in.get("/api/v1/research/experiments")).json() + assert all_items["total"] == 2 + assert variant["id"] not in {item["id"] for item in all_items["items"]} + variants = (await logged_in.get("/api/v1/research/experiments?kind=variant&limit=1&offset=1")).json() + assert variants["total"] == 1 and variants["items"] == [] + after = (await logged_in.get(url)).json() + assert after == {**before, "archived": True} + saved_child = (await logged_in.get(f"/api/v1/research/experiments/{child.json()['id']}")).json() + assert saved_child["parents"] == child.json()["parents"] + results = (await logged_in.get(f"/api/v1/backtests/runs/{run['backtest_run_id']}/results")).json() + alpha_id = results["items"][0]["alpha_id"] + sources = (await logged_in.get(f"/api/v1/alphas/{alpha_id}/sources")).json() + assert sources["items"][0]["source"]["research_id"] == variant["id"] + assert (await logged_in.delete(f"/api/v1/research/experiments/{parent['id']}")).status_code == 200 + assert (await logged_in.get("/api/v1/research/experiments")).json()["total"] == 1 + + +async def test_delete_research_requires_auth_and_known_id(client): + for url in ("assets/missing?version=1", "experiments/missing"): + assert (await client.delete(f"/api/v1/research/{url}")).status_code == 401 + + +async def test_delete_unknown_experiment(logged_in): + assert (await logged_in.delete("/api/v1/research/experiments/missing")).status_code == 404 + + +def test_archive_migration_preserves_existing_rows(tmp_path): + path = Path(__file__).parents[1] / "migrations/versions/0014_research_experiment_archive.py" + spec = importlib.util.spec_from_file_location("research_archive_migration", path) + migration = importlib.util.module_from_spec(spec) + spec.loader.exec_module(migration) + engine = sa.create_engine(f"sqlite:///{tmp_path}/migration.db") + with engine.begin() as connection: + connection.execute(sa.text("CREATE TABLE research_experiments (id VARCHAR(36) PRIMARY KEY, evidence JSON NOT NULL)")) + connection.execute(sa.text("INSERT INTO research_experiments VALUES ('existing', :evidence)"), {"evidence": '{"source":"old"}'}) + with Operations.context(MigrationContext.configure(connection)): + migration.upgrade() + assert connection.execute(sa.text("SELECT archived FROM research_experiments")).scalar_one() == 0 + connection.execute(sa.text("UPDATE research_experiments SET archived = 1")) + migration.downgrade() + migration.upgrade() + assert connection.execute(sa.text("SELECT evidence FROM research_experiments")).scalar_one() == '{"source":"old"}' + column = next(c for c in sa.inspect(connection).get_columns("research_experiments") if c["name"] == "archived") + assert column["nullable"] is False + engine.dispose() diff --git a/frontend/src/ai/AlphaToolCard.tsx b/frontend/src/ai/AlphaToolCard.tsx index c136a37..a32e928 100644 --- a/frontend/src/ai/AlphaToolCard.tsx +++ b/frontend/src/ai/AlphaToolCard.tsx @@ -1,5 +1,5 @@ import { useEffect, useState } from "react"; -import { Toast } from "@douyinfe/semi-ui-19"; +import { Table, Toast } from "@douyinfe/semi-ui-19"; import { api, formatNumber, formatTime, stateLabels } from "../api"; import { PnlChart } from "../components/PnlChart"; import type { Alpha, Pnl, Research } from "../types"; @@ -62,26 +62,33 @@ export function AlphaToolCard({ call, onAction, timezone }: ToolCardProps) { {alpha.id} · {alpha.region ?? "地区未提供"} -
| Sharpe | -Fitness | -Turnover | -||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| {formatNumber(alpha.sharpe)} | -{formatNumber(alpha.fitness)} | -
- {alpha.turnover == null
+
-
|