Files
Simon 633176a3d1 feat(api): 实验归档支持批量删除(含缺失项如实回报)
「实验对比」页需要批量删除,逐个 DELETE 会有 N 次往返且中途失败会留下半删除状态。

- `POST /experiments/bulk-delete`:一次最多 200 个 id(`BULK_DELETE_MAX_IDS`),
  按请求顺序去重;返回 `deleted` / `missing` / `count`,**存在的删掉、不存在的如实列出**,
  不假装全部成功(前端据此提示「N 个已删、M 个不存在」)。
- 路由声明在 `GET /{experiment_id}` 之前,避免被路径参数吞掉。
- 测试 +2:删除与缺失混合场景、id 列表校验(空/超长)。
2026-10-01 17:57:04 +08:00

177 lines
7.3 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Experiment API(Phase 4):列表 / 详情 / 删除(单个 + 批量)/ 一键复跑。"""
from __future__ import annotations
import json
from datetime import datetime
from typing import Annotated
from fastapi import APIRouter, BackgroundTasks, HTTPException, Query, Response
from pydantic import BaseModel, Field
from app.api.deps import DbSession, ExperimentRepoDep, JobRepoDep
from app.application.services.job_executor import new_id, run_job_background
from app.domain.entities.research import (
ExperimentRecord,
ExperimentSummary,
JobRecord,
JobStatus,
)
router = APIRouter(prefix="/experiments", tags=["experiments"])
# 列表默认返回条数与上限:原实现硬编码 limit=50 且不暴露总数,>50 条时更老的
# 归档静默不可见(AGENT.md §7);现在默认 200、上限 1000,并用 X-Total-Count
# 暴露过滤后的真实总数,客户端可据此翻页(offset)。
LIST_DEFAULT_LIMIT = 200
LIST_MAX_LIMIT = 1000
# 单次批量删除的 id 上限:够覆盖列表一页(默认 200 条),又不至于让一次请求
# 把整张表拖进内存。超了直接 422(附上限值),不静默截断成前 N 个。
BULK_DELETE_MAX_IDS = 200
class BulkDeleteRequest(BaseModel):
"""批量删除请求体(**必填** id 列表,1~200 个)。"""
ids: list[str] = Field(
min_length=1,
max_length=BULK_DELETE_MAX_IDS,
description=f"要删除的归档 id,1~{BULK_DELETE_MAX_IDS} 个(重复 id 自动去重)",
)
def _experiment_meta(exp: ExperimentRecord | ExperimentSummary) -> dict:
"""列表项视图(body 形状与旧版一致,仅**新增** data_version / job_id / result_bytes)。
`exp` 可以是完整实体(详情路径)或 `ExperimentSummary`(列表路径,不含
result_json):两条路径都只读元数据字段,无需把大字段拉回来算体积。
"""
spec = json.loads(exp.spec_json)
result_bytes = getattr(exp, "result_bytes", None)
if result_bytes is None: # 完整实体:result_json 已在内存,len() 零成本
result_bytes = len(exp.result_json or "")
return {
"id": exp.id,
"kind": exp.kind,
"factors": [f["name"] for f in spec.get("factors", [])],
"period": spec.get("period"),
"rebalance": spec.get("rebalance"),
"top_n": spec.get("selection", {}).get("top_n"),
"summary_text": exp.summary_text,
"code_version": exp.code_version,
"data_version": exp.data_version,
"job_id": exp.job_id,
"result_bytes": int(result_bytes),
"created_at": exp.created_at,
}
def _experiment_full(exp: ExperimentRecord) -> dict:
from app.api.jobs import _decode_result
return {
**_experiment_meta(exp),
"spec": json.loads(exp.spec_json),
"result": _decode_result(exp.kind, exp.result_json),
}
@router.get("", summary="Experiment 列表(过滤 + 分页,X-Total-Count 给总数)")
def list_experiments(
experiment_repo: ExperimentRepoDep,
response: Response,
kind: Annotated[str | None, Query(description="按类型精确过滤(backtest/factor_test/selection)")] = None,
q: Annotated[
str | None, Query(description="大小写不敏感模糊匹配 id / 因子名 / summary_text")
] = None,
limit: Annotated[int, Query(ge=1, le=LIST_MAX_LIMIT)] = LIST_DEFAULT_LIMIT,
offset: Annotated[int, Query(ge=0)] = 0,
) -> list[dict]:
"""归档列表。
- 过滤与分页在 SQL 层完成(仓储 `list_filtered`),不把全表拉回内存;
- 响应头 `X-Total-Count` = **过滤后**的归档总数(不受 limit/offset 影响),
客户端据此判断是否被截断并翻页(AGENT.md §7:不静默截断)。
"""
rows = experiment_repo.list_filtered(kind=kind, q=q, limit=limit, offset=offset)
total = experiment_repo.count_filtered(kind=kind, q=q)
response.headers["X-Total-Count"] = str(total)
return [_experiment_meta(e) for e in rows]
@router.post("/bulk-delete", summary=f"批量删除归档(1~{BULK_DELETE_MAX_IDS} 个,逐个回报)")
def bulk_delete_experiments(
body: BulkDeleteRequest,
session: DbSession,
experiment_repo: ExperimentRepoDep,
) -> dict:
"""批量删除归档,返回 `{"deleted": [...], "missing": [...], "count": n}`。
语义与单个删除完全一致(只删 experiment 行,job 历史保留),另外:
- **重复 id 先按出现顺序去重**(同一个 id 报两次没有意义,也不该算两次成功);
- **不静默跳过**:库里没有的 id 单独放进 `missing`,让界面能如实说
「删了 3 个,2 个没找到(可能已被别处删掉)」——把缺失当成功会更难排查;
- 一次请求内的 id 上限 `BULK_DELETE_MAX_IDS`,超了 Pydantic 直接 422 并带上限值。
"""
ids = list(dict.fromkeys(body.ids))
deleted: list[str] = []
missing: list[str] = []
for experiment_id in ids:
(deleted if experiment_repo.delete(experiment_id) else missing).append(experiment_id)
session.commit()
return {"deleted": deleted, "missing": missing, "count": len(deleted)}
@router.get("/{experiment_id}", summary="Experiment 详情(含完整结果)")
def get_experiment(experiment_id: str, experiment_repo: ExperimentRepoDep) -> dict:
exp = experiment_repo.get(experiment_id)
if exp is None:
raise HTTPException(status_code=404, detail=f"Experiment {experiment_id} 不存在")
return _experiment_full(exp)
@router.delete("/{experiment_id}", summary="删除 Experiment 归档(不影响关联 Job 记录)")
def delete_experiment(
experiment_id: str,
session: DbSession,
experiment_repo: ExperimentRepoDep,
) -> dict:
"""删除归档本身,返回 `{"deleted": "<id>"}`。
语义(写清楚避免误解):
- **只删 experiment 行**。关联的 job 记录是「执行历史」,一律保留,
`GET /api/jobs/{id}` 仍可查到该 Job 的状态、阶段与错误信息。
- 注意:完整结果现在只存归档一份(见 experiment_archive / job_executor),
因此删除归档后 `GET /api/jobs/{id}` 的 `result` 会是 null,并附带
`result_unavailable_reason` 说明归档已被删除(如实暴露,不静默给空结果)。
- 删除不可恢复;如需长期保留结果,请勿删除对应归档。
"""
if not experiment_repo.delete(experiment_id):
raise HTTPException(status_code=404, detail=f"Experiment {experiment_id} 不存在")
session.commit()
return {"deleted": experiment_id}
@router.post("/{experiment_id}/rerun", summary="一键复跑(AGENT §21:历史实验可重放)")
def rerun_experiment(
experiment_id: str,
background: BackgroundTasks,
session: DbSession,
experiment_repo: ExperimentRepoDep,
job_repo: JobRepoDep,
) -> dict:
exp = experiment_repo.get(experiment_id)
if exp is None:
raise HTTPException(status_code=404, detail=f"Experiment {experiment_id} 不存在")
job = JobRecord(
id=new_id("JOB"),
kind=exp.kind,
spec_json=exp.spec_json,
status=JobStatus.QUEUED,
created_at=datetime.now(),
)
job_repo.create(job)
session.commit()
background.add_task(run_job_background, job.id)
return {"job_id": job.id, "status": job.status, "origin_experiment": exp.id}