feat: 支持研究素材删除并统一 Semi 表格
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user