"""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": ""}`。 语义(写清楚避免误解): - **只删 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}