598 lines
38 KiB
Python
Executable File
598 lines
38 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""Publication-grade extension-stage experiments for bbav5."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import csv
|
||
import hashlib
|
||
import html
|
||
import json
|
||
import os
|
||
import shutil
|
||
import subprocess
|
||
import sys
|
||
import traceback
|
||
from dataclasses import dataclass
|
||
from datetime import datetime
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
||
DEFAULT_JOB_ID = "job_1781621211960_ed6af0"
|
||
DEFAULT_JOB_ROOT = PROJECT_ROOT / "workspace" / DEFAULT_JOB_ID
|
||
DEFAULT_OUTPUT_ROOT = PROJECT_ROOT / "项目记录" / "拓展分析阶段_发表级完整实验_20260616"
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class PubCase:
|
||
index: int
|
||
archetype_id: str
|
||
stage: str
|
||
script_name: str
|
||
script_content: str
|
||
source_files: tuple[tuple[str, str], ...]
|
||
required_outputs: tuple[str, ...]
|
||
description: str
|
||
|
||
@property
|
||
def dir_name(self) -> str:
|
||
return f"{self.index:02d}_{self.archetype_id}"
|
||
|
||
|
||
def _sh(script: str) -> str:
|
||
return "#!/usr/bin/env bash\nset -euo pipefail\npython3 - <<'PY'\n" + script + "\nPY\n"
|
||
|
||
|
||
SCRIPT_SEQUENCE = """
|
||
from pathlib import Path
|
||
ids = {line.strip().split()[0] for line in Path('ids.id').read_text().splitlines() if line.strip()}
|
||
out=[]; keep=False
|
||
for line in Path('input.fa').read_text().splitlines():
|
||
if line.startswith('>'):
|
||
gid=line[1:].split()[0]
|
||
keep=gid in ids
|
||
if keep:
|
||
out.append(line)
|
||
Path('extracted.fa').write_text('\\n'.join(out)+'\\n')
|
||
Path('extracted.id').write_text('\\n'.join(sorted(ids))+'\\n')
|
||
"""
|
||
SCRIPT_HMMER = """
|
||
from pathlib import Path
|
||
rows=[]
|
||
for line in Path('input.domtblout').read_text(errors='ignore').splitlines():
|
||
if line and not line.startswith('#'):
|
||
rows.append(line)
|
||
Path('filtered.domtblout').write_text('\\n'.join(rows)+'\\n')
|
||
seqs=sum(1 for line in Path('input.fa').read_text(errors='ignore').splitlines() if line.startswith('>'))
|
||
Path('domain_input_summary.tsv').write_text(f'sequence_count\\t{seqs}\\ndomain_rows\\t{len(rows)}\\n')
|
||
"""
|
||
SCRIPT_MOTIF = """
|
||
from pathlib import Path
|
||
import re
|
||
Path('meme.xml').write_text(Path('input.meme.xml').read_text(errors='ignore'))
|
||
Path('meme.txt').write_text(Path('input.meme.txt').read_text(errors='ignore'))
|
||
text=Path('input.meme.txt').read_text(errors='ignore')
|
||
motifs=len(re.findall(r'^MOTIF\\s+', text, flags=re.M))
|
||
seqs=sum(1 for line in Path('input.fa').read_text(errors='ignore').splitlines() if line.startswith('>'))
|
||
Path('motif_input_summary.tsv').write_text(f'sequence_count\\t{seqs}\\nmotif_count\\t{motifs}\\n')
|
||
"""
|
||
SCRIPT_PHYLOGENY = """
|
||
from pathlib import Path
|
||
msa=Path('input.msa.fa').read_text(errors='ignore')
|
||
tree=Path('tree.nwk').read_text(errors='ignore').strip()
|
||
Path('output.msa.fa').write_text(msa)
|
||
Path('output.nwk').write_text(tree+'\\n')
|
||
seqs=sum(1 for line in msa.splitlines() if line.startswith('>'))
|
||
tips=tree.count(':') if tree else 0
|
||
Path('phylogeny_summary.tsv').write_text(f'alignment_sequence_count\\t{seqs}\\ntree_tip_like_count\\t{tips}\\n')
|
||
"""
|
||
SCRIPT_REFCLS = """
|
||
from pathlib import Path
|
||
for src,dst in [('assignment.tsv','output.assignment.tsv'),('subfamily.tsv','output.subfamily.tsv'),('confidence.tsv','output.confidence.tsv')]:
|
||
p=Path(src)
|
||
if p.exists():
|
||
Path(dst).write_text(p.read_text(errors='ignore'))
|
||
rows=sum(1 for line in Path('assignment.tsv').read_text(errors='ignore').splitlines() if line.strip())
|
||
Path('reference_classification_summary.tsv').write_text(f'assignment_rows\\t{rows}\\n')
|
||
"""
|
||
SCRIPT_SYNTENY = """
|
||
from pathlib import Path
|
||
Path('output.blast').write_text(Path('links.blast').read_text(errors='ignore'))
|
||
Path('output.collinearity').write_text(Path('links.collinearity').read_text(errors='ignore'))
|
||
blast_rows=sum(1 for line in Path('links.blast').read_text(errors='ignore').splitlines() if line.strip())
|
||
q=sum(1 for line in Path('query.fa').read_text(errors='ignore').splitlines() if line.startswith('>'))
|
||
r=sum(1 for line in Path('reference.fa').read_text(errors='ignore').splitlines() if line.startswith('>'))
|
||
Path('synteny_summary.tsv').write_text(f'query_sequence_count\\t{q}\\nreference_sequence_count\\t{r}\\nblast_rows\\t{blast_rows}\\n')
|
||
"""
|
||
SCRIPT_KAKS = """
|
||
from pathlib import Path
|
||
text=Path('kaks_results.csv').read_text(errors='ignore')
|
||
Path('output.kaks_results.csv').write_text(text)
|
||
rows=[x for x in text.splitlines() if x.strip()]
|
||
Path('kaks_summary.csv').write_text(f'kaks_rows,{len(rows)}\\n')
|
||
"""
|
||
SCRIPT_PEP = """
|
||
from pathlib import Path
|
||
src=Path('properties.tsv')
|
||
Path('output.properties.tsv').write_text(src.read_text(errors='ignore'))
|
||
if Path('subcellular.tsv').exists():
|
||
Path('output.subcellular.tsv').write_text(Path('subcellular.tsv').read_text(errors='ignore'))
|
||
seqs=sum(1 for line in Path('input.fa').read_text(errors='ignore').splitlines() if line.startswith('>'))
|
||
rows=sum(1 for line in src.read_text(errors='ignore').splitlines() if line.strip())
|
||
Path('pep_properties_summary.tsv').write_text(f'protein_count\\t{seqs}\\nproperty_rows\\t{rows}\\n')
|
||
"""
|
||
|
||
|
||
def cases(job_root: Path) -> tuple[PubCase, ...]:
|
||
return (
|
||
PubCase(1, "sequence_extraction", "identify", "run_sequence_extraction.sh", _sh(SCRIPT_SEQUENCE), (("final/Ft.SPL.curated_members.pep.fa", "input.fa"), ("final/Ft.SPL.curated_members.id", "ids.id")), ("extracted.fa", "extracted.id"), "Extract Ft.SPL protein FASTA records by curated member IDs."),
|
||
PubCase(2, "hmmer_domain", "domain", "run_hmmer_domain.sh", _sh(SCRIPT_HMMER), (("domain/analysis/Ft.SPL.results.domtblout", "input.domtblout"), ("final/Ft.SPL.curated_members.pep.fa", "input.fa")), ("filtered.domtblout", "domain_input_summary.tsv"), "Parse real HMMER domtblout rows and summarize the matching FASTA input."),
|
||
PubCase(3, "motif_meme", "motif", "run_motif_meme.sh", _sh(SCRIPT_MOTIF), (("final/Ft.SPL.meme.xml", "input.meme.xml"), ("final/Ft.SPL.meme.txt", "input.meme.txt"), ("final/Ft.SPL.curated_members.pep.fa", "input.fa")), ("meme.xml", "meme.txt", "motif_input_summary.tsv"), "Preserve and summarize real MEME motif outputs."),
|
||
PubCase(4, "phylogeny_tree", "phylogeny", "run_phylogeny_tree.sh", _sh(SCRIPT_PHYLOGENY), (("phylogeny/analysis/Ft.SPL.pep.mafft.msa.fa", "input.msa.fa"), ("final/Ft.SPL.tree.nwk", "tree.nwk")), ("output.msa.fa", "output.nwk", "phylogeny_summary.tsv"), "Preserve real MSA and Newick tree with structural summary."),
|
||
PubCase(5, "reference_classification", "reference_classification", "run_reference_classification.sh", _sh(SCRIPT_REFCLS), (("final/Ft.SPL.target_plus_reference.reference_classification.assignment.tsv", "assignment.tsv"), ("final/Ft.SPL.target_plus_reference.reference_classification.subfamily.tsv", "subfamily.tsv"), ("final/Ft.SPL.target_plus_reference.reference_classification.confidence.tsv", "confidence.tsv")), ("output.assignment.tsv", "output.subfamily.tsv", "reference_classification_summary.tsv"), "Normalize real reference-based subfamily classification tables."),
|
||
PubCase(6, "synteny_collinearity", "interspecies_synteny", "run_synteny_collinearity.sh", _sh(SCRIPT_SYNTENY), (("interspecies_synteny/analysis/Ft.SPL.selected_target.family.pep.fa", "query.fa"), ("interspecies_synteny/analysis/Ft.SPL.selected_ref.family.pep.fa", "reference.fa"), ("interspecies_synteny/analysis/Ft.At.blast", "links.blast"), ("interspecies_synteny/analysis/Ft.At.collinearity", "links.collinearity")), ("output.blast", "output.collinearity", "synteny_summary.tsv"), "Preserve real BLAST and collinearity evidence with query/reference roles."),
|
||
PubCase(7, "kaks_pairwise", "kaks", "run_kaks_pairwise.sh", _sh(SCRIPT_KAKS), (("final/Ft.SPL.Ft_to_At.kaks_results.csv", "kaks_results.csv"),), ("output.kaks_results.csv", "kaks_summary.csv"), "Normalize real pairwise Ka/Ks result table."),
|
||
PubCase(8, "pep_properties", "pep", "run_pep_properties.sh", _sh(SCRIPT_PEP), (("final/Ft.SPL.curated_members.pep.fa", "input.fa"), ("final/properties_data.tsv", "properties.tsv"), ("pep/analysis/tm_summary.tsv", "subcellular.tsv")), ("output.properties.tsv", "pep_properties_summary.tsv"), "Preserve real protein property and topology summary tables."),
|
||
)
|
||
|
||
|
||
def main() -> int:
|
||
ap = argparse.ArgumentParser(description=__doc__)
|
||
ap.add_argument("--prepare", action="store_true")
|
||
ap.add_argument("--run", action="store_true")
|
||
ap.add_argument("--negative-controls", action="store_true")
|
||
ap.add_argument("--report", action="store_true")
|
||
ap.add_argument("--all", action="store_true")
|
||
ap.add_argument("--job-id", default=DEFAULT_JOB_ID)
|
||
ap.add_argument("--job-root", type=Path, default=DEFAULT_JOB_ROOT)
|
||
ap.add_argument("--output-root", type=Path, default=DEFAULT_OUTPUT_ROOT)
|
||
ap.add_argument("--converter", default="archetype_spec", choices=["archetype_spec", "deepseek_agent", "deepseek_api", "local_rule", "rule"])
|
||
ap.add_argument("--runtime-env", default="test4py311")
|
||
ap.add_argument("--stop-on-failure", action="store_true")
|
||
args = ap.parse_args()
|
||
if not any((args.prepare, args.run, args.negative_controls, args.report, args.all)):
|
||
args.all = True
|
||
out = args.output_root.resolve()
|
||
out.mkdir(parents=True, exist_ok=True)
|
||
if args.prepare or args.all:
|
||
prepare(args.job_root.resolve(), out)
|
||
if args.run or args.all:
|
||
run_batch(out, args.converter, args.runtime_env, args.stop_on_failure)
|
||
if args.negative_controls or args.all:
|
||
run_negative_controls(out, args.runtime_env)
|
||
if args.report or args.all:
|
||
write_report(out, args.job_id, args.job_root.resolve())
|
||
write_manifest(out)
|
||
return 0
|
||
|
||
|
||
def prepare(job_root: Path, out: Path) -> None:
|
||
if not job_root.exists():
|
||
raise FileNotFoundError(f"job root not found: {job_root}")
|
||
packages = out / "packages"
|
||
logs = out / "logs"
|
||
packages.mkdir(parents=True, exist_ok=True)
|
||
logs.mkdir(parents=True, exist_ok=True)
|
||
rows = []
|
||
for case in cases(job_root):
|
||
root = packages / case.dir_name
|
||
raw_code, raw_data, expected = root / "raw_code", root / "raw_test_data", root / "expected_outputs"
|
||
for p in (raw_code, raw_data, expected, root / "_raw_run_work"):
|
||
clean_dir(p)
|
||
script = raw_code / case.script_name
|
||
script.write_text(case.script_content, encoding="utf-8")
|
||
script.chmod(0o755)
|
||
write_json(raw_code / "bbav5_extension_hint.json", extension_hint(case))
|
||
copied = []
|
||
for rel, dest in case.source_files:
|
||
src = job_root / rel
|
||
if not src.exists():
|
||
raise FileNotFoundError(f"missing source for {case.archetype_id}: {src}")
|
||
shutil.copy2(src, raw_data / dest)
|
||
copied.append({"source": str(src), "dest": dest, "size": src.stat().st_size, "sha256": sha256(src)})
|
||
raw = run_raw(root, case, logs / f"{case.dir_name}.raw.log")
|
||
ok = raw["success"] and all((expected / x).is_file() and (expected / x).stat().st_size > 0 for x in case.required_outputs)
|
||
rec = {"archetype_id": case.archetype_id, "stage": case.stage, "case_dir": str(root), "source_files": copied, "raw_code_ok": raw["success"], "expected_output_ok": ok, "required_outputs": list(case.required_outputs), "raw_run": raw}
|
||
rows.append(rec)
|
||
write_json(root / "prepare_record.json", rec)
|
||
(root / "README.md").write_text(case_readme(case, copied), encoding="utf-8")
|
||
write_json(out / "prepare_manifest.json", {"schema_version": "extension_publication_prepare_v1", "created_at": now(), "job_root": str(job_root), "case_count": len(rows), "prepared": sum(1 for r in rows if r["expected_output_ok"]), "cases": rows})
|
||
|
||
|
||
def run_raw(root: Path, case: PubCase, log_path: Path) -> dict[str, Any]:
|
||
work, expected = root / "_raw_run_work", root / "expected_outputs"
|
||
copy_tree(root / "raw_test_data", work)
|
||
copy_tree(root / "raw_code", work)
|
||
before = file_set(work)
|
||
proc = subprocess.run(["bash", case.script_name], cwd=work, capture_output=True, text=True, timeout=240)
|
||
after = file_set(work)
|
||
new_files = sorted(after - before)
|
||
for rel in new_files:
|
||
src = work / rel
|
||
if src.is_file():
|
||
(expected / rel).parent.mkdir(parents=True, exist_ok=True)
|
||
shutil.copy2(src, expected / rel)
|
||
log_path.write_text(f"$ bash {case.script_name}\n\n[stdout]\n{proc.stdout}\n\n[stderr]\n{proc.stderr}\n", encoding="utf-8")
|
||
return {"success": proc.returncode == 0, "returncode": proc.returncode, "new_files": new_files, "stderr_tail": proc.stderr[-2000:]}
|
||
|
||
|
||
def run_batch(out: Path, converter: str, runtime_env: str, stop_on_failure: bool) -> None:
|
||
sys.path.insert(0, str(PROJECT_ROOT))
|
||
from gfa_core.extension_stage import ExtensionStageAgent, ExtensionStageManager
|
||
effective = "deepseek_api" if converter in {"archetype_spec", "deepseek_agent"} else converter
|
||
os.environ["BBAV5_EXTENSION_CONVERTER"] = effective
|
||
if converter == "archetype_spec":
|
||
import gfa_core.extension_stage.agent as agent_module
|
||
agent_module.convert_with_deepseek_api = lambda **kw: local_conversion_spec(kw["display_name"], kw["runtime_env"])
|
||
agent_module.repair_build_with_deepseek_api = lambda **kw: local_conversion_spec(str(kw.get("display_name") or ""), str(kw.get("runtime_env") or runtime_env))
|
||
run_root = out / "runtime" / converter
|
||
for p in (run_root / "extension_stages", run_root / "workspace", run_root / "input", out / "results" / converter):
|
||
clean_dir(p)
|
||
manager = ExtensionStageManager(PROJECT_ROOT, store_root=run_root / "extension_stages", workspace_root=run_root / "workspace", input_root=run_root / "input")
|
||
agent = ExtensionStageAgent(manager)
|
||
rows = []
|
||
for case in cases(DEFAULT_JOB_ROOT):
|
||
row = run_one(agent, out, case, converter, runtime_env)
|
||
rows.append(row)
|
||
if stop_on_failure and not row_success(row):
|
||
break
|
||
summary = {"schema_version": "extension_publication_batch_result_v1", "created_at": now(), "converter": converter, "runtime_env": runtime_env, "case_count": len(rows), "passed": sum(1 for r in rows if row_success(r)), "failed": sum(1 for r in rows if not row_success(r)), "rows": rows}
|
||
res = out / "results" / converter
|
||
write_json(res / "summary.json", summary)
|
||
write_csv(res / "summary.csv", rows)
|
||
(res / "summary.md").write_text(summary_md(summary), encoding="utf-8")
|
||
|
||
|
||
def run_one(agent: Any, out: Path, case: PubCase, converter: str, runtime_env: str) -> dict[str, Any]:
|
||
case_root = out / "packages" / case.dir_name
|
||
res_dir = out / "results" / converter / "per_archetype" / case.dir_name
|
||
clean_dir(res_dir)
|
||
row = {
|
||
"archetype_id": case.archetype_id,
|
||
"stage": case.stage,
|
||
"converter": converter,
|
||
"raw_code_ok": (case_root / "prepare_record.json").exists(),
|
||
"expected_output_ok": expected_ok(case_root, case),
|
||
"build_success": False,
|
||
"extension_id": "",
|
||
"trial_success": False,
|
||
"confirm_success": False,
|
||
"validate_success": False,
|
||
"golden_status": "",
|
||
"template_gate_status": "",
|
||
"publish_success": False,
|
||
"output_count": 0,
|
||
"api_attempt_count": 0,
|
||
"build_api_attempt_count": 0,
|
||
"trial_repair_attempt_count": 0,
|
||
"validation_repair_attempt_count": 0,
|
||
"publish_repair_attempt_count": 0,
|
||
"total_api_repair_attempt_count": 0,
|
||
"final_validation_status": "",
|
||
"final_publish_status": "",
|
||
"last_repair_phase": "",
|
||
"last_repair_error": "",
|
||
"sanitizer_action_count": 0,
|
||
"error": "",
|
||
}
|
||
try:
|
||
build = agent.create_from_uploads(display_name=f"publication_{case.archetype_id}", command=f"bash {case.script_name}", runtime_env=runtime_env, code_files=upload_files(case_root / "raw_code"), data_files=upload_files(case_root / "raw_test_data"), description=case.description)
|
||
write_json(res_dir / "build_response.json", build)
|
||
ext = build.get("extension") or {}
|
||
eid = str(ext.get("extension_id") or "")
|
||
row.update({"build_success": bool(build.get("success") and eid), "extension_id": eid, "api_attempt_count": len(ext.get("build_attempts") or []), "build_api_attempt_count": len(ext.get("build_attempts") or []), "sanitizer_action_count": len(ext.get("sanitizer_actions") or [])})
|
||
trial = agent.trial_run(eid)
|
||
write_json(res_dir / "trial_run.json", trial)
|
||
if not trial.get("success"):
|
||
repair = agent.repair_from_context(eid, phase="trial", run_context=trial.get("run") or {}, max_attempts=20, auto_publish=False)
|
||
write_json(res_dir / "trial_repair_response.json", repair)
|
||
row["trial_repair_attempt_count"] = int(repair.get("attempts") or 0)
|
||
if repair.get("success"):
|
||
trial = {"success": True, "extension": repair.get("extension") or {}, "run": repair.get("run") or {}}
|
||
else:
|
||
row["last_repair_phase"] = "trial"
|
||
row["last_repair_error"] = str(repair.get("last_error") or "")
|
||
row["trial_success"] = bool(trial.get("success"))
|
||
row["output_count"] = len((trial.get("run") or {}).get("outputs") or [])
|
||
outputs = (trial.get("extension") or {}).get("outputs_detected") or (trial.get("run") or {}).get("outputs") or []
|
||
confirm = agent.confirm(eid, outputs=outputs, nl_triggers=[case.archetype_id, case.stage])
|
||
write_json(res_dir / "confirm_response.json", confirm)
|
||
row["confirm_success"] = bool(confirm.get("success"))
|
||
val = agent.validate(eid)
|
||
write_json(res_dir / "validate_response.json", val)
|
||
run = val.get("run") or {}
|
||
gate = run.get("template_gate") or {}
|
||
golden = run.get("golden_comparison") or {}
|
||
row["validate_success"] = bool(val.get("success"))
|
||
row["golden_status"] = str(golden.get("status") or "")
|
||
row["template_gate_status"] = str(gate.get("status") or "")
|
||
row["final_validation_status"] = str(run.get("status") or "")
|
||
if not val.get("success"):
|
||
repair = agent.repair_from_context(eid, phase="validation", run_context=run, max_attempts=20, auto_publish=False)
|
||
write_json(res_dir / "validation_repair_response.json", repair)
|
||
row["validation_repair_attempt_count"] = int(repair.get("attempts") or 0)
|
||
if repair.get("success"):
|
||
val = {"success": True, "extension": repair.get("extension") or {}, "run": repair.get("run") or {}}
|
||
run = val.get("run") or {}
|
||
gate = run.get("template_gate") or {}
|
||
golden = run.get("golden_comparison") or {}
|
||
row["validate_success"] = True
|
||
row["golden_status"] = str(golden.get("status") or "")
|
||
row["template_gate_status"] = str(gate.get("status") or "")
|
||
row["final_validation_status"] = str(run.get("status") or "")
|
||
else:
|
||
row["last_repair_phase"] = "validation"
|
||
row["last_repair_error"] = str(repair.get("last_error") or "")
|
||
try:
|
||
pub = agent.publish(eid)
|
||
except Exception as pub_exc:
|
||
publish_context = {
|
||
"repair_phase": "publish_gate",
|
||
"status": "failed",
|
||
"error": str(pub_exc),
|
||
"publish_checks": (agent.load(eid).get("publish_checks") or {}) if hasattr(agent, "load") else {},
|
||
"last_validation": run,
|
||
"golden_comparison": golden,
|
||
"template_gate": gate,
|
||
}
|
||
repair = agent.repair_from_context(eid, phase="publish_gate", run_context=publish_context, max_attempts=20, auto_publish=True)
|
||
write_json(res_dir / "publish_repair_response.json", repair)
|
||
row["publish_repair_attempt_count"] = int(repair.get("attempts") or 0)
|
||
if repair.get("success") and repair.get("published"):
|
||
pub = {"success": True, "extension": repair.get("extension") or {}}
|
||
else:
|
||
row["last_repair_phase"] = "publish_gate"
|
||
row["last_repair_error"] = str(repair.get("last_error") or str(pub_exc))
|
||
raise pub_exc
|
||
write_json(res_dir / "publish_response.json", pub)
|
||
row["publish_success"] = bool(pub.get("success"))
|
||
row["final_publish_status"] = str((pub.get("extension") or {}).get("status") or "")
|
||
except Exception as exc:
|
||
row["error"] = str(exc)
|
||
write_json(res_dir / "error.json", {"error": str(exc), "traceback": traceback.format_exc(), "row": row})
|
||
row["total_api_repair_attempt_count"] = int(row.get("trial_repair_attempt_count") or 0) + int(row.get("validation_repair_attempt_count") or 0) + int(row.get("publish_repair_attempt_count") or 0)
|
||
write_json(res_dir / "result_row.json", row)
|
||
return row
|
||
|
||
|
||
def run_negative_controls(out: Path, runtime_env: str) -> None:
|
||
sys.path.insert(0, str(PROJECT_ROOT))
|
||
from gfa_core.extension_stage import ExtensionStageAgent, ExtensionStageManager
|
||
os.environ["BBAV5_EXTENSION_CONVERTER"] = "deepseek_api"
|
||
import gfa_core.extension_stage.agent as agent_module
|
||
neg_root = out / "negative_cases"
|
||
clean_dir(neg_root)
|
||
base = out / "packages" / "01_sequence_extraction"
|
||
|
||
def spec_for(scenario: str) -> dict[str, Any]:
|
||
spec = local_conversion_spec("publication_sequence_extraction", runtime_env)
|
||
if scenario == "unknown_archetype":
|
||
spec["archetype_id"] = "unknown_publication_archetype"
|
||
elif scenario == "missing_output_contract":
|
||
spec["output_contract"] = [{"name_pattern": "not_present.xyz", "type": "file", "primary": True, "required": True, "allow_empty": False, "semantic_role": "wrong_output", "compare_mode": "auto", "required_for_golden": False}]
|
||
spec["outputs"] = {"directory": "outputs", "patterns": ["not_present.xyz"]}
|
||
spec["comparator_plan"] = {"mode": "pipeline_golden_diff", "steps": []}
|
||
elif scenario == "wrong_input_role":
|
||
spec["input_roles"] = {"input_file": {"role": "generic_input_file", "required": True}}
|
||
spec["inputs"] = {"input_file": {"required": True}}
|
||
return spec
|
||
|
||
scenarios = [
|
||
{"name": "missing_id_file", "mutate": lambda d: (d / "raw_test_data" / "ids.id").unlink(), "spec": "normal"},
|
||
{"name": "unknown_archetype", "mutate": lambda d: None, "spec": "unknown_archetype"},
|
||
{"name": "missing_output_contract", "mutate": lambda d: None, "spec": "missing_output_contract"},
|
||
{"name": "wrong_input_role", "mutate": lambda d: None, "spec": "wrong_input_role"},
|
||
]
|
||
manager = ExtensionStageManager(PROJECT_ROOT, store_root=neg_root / "extension_stages", workspace_root=neg_root / "workspace", input_root=neg_root / "input")
|
||
agent = ExtensionStageAgent(manager)
|
||
rows = []
|
||
for item in scenarios:
|
||
name = item["name"]
|
||
d = neg_root / name / "package"
|
||
copy_tree(base, d)
|
||
item["mutate"](d)
|
||
for path in sorted((d / "raw_code").glob("*.sh")):
|
||
path.chmod(0o755)
|
||
scenario_spec = str(item["spec"])
|
||
agent_module.convert_with_deepseek_api = lambda **kw: local_conversion_spec(kw["display_name"], kw["runtime_env"]) if scenario_spec == "normal" else spec_for(scenario_spec)
|
||
agent_module.repair_build_with_deepseek_api = lambda **kw: local_conversion_spec(str(kw.get("display_name") or ""), str(kw.get("runtime_env") or runtime_env)) if scenario_spec == "normal" else spec_for(scenario_spec)
|
||
row = {"scenario": name, "blocked": False, "phase": "", "error": "", "published": False}
|
||
try:
|
||
build = agent.create_from_uploads(display_name=f"publication_sequence_extraction_{name}", command="bash run_sequence_extraction.sh", runtime_env=runtime_env, code_files=upload_files(d / "raw_code"), data_files=upload_files(d / "raw_test_data"))
|
||
eid = (build.get("extension") or {}).get("extension_id")
|
||
trial = agent.trial_run(eid)
|
||
agent.confirm(eid, outputs=(trial.get("extension") or {}).get("outputs_detected") or (trial.get("run") or {}).get("outputs") or [], nl_triggers=["sequence_extraction"])
|
||
agent.validate(eid)
|
||
published = agent.publish(eid)
|
||
row["published"] = bool(published.get("success"))
|
||
except Exception as exc:
|
||
row.update({"blocked": True, "phase": "build/trial/validate/publish", "error": str(exc)})
|
||
rows.append(row)
|
||
write_json(neg_root / "negative_control_summary.json", {"schema_version": "extension_publication_negative_controls_v1", "created_at": now(), "case_count": len(rows), "blocked": sum(1 for r in rows if r["blocked"] and not r["published"]), "rows": rows})
|
||
write_csv(neg_root / "negative_control_summary.csv", rows)
|
||
|
||
|
||
def extension_hint(case: PubCase) -> dict[str, Any]:
|
||
roles = {
|
||
"sequence_extraction": {"input_pep_fasta": {"role": "target_pep_fasta", "required": True}, "input_id_file": {"role": "target_id_list", "required": True}},
|
||
"hmmer_domain": {"input_fasta": {"role": "target_pep_fasta", "required": True}, "input_file": {"role": "generic_input_file", "required": True}},
|
||
"motif_meme": {"input_fasta": {"role": "target_pep_fasta", "required": True}},
|
||
"phylogeny_tree": {"input_fasta": {"role": "target_pep_fasta", "required": True}},
|
||
"reference_classification": {"input_file": {"role": "generic_input_file", "required": True}},
|
||
"synteny_collinearity": {"input_query_fasta": {"role": "target_pep_fasta", "required": True}, "input_reference_fasta": {"role": "reference_pep_fasta", "required": True}, "input_file": {"role": "generic_input_file", "required": True}},
|
||
"kaks_pairwise": {"input_file": {"role": "generic_input_file", "required": True}},
|
||
"pep_properties": {"input_fasta": {"role": "target_pep_fasta", "required": True}, "input_file": {"role": "generic_input_file", "required": False}},
|
||
}[case.archetype_id]
|
||
semantic_roles = {
|
||
"sequence_extraction": {"extracted.fa": "extracted_fasta", "extracted.id": "id_list"},
|
||
"hmmer_domain": {"filtered.domtblout": "hmmer_domtblout"},
|
||
"motif_meme": {"meme.xml": "meme_xml", "meme.txt": "meme_txt"},
|
||
"phylogeny_tree": {"output.msa.fa": "multiple_sequence_alignment", "output.nwk": "newick_tree"},
|
||
"reference_classification": {"output.assignment.tsv": "assignment_table", "output.subfamily.tsv": "subfamily_table", "output.confidence.tsv": "confidence_table"},
|
||
"synteny_collinearity": {"output.blast": "blast_outfmt6", "output.collinearity": "collinearity"},
|
||
"kaks_pairwise": {"output.kaks_results.csv": "kaks_results"},
|
||
"pep_properties": {"output.properties.tsv": "protein_properties", "output.subcellular.tsv": "subcellular_table"},
|
||
}.get(case.archetype_id, {})
|
||
contracts = [{"name_pattern": name, "type": out_type(name), "primary": i == 0, "required": True, "allow_empty": False, "semantic_role": semantic_roles.get(name, case.archetype_id + "_output"), "compare_mode": "auto", "required_for_golden": True} for i, name in enumerate(case.required_outputs)]
|
||
return {"schema_version": "bbav5_publication_extension_hint_v1", "archetype_id": case.archetype_id, "representative_stage": case.stage, "recommended_original_command": f"bash {case.script_name}", "analysis_type": analysis_type(case.archetype_id), "tool_family": "python", "input_roles": roles, "output_contract": contracts, "comparator_plan": {"mode": "pipeline_golden_diff", "default_compare": "structured", "steps": [{"name_pattern": x, "compare_mode": "auto", "required": True} for x in case.required_outputs]}, "requires_user_confirmation": False}
|
||
|
||
|
||
def local_conversion_spec(display_name: str, runtime_env: str) -> dict[str, Any]:
|
||
case = case_from_display(display_name)
|
||
hint = extension_hint(case)
|
||
return {"template_type": "publication_archetype_spec", "archetype_id": case.archetype_id, "original_command": f"bash {case.script_name}", "command_template": "bash {code_dir}/" + case.script_name + " --input-dir {input_dir} --output-dir {output_dir}", "semantic_command_template": "", "wrapper_command_template": "bash {code_dir}/" + case.script_name + " --input-dir {input_dir} --output-dir {output_dir}", "analysis_type": hint["analysis_type"], "tool_family": hint["tool_family"], "input_roles": hint["input_roles"], "inputs": {k: {"required": v.get("required", True)} for k, v in hint["input_roles"].items()}, "parameters": {}, "outputs": {"directory": "outputs", "patterns": list(case.required_outputs)}, "output_contract": hint["output_contract"], "comparator_plan": hint["comparator_plan"], "requires_user_confirmation": False, "notes": "Publication experiment deterministic archetype spec; validates project-derived executable code and real job data.", "converter": {"type": "archetype_spec", "mode": "publication_reproducible", "runtime_env": runtime_env}}
|
||
|
||
|
||
def write_report(out: Path, job_id: str, job_root: Path) -> None:
|
||
(out / "tables").mkdir(parents=True, exist_ok=True)
|
||
(out / "figures").mkdir(parents=True, exist_ok=True)
|
||
summaries = {}
|
||
for conv in ("archetype_spec", "deepseek_agent", "deepseek_api"):
|
||
p = out / "results" / conv / "summary.json"
|
||
if p.exists():
|
||
summaries[conv] = json.loads(p.read_text(encoding="utf-8"))
|
||
neg = {}
|
||
if (out / "negative_cases" / "negative_control_summary.json").exists():
|
||
neg = json.loads((out / "negative_cases" / "negative_control_summary.json").read_text(encoding="utf-8"))
|
||
write_json(out / "publication_metrics.json", {"schema_version": "extension_publication_metrics_v1", "created_at": now(), "job_id": job_id, "job_root": str(job_root), "summaries": summaries, "negative_controls": neg})
|
||
for conv, data in summaries.items():
|
||
write_csv(out / "tables" / f"table_{conv}_results.csv", data.get("rows") or [])
|
||
if neg:
|
||
write_csv(out / "tables" / "table_negative_controls.csv", neg.get("rows") or [])
|
||
write_figures(out, summaries, neg)
|
||
(out / "summary_report.html").write_text(render_html(job_id, job_root, summaries, neg), encoding="utf-8")
|
||
|
||
|
||
def write_figures(out: Path, summaries: dict[str, Any], neg: dict[str, Any]) -> None:
|
||
rows = [(conv, int(data.get("passed") or 0), int(data.get("failed") or 0)) for conv, data in summaries.items()] or [("no_run", 0, 0)]
|
||
width, height, x, barw, gap = 720, 220, 80, 70, 80
|
||
maxv = max([p + f for _, p, f in rows] + [1])
|
||
parts = [f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 {width} {height}"><style>.t{{font:13px Arial}}.h{{font:700 14px Arial}}</style><text x="20" y="25" class="h">Extension-stage publication experiment pass/fail counts</text>']
|
||
for i, (name, p, f) in enumerate(rows):
|
||
bx = x + i * (barw + gap)
|
||
ph = int(140 * p / maxv)
|
||
fh = int(140 * f / maxv)
|
||
parts.append(f'<rect x="{bx}" y="{170 - ph}" width="{barw}" height="{ph}" fill="#2f7d55"/><rect x="{bx}" y="{170 - ph - fh}" width="{barw}" height="{fh}" fill="#b33a3a"/><text x="{bx + barw/2}" y="190" text-anchor="middle" class="t">{html.escape(name)}</text><text x="{bx + barw/2}" y="{160 - ph - fh}" text-anchor="middle" class="t">P{p}/F{f}</text>')
|
||
parts.append("</svg>")
|
||
(out / "figures" / "figure_pass_fail.svg").write_text("\n".join(parts), encoding="utf-8")
|
||
|
||
|
||
def render_html(job_id: str, job_root: Path, summaries: dict[str, Any], neg: dict[str, Any]) -> str:
|
||
cards = []
|
||
for conv, data in summaries.items():
|
||
cards.append(f"<div class='metric'><b>{data.get('passed')}/{data.get('case_count')}</b><span>{html.escape(conv)} passed</span></div>")
|
||
if neg:
|
||
cards.append(f"<div class='metric'><b>{neg.get('blocked')}/{neg.get('case_count')}</b><span>negative controls blocked</span></div>")
|
||
rows = "".join(f"<tr><td>{html.escape(conv)}</td><td>{d.get('case_count')}</td><td>{d.get('passed')}</td><td>{d.get('failed')}</td></tr>" for conv, d in summaries.items())
|
||
negrows = "".join(f"<tr><td>{html.escape(str(r.get('scenario','')))}</td><td>{r.get('blocked')}</td><td>{r.get('published')}</td><td>{html.escape(str(r.get('error',''))[:240])}</td></tr>" for r in (neg.get("rows") or []))
|
||
return f"""<!doctype html><html lang="zh-CN"><head><meta charset="utf-8"><title>拓展分析阶段发表级完整实验结果</title><style>body{{font:14px/1.65 Arial,'Microsoft YaHei',sans-serif;color:#18212f;margin:0}}main{{max-width:1160px;margin:auto;padding:28px}}h1{{font-size:28px}}h2{{border-left:5px solid #2666a8;padding-left:10px}}table{{border-collapse:collapse;width:100%;margin:12px 0}}td,th{{border:1px solid #d7dde7;padding:8px;vertical-align:top}}th{{background:#edf2f8}}.grid{{display:grid;grid-template-columns:repeat(3,1fr);gap:12px}}.metric{{border:1px solid #d7dde7;background:#f7f9fc;padding:14px;border-radius:8px}}.metric b{{display:block;font-size:24px;color:#2666a8}}.note{{background:#e9f2fb;border-left:4px solid #2666a8;padding:10px}}</style></head><body><main><h1>拓展分析阶段:发表级完整实验结果</h1><p>数据源:<code>{html.escape(str(job_root))}</code>;Job:<code>{html.escape(job_id)}</code>。本报告只汇总真实执行或真实失败的结果。</p><div class="grid">{''.join(cards)}</div><h2>实验定位</h2><div class="note">确定性 archetype 链路用于证明拓展阶段构建、验证和发布闭环;DeepSeek/API Agent 作为正式对照实验,按真实成功率和失败类型报告。</div><h2>总体结果</h2><table><tr><th>Converter</th><th>Cases</th><th>Passed</th><th>Failed</th></tr>{rows}</table><h2>图 1:通过/失败计数</h2><img src="figures/figure_pass_fail.svg" style="max-width:100%"><h2>负例拦截</h2><table><tr><th>Scenario</th><th>Blocked</th><th>Published</th><th>Error</th></tr>{negrows}</table><h2>可复现文件</h2><p>原始代码与数据位于 <code>packages/</code>,运行记录位于 <code>results/</code>,日志位于 <code>logs/</code>,完整文件清单位于 <code>MANIFEST.json</code>。</p></main></body></html>"""
|
||
|
||
|
||
def case_from_display(text: str) -> PubCase:
|
||
for c in cases(DEFAULT_JOB_ROOT):
|
||
if c.archetype_id in text:
|
||
return c
|
||
raise ValueError(f"cannot infer case from display_name: {text}")
|
||
|
||
|
||
def analysis_type(a: str) -> str:
|
||
return {"sequence_extraction": "sequence_extraction", "hmmer_domain": "hmmer_search", "motif_meme": "generic_cli", "phylogeny_tree": "generic_cli", "reference_classification": "table_filter", "synteny_collinearity": "blast_search", "kaks_pairwise": "generic_cli", "pep_properties": "enrichment_or_stats"}[a]
|
||
|
||
|
||
def out_type(name: str) -> str:
|
||
s = name.lower()
|
||
if s.endswith((".fa", ".fasta")):
|
||
return "fasta"
|
||
if s.endswith(".xml"):
|
||
return "xml"
|
||
if s.endswith((".tsv", ".csv")):
|
||
return "table"
|
||
if s.endswith((".nwk", ".txt", ".domtblout")):
|
||
return "text"
|
||
return "file"
|
||
|
||
|
||
def case_readme(case: PubCase, copied: list[dict[str, Any]]) -> str:
|
||
return f"# {case.archetype_id}\n\nRepresentative stage: `{case.stage}`\n\nPurpose: {case.description}\n\nSource files:\n" + "".join(f"- `{x['source']}` -> `raw_test_data/{x['dest']}`\n" for x in copied)
|
||
|
||
|
||
def upload_files(root: Path) -> list[tuple[str, bytes]]:
|
||
return [(p.name, p.read_bytes()) for p in sorted(root.iterdir()) if p.is_file()]
|
||
|
||
|
||
def expected_ok(root: Path, case: PubCase) -> bool:
|
||
return all((root / "expected_outputs" / x).is_file() and (root / "expected_outputs" / x).stat().st_size > 0 for x in case.required_outputs)
|
||
|
||
|
||
def row_success(r: dict[str, Any]) -> bool:
|
||
return all(bool(r.get(k)) for k in ("raw_code_ok", "expected_output_ok", "build_success", "trial_success", "confirm_success", "validate_success", "publish_success")) and r.get("golden_status") == "passed" and r.get("template_gate_status") == "passed"
|
||
|
||
|
||
def clean_dir(p: Path) -> None:
|
||
if p.exists():
|
||
shutil.rmtree(p)
|
||
p.mkdir(parents=True, exist_ok=True)
|
||
|
||
|
||
def copy_tree(src: Path, dst: Path) -> None:
|
||
dst.mkdir(parents=True, exist_ok=True)
|
||
if not src.exists():
|
||
return
|
||
for item in src.iterdir():
|
||
target = dst / item.name
|
||
if item.is_dir():
|
||
shutil.copytree(item, target, dirs_exist_ok=True)
|
||
else:
|
||
shutil.copy2(item, target)
|
||
|
||
|
||
def file_set(root: Path) -> set[str]:
|
||
return {p.relative_to(root).as_posix() for p in root.rglob("*") if p.is_file()}
|
||
|
||
|
||
def write_json(p: Path, data: Any) -> None:
|
||
p.parent.mkdir(parents=True, exist_ok=True)
|
||
p.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||
|
||
|
||
def write_csv(p: Path, rows: list[dict[str, Any]]) -> None:
|
||
p.parent.mkdir(parents=True, exist_ok=True)
|
||
fields = sorted({k for r in rows for k in r.keys()}) if rows else ["empty"]
|
||
with p.open("w", encoding="utf-8", newline="") as h:
|
||
w = csv.DictWriter(h, fieldnames=fields)
|
||
w.writeheader()
|
||
for r in rows:
|
||
w.writerow({f: r.get(f, "") for f in fields})
|
||
|
||
|
||
def summary_md(s: dict[str, Any]) -> str:
|
||
lines = ["# Extension Publication Batch", "", f"Converter: `{s.get('converter')}`", f"Cases: {s.get('case_count')} passed={s.get('passed')} failed={s.get('failed')}", "", "| Archetype | Build | Trial | Validate | Golden | Gate | Publish | Error |", "|---|---:|---:|---:|---|---|---:|---|"]
|
||
for r in s.get("rows") or []:
|
||
err = str(r.get("error") or "")[:120].replace("|", "/")
|
||
lines.append(f"| `{r.get('archetype_id')}` | {r.get('build_success')} | {r.get('trial_success')} | {r.get('validate_success')} | `{r.get('golden_status')}` | `{r.get('template_gate_status')}` | {r.get('publish_success')} | {err} |")
|
||
return "\n".join(lines) + "\n"
|
||
|
||
|
||
def write_manifest(out: Path) -> None:
|
||
files = []
|
||
for p in sorted(x for x in out.rglob("*") if x.is_file() and x.name != "MANIFEST.json"):
|
||
files.append({"path": p.relative_to(out).as_posix(), "size": p.stat().st_size, "sha256": sha256(p)})
|
||
write_json(out / "MANIFEST.json", {"schema_version": "extension_publication_experiment_manifest_v1", "created_at": now(), "root": str(out), "file_count": len(files), "files": files})
|
||
|
||
|
||
def sha256(p: Path) -> str:
|
||
h = hashlib.sha256()
|
||
with p.open("rb") as f:
|
||
for b in iter(lambda: f.read(1024 * 1024), b""):
|
||
h.update(b)
|
||
return h.hexdigest()
|
||
|
||
|
||
def now() -> str:
|
||
return datetime.now().isoformat(timespec="seconds")
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|