"""从 Job 结果副本重建 Experiment 归档(删除后的可选恢复路径)。 **为什么会有这个工具**:完整存档上线前,历史归档在 `job.result_json` 里另存了一份完整结果 (双写遗留)。删除归档只删 `experiment` 行、不动 `job` 行,因此这些历史记录 **可以从 Job 副本原样重建** —— 删除不等于数据永久消失。 新归档(`job.result_json IS NULL`)没有副本,删除即不可恢复,本工具会明确拒绝而不是假装能救。 注意:本工具是**恢复手段**,不是"删除的撤销键"。删除本身是正常功能: 已有历史归档可重建、新归档不可;是否恢复由人决定,工具默认 dry-run、不做任何自动动作。 用法(**默认 dry-run**,只打印将要写入的内容;`--apply` 才写库): cd backend PYTHONPATH=. .venv/bin/python -m app.cli.restore_experiment_from_job --job-id JOB-XXXX PYTHONPATH=. .venv/bin/python -m app.cli.restore_experiment_from_job --job-id JOB-XXXX \ --code-version 92627f5 --apply 诚实性要求(AGENT.md §7/§24): - **归档 id 用回原 id**(`job.experiment_id`),移动端/书签里的旧链接继续有效; - `spec_json` / `result_json` 逐字复制 Job 副本,不重新计算、不"顺手修正"; - `summary_text` 由副本 JSON 反序列化后按归档同一函数重新生成(口径一致); - `code_version` **必须显式提供**,不做猜测:显式传 `--code-version ""` 表示"未知则留空"; - `data_version` 留空 —— 历史归档当年没有数据指纹,补一个今天的指纹是伪造复现依据; - 重建前会检查该 job 的归档是否已存在,已存在则拒绝(除非 `--force`)。 """ from __future__ import annotations import argparse import json import sys from datetime import datetime from app.application.services.experiment_archive import _summary_text from app.domain.entities.research import ( BacktestResult, ExperimentRecord, FactorTestReport, ) from app.domain.entities.selection import SelectionResult from app.infrastructure.persistence.sqlalchemy.repositories.jobs_impl import ( SqlAlchemyExperimentRepository, ) from app.infrastructure.persistence.sqlalchemy.session import SessionLocal def _parse_result(kind: str, raw: str): """按 kind 把 Job 里的结果 JSON 反序列化成领域对象(用于生成同一个摘要口径)。""" payload = json.loads(raw) if kind == "backtest": return BacktestResult.model_validate(payload) if kind == "factor_test": return FactorTestReport.model_validate(payload) if kind == "selection": return SelectionResult.model_validate(payload) return None def _load_job(session, job_id: str): """按 ORM 读 Job(**不要用裸 SQL**)。 裸 `text()` 查询在 SQLite 下把 DateTime 列原样返回成字符串,写回 ORM 的 DateTime 字段会抛 `TypeError: SQLite DateTime type only accepts Python datetime...` (MySQL+pymysql 返回 datetime 所以当时看不出问题)。ORM 读法跨方言类型一致, 这个坑由 `tests/test_restore_experiment.py::test_restores_archive_from_job_copy` 钉住。 """ from app.infrastructure.persistence.sqlalchemy.models.jobs import JobModel return session.get(JobModel, job_id) def _experiment_exists(session, exp_id: str) -> bool: from app.infrastructure.persistence.sqlalchemy.models import ExperimentModel return session.get(ExperimentModel, exp_id) is not None def main(argv: list[str] | None = None) -> int: ap = argparse.ArgumentParser(description="从 Job 结果副本重建 Experiment 归档(删除后的可选恢复路径)") ap.add_argument("--job-id", required=True, help="来源 Job id(如 JOB-D3C120DC)") ap.add_argument( "--code-version", default=None, help="重建记录的 code_version(必填;传空串表示未知留空)。不猜版本。", ) ap.add_argument("--force", action="store_true", help="归档已存在时也覆盖(默认拒绝)") ap.add_argument("--apply", action="store_true", help="真正写库(默认 dry-run)") args = ap.parse_args(argv) if args.code_version is None: print( "❌ 必须显式给出 --code-version(不猜版本);确定未知请传 --code-version \"\"。", file=sys.stderr, ) return 2 session = SessionLocal() try: row = _load_job(session, args.job_id) if row is None: print(f"❌ Job {args.job_id} 不存在", file=sys.stderr) return 1 job_id = row.id kind = row.kind status = row.status spec_json = row.spec_json result_json = row.result_json exp_id = row.experiment_id finished_at = row.finished_at created_at = row.created_at if not exp_id: print(f"❌ Job {job_id} 没有 experiment_id,无法确定重建为哪个归档", file=sys.stderr) return 1 if result_json is None: print( f"❌ Job {job_id} 的 result_json 为空(完整存档上线后结果只存归档一份)," "本工具无法重建 —— 这类归档删除后不可恢复。", file=sys.stderr, ) return 1 exists = _experiment_exists(session, exp_id) result = _parse_result(kind, result_json) summary = _summary_text(kind, result) if result is not None else None restored_at = finished_at or created_at or datetime.now() print(f"来源 Job : {job_id}(status={status},finished_at={finished_at})") print(f"归档 id : {exp_id}(已存在: {exists})") print(f"kind : {kind}") print(f"spec 长度 : {len(spec_json)} 字符") print(f"result 长度 : {len(result_json)} 字符") print(f"summary_text : {summary!r}") print(f"code_version : {args.code_version!r}") print("data_version : None(历史归档无指纹,不伪造)") print(f"created_at : {restored_at}") if exists and not args.force: print("❌ 该归档已存在;如确要覆盖请加 --force", file=sys.stderr) return 1 if not args.apply: print("\n(dry-run) 未写库。确认无误后加 --apply。") return 0 # 走仓储而不是直接操作 ORM 模型(AGENT §10:持久化只经 Repository)。 # 这样 experiment 表 ↔ 领域实体的字段映射只有仓储一份:将来加列(尤其 NOT NULL) # 不会在这里静默漏写。覆盖语义由 `upsert`(merge)承担,归档 id 保持不变。 record = ExperimentRecord( id=exp_id, kind=kind, spec_json=spec_json, result_json=result_json, summary_text=summary, code_version=args.code_version or None, data_version=None, job_id=job_id, created_at=restored_at, ) repo = SqlAlchemyExperimentRepository(session) repo.upsert(record) session.commit() got = repo.get(exp_id) print( f"\n✅ 已重建:{got.id} / {got.kind} / 体积 {len(got.result_json)} 字符 / " f"code_version={got.code_version}" ) return 0 finally: session.close() if __name__ == "__main__": raise SystemExit(main())