feat: add gene family result reports
This commit is contained in:
@@ -0,0 +1,226 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Small real-data journey for the gene-family report module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from apps.cli.run_gene_family_report import main as report_cli_main
|
||||
from gfa_core.mas.utils.intent_parser import parse_intent_from_query
|
||||
|
||||
|
||||
PREFIX = "Real.SPL"
|
||||
SCOPE_ID = "scope-real-spl"
|
||||
ANALYSIS_ROUTE = "post_subfamily_confirm"
|
||||
MEMBER_SET_ID = "members-real-spl"
|
||||
|
||||
|
||||
def _sequence_count(path: Path) -> int:
|
||||
count = sum(line.startswith(">") for line in path.read_text(encoding="utf-8").splitlines())
|
||||
if not count:
|
||||
raise AssertionError("representative FASTA is empty")
|
||||
return count
|
||||
|
||||
|
||||
def _write_identify_manifest(workspace: Path, fasta: Path, count: int) -> None:
|
||||
final_dir = workspace / "final"
|
||||
final_dir.mkdir(parents=True, exist_ok=True)
|
||||
manifest = {
|
||||
"manifest_version": "1.0",
|
||||
"stage": "identify",
|
||||
"status": "success",
|
||||
"prefix": PREFIX,
|
||||
"scope_id": SCOPE_ID,
|
||||
"analysis_route": ANALYSIS_ROUTE,
|
||||
"member_set_id": MEMBER_SET_ID,
|
||||
"parameters": {
|
||||
"scope_id": SCOPE_ID,
|
||||
"analysis_route": ANALYSIS_ROUTE,
|
||||
"member_set_id": MEMBER_SET_ID,
|
||||
},
|
||||
"metrics": {"member_count": count, "final_members": count, "total_proteins": count},
|
||||
"artifacts": {
|
||||
"final_members": {"path": str(fasta), "full_path": str(fasta), "type": "protein_fasta"}
|
||||
},
|
||||
}
|
||||
(final_dir / f"{PREFIX}.identify.manifest.json").write_text(
|
||||
json.dumps(manifest, ensure_ascii=False, indent=2) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _run(workspace: Path, action: str):
|
||||
return report_cli_main(
|
||||
[
|
||||
"--workspace", str(workspace),
|
||||
"--prefix", PREFIX,
|
||||
"--job-id", "representative-gene-family-report",
|
||||
"--expected-stages", "identify",
|
||||
"--stage-statuses-json", '{"identify":"success"}',
|
||||
"--scope-id", SCOPE_ID,
|
||||
"--analysis-route", ANALYSIS_ROUTE,
|
||||
"--member-set-id", MEMBER_SET_ID,
|
||||
"--action", action,
|
||||
"--no-model",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _stage_section(report: str, stage: str) -> str:
|
||||
heading = re.search(rf"(?m)^## (?!#).*?\(`{re.escape(stage)}`\)", report)
|
||||
if heading is None:
|
||||
raise AssertionError(f"missing report chapter for {stage}")
|
||||
next_heading = re.search(r"(?m)^## (?!#)", report[heading.end():])
|
||||
end = heading.end() + next_heading.start() if next_heading else len(report)
|
||||
return report[heading.start():end]
|
||||
|
||||
|
||||
def _subsection(report: str, number: str) -> str:
|
||||
heading = re.search(rf"(?m)^### {re.escape(number)}(?:\s|$)", report)
|
||||
if heading is None:
|
||||
raise AssertionError(f"missing report subsection {number}")
|
||||
next_heading = re.search(r"(?m)^#{2,3} ", report[heading.end():])
|
||||
end = heading.end() + next_heading.start() if next_heading else len(report)
|
||||
return report[heading.start():end]
|
||||
|
||||
|
||||
def _verify_real_job(workspace: Path, prefix: str) -> None:
|
||||
final_dir = workspace / "final"
|
||||
report_path = final_dir / f"{prefix}.gene_family_report.md"
|
||||
manifest_path = final_dir / f"{prefix}.gene_family_report.manifest.json"
|
||||
report = report_path.read_text(encoding="utf-8")
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
expected_stages = [
|
||||
"identify",
|
||||
"phylogeny",
|
||||
"reference_classification",
|
||||
"motif",
|
||||
"domain",
|
||||
"structure",
|
||||
"combo",
|
||||
"promoter",
|
||||
"tfbs",
|
||||
"synteny",
|
||||
"karyotype",
|
||||
"interspecies_synteny",
|
||||
"pep",
|
||||
]
|
||||
|
||||
assert report.startswith(f"# {prefix} 基因家族分析结果解读报告\n")
|
||||
for placeholder in ("【填写】", "未解析", "N/A", "None"):
|
||||
assert placeholder not in report
|
||||
assert "/home/xzq/" not in report
|
||||
assert not re.search(r"(?m)^- `[a-zA-Z0-9_.-]+`:\s*", report)
|
||||
assert manifest["parameters"]["expected_stages"] == expected_stages
|
||||
assert manifest["metrics"]["included_stages"] == expected_stages
|
||||
assert manifest["metrics"]["generation_mode"] == "fallback_script"
|
||||
assert sorted(path.name for path in final_dir.glob(f"{prefix}.gene_family_report*")) == [
|
||||
f"{prefix}.gene_family_report.manifest.json",
|
||||
f"{prefix}.gene_family_report.md",
|
||||
]
|
||||
for stage in expected_stages:
|
||||
section = _stage_section(report, stage)
|
||||
for chunk in re.split(r"(?m)(?=^### )", section)[1:]:
|
||||
assert "\n".join(chunk.splitlines()[1:]).strip(), chunk.splitlines()[0]
|
||||
assert "分析对象:" in section
|
||||
assert "限制:" in section
|
||||
assert report.count("本节解释仅限上述可追溯结果,不据此断言真实功能或因果关系。") <= 1
|
||||
summary = _subsection(report, "1.3")
|
||||
assert 6 <= summary.count("。") <= 10
|
||||
assert re.search(r"目标物种.*24.*家族成员", summary)
|
||||
assert re.search(r"形成\s*24\s*个\s*SPL家族", _subsection(report, "4.3"))
|
||||
assert re.search(
|
||||
r"使用\s*40\s*条序列.*比对长度为\s*1449", _stage_section(report, "phylogeny")
|
||||
)
|
||||
motif = _stage_section(report, "motif")
|
||||
assert re.search(r"识别\s*10\s*个\s*motif", motif)
|
||||
assert re.search(r"总位点数为\s*105", motif)
|
||||
assert re.search(r"宽度范围为\s*27–138", motif)
|
||||
assert re.search(r"将\s*24\s*个家族成员定位到\s*8\s*条染色体", _stage_section(report, "karyotype"))
|
||||
assert re.search(
|
||||
r"全基因组\s*block\s*521\s*个.*目标-参考家族.*?2\s*对",
|
||||
_stage_section(report, "interspecies_synteny"),
|
||||
)
|
||||
assert re.search(r"覆盖\s*40\s*个蛋白,子任务成功数为\s*3/3", _stage_section(report, "pep"))
|
||||
assert re.search(r"24\s*个目标成员.*8\s*个小组.*高信心\s*14.*中信心\s*7.*低信心\s*3", _subsection(report, "6.2"))
|
||||
assert re.search(r"40/40.*SBP.*40.*Ig_SPL7.*12", _subsection(report, "8.1"))
|
||||
assert re.search(r"24/40.*FtPinG0003446200\.01\.T01.*12454", _subsection(report, "9.1"))
|
||||
assert "Motif、Domain、Structure" in _subsection(report, "10.1")
|
||||
promoter = _subsection(report, "11.1")
|
||||
assert re.search(r"23\s*个目标基因.*3699.*TATA-box.*1460", promoter)
|
||||
assert "CAAT-box—MYB" in promoter and "FtPinG0006337300.01.T01" in promoter
|
||||
tfbs = _subsection(report, "12.1")
|
||||
assert re.search(r"24\s*条目标启动子.*3734.*AHYPO_015621.*153", tfbs)
|
||||
assert re.search(r"背景启动子为\s*0.*不能.*富集", tfbs)
|
||||
synteny = _subsection(report, "13.1")
|
||||
assert re.search(r"全基因组.*316.*3267.*8\s*条染色体", synteny)
|
||||
assert "源结果未提供家族专属复制基因对" in synteny
|
||||
assert re.search(r"Nucleus\s*37.*Cytoplasm\s*2.*Chloroplast\s*1", _subsection(report, "16.4"))
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("--mode", choices=("data", "e2e"))
|
||||
parser.add_argument("--workspace", type=Path)
|
||||
parser.add_argument("--prefix")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.workspace is not None:
|
||||
if not args.prefix:
|
||||
parser.error("--prefix is required with --workspace")
|
||||
_verify_real_job(args.workspace, args.prefix)
|
||||
print(json.dumps({"status": "passed", "workspace": str(args.workspace)}, ensure_ascii=False))
|
||||
return 0
|
||||
if args.mode is None:
|
||||
parser.error("--mode is required when --workspace is not provided")
|
||||
|
||||
output_dir = PROJECT_ROOT / "test-results/gene-family-report" / args.mode
|
||||
if output_dir.exists():
|
||||
shutil.rmtree(output_dir)
|
||||
workspace = output_dir / "job"
|
||||
fasta = PROJECT_ROOT / "tests/ft_data/SPL_Ath.fasta"
|
||||
count = _sequence_count(fasta)
|
||||
_write_identify_manifest(workspace, fasta, count)
|
||||
|
||||
generated = _run(workspace, "generate")
|
||||
report = generated.report_path.read_text(encoding="utf-8")
|
||||
manifest = json.loads(generated.manifest_path.read_text(encoding="utf-8"))
|
||||
assert generated.report_path.name == f"{PREFIX}.gene_family_report.md"
|
||||
assert generated.manifest_path.name == f"{PREFIX}.gene_family_report.manifest.json"
|
||||
assert "生成模式:候补脚本" in report
|
||||
assert f"最终成员数:{count}" in report
|
||||
assert "## 基因家族鉴定 (`identify`)" in report
|
||||
assert "(`motif`)" not in report
|
||||
assert manifest["scope_id"] == SCOPE_ID
|
||||
assert manifest["analysis_route"] == ANALYSIS_ROUTE
|
||||
assert manifest["member_set_id"] == MEMBER_SET_ID
|
||||
assert manifest["metrics"]["included_stages"] == ["identify"]
|
||||
assert set(manifest["artifacts"]) == {"gene_family_report", "gene_family_report_manifest"}
|
||||
|
||||
reopened = _run(workspace, "open")
|
||||
assert reopened.reused is True
|
||||
intent = parse_intent_from_query("打开基因家族结果报告")
|
||||
assert intent["stages"] == ["gene_family_report"]
|
||||
assert intent["params"]["reportAction"] == "open"
|
||||
|
||||
print(json.dumps({
|
||||
"status": "passed",
|
||||
"mode": args.mode,
|
||||
"sequence_count": count,
|
||||
"report": str(generated.report_path),
|
||||
}, ensure_ascii=False))
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user