feat: add gene family result reports

This commit is contained in:
wtx
2026-08-09 04:32:28 +08:00
parent 65f79207f0
commit cb122fd088
20 changed files with 3676 additions and 20 deletions
@@ -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())