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"