Files
PlantAI-dev/tests/unit/test_gene_family_report.py

845 lines
34 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
from __future__ import annotations
import json
from pathlib import Path
import re
import pytest
import gfa_core.gene_family_report as report_module
from gfa_core.gene_family_report import (
NoSuccessfulGeneFamilyEvidence,
generate_gene_family_report,
)
def _write_manifest(
workspace: Path,
*,
prefix: str,
stage: str,
status: str = "success",
scope_id: str = "scope-a",
analysis_route: str = "post_subfamily_confirm",
member_set_id: str = "members-a",
metric_value: int = 1,
) -> Path:
final_dir = workspace / "final"
final_dir.mkdir(parents=True, exist_ok=True)
token = f"{scope_id}.{analysis_route}.{member_set_id}"
table_path = final_dir / f"{prefix}.{token}.{stage}.tsv"
table_path.write_text(f"gene\tvalue\n{scope_id}_gene\t{metric_value}\n", encoding="utf-8")
manifest_path = final_dir / f"{prefix}.{token}.{stage}.manifest.json"
manifest = {
"manifest_version": "1.0",
"stage": stage,
"status": status,
"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": metric_value},
"artifacts": {
"result_table": {
"path": f"final/{table_path.name}",
"full_path": str(table_path),
"type": "table",
}
},
}
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8")
return manifest_path
def _generate(
workspace: Path,
*,
expected_stages: list[str],
stage_statuses: dict[str, str],
use_model: bool = False,
action: str = "generate",
):
return generate_gene_family_report(
workspace,
"Ft.SPL",
expected_stages=expected_stages,
stage_statuses=stage_statuses,
scope_id="scope-a",
analysis_route="post_subfamily_confirm",
member_set_id="members-a",
use_model=use_model,
action=action,
job_id="job-report",
)
def _stage_section(markdown: str, stage: str) -> str:
heading = re.search(rf"(?m)^## (?!#).*?\(`{re.escape(stage)}`\)", markdown)
if heading is None:
raise AssertionError(f"missing report chapter for {stage}")
next_heading = re.search(r"(?m)^## (?!#)", markdown[heading.end():])
end = heading.end() + next_heading.start() if next_heading else len(markdown)
return markdown[heading.start():end]
def _subsection(markdown: str, number: str) -> str:
heading = re.search(rf"(?m)^### {re.escape(number)}(?:\s|$)", markdown)
if heading is None:
raise AssertionError(f"missing report subsection {number}")
next_heading = re.search(r"(?m)^#{2,3} ", markdown[heading.end():])
end = heading.end() + next_heading.start() if next_heading else len(markdown)
return markdown[heading.start():end]
def _valid_api_arrangement(payload: dict, *, reverse: bool = False) -> dict:
sections = []
for section in payload["sections"]:
sentence_ids = [sentence["sentence_id"] for sentence in section["sentences"]]
if reverse:
sentence_ids.reverse()
sections.append(
{
"section_id": section["section_id"],
"paragraphs": [
{
"sentence_ids": sentence_ids,
"connector_ids": ["also"] * max(0, len(sentence_ids) - 1),
}
],
}
)
return {"sections": sections}
def test_report_uses_only_planned_stages_and_current_identity(tmp_path: Path) -> None:
workspace = tmp_path / "job"
_write_manifest(workspace, prefix="Ft.SPL", stage="identify", metric_value=3)
other = _write_manifest(
workspace,
prefix="Ft.SPL",
stage="identify",
scope_id="scope-b",
analysis_route="target_only",
member_set_id="members-b",
metric_value=99,
)
result = _generate(
workspace,
expected_stages=["identify"],
stage_statuses={"identify": "success"},
)
report_text = result.report_path.read_text(encoding="utf-8")
assert "最终成员数:3" in report_text
assert other.name not in report_text
generated = {path.name for path in (workspace / "final").glob("Ft.SPL.gene_family_report.*")}
assert generated == {
"Ft.SPL.gene_family_report.md",
"Ft.SPL.gene_family_report.manifest.json",
}
def test_report_renders_template_subsections_for_only_planned_stages(tmp_path: Path) -> None:
workspace = tmp_path / "job"
for stage in ("identify", "motif", "pep"):
_write_manifest(workspace, prefix="Ft.SPL", stage=stage)
result = _generate(
workspace,
expected_stages=["identify", "motif", "pep"],
stage_statuses={"identify": "success", "motif": "success", "pep": "success"},
)
markdown = result.report_path.read_text(encoding="utf-8")
expected_subsections = {
"identify": "### 4.3 最终成员数量与命名",
"motif": "### 7.2 Motif 数量与出现频率",
"pep": "### 16.1 蛋白理化性质分布图",
}
for stage, subsection in expected_subsections.items():
assert subsection in _stage_section(markdown, stage)
assert "(`synteny`)" not in markdown
assert "【填写】" not in markdown
def test_report_renders_nested_motif_metrics_without_placeholder_values(tmp_path: Path) -> None:
workspace = tmp_path / "job"
manifest_path = _write_manifest(workspace, prefix="Ft.SPL", stage="motif")
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest["metrics"] = {
"motif_count": 10,
"total_sites": 105,
"motifs": [
{"width": width}
for width in (78, 76, 80, 113, 57, 29, 52, 138, 27, 29)
],
}
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8")
result = _generate(
workspace,
expected_stages=["motif"],
stage_statuses={"motif": "success"},
)
motif_section = _stage_section(result.report_path.read_text(encoding="utf-8"), "motif")
assert re.search(r"识别\s*10\s*个\s*motif", motif_section)
assert re.search(r"总位点数为\s*105", motif_section)
assert re.search(r"宽度范围为\s*27–138", motif_section)
assert "未解析" not in motif_section
assert "N/A" not in motif_section
assert "None" not in motif_section
def test_identify_uses_real_manifest_member_count_aliases(tmp_path: Path) -> None:
workspace = tmp_path / "job"
manifest_path = _write_manifest(workspace, prefix="Ft.SPL", stage="identify")
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest["metrics"] = {"identified_members_count": 24, "final_members": 24}
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8")
result = _generate(
workspace,
expected_stages=["identify"],
stage_statuses={"identify": "success"},
)
section = _stage_section(result.report_path.read_text(encoding="utf-8"), "identify")
assert "最终成员数:24" in section
assert re.search(r"形成\s*24\s*个\s*SPL家族", section)
assert "源结果未提供此指标" not in section
def test_phylogeny_renders_real_manifest_schema_with_explicit_labels(tmp_path: Path) -> None:
workspace = tmp_path / "job"
manifest_path = _write_manifest(workspace, prefix="Ft.SPL", stage="phylogeny")
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest["metrics"] = {"sequences_count": 40, "msa_length": 1449}
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8")
result = _generate(
workspace,
expected_stages=["phylogeny"],
stage_statuses={"phylogeny": "success"},
)
section = _stage_section(result.report_path.read_text(encoding="utf-8"), "phylogeny")
assert "序列数:40" in section
assert "多序列比对长度:1449" in section
assert re.search(r"使用\s*40\s*条序列.*比对长度为\s*1449", section)
def test_interspecies_synteny_reads_only_named_real_schema_artifacts(tmp_path: Path) -> None:
workspace = tmp_path / "job"
manifest_path = _write_manifest(workspace, prefix="Ft.SPL", stage="interspecies_synteny")
final_dir = workspace / "final"
gene_to_species = final_dir / "Ft.SPL.gene_to_species.tsv"
gene_to_species.write_text(
"gene_id\tspecies\tgroup\n"
"FtGene1.01.T01\tFt\ttarget\n"
"FtGene2.01.T01\tFt\ttarget\n"
"AT1G00010\tAT\tref\n"
"AT1G00020\tAT\tref\n",
encoding="utf-8",
)
collinearity = final_dir / "Ft.At.collinearity"
lines = []
for index in range(521):
lines.append(f"## Alignment {index}: score=1 e_value=1e-10 N=1 Ft&At plus")
if index == 0:
lines.append(" 0- 0:\tAT1G00010.1\tFtGene1.01.T01\t1e-20")
elif index == 1:
lines.append(" 1- 0:\tAT1G00020.1\tFtGene2.01.T01\t1e-20")
collinearity.write_text("\n".join(lines) + "\n", encoding="utf-8")
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest["metrics"] = {"pair_success_count": 1, "pair_total_count": 1}
manifest["artifacts"] = {
"gene_to_species": {"path": f"final/{gene_to_species.name}", "type": "tsv"},
"Ft.At_collinearity": {"path": f"final/{collinearity.name}", "type": "data"},
}
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8")
result = _generate(
workspace,
expected_stages=["interspecies_synteny"],
stage_statuses={"interspecies_synteny": "success"},
)
section = _stage_section(result.report_path.read_text(encoding="utf-8"), "interspecies_synteny")
assert "共线性区块数:521" in section
assert "家族成员目标—参考配对数:2" in section
assert re.search(r"全基因组\s*block\s*521\s*个.*目标-参考家族.*?2\s*对", section)
def test_pep_renders_real_manifest_task_summary_with_explicit_labels(tmp_path: Path) -> None:
workspace = tmp_path / "job"
manifest_path = _write_manifest(workspace, prefix="Ft.SPL", stage="pep")
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest["metrics"] = {"protein_count": 40, "tasks_success": 3, "tasks_total": 3}
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8")
result = _generate(
workspace,
expected_stages=["pep"],
stage_statuses={"pep": "success"},
)
section = _stage_section(result.report_path.read_text(encoding="utf-8"), "pep")
assert "蛋白数:40" in section
assert "子任务成功数:3/3" in section
assert re.search(r"覆盖\s*40\s*个蛋白,子任务成功数为\s*3/3", section)
def test_report_has_no_empty_third_level_subsections_or_repeated_generic_interpretation(
tmp_path: Path,
) -> None:
workspace = tmp_path / "job"
for stage in ("identify", "phylogeny", "motif"):
manifest_path = _write_manifest(workspace, prefix="Ft.SPL", stage=stage)
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
if stage == "phylogeny":
manifest["metrics"] = {"sequences_count": 40, "msa_length": 1449}
elif stage == "motif":
manifest["metrics"] = {
"sequence_count": 40,
"motif_count": 10,
"total_sites": 105,
"motifs": [{"width": 27}, {"width": 138}],
}
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8")
result = _generate(
workspace,
expected_stages=["identify", "phylogeny", "motif"],
stage_statuses={stage: "success" for stage in ("identify", "phylogeny", "motif")},
)
markdown = result.report_path.read_text(encoding="utf-8")
for stage in ("identify", "phylogeny", "motif"):
section = _stage_section(markdown, stage)
chunks = re.split(r"(?m)(?=^### )", section)[1:]
assert chunks
for chunk in chunks:
body = "\n".join(chunk.splitlines()[1:]).strip()
assert body, f"empty subsection: {chunk.splitlines()[0]}"
assert markdown.count("本节解释仅限上述可追溯结果,不据此断言真实功能或因果关系。") <= 1
assert markdown.count("结果范围受当前输入、参数和源文件完整性限制。") <= 1
def test_front_summary_contains_project_numbers_in_six_to_ten_sentences(tmp_path: Path) -> None:
workspace = tmp_path / "job"
identify_path = _write_manifest(workspace, prefix="Ft.SPL", stage="identify")
identify = json.loads(identify_path.read_text(encoding="utf-8"))
identify["metrics"] = {"identified_members_count": 24, "final_members": 24}
identify_path.write_text(json.dumps(identify, ensure_ascii=False), encoding="utf-8")
phylogeny_path = _write_manifest(workspace, prefix="Ft.SPL", stage="phylogeny")
phylogeny = json.loads(phylogeny_path.read_text(encoding="utf-8"))
phylogeny["metrics"] = {"sequences_count": 40, "msa_length": 1449}
phylogeny_path.write_text(json.dumps(phylogeny, ensure_ascii=False), encoding="utf-8")
result = _generate(
workspace,
expected_stages=["identify", "phylogeny"],
stage_statuses={"identify": "success", "phylogeny": "success"},
)
summary = _subsection(result.report_path.read_text(encoding="utf-8"), "1.3")
assert re.search(r"目标物种.*24.*家族成员", summary)
assert re.search(r"目标.*参考.*40.*1449", summary)
assert 6 <= summary.count("。") <= 10
def test_stage_specific_key_facts_land_in_their_semantic_subsections(tmp_path: Path) -> None:
workspace = tmp_path / "job"
final_dir = workspace / "final"
stages = [
"reference_classification", "domain", "structure", "combo",
"promoter", "tfbs", "synteny",
]
manifests = {stage: _write_manifest(workspace, prefix="Ft.SPL", stage=stage) for stage in stages}
assignment = final_dir / "Ft.SPL.reference_classification.assignment.tsv"
assignment.write_text(
"target_gene\tassigned_subfamily\tconfidence\n"
+ "\n".join(
f"FtGene{i}\t{'I' if i < 2 else 'II'}\t{confidence}"
for i, confidence in enumerate(("high", "high", "medium", "low"), start=1)
)
+ "\n",
encoding="utf-8",
)
reference = json.loads(manifests["reference_classification"].read_text(encoding="utf-8"))
reference["metrics"] = {"target_gene_count": 4, "subfamily_rows": 6}
reference["artifacts"] = {"assignment_table": {"path": f"final/{assignment.name}", "type": "tsv"}}
manifests["reference_classification"].write_text(json.dumps(reference), encoding="utf-8")
domain = json.loads(manifests["domain"].read_text(encoding="utf-8"))
domain["metrics"] = {
"proteins_total": 40, "proteins_with_domains": 40, "domain_coverage_rate": 1.0,
"top_domains": [["SBP", 40], ["Ig_SPL7", 12]],
}
manifests["domain"].write_text(json.dumps(domain), encoding="utf-8")
structure = json.loads(manifests["structure"].read_text(encoding="utf-8"))
structure["metrics"] = {
"genes_total": 40, "genes_with_structure": 24, "structure_coverage_rate": 0.6,
"long_gene_outlier_count": 1,
"long_gene_outliers": [{"gene_id": "FtLong1", "length_bp": 12454, "cds_count": 10}],
}
manifests["structure"].write_text(json.dumps(structure), encoding="utf-8")
combo = json.loads(manifests["combo"].read_text(encoding="utf-8"))
combo["metrics"] = None
combo["parameters"] = {"panels": ["motif", "domain", "structure"]}
combo["artifacts"] = {
"combo_plot_png": {"path": "final/Ft.SPL.combo.png", "type": "image"},
"combo_plot_pdf": {"path": "final/Ft.SPL.combo.pdf", "type": "image"},
}
manifests["combo"].write_text(json.dumps(combo), encoding="utf-8")
element_counts = final_dir / "Ft.SPL.element_counts.txt"
element_counts.write_text("1460\tTATA-box\n852\tCAAT-box\n72\tMYB\n", encoding="utf-8")
category_summary = final_dir / "Ft.SPL.promoter_category_summary.tsv"
category_summary.write_text("big7\telement_instances\nCore promoter\t2600\nLight response\t188\n", encoding="utf-8")
enrichment = final_dir / "Ft.SPL.enrichment_analysis.txt"
enrichment.write_text(
"Subfamily\tElement\tCount\tSubfamilyTotal\tGlobalCount\tEnrichmentRatio\nI\tAACA_motif\t1\t222\t1\t16.66\n",
encoding="utf-8",
)
cooccurrence = final_dir / "Ft.SPL.cooccurrence_analysis.txt"
cooccurrence.write_text(
"Element1\tElement2\tCo-occurrenceCount\tGenes\nCAAT-box\tMYB\t23\tFtGene1,FtGene2\n",
encoding="utf-8",
)
complexity = final_dir / "Ft.SPL.complexity_analysis.txt"
complexity.write_text(
"Gene\tTotalElements\tUniqueElements\tComplexityScore\tComplexityClass\nFtComplex1\t278\t33\t361.44\tVery High\n",
encoding="utf-8",
)
promoter = json.loads(manifests["promoter"].read_text(encoding="utf-8"))
promoter["metrics"] = {"total_elements": 3699, "unique_elements": 87, "genes_with_elements": 23}
promoter["artifacts"] = {
"element_counts": {"path": f"final/{element_counts.name}"},
"viz_promoter_position_distribution_summary": {"path": f"final/{category_summary.name}"},
"enrichment_analysis": {"path": f"final/{enrichment.name}"},
"cooccurrence_analysis": {"path": f"final/{cooccurrence.name}"},
"complexity_analysis": {"path": f"final/{complexity.name}"},
}
manifests["promoter"].write_text(json.dumps(promoter), encoding="utf-8")
motif_summary = final_dir / "Ft.SPL.tfbs.motif_summary.tsv"
motif_summary.write_text(
"motif_id\tmotif_alt_id\tfamily_sites\tfamily_genes_with_site\tbackground_sites\tbackground_genes_with_site\n"
"MOTIF_A\t\t153\t18\t0\t0\n",
encoding="utf-8",
)
tfbs = json.loads(manifests["tfbs"].read_text(encoding="utf-8"))
tfbs["metrics"] = {
"family_promoter_count": 24, "background_promoter_count": 0,
"family_site_count": 3734, "family_motif_count": 230, "motif_database": "PlantTFDB",
}
tfbs["artifacts"] = {"motif_summary": {"path": f"final/{motif_summary.name}"}}
manifests["tfbs"].write_text(json.dumps(tfbs), encoding="utf-8")
synteny = json.loads(manifests["synteny"].read_text(encoding="utf-8"))
synteny["metrics"] = {"gene_count": 31679, "synteny_blocks": 316, "gene_pairs": 3267, "circos_chromosomes_plotted": 8}
manifests["synteny"].write_text(json.dumps(synteny), encoding="utf-8")
result = _generate(
workspace,
expected_stages=stages,
stage_statuses={stage: "success" for stage in stages},
)
markdown = result.report_path.read_text(encoding="utf-8")
assert re.search(r"4\s*个目标成员.*2\s*个小组.*高信心\s*2", _subsection(markdown, "6.2"))
assert re.search(r"40/40.*SBP.*40.*Ig_SPL7.*12", _subsection(markdown, "8.1"))
assert re.search(r"24/40.*FtLong1.*12454", _subsection(markdown, "9.1"))
assert "Motif、Domain、Structure" in _subsection(markdown, "10.1")
promoter_text = _subsection(markdown, "11.1")
assert re.search(r"23\s*个目标基因.*3699.*TATA-box.*1460", promoter_text)
assert "FtComplex1" in promoter_text and "CAAT-box—MYB" in promoter_text
assert re.search(r"24\s*条目标启动子.*3734.*MOTIF_A.*153", _subsection(markdown, "12.1"))
assert re.search(r"背景启动子为\s*0.*不能.*富集", _subsection(markdown, "12.1"))
assert re.search(r"全基因组.*316.*3267.*8\s*条染色体", _subsection(markdown, "13.1"))
assert "源结果未提供家族专属复制基因对" in _subsection(markdown, "13.1")
def test_all_planned_success_is_final(tmp_path: Path) -> None:
workspace = tmp_path / "job"
_write_manifest(workspace, prefix="Ft.SPL", stage="identify")
_write_manifest(workspace, prefix="Ft.SPL", stage="motif")
result = _generate(
workspace,
expected_stages=["identify", "motif"],
stage_statuses={"identify": "success", "motif": "success"},
)
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
assert result.report_status == "final"
assert manifest["metrics"]["report_status"] == "final"
assert manifest["metrics"]["included_stages"] == ["identify", "motif"]
def test_unsupported_successful_stage_is_visible_but_not_included_or_final(tmp_path: Path) -> None:
workspace = tmp_path / "job"
_write_manifest(workspace, prefix="Ft.SPL", stage="identify")
_write_manifest(workspace, prefix="Ft.SPL", stage="ppi")
result = _generate(
workspace,
expected_stages=["identify", "ppi"],
stage_statuses={"identify": "success", "ppi": "success"},
)
markdown = result.report_path.read_text(encoding="utf-8")
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
assert result.report_status == "partial"
assert result.included_stages == ("identify",)
assert "ppi" in markdown
assert "不支持" in markdown
assert "(`ppi`)" not in markdown
assert manifest["metrics"]["included_stages"] == ["identify"]
assert manifest["metrics"]["unsupported_stages"] == ["ppi"]
@pytest.mark.parametrize("expected_stages", [None, []])
def test_generate_rejects_missing_plan_instead_of_inferring_historical_manifests(
tmp_path: Path, expected_stages: list[str] | None
) -> None:
workspace = tmp_path / "job"
_write_manifest(workspace, prefix="Ft.SPL", stage="identify")
with pytest.raises(ValueError, match="expected_stages"):
generate_gene_family_report(
workspace,
"Ft.SPL",
expected_stages=expected_stages,
stage_statuses={"identify": "success"},
scope_id="scope-a",
analysis_route="post_subfamily_confirm",
member_set_id="members-a",
use_model=False,
action="generate",
)
assert not list((workspace / "final").glob("*.gene_family_report.*"))
def test_report_selects_key_facts_without_dumping_manifest_or_stale_paths(tmp_path: Path) -> None:
workspace = tmp_path / "job"
manifest_path = _write_manifest(workspace, prefix="Ft.SPL", stage="identify", metric_value=3)
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest["metrics"].update({"total_proteins": 31679, "blast_enabled": True})
manifest["artifacts"]["result_table"]["path"] = "/home/xzq/old/job/final/Ft.SPL.identify.tsv"
manifest["artifacts"]["result_table"]["full_path"] = "/home/xzq/old/job/final/Ft.SPL.identify.tsv"
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8")
result = _generate(
workspace,
expected_stages=["identify"],
stage_statuses={"identify": "success"},
)
markdown = result.report_path.read_text(encoding="utf-8")
assert "最终成员数:3" in _subsection(markdown, "4.3")
assert not re.search(r"(?m)^- `(?:member_count|total_proteins|blast_enabled)\s*`:", markdown)
assert "/home/xzq/" not in markdown
def test_fallback_uses_nested_karyotype_metrics(tmp_path: Path) -> None:
workspace = tmp_path / "job"
manifest_path = _write_manifest(workspace, prefix="Ft.SPL", stage="karyotype")
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest["metrics"] = {
"species": {
"Ft": {
"gene_count": 24,
"chromosome_count": 8,
"chromosomes": [f"Ft{i}" for i in range(1, 9)],
"tandem_count": 0,
}
}
}
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8")
result = _generate(
workspace,
expected_stages=["karyotype"],
stage_statuses={"karyotype": "success"},
)
markdown = result.report_path.read_text(encoding="utf-8")
assert "24个家族成员定位到8条染色体" in markdown
assert "核型分析将N/A" not in markdown
def test_artifact_path_remaps_remote_job_to_current_mount(tmp_path: Path) -> None:
from gfa_core.mas.agents.interpretation_evidence import _artifact_path
final_dir = tmp_path / "job" / "final"
final_dir.mkdir(parents=True)
manifest_path = final_dir / "Ft.SPL.stage.manifest.json"
local_artifact = final_dir / "Ft.SPL.result.tsv"
local_artifact.write_text("gene\tvalue\nFtSPL1\t1\n", encoding="utf-8")
manifest = {
"_manifest_path": str(manifest_path),
"artifacts": {
"result": {
"full_path": "/home/xzq/old/PlantAI-dev/workspace/job-old/final/Ft.SPL.result.tsv",
"path": "/home/xzq/old/PlantAI-dev/workspace/job-old/final/Ft.SPL.result.tsv",
}
},
}
assert _artifact_path(manifest, "result") == local_artifact
def test_failed_or_missing_planned_stage_is_partial(tmp_path: Path) -> None:
workspace = tmp_path / "job"
_write_manifest(workspace, prefix="Ft.SPL", stage="identify")
_write_manifest(workspace, prefix="Ft.SPL", stage="motif", status="failed")
result = _generate(
workspace,
expected_stages=["identify", "motif"],
stage_statuses={"identify": "success", "motif": "failed"},
)
markdown = result.report_path.read_text(encoding="utf-8")
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
assert result.report_status == "partial"
assert "motif" in markdown.lower()
assert "failed" in markdown.lower()
assert manifest["metrics"]["failed_or_missing_stages"] == ["motif"]
def test_model_failure_creates_labeled_fallback_report(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
workspace = tmp_path / "job"
_write_manifest(workspace, prefix="Ft.SPL", stage="identify")
def fail_model(_evidence: dict, _stages: list[str]):
raise TimeoutError("provider timeout")
monkeypatch.setattr(report_module, "_generate_api_narrative", fail_model)
result = _generate(
workspace,
expected_stages=["identify"],
stage_statuses={"identify": "success"},
use_model=True,
)
markdown = result.report_path.read_text(encoding="utf-8")
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
assert result.generation_mode == "fallback_script"
assert "生成模式:候补脚本" in markdown
assert manifest["metrics"]["generation_mode"] == "fallback_script"
assert manifest["model"]["reason"] == "api_unavailable_or_boundary_violation"
assert "{'action':" not in markdown
def test_valid_model_sections_create_api_report(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
workspace = tmp_path / "job"
_write_manifest(workspace, prefix="Ft.SPL", stage="identify")
seen_payload: dict = {}
def valid_model(payload: dict, _stages: list[str]):
seen_payload.update(payload)
return (
_valid_api_arrangement(payload, reverse=True),
{"status": "generated", "provider": "test", "model": "test-model"},
)
monkeypatch.setattr(report_module, "_generate_api_narrative", valid_model)
result = _generate(
workspace,
expected_stages=["identify"],
stage_statuses={"identify": "success"},
use_model=True,
)
markdown = result.report_path.read_text(encoding="utf-8")
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
assert result.generation_mode == "api"
assert set(seen_payload) == {"sections", "allowed_connectors"}
assert all(set(section) == {"section_id", "sentences"} for section in seen_payload["sections"])
assert all(
set(sentence) == {"sentence_id", "text"}
for section in seen_payload["sections"]
for sentence in section["sentences"]
)
assert "最终成员数:1" in markdown
assert manifest["metrics"]["generation_mode"] == "api"
assert "【填写】" not in markdown
def test_unknown_model_sentence_id_falls_back_for_the_whole_report(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
workspace = tmp_path / "job"
_write_manifest(workspace, prefix="Ft.SPL", stage="identify", metric_value=3)
def invalid_model(payload: dict, _stages: list[str]):
response = _valid_api_arrangement(payload)
response["sections"][0]["paragraphs"][0]["sentence_ids"][0] = "unknown.sentence"
return response, {"status": "generated"}
monkeypatch.setattr(report_module, "_generate_api_narrative", invalid_model)
result = _generate(
workspace,
expected_stages=["identify"],
stage_statuses={"identify": "success"},
use_model=True,
)
markdown = result.report_path.read_text(encoding="utf-8")
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
assert result.generation_mode == "fallback_script"
assert "最终成员数:3" in markdown
assert manifest["model"]["reason"] == "api_unavailable_or_boundary_violation"
def test_missing_and_duplicate_model_sentence_ids_fall_back_for_the_whole_report(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
workspace = tmp_path / "job"
_write_manifest(workspace, prefix="Ft.SPL", stage="identify", metric_value=3)
def invalid_model(payload: dict, _stages: list[str]):
response = _valid_api_arrangement(payload)
paragraph = next(
item["paragraphs"][0]
for item in response["sections"]
if len(item["paragraphs"][0]["sentence_ids"]) >= 2
)
paragraph["sentence_ids"][-1] = paragraph["sentence_ids"][0]
return response, {"status": "generated"}
monkeypatch.setattr(report_module, "_generate_api_narrative", invalid_model)
result = _generate(
workspace,
expected_stages=["identify"],
stage_statuses={"identify": "success"},
use_model=True,
)
markdown = result.report_path.read_text(encoding="utf-8")
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
assert result.generation_mode == "fallback_script"
assert "最终成员数:3" in markdown
assert manifest["model"]["reason"] == "api_unavailable_or_boundary_violation"
@pytest.mark.parametrize(
"violation",
["unknown_connector", "extra_field", "free_text_field", "changed_section"],
)
def test_model_schema_or_connector_boundary_violation_falls_back_for_the_whole_report(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, violation: str
) -> None:
workspace = tmp_path / "job"
_write_manifest(workspace, prefix="Ft.SPL", stage="identify", metric_value=3)
def invalid_model(payload: dict, _stages: list[str]):
response = _valid_api_arrangement(payload)
if violation == "unknown_connector":
paragraph = next(
item["paragraphs"][0]
for item in response["sections"]
if item["paragraphs"][0]["connector_ids"]
)
paragraph["connector_ids"][0] = "invented"
elif violation == "extra_field":
response["unexpected"] = True
elif violation == "free_text_field":
response["sections"][0]["paragraphs"][0]["text"] = "API 自由文本"
else:
response["sections"][0]["section_id"] = "changed.section"
return response, {"status": "generated"}
monkeypatch.setattr(report_module, "_generate_api_narrative", invalid_model)
result = _generate(
workspace,
expected_stages=["identify"],
stage_statuses={"identify": "success"},
use_model=True,
)
markdown = result.report_path.read_text(encoding="utf-8")
manifest = json.loads(result.manifest_path.read_text(encoding="utf-8"))
assert result.generation_mode == "fallback_script"
assert "最终成员数:3" in markdown
assert "API 自由文本" not in markdown
assert manifest["model"]["reason"] == "api_unavailable_or_boundary_violation"
def test_open_reuses_existing_report(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
workspace = tmp_path / "job"
manifest_path = _write_manifest(workspace, prefix="Ft.SPL", stage="identify")
calls = {"count": 0}
def valid_model(payload: dict, _stages: list[str]):
calls["count"] += 1
return (
_valid_api_arrangement(payload),
{"status": "generated", "provider": "test", "model": "test-model"},
)
monkeypatch.setattr(report_module, "_generate_api_narrative", valid_model)
first = _generate(
workspace,
expected_stages=["identify"],
stage_statuses={"identify": "success"},
use_model=True,
)
reopened = _generate(
workspace,
expected_stages=["identify"],
stage_statuses={"identify": "success"},
use_model=True,
action="open",
)
assert first.reused is False
assert reopened.reused is True
assert calls["count"] == 1
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest["metrics"]["member_count"] = 2
manifest_path.write_text(json.dumps(manifest, ensure_ascii=False), encoding="utf-8")
reopened_after_source_change = _generate(
workspace,
expected_stages=["identify"],
stage_statuses={"identify": "success"},
use_model=True,
action="open",
)
assert reopened_after_source_change.reused is True
assert calls["count"] == 1
def test_no_successful_evidence_creates_no_empty_report(tmp_path: Path) -> None:
workspace = tmp_path / "job"
_write_manifest(workspace, prefix="Ft.SPL", stage="identify", status="failed")
with pytest.raises(NoSuccessfulGeneFamilyEvidence, match="暂无可生成"):
_generate(
workspace,
expected_stages=["identify"],
stage_statuses={"identify": "failed"},
)
assert not list((workspace / "final").glob("*.gene_family_report.*"))