658 lines
28 KiB
Python
658 lines
28 KiB
Python
import json
|
||||
|
|
from pathlib import Path
|
|||
|
|
|
|||
|
|
from gfa_core.mas.agents.interpretation.report_builder import build_claim_layers
|
|||
|
|
from gfa_core.mas.agents.interpretation_evidence import build_evidence_report
|
|||
|
|
from gfa_core.mas.agents.result_parser import ResultParserAgent
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _bundle(stage, parsed=None, evidence=None, risks=None, metrics=None, status="success"):
|
|||
|
|
return {
|
|||
|
|
"stage": stage,
|
|||
|
|
"status": status,
|
|||
|
|
"parsed": parsed or {},
|
|||
|
|
"metrics": metrics or {},
|
|||
|
|
"evidence": evidence or [
|
|||
|
|
{
|
|||
|
|
"stage": stage,
|
|||
|
|
"claim": f"{stage} evidence",
|
|||
|
|
"evidence": f"{stage} local evidence",
|
|||
|
|
"source_type": "manifest",
|
|||
|
|
"source_file": f"{stage}.manifest.json",
|
|||
|
|
"confidence": "high",
|
|||
|
|
"interpretation_level": "fact",
|
|||
|
|
}
|
|||
|
|
],
|
|||
|
|
"risks": risks or [],
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_claim_layers_generate_local_claims_and_audit():
|
|||
|
|
bundles = {
|
|||
|
|
"identify": _bundle("identify", parsed={"final_members": 12, "hmm_hits": 12}),
|
|||
|
|
"domain": _bundle("domain", parsed={"significant_domain_hits": 12, "sbp_protein_count": 12}),
|
|||
|
|
"motif": _bundle("motif", parsed={"motif_count": 10, "most_conserved_motif": {"sites": 12}}),
|
|||
|
|
"promoter": _bundle("promoter", parsed={"genes_with_elements": 12, "top_elements": [{"name": "ABRE", "count": 8}]}),
|
|||
|
|
"expression": _bundle("expression", parsed={"tables": {"gene_exp": {"rows": 12, "columns": 4}}, "top_expression_genes": [{"gene": "FtGene1"}]}),
|
|||
|
|
}
|
|||
|
|
stages = list(bundles)
|
|||
|
|
evidence_cards = [item for stage in stages for item in bundles[stage]["evidence"]]
|
|||
|
|
|
|||
|
|
layers = build_claim_layers(
|
|||
|
|
stages,
|
|||
|
|
bundles,
|
|||
|
|
evidence_cards=evidence_cards,
|
|||
|
|
risk_flags=[],
|
|||
|
|
rag_references={},
|
|||
|
|
scope={"scope_id": "scope.test"},
|
|||
|
|
prefix="Ft.SPL",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
assert layers["evidence_audit"]["schema"] == "evidence_audit_v1"
|
|||
|
|
assert layers["claims"]
|
|||
|
|
groups = {claim["claim_group"] for claim in layers["claims"]}
|
|||
|
|
assert "family_membership_confidence" in groups
|
|||
|
|
assert "protein_architecture" in groups
|
|||
|
|
assert "candidate_response_gene" in groups
|
|||
|
|
assert all(claim["local_evidence"] for claim in layers["major_claims"])
|
|||
|
|
assert all(claim.get("title") for claim in layers["major_claims"])
|
|||
|
|
assert all(claim.get("interpretation") for claim in layers["major_claims"])
|
|||
|
|
assert all(claim.get("caveat") for claim in layers["major_claims"])
|
|||
|
|
assert all(claim.get("evidence_chain") for claim in layers["major_claims"])
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_claim_critic_downgrades_high_risk_claims():
|
|||
|
|
high_risk = {"stage": "domain", "severity": "high", "message": "domain统计冲突"}
|
|||
|
|
bundles = {
|
|||
|
|
"domain": _bundle(
|
|||
|
|
"domain",
|
|||
|
|
parsed={"significant_domain_hits": 8, "sbp_protein_count": 8},
|
|||
|
|
risks=[high_risk],
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
layers = build_claim_layers(
|
|||
|
|
["domain"],
|
|||
|
|
bundles,
|
|||
|
|
evidence_cards=bundles["domain"]["evidence"],
|
|||
|
|
risk_flags=[high_risk],
|
|||
|
|
rag_references={},
|
|||
|
|
scope={},
|
|||
|
|
prefix="Ft.SPL",
|
|||
|
|
)
|
|||
|
|
claim = layers["claims"][0]
|
|||
|
|
assert claim["confidence"] == "low"
|
|||
|
|
assert any("high" in item for item in claim["limitations"])
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_build_evidence_report_includes_claim_fields(tmp_path: Path):
|
|||
|
|
job_dir = tmp_path / "job_1"
|
|||
|
|
final_dir = job_dir / "final"
|
|||
|
|
final_dir.mkdir(parents=True)
|
|||
|
|
|
|||
|
|
domtblout = final_dir / "Ft.SPL.domtblout"
|
|||
|
|
domtblout.write_text(
|
|||
|
|
"SBP PF03110.20 100 FtGene1 - 200 1e-20 100 0 1 1 1e-20 1e-20 100 0 1 100 5 104 1 105 0.98 SBP domain\n"
|
|||
|
|
"SBP PF03110.20 100 FtGene2 - 200 1e-18 95 0 1 1 1e-18 1e-18 95 0 1 100 5 104 1 105 0.98 SBP domain\n",
|
|||
|
|
encoding="utf-8",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
manifests = [
|
|||
|
|
{
|
|||
|
|
"manifest_version": "1.0",
|
|||
|
|
"stage": "identify",
|
|||
|
|
"prefix": "Ft.SPL",
|
|||
|
|
"status": "success",
|
|||
|
|
"metrics": {"final_members": 2, "total_proteins": 100, "hmm_hits": 2, "blast_hits": 2},
|
|||
|
|
"artifacts": {},
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
"manifest_version": "1.0",
|
|||
|
|
"stage": "domain",
|
|||
|
|
"prefix": "Ft.SPL",
|
|||
|
|
"status": "success",
|
|||
|
|
"metrics": {"significant_domain_hits": 2, "proteins_total": 2},
|
|||
|
|
"artifacts": {"domtblout": {"path": "final/Ft.SPL.domtblout", "type": "text"}},
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
"manifest_version": "1.0",
|
|||
|
|
"stage": "motif",
|
|||
|
|
"prefix": "Ft.SPL",
|
|||
|
|
"status": "success",
|
|||
|
|
"metrics": {"motif_count": 3, "total_sites": 6, "sequence_count": 2, "motifs": [{"id": "motif_1", "sites": 2, "width": 21}]},
|
|||
|
|
"artifacts": {},
|
|||
|
|
},
|
|||
|
|
]
|
|||
|
|
for manifest in manifests:
|
|||
|
|
(final_dir / f"Ft.SPL.{manifest['stage']}.manifest.json").write_text(
|
|||
|
|
json.dumps(manifest, ensure_ascii=False),
|
|||
|
|
encoding="utf-8",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
report = build_evidence_report(final_dir, "Ft.SPL", job_id="job_1")
|
|||
|
|
|
|||
|
|
assert report is not None
|
|||
|
|
assert report["claims"]
|
|||
|
|
assert report["major_claims"]
|
|||
|
|
assert report["evidence_audit"]["schema"] == "evidence_audit_v1"
|
|||
|
|
assert report["rag_audit"]["status"] == "not_available"
|
|||
|
|
assert "## Major Claims" in report["download_info"]["markdown"]
|
|||
|
|
assert report["key_findings"]
|
|||
|
|
assert report["scientific_discoveries"]
|
|||
|
|
assert report["scientific_discoveries"][0]["interpretation"]
|
|||
|
|
assert report["scientific_discoveries"][0]["caveat"]
|
|||
|
|
assert report["scientific_discoveries"][0]["evidence_chain"]
|
|||
|
|
assert report["evidence_cards"][0]["id"].startswith("E")
|
|||
|
|
assert all(claim.get("evidence_refs") for claim in report["major_claims"])
|
|||
|
|
assert all(item.get("evidence_refs") for item in report["scientific_discoveries"])
|
|||
|
|
assert report["agent_contribution_audit"]["result_agent"]["fact_count"] == len(report["evidence_cards"])
|
|||
|
|
assert report["agent_contribution_audit"]["discovery_agent"]["evidence_bound_claim_count"] >= 1
|
|||
|
|
assert report["agent_contribution_audit"]["rag_agent"]["claim_bound_count"] == 0
|
|||
|
|
assert report["coverage_matrix"]["schema"] == "interpretation_coverage_matrix_v1"
|
|||
|
|
assert report["coverage_matrix"]["summary"]["present_stage_count"] >= 3
|
|||
|
|
assert report["metadata"]["report_kind"] == "route_live"
|
|||
|
|
assert report["metadata"]["completed_expected_stages"]
|
|||
|
|
assert isinstance(report["metadata"]["missing_expected_stages"], list)
|
|||
|
|
structured = report["structured_result_interpretations"]
|
|||
|
|
assert structured["identify"]["result_interpretation"]
|
|||
|
|
assert structured["motif"]["interpretation_depth"] == "article_results_style_stage_interpretation"
|
|||
|
|
assert "3 个 motif" in structured["motif"]["result_interpretation"]
|
|||
|
|
assert "## Structured Result Interpretations" in report["download_info"]["markdown"]
|
|||
|
|
assert "## Scientific Discoveries" in report["download_info"]["markdown"]
|
|||
|
|
assert "## Agent Contribution Audit" in report["download_info"]["markdown"]
|
|||
|
|
assert "## Evidence Cards" in report["download_info"]["markdown"]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_claim_layers_generate_evolution_and_property_claims():
|
|||
|
|
bundles = {
|
|||
|
|
"synteny": _bundle("synteny", parsed={"family_pair_count": 2, "synteny_blocks": 316}),
|
|||
|
|
"interspecies_synteny": _bundle("interspecies_synteny", parsed={"family_ref_pair_count": 112, "alignment_blocks": 520}),
|
|||
|
|
"ortholog": _bundle("ortholog", metrics={"ortholog_pairs": 24}),
|
|||
|
|
"kaks": _bundle("kaks", parsed={"successful_pairs": 24, "mean_ka_ks": 1.2, "positive_selection_count": 3}),
|
|||
|
|
"reference_classification": _bundle(
|
|||
|
|
"reference_classification",
|
|||
|
|
metrics={"target_gene_count": 24, "confidence_counts": {"high": 20, "medium": 1, "low": 3}},
|
|||
|
|
),
|
|||
|
|
"subfamily_confirm": _bundle("subfamily_confirm", metrics={"confirmed_genes": 24, "subfamily_counts": {"I": 2, "II": 5}}),
|
|||
|
|
"pep": _bundle("pep", parsed={"protein_count": 24, "avg_length": 410, "avg_pi": 6.5, "avg_gravy": -0.2}),
|
|||
|
|
"ppi": _bundle("ppi", parsed={"genes_in_network": 11, "total_interactions": 27}),
|
|||
|
|
}
|
|||
|
|
stages = list(bundles)
|
|||
|
|
evidence_cards = [item for stage in stages for item in bundles[stage]["evidence"]]
|
|||
|
|
|
|||
|
|
layers = build_claim_layers(
|
|||
|
|
stages,
|
|||
|
|
bundles,
|
|||
|
|
evidence_cards=evidence_cards,
|
|||
|
|
risk_flags=[],
|
|||
|
|
rag_references={},
|
|||
|
|
scope={},
|
|||
|
|
prefix="Ft.SPL",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
groups = {claim["claim_group"] for claim in layers["claims"]}
|
|||
|
|
assert "evolutionary_duplication" in groups
|
|||
|
|
assert "evolutionary_conservation" in groups
|
|||
|
|
assert "selection_pressure" in groups
|
|||
|
|
assert "subfamily_assignment" in groups
|
|||
|
|
assert "protein_property_profile" in groups
|
|||
|
|
assert "interaction_network" in groups
|
|||
|
|
assert any("24" in claim["claim"] for claim in layers["claims"])
|
|||
|
|
assert any("112" in claim["claim"] for claim in layers["claims"])
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_failed_subfamily_prediction_generates_low_confidence_reference_claim():
|
|||
|
|
bundles = {
|
|||
|
|
"reference_classification": _bundle(
|
|||
|
|
"reference_classification",
|
|||
|
|
metrics={
|
|||
|
|
"classifiable_species_count": 1,
|
|||
|
|
"failed_species_count": 1,
|
|||
|
|
"per_species_count": 0,
|
|||
|
|
"errors": ["reference sequences matching subfamily table=0"],
|
|||
|
|
},
|
|||
|
|
status="failed",
|
|||
|
|
),
|
|||
|
|
}
|
|||
|
|
layers = build_claim_layers(
|
|||
|
|
["reference_classification"],
|
|||
|
|
bundles,
|
|||
|
|
evidence_cards=bundles["reference_classification"]["evidence"],
|
|||
|
|
risk_flags=[
|
|||
|
|
{
|
|||
|
|
"stage": "reference_classification",
|
|||
|
|
"severity": "high",
|
|||
|
|
"message": "reference_classification 未形成可确认亚家族分类",
|
|||
|
|
}
|
|||
|
|
],
|
|||
|
|
rag_references={},
|
|||
|
|
scope={},
|
|||
|
|
prefix="Ft.SPL",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
claim = next(item for item in layers["claims"] if item["claim_group"] == "subfamily_prediction_reference")
|
|||
|
|
assert claim["confidence"] == "low"
|
|||
|
|
assert "不能作为目标成员亚家族归属确认" in claim["claim"]
|
|||
|
|
assert "reference sequences matching subfamily table=0" in claim["claim"]
|
|||
|
|
assert claim["counter_evidence"]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_claim_level_rag_support_does_not_bind_background_to_kaks():
|
|||
|
|
bundles = {
|
|||
|
|
"kaks": _bundle("kaks", parsed={"successful_pairs": 24, "mean_ka_ks": 0.14, "positive_selection_count": 0}),
|
|||
|
|
}
|
|||
|
|
layers = build_claim_layers(
|
|||
|
|
["kaks"],
|
|||
|
|
bundles,
|
|||
|
|
evidence_cards=bundles["kaks"]["evidence"],
|
|||
|
|
risk_flags=[],
|
|||
|
|
rag_references={
|
|||
|
|
"deep_interpretation": {
|
|||
|
|
"schema": "deep_interpretation_rag_references_v1",
|
|||
|
|
"references": [
|
|||
|
|
{
|
|||
|
|
"claim_id": "selection_pressure.001",
|
|||
|
|
"claim_group": "selection_pressure",
|
|||
|
|
"support_level": "biological_background",
|
|||
|
|
"title": "SPL genes regulate plant development",
|
|||
|
|
"evidence": "SPL/SBP family plays roles in multiple developmental processes.",
|
|||
|
|
}
|
|||
|
|
],
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
scope={},
|
|||
|
|
prefix="Ft.SPL",
|
|||
|
|
)
|
|||
|
|
claim = next(item for item in layers["claims"] if item["claim_group"] == "selection_pressure")
|
|||
|
|
assert claim["rag_binding_status"] == "biological_background"
|
|||
|
|
assert claim["rag_interpretation"]["biological_context"]
|
|||
|
|
assert claim["rag_interpretation"]["claim_support"] == []
|
|||
|
|
assert layers["rag_audit"]["claim_binding_count"] == 0
|
|||
|
|
assert layers["rag_audit"]["biological_context_count"] == 1
|
|||
|
|
assert layers["rag_audit"]["status"] == "background_only"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_claim_level_rag_support_binds_method_support_to_kaks():
|
|||
|
|
bundles = {
|
|||
|
|
"kaks": _bundle("kaks", parsed={"successful_pairs": 24, "mean_ka_ks": 0.14, "positive_selection_count": 0}),
|
|||
|
|
}
|
|||
|
|
layers = build_claim_layers(
|
|||
|
|
["kaks"],
|
|||
|
|
bundles,
|
|||
|
|
evidence_cards=bundles["kaks"]["evidence"],
|
|||
|
|
risk_flags=[],
|
|||
|
|
rag_references={
|
|||
|
|
"deep_interpretation": {
|
|||
|
|
"schema": "deep_interpretation_rag_references_v1",
|
|||
|
|
"references": [
|
|||
|
|
{
|
|||
|
|
"claim_id": "selection_pressure.001",
|
|||
|
|
"claim_group": "selection_pressure",
|
|||
|
|
"support_level": "method_support",
|
|||
|
|
"title": "dN/dS and Ka/Ks estimate selection pressure",
|
|||
|
|
"evidence": "Ka/Ks and dN/dS are used to screen purifying or positive selection in plant orthologs.",
|
|||
|
|
}
|
|||
|
|
],
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
scope={},
|
|||
|
|
prefix="Ft.SPL",
|
|||
|
|
)
|
|||
|
|
claim = next(item for item in layers["claims"] if item["claim_group"] == "selection_pressure")
|
|||
|
|
assert claim["rag_binding_status"] == "method_support"
|
|||
|
|
assert claim["rag_interpretation"]["method_reference"]
|
|||
|
|
assert claim["rag_interpretation"]["claim_support"] == []
|
|||
|
|
assert layers["rag_audit"]["claim_binding_count"] == 0
|
|||
|
|
assert layers["rag_audit"]["method_reference_count"] == 1
|
|||
|
|
assert layers["rag_audit"]["status"] == "method_support"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_claim_level_rag_support_counts_only_claim_bound_references():
|
|||
|
|
bundles = {
|
|||
|
|
"kaks": _bundle("kaks", parsed={"successful_pairs": 24, "mean_ka_ks": 0.14, "positive_selection_count": 0}),
|
|||
|
|
}
|
|||
|
|
layers = build_claim_layers(
|
|||
|
|
["kaks"],
|
|||
|
|
bundles,
|
|||
|
|
evidence_cards=bundles["kaks"]["evidence"],
|
|||
|
|
risk_flags=[],
|
|||
|
|
rag_references={
|
|||
|
|
"deep_interpretation": {
|
|||
|
|
"schema": "deep_interpretation_rag_references_v1",
|
|||
|
|
"references": [
|
|||
|
|
{
|
|||
|
|
"claim_id": "selection_pressure.001",
|
|||
|
|
"claim_group": "selection_pressure",
|
|||
|
|
"support_level": "claim_bound",
|
|||
|
|
"title": "Plant gene family Ka/Ks result",
|
|||
|
|
"evidence": "The observed low Ka/Ks values support purifying selection for this family.",
|
|||
|
|
}
|
|||
|
|
],
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
scope={},
|
|||
|
|
prefix="Ft.SPL",
|
|||
|
|
)
|
|||
|
|
claim = next(item for item in layers["claims"] if item["claim_group"] == "selection_pressure")
|
|||
|
|
assert claim["rag_binding_status"] == "claim_bound"
|
|||
|
|
assert claim["rag_interpretation"]["claim_support"]
|
|||
|
|
assert layers["rag_audit"]["claim_binding_count"] == 1
|
|||
|
|
assert layers["rag_audit"]["status"] == "claim_bound"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_evidence_report_prefers_complete_reference_classification_manifest(tmp_path: Path):
|
|||
|
|
final_dir = tmp_path / "final"
|
|||
|
|
final_dir.mkdir()
|
|||
|
|
scope_id = "SPL.target-Ft.ref-At.target_plus_reference"
|
|||
|
|
member_set_id = "SPL.target-Ft.ref-At.target_plus_reference:subfamily_prediction:all_members"
|
|||
|
|
common = {
|
|||
|
|
"stage": "reference_classification",
|
|||
|
|
"status": "success",
|
|||
|
|
"prefix": "Ft.SPL",
|
|||
|
|
"analysis_route": "subfamily_prediction",
|
|||
|
|
"scope_id": scope_id,
|
|||
|
|
"member_set_id": member_set_id,
|
|||
|
|
}
|
|||
|
|
light = dict(common)
|
|||
|
|
light["metrics"] = {"target_gene_count": 24}
|
|||
|
|
light["artifacts"] = {"report": {"path": "final/light.md"}}
|
|||
|
|
complete = dict(common)
|
|||
|
|
complete["metrics"] = {
|
|||
|
|
"target_gene_count": 24,
|
|||
|
|
"reference_gene_count": 16,
|
|||
|
|
"assigned_subfamily_counts": {"I": 2, "II": 5, "III": 2},
|
|||
|
|
"confidence_counts": {"high": 16, "medium": 5, "low": 3},
|
|||
|
|
}
|
|||
|
|
complete["artifacts"] = {
|
|||
|
|
"assignment_table": {"path": "final/full.assignment.tsv"},
|
|||
|
|
"subfamily_file": {"path": "final/full.subfamily.tsv"},
|
|||
|
|
"confidence_table": {"path": "final/full.confidence.tsv"},
|
|||
|
|
}
|
|||
|
|
(final_dir / "Ft.SPL.target_plus_reference.reference_classification.manifest.json").write_text(json.dumps(light), encoding="utf-8")
|
|||
|
|
(final_dir / "Ft.SPL.SPL.target-Ft.ref-At.target_plus_reference.reference_classification.manifest.json").write_text(json.dumps(complete), encoding="utf-8")
|
|||
|
|
|
|||
|
|
report = build_evidence_report(
|
|||
|
|
final_dir,
|
|||
|
|
"Ft.SPL",
|
|||
|
|
current_stage="reference_classification",
|
|||
|
|
scope_id=scope_id,
|
|||
|
|
analysis_route="subfamily_prediction",
|
|||
|
|
member_set_id=member_set_id,
|
|||
|
|
include_stages=["reference_classification"],
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
assert report is not None
|
|||
|
|
assert report["metadata"]["report_kind"] == "route_live"
|
|||
|
|
assert report["metadata"]["report_mode"] == "subfamily_prediction_report"
|
|||
|
|
assert "亚家族分类预测" in report["metadata"]["report_mode_label"]
|
|||
|
|
assert "不得直接写成家族进化" in report["metadata"]["interpretation_boundary"]
|
|||
|
|
assert report["metadata"]["expected_stages"][-1] == "reference_classification"
|
|||
|
|
assert report["metadata"]["is_final"] is False
|
|||
|
|
claim = next(item for item in report["major_claims"] if item["claim_group"] == "subfamily_assignment")
|
|||
|
|
assert "I:2" in claim["claim"]
|
|||
|
|
assert "high:16" in claim["claim"]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_integrated_target_plus_reference_report_does_not_require_reference_classification(tmp_path: Path):
|
|||
|
|
final_dir = tmp_path / "final"
|
|||
|
|
final_dir.mkdir()
|
|||
|
|
scope_id = "SPL.target-Ft.ref-At.target_plus_reference"
|
|||
|
|
for stage in ["identify", "phylogeny", "domain", "motif", "structure", "combo", "interspecies_synteny"]:
|
|||
|
|
manifest = {
|
|||
|
|
"stage": stage,
|
|||
|
|
"status": "success",
|
|||
|
|
"prefix": "Ft.SPL",
|
|||
|
|
"analysis_route": "target_plus_reference",
|
|||
|
|
"scope_id": scope_id,
|
|||
|
|
"metrics": {},
|
|||
|
|
"artifacts": {},
|
|||
|
|
}
|
|||
|
|
if stage == "identify":
|
|||
|
|
manifest["metrics"] = {"final_members": 24}
|
|||
|
|
if stage == "interspecies_synteny":
|
|||
|
|
manifest["metrics"] = {"pair_success_count": 1}
|
|||
|
|
(final_dir / f"Ft.SPL.{scope_id}.{stage}.manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
|
|||
|
|
|
|||
|
|
report = build_evidence_report(
|
|||
|
|
final_dir,
|
|||
|
|
"Ft.SPL",
|
|||
|
|
current_stage="interspecies_synteny",
|
|||
|
|
scope_id=scope_id,
|
|||
|
|
analysis_route="target_plus_reference",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
assert report is not None
|
|||
|
|
assert report["metadata"]["report_mode"] == "gene_family_integrated_report"
|
|||
|
|
assert "reference_classification" not in report["metadata"]["expected_stages"]
|
|||
|
|
assert "reference_classification" not in report["metadata"]["missing_expected_stages"]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_rnaseq_report_uses_rnaseq_mode(tmp_path: Path):
|
|||
|
|
final_dir = tmp_path / "final"
|
|||
|
|
final_dir.mkdir()
|
|||
|
|
for stage, metrics in {
|
|||
|
|
"rnaseq_qc": {"total_samples": 2},
|
|||
|
|
"rnaseq_de": {"de_genes_total": 12, "de_genes_up": 7, "de_genes_down": 5},
|
|||
|
|
}.items():
|
|||
|
|
manifest = {
|
|||
|
|
"stage": stage,
|
|||
|
|
"status": "success",
|
|||
|
|
"prefix": "Os",
|
|||
|
|
"metrics": metrics,
|
|||
|
|
"artifacts": {},
|
|||
|
|
}
|
|||
|
|
if stage == "rnaseq_de":
|
|||
|
|
manifest["rnaseq_scope_id"] = "Os.rnaseq.test"
|
|||
|
|
(final_dir / f"Os.{stage}.manifest.json").write_text(json.dumps(manifest), encoding="utf-8")
|
|||
|
|
|
|||
|
|
report = build_evidence_report(
|
|||
|
|
final_dir,
|
|||
|
|
"Os",
|
|||
|
|
current_stage="rnaseq_de",
|
|||
|
|
job_id="job_rna",
|
|||
|
|
rnaseq_scope_id="Os.rnaseq.test",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
assert report is not None
|
|||
|
|
assert report["metadata"]["report_mode"] == "rnaseq_integrated_report"
|
|||
|
|
assert report["metadata"]["required_stage_policy"] == "rnaseq_integrated_evidence"
|
|||
|
|
assert "rnaseq_qc" in report["metadata"]["included_stages"]
|
|||
|
|
assert "rnaseq_de" in report["metadata"]["included_stages"]
|
|||
|
|
assert "RNA-seq" in report["metadata"]["report_mode_label"]
|
|||
|
|
assert "调控因果" in report["metadata"]["interpretation_boundary"]
|
|||
|
|
assert "RNA-seq 质控阶段覆盖 2 个样本" in report["structured_result_interpretations"]["rnaseq_qc"]["result_interpretation"]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_grouped_interpretation_downgrades_high_gap_and_partial_motif(tmp_path: Path):
|
|||
|
|
final_dir = tmp_path / "final"
|
|||
|
|
final_dir.mkdir()
|
|||
|
|
manifests = [
|
|||
|
|
{
|
|||
|
|
"stage": "phylogeny",
|
|||
|
|
"status": "success",
|
|||
|
|
"prefix": "Si.HDZIP",
|
|||
|
|
"scope_id": "HDZIP.target-Si.ref-At.target_plus_reference",
|
|||
|
|
"analysis_route": "multi_species",
|
|||
|
|
"metrics": {
|
|||
|
|
"sequences_count": 127,
|
|||
|
|
"tree_tip_count": 127,
|
|||
|
|
"gap_percentage": 85.59,
|
|||
|
|
"tree_model": "Q.INSECT+F+R5",
|
|||
|
|
"scope_species_counts": {"Si": 79, "At": 48},
|
|||
|
|
},
|
|||
|
|
"artifacts": {},
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
"stage": "domain",
|
|||
|
|
"status": "success",
|
|||
|
|
"prefix": "Si.HDZIP",
|
|||
|
|
"scope_id": "HDZIP.target-Si.ref-At.target_plus_reference",
|
|||
|
|
"analysis_route": "subfamily_evidence",
|
|||
|
|
"metrics": {
|
|||
|
|
"proteins_total": 127,
|
|||
|
|
"proteins_with_domains": 127,
|
|||
|
|
"significant_domain_hits": 273,
|
|||
|
|
"top_domains": [
|
|||
|
|
{"domain": "Homeodomain", "count": 125},
|
|||
|
|
{"domain": "HALZ", "count": 45},
|
|||
|
|
{"domain": "START", "count": 40},
|
|||
|
|
],
|
|||
|
|
},
|
|||
|
|
"artifacts": {},
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
"stage": "motif",
|
|||
|
|
"status": "partial_success",
|
|||
|
|
"prefix": "Si.HDZIP",
|
|||
|
|
"scope_id": "HDZIP.target-Si.ref-At.target_plus_reference",
|
|||
|
|
"analysis_route": "subfamily_evidence",
|
|||
|
|
"metrics": {
|
|||
|
|
"sequence_count": 127,
|
|||
|
|
"motif_count": 10,
|
|||
|
|
"total_sites": 215,
|
|||
|
|
"motifs": [{"id": "motif_1", "sites": 27, "width": 82}],
|
|||
|
|
},
|
|||
|
|
"parameters": {"stage_status": {"meme": True, "summary": True, "seqlogo": False, "tree_motif": True}},
|
|||
|
|
"artifacts": {},
|
|||
|
|
},
|
|||
|
|
{
|
|||
|
|
"stage": "structure",
|
|||
|
|
"status": "success",
|
|||
|
|
"prefix": "Si.HDZIP",
|
|||
|
|
"scope_id": "HDZIP.target-Si.ref-At.target_plus_reference",
|
|||
|
|
"analysis_route": "subfamily_evidence",
|
|||
|
|
"metrics": {"genes_total": 127, "genes_with_structure": 127, "avg_exon_count_per_gene": 4.2},
|
|||
|
|
"artifacts": {},
|
|||
|
|
},
|
|||
|
|
]
|
|||
|
|
for manifest in manifests:
|
|||
|
|
manifest["manifest_version"] = "1.0"
|
|||
|
|
(final_dir / f"Si.HDZIP.{manifest['stage']}.manifest.json").write_text(
|
|||
|
|
json.dumps(manifest, ensure_ascii=False),
|
|||
|
|
encoding="utf-8",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
report = build_evidence_report(
|
|||
|
|
final_dir,
|
|||
|
|
"Si.HDZIP",
|
|||
|
|
current_stage="structure",
|
|||
|
|
scope_id="HDZIP.target-Si.ref-At.target_plus_reference",
|
|||
|
|
analysis_route="target_plus_reference",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
assert report is not None
|
|||
|
|
structured = report["structured_result_interpretations"]
|
|||
|
|
assert structured["phylogeny"]["schema"] == "structured_result_interpretation_v1"
|
|||
|
|
assert structured["phylogeny"]["stage_group"] == "classification_structure_support"
|
|||
|
|
assert structured["phylogeny"]["interpretation_level"] == "descriptive_only"
|
|||
|
|
assert any("gap比例较高" in reason for reason in structured["phylogeny"]["downgrade_reasons"])
|
|||
|
|
assert structured["motif"]["interpretation_level"] == "descriptive_only"
|
|||
|
|
assert any("partial_success" in reason for reason in structured["motif"]["downgrade_reasons"])
|
|||
|
|
assert structured["motif"]["missing_slots"]
|
|||
|
|
grouped = report["grouped_result_interpretations"]["classification_structure_support"]
|
|||
|
|
assert grouped["schema"] == "grouped_result_interpretation_v1"
|
|||
|
|
assert grouped["readiness"] == "descriptive_only"
|
|||
|
|
assert "127 条序列" in grouped["result_interpretation"]
|
|||
|
|
assert "Homeodomain" in grouped["result_interpretation"]
|
|||
|
|
assert "强拓扑结论" in grouped["boundary"]
|
|||
|
|
assert "## Grouped Results Interpretation" in report["download_info"]["markdown"]
|
|||
|
|
assert report["evidence_bundle"]["discovery_agent"]["grouped_result_interpretations"]["classification_structure_support"]["readiness"] == "descriptive_only"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_interspecies_synteny_uses_pairwise_collinearity_artifact(tmp_path: Path):
|
|||
|
|
job_dir = tmp_path / "job_1"
|
|||
|
|
final_dir = job_dir / "final"
|
|||
|
|
analysis_dir = job_dir / "interspecies_synteny" / "analysis"
|
|||
|
|
final_dir.mkdir(parents=True)
|
|||
|
|
analysis_dir.mkdir(parents=True)
|
|||
|
|
family_fasta = final_dir / "Ft.SPL.final_members.pep.fa"
|
|||
|
|
family_fasta.write_text(">FtPinG0000010000.01.T01\nMA\n", encoding="utf-8")
|
|||
|
|
ref_fasta = analysis_dir / "Ft.SPL.selected_ref.family.pep.fa"
|
|||
|
|
ref_fasta.write_text(">AT1G01010.1\nMA\n", encoding="utf-8")
|
|||
|
|
collinearity = analysis_dir / "Ft.At.collinearity"
|
|||
|
|
collinearity.write_text(
|
|||
|
|
"## Alignment 0: score=100 e_value=1e-10 N=1 1&Ft1 plus\n"
|
|||
|
|
" 0- 0:\tAT1G01010.1\tFtPinG0000010000.01.T01\t1e-50\n",
|
|||
|
|
encoding="utf-8",
|
|||
|
|
)
|
|||
|
|
identify_manifest = {
|
|||
|
|
"stage": "identify",
|
|||
|
|
"status": "success",
|
|||
|
|
"prefix": "Ft.SPL",
|
|||
|
|
"metrics": {"final_members": 1},
|
|||
|
|
"artifacts": {"final_members": {"path": "final/Ft.SPL.final_members.pep.fa"}},
|
|||
|
|
}
|
|||
|
|
inter_manifest = {
|
|||
|
|
"stage": "interspecies_synteny",
|
|||
|
|
"status": "success",
|
|||
|
|
"prefix": "Ft.SPL",
|
|||
|
|
"analysis_route": "multi_species",
|
|||
|
|
"metrics": {
|
|||
|
|
"pairwise_synteny": [
|
|||
|
|
{
|
|||
|
|
"pair": "Ft.At",
|
|||
|
|
"anchor_count": 1,
|
|||
|
|
"family_highlight_links": 1,
|
|||
|
|
"marked_block_count": 1,
|
|||
|
|
}
|
|||
|
|
]
|
|||
|
|
},
|
|||
|
|
"parameters": {"ref_protein_spl": str(ref_fasta)},
|
|||
|
|
"artifacts": {"Ft.At_collinearity": {"full_path": str(collinearity)}},
|
|||
|
|
}
|
|||
|
|
(final_dir / "Ft.SPL.identify.manifest.json").write_text(json.dumps(identify_manifest), encoding="utf-8")
|
|||
|
|
(final_dir / "Ft.SPL.interspecies_synteny.manifest.json").write_text(json.dumps(inter_manifest), encoding="utf-8")
|
|||
|
|
|
|||
|
|
report = build_evidence_report(
|
|||
|
|
final_dir,
|
|||
|
|
"Ft.SPL",
|
|||
|
|
current_stage="interspecies_synteny",
|
|||
|
|
analysis_route="multi_species",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
assert report is not None
|
|||
|
|
review = next(item for item in report["stage_reviews"] if item["stage"] == "interspecies_synteny")
|
|||
|
|
evidence = next(item for item in report["evidence_cards"] if item["stage"] == "interspecies_synteny" and item["claim"] == "种间共线性原始解析")
|
|||
|
|
assert "全基因组种间block为1" in ";".join(review["key_points"])
|
|||
|
|
assert "1对目标-参考家族共线性" in report["stage_flow"]["stages"][-1]["key_result"]
|
|||
|
|
assert report["source_summary"]["high_risk_count"] == 0
|
|||
|
|
assert "图中家族高亮连线1条" in evidence["evidence"]
|
|||
|
|
|
|||
|
|
|
|||
|
|
def test_interpretation_index_uses_report_metadata(tmp_path: Path):
|
|||
|
|
workspace_dir = tmp_path / "final"
|
|||
|
|
workspace_dir.mkdir()
|
|||
|
|
manifest = workspace_dir / "Ft.SPL.identify.manifest.json"
|
|||
|
|
manifest.write_text(json.dumps({"stage": "identify", "status": "success"}), encoding="utf-8")
|
|||
|
|
report = {
|
|||
|
|
"metadata": {
|
|||
|
|
"prefix": "Ft.SPL",
|
|||
|
|
"scope_id": "SPL.target-Ft.target_only",
|
|||
|
|
"analysis_route": "single_species",
|
|||
|
|
"included_stages": ["identify", "pep"],
|
|||
|
|
"stage_count": 2,
|
|||
|
|
"generated_at": "2026-06-23T02:10:00",
|
|||
|
|
},
|
|||
|
|
"source_summary": {"claim_count": 4, "major_claim_count": 3},
|
|||
|
|
"evidence_bundle": {
|
|||
|
|
"result_agent": {
|
|||
|
|
"stage_status": [{"stage": "identify", "manifest_path": str(manifest)}]
|
|||
|
|
}
|
|||
|
|
},
|
|||
|
|
"claims": [1, 2, 3, 4],
|
|||
|
|
"major_claims": [1, 2, 3],
|
|||
|
|
}
|
|||
|
|
agent = ResultParserAgent(None)
|
|||
|
|
identity = agent._interpretation_identity("Ft.SPL", report)
|
|||
|
|
cache_path = workspace_dir / agent._interpretation_cache_name(identity)
|
|||
|
|
cache_path.write_text(json.dumps(report), encoding="utf-8")
|
|||
|
|
|
|||
|
|
agent._update_interpretation_index(workspace_dir, cache_path, identity, report)
|
|||
|
|
|
|||
|
|
index = json.loads((workspace_dir / "interpretation.index.json").read_text(encoding="utf-8"))
|
|||
|
|
entry = index["entries"][0]
|
|||
|
|
assert entry["stage_count"] == 2
|
|||
|
|
assert entry["included_stages"] == ["identify", "pep"]
|
|||
|
|
assert entry["claim_count"] == 4
|
|||
|
|
assert entry["major_claim_count"] == 3
|
|||
|
|
assert entry["analysis_route"] == "single_species"
|