"""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 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 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.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}