fix: complete PPI and KaKs reference handoff

This commit is contained in:
potaser
2026-08-10 23:13:05 +08:00
parent 39a2c969de
commit 7b7e334213
37 changed files with 4770 additions and 561 deletions
+234 -117
View File
@@ -1,6 +1,7 @@
# web/pipeline_backend.py
import csv
import json
import csv
import http.client
import json
import os
import re
import shlex
@@ -48,6 +49,7 @@ from gfa_core.ppi_provenance import (
record_ppi_preflight_decision,
)
from gfa_core.string_species_registry import (
ARABIDOPSIS_TAXON_ID,
StringSnapshotError,
load_string_snapshot,
resolve_ppi_string_species,
@@ -1721,7 +1723,7 @@ def infer_string_species_from_ortholog_mapping(mapping_file: Path, fallback: str
return fallback
def infer_reference_species_from_ortholog_mapping(mapping_file: Path, fallback: str = "") -> str:
def infer_reference_species_from_ortholog_mapping(mapping_file: Path, fallback: str = "") -> str:
"""Infer the reference species label from the first data row of an ortholog mapping."""
try:
with mapping_file.open("r", encoding="utf-8", errors="ignore") as src:
@@ -1735,10 +1737,34 @@ def infer_reference_species_from_ortholog_mapping(mapping_file: Path, fallback:
break
except Exception:
pass
return fallback
def _reference_species_from_manifest_data(data: dict[str, Any]) -> str:
return fallback
def ortholog_mapping_candidates(
final_dir: Path,
prefix: str,
*,
dependency_owner: str = "",
scope_id: str = "",
) -> list[Path]:
from gfa_core.ortholog_materialize import ortholog_output_stem
output_stem = ortholog_output_stem(prefix, dependency_owner)
candidates: list[Path] = []
if scope_id:
scoped_stem = f"{output_stem}.{sanitize_token(scope_id)}"
candidates.extend([
final_dir / f"{scoped_stem}.orthologs.txt",
final_dir / f"{scoped_stem}.ortholog_pairs.txt",
])
candidates.extend([
final_dir / f"{output_stem}.orthologs.txt",
final_dir / f"{output_stem}.ortholog_pairs.txt",
])
return candidates
def _reference_species_from_manifest_data(data: dict[str, Any]) -> str:
for key in ("reference_species", "referenceSpecies"):
value = data.get(key)
if isinstance(value, list) and value:
@@ -1905,11 +1931,10 @@ NCBI_REFERENCE_ANNOTATION_TYPES = {
}
def _reference_bundle_file_plan(requested_stages: Optional[list[str]], include_recommended: bool = False) -> list[str]:
stages = _normalized_dependency_stages(requested_stages)
files = ["pep"]
if "kaks" in stages:
files.append("cds")
def _reference_bundle_file_plan(requested_stages: Optional[list[str]], include_recommended: bool = False) -> list[str]:
# PPI owns reference preparation. Download PEP and CDS together so the
# later Ka/Ks choice can reuse the exact same bundle without a second job.
files = ["pep", "cds"]
if include_recommended:
for item in ("gff3", "genome"):
if item not in files:
@@ -2005,7 +2030,7 @@ def _download_url_to_file(
raise RuntimeError("downloaded file is empty")
temp_path.replace(output_path)
return total
except (urllib.error.URLError, TimeoutError, OSError, RuntimeError) as exc:
except (http.client.IncompleteRead, urllib.error.URLError, TimeoutError, OSError, RuntimeError) as exc:
last_error = exc
if temp_path.exists():
try:
@@ -2119,6 +2144,8 @@ def _fasta_header_index(fasta_path: Path | str | None) -> dict[str, dict[str, st
if not seq_id or seq_id in records:
continue
gene_match = re.search(r"\[gene=([^\]]+)\]", header)
if not gene_match:
gene_match = re.search(r"(?:^|\s)gene:([^\s]+)", header)
ncbi_gene_match = re.search(r"\[db_xref=GeneID:(\d+)\]", header)
protein_match = re.search(r"\[protein_id=([^\]]+)\]", header)
records[seq_id] = {
@@ -2220,7 +2247,7 @@ def download_reference_annotation_bundle(
include_recommended=include_recommended,
)
assembly = {"accession": accession}
emit({"phase": "select_assembly", "message": "正在解析推荐参考物种装配", "percentage": 5, "indeterminate": True})
emit({"phase": "select_assembly", "message": "Resolving the recommended reference assembly", "percentage": 5, "indeterminate": True})
if not accession:
assembly = _select_ncbi_reference_assembly(taxon_id=taxon_id, species_name=species_name, timeout_sec=min(timeout_sec, 60))
accession = assembly.get("accession", "")
@@ -2231,24 +2258,24 @@ def download_reference_annotation_bundle(
stamp = datetime.now().strftime("%Y%m%d_%H%M%S")
work_dir = ws / "reference_bundle_download" / f"{code}_{stamp}"
zip_path = work_dir / f"{code}.ncbi_dataset.zip"
emit({"phase": "download", "message": "正在下载 NCBI 参考注释包", "percentage": 10, "indeterminate": True})
emit({"phase": "download", "message": "Downloading reference PEP and CDS from NCBI", "percentage": 10, "indeterminate": True})
download_kwargs = {"timeout_sec": timeout_sec}
if progress_callback:
download_kwargs["progress_callback"] = lambda event: emit({"message": "正在下载 NCBI 参考注释包", **event})
download_kwargs["progress_callback"] = lambda event: emit({"message": "Downloading reference PEP and CDS from NCBI", **event})
bytes_downloaded = _download_url_to_file(download_url, zip_path, **download_kwargs)
if not zipfile.is_zipfile(zip_path):
raise RuntimeError(
f"NCBI reference bundle download returned an invalid zip archive: {zip_path}"
)
species_dir = in_dir / code
emit({"phase": "extract", "message": "正在解压参考 PEP/CDS", "percentage": 80, "indeterminate": True})
emit({"phase": "extract", "message": "Extracting reference PEP and CDS", "percentage": 80, "indeterminate": True})
extracted = _extract_ncbi_reference_bundle_zip(
zip_path=zip_path,
destination_dir=species_dir,
species_code=code,
requested_file_types=file_types,
)
emit({"phase": "validate_ids", "message": "正在校验 PEP/CDS ID 对齐", "percentage": 90, "indeterminate": True})
emit({"phase": "validate_ids", "message": "Validating PEP/CDS identifier correspondence", "percentage": 90, "indeterminate": True})
id_validation = _build_reference_bundle_id_map(
species_code=code,
pep_path=extracted.get("pep"),
@@ -2288,7 +2315,7 @@ def download_reference_annotation_bundle(
manifest["manifest_path"] = str(manifest_path)
emit({
"phase": "completed",
"message": "参考物种注释包下载完成",
"message": "PPI and Ka/Ks reference species data are ready",
"percentage": 100,
"indeterminate": False,
"bytes_downloaded": bytes_downloaded,
@@ -2311,6 +2338,7 @@ def _ppi_only_preflight_response(
selected_string_species: str = "",
reference_storage_key: str = "",
reference_pep: str = "",
reference_cds: str = "",
mapping_file: Optional[Path] = None,
mapping_reference_species: str = "",
required_next_stage: str = "",
@@ -2319,22 +2347,28 @@ def _ppi_only_preflight_response(
) -> dict[str, Any]:
candidates = list(species_resolution.get("candidates") or [])
selection_mode = str(species_resolution.get("selection_mode") or "unresolved")
required_files = ["pep", "cds"] if reference_storage_key else []
bundle_files = {}
if reference_pep:
bundle_files["pep"] = reference_pep
if reference_cds:
bundle_files["cds"] = reference_cds
reference_bundle_status = {
"required_files": ["pep"],
"required_files": required_files,
"recommended_files": [],
"files": {"pep": reference_pep} if reference_pep else {},
"files": bundle_files,
"missing_required": list(missing_required or []),
"missing_recommended": [],
"mode": (
"complete"
if reference_pep
if not required_files or (reference_pep and reference_cds)
else "not_selected"
if status in {"confirmation_required", "unresolved", "conflict"}
else "unavailable"
if status == "resource_error"
else "incomplete"
),
"kaks_ready": True,
"kaks_ready": (not required_files) or bool(reference_pep and reference_cds),
}
diagnostics = {
"issue_code": issue_code,
@@ -2343,7 +2377,7 @@ def _ppi_only_preflight_response(
"required_next_stage": required_next_stage,
"recommendation": message,
"dependency_owner_stages": ["ppi"],
"required_bundle_files": ["pep"],
"required_bundle_files": required_files,
"reference_bundle_status": reference_bundle_status,
"ortholog_dependency_role": "producer" if required_next_stage == "ortholog" else "not_requested",
"reference_recommendations": candidates,
@@ -2399,24 +2433,12 @@ def _effective_ppi_owner_scope_id(
scope_data: dict[str, Any],
reference_storage_key: str,
) -> str:
scope_mode = str(scope_data.get("scope_mode") or "target_plus_reference")
comparative_species = [
str(x) for x in scope_data.get("comparative_species", []) if str(x)
]
if scope_mode == "target_only":
return str(scope_data.get("scope_id") or "") or build_scope_id(
family,
selected_targets,
[str(x) for x in scope_data.get("reference_species", []) if str(x)],
comparative_species,
scope_mode,
)
return build_scope_id(
family,
selected_targets,
[reference_storage_key],
comparative_species,
scope_mode,
[],
[],
"target_only",
)
@@ -2616,6 +2638,16 @@ def _resolve_ppi_only_snapshot_preflight(
),
None,
)
explicit_model_fallback = bool(
str(ppi_reference_selection_mode or "").strip().lower() == "model_fallback"
and chosen_taxon_id == ARABIDOPSIS_TAXON_ID
and chosen_taxon_id in snapshot.registry
)
if explicit_model_fallback:
chosen = {
"selected_string_taxon_id": chosen_taxon_id,
"selected_string_name": snapshot.nodes[chosen_taxon_id].taxon_name,
}
if not (ppi_reference_user_confirmed and chosen):
return _ppi_only_preflight_response(
status="confirmation_required",
@@ -2628,6 +2660,13 @@ def _resolve_ppi_only_snapshot_preflight(
requested_ppi_species=requested_ppi_species,
dependency_owner=dependency_owner,
)
if explicit_model_fallback:
species_resolution = {
**species_resolution,
"selection_mode": "model_fallback",
"candidate_source": "explicit_model_fallback",
"issue_code": "",
}
storage_key = str(ppi_reference_storage_key or "").strip() or f"string_taxon_{chosen_taxon_id}"
selected_name = str(chosen.get("selected_string_name") or "")
@@ -2638,16 +2677,16 @@ def _resolve_ppi_only_snapshot_preflight(
ws, final_dir, in_dir, scope_data, storage_key, family, "pep", family_only=False
)
reference_pep = str(reference_pep_path) if reference_pep_path else ""
mapping_candidates: list[Path] = []
if scope_id:
mapping_candidates.extend([
final_dir / f"{prefix}.{sanitize_token(scope_id)}.orthologs.txt",
final_dir / f"{prefix}.{sanitize_token(scope_id)}.ortholog_pairs.txt",
])
mapping_candidates.extend([
final_dir / f"{prefix}.orthologs.txt",
final_dir / f"{prefix}.ortholog_pairs.txt",
])
reference_cds_path = resolve_species_sequence_file(
ws, final_dir, in_dir, scope_data, storage_key, family, "cds", family_only=False
)
reference_cds = str(reference_cds_path) if reference_cds_path else ""
mapping_candidates = ortholog_mapping_candidates(
final_dir,
prefix,
dependency_owner="ppi",
scope_id=scope_id,
)
mapping_file = None if ignore_existing_ortholog else next(
(path for path in mapping_candidates if path.exists() and path.stat().st_size > 0),
None,
@@ -2696,18 +2735,24 @@ def _resolve_ppi_only_snapshot_preflight(
"selected_string_species": species_to_string_code(selected_name, ""),
"reference_storage_key": storage_key,
"reference_pep": reference_pep,
"reference_cds": reference_cds,
"mapping_file": mapping_file,
"mapping_reference_species": mapping_reference_species,
"dependency_owner": dependency_owner,
}
if not reference_pep:
missing_reference_files = [
file_type
for file_type, file_path in (("pep", reference_pep), ("cds", reference_cds))
if not file_path
]
if missing_reference_files:
return _ppi_only_preflight_response(
status="needs_reference_data",
issue_code="ppi_reference_data_missing",
message="The confirmed STRING reference requires a protein FASTA (PEP) before Ortholog.",
message="The confirmed STRING reference requires matching PEP and CDS before Ortholog.",
decision="needs_reference_bundle",
required_next_stage="",
missing_required=["pep"],
missing_required=missing_reference_files,
**common,
)
if mapping_file and not mapping_matches:
@@ -2775,9 +2820,7 @@ def resolve_ppi_species_resolution(
dependency_stages = _normalized_dependency_stages(requested_stages)
needs_ppi = "ppi" in dependency_stages
needs_kaks = "kaks" in dependency_stages
required_bundle_files = ["pep"]
if needs_kaks and "cds" not in required_bundle_files:
required_bundle_files.append("cds")
required_bundle_files = ["pep", "cds"]
recommended_bundle_files = ["gff3", "genome"] if needs_kaks else []
selected_targets = canonicalize_species_codes_for_job(in_dir, [str(x).strip() for x in (target_species or []) if str(x).strip()])
references = canonicalize_species_codes_for_job(in_dir, [str(x).strip() for x in (reference_species or []) if str(x).strip()])
@@ -2818,6 +2861,22 @@ def resolve_ppi_species_resolution(
)
if needs_ppi and not needs_kaks:
scope_id = _effective_ppi_owner_scope_id(
family=family,
selected_targets=selected_targets,
scope_data=scope_data,
reference_storage_key=str(ppi_reference_storage_key or ""),
)
scope_data = {
**scope_data,
"family": family,
"scope_id": scope_id,
"scope_mode": "target_only",
"target_species": list(selected_targets),
"reference_species": [],
"comparative_species": [],
"selected_species": list(selected_targets),
}
result = _resolve_ppi_only_snapshot_preflight(
job=job,
prefix=prefix,
@@ -2886,17 +2945,15 @@ def resolve_ppi_species_resolution(
and target_string_code
and selected_string_species == target_string_code
)
mapping_candidates: list[Path] = []
if not use_target_string_directly or needs_kaks:
if scope_id:
mapping_candidates.extend([
final_dir / f"{prefix}.{sanitize_token(scope_id)}.orthologs.txt",
final_dir / f"{prefix}.{sanitize_token(scope_id)}.ortholog_pairs.txt",
])
mapping_candidates.extend([
final_dir / f"{prefix}.orthologs.txt",
final_dir / f"{prefix}.ortholog_pairs.txt",
])
mapping_candidates: list[Path] = []
if not use_target_string_directly or needs_kaks:
mapping_owner = str(dependency_owner or "").strip().lower()
mapping_candidates = ortholog_mapping_candidates(
final_dir,
prefix,
dependency_owner=mapping_owner if mapping_owner in {"ppi", "kaks"} else "",
scope_id=scope_id,
)
mapping_file = None if ignore_existing_ortholog else next((p for p in mapping_candidates if p.exists() and p.stat().st_size > 0), None)
reference_data_source = ""
@@ -2917,7 +2974,7 @@ def resolve_ppi_species_resolution(
reference_data_source = str(ref_pep) if ref_pep else ""
if ref_pep:
reference_bundle_files["pep"] = str(ref_pep)
if needs_kaks:
if "cds" in required_bundle_files:
ref_cds = resolve_species_sequence_file(
ws,
final_dir,
@@ -6025,6 +6082,7 @@ def run_stage(
ppi_reference_selection_mode: str = "",
ppi_reference_user_confirmed: bool = False,
dependency_owner: str = "",
ortholog_mapping_owner: str = "",
ppi_score: int = 200,
# Genome Prepare 参数
genome_prepare_type: str = "auto",
@@ -12394,20 +12452,42 @@ def run_stage(
if not references:
return "❌ Ortholog scope 中没有 reference_species,无法确定 subject/reference 物种。", manifest
reference_species = references[0]
subject_fasta = first_existing_scope_file(ortholog_scope_data, reference_species, ["family_pep", "pep"])
if not subject_fasta:
subject_fasta = resolve_species_family_pep(ws, final_dir, in_dir, reference_species, family)
if not subject_fasta:
subject_fasta = resolve_species_sequence_file(
ws,
final_dir,
if ppi_owned_producer:
subject_fasta = resolve_species_sequence_file(
ws,
final_dir,
in_dir,
ortholog_scope_data,
reference_species,
family,
"pep",
family_only=False,
)
"pep",
family_only=False,
)
else:
subject_fasta = first_existing_scope_file(
ortholog_scope_data,
reference_species,
["family_pep", "pep"],
)
if not subject_fasta:
subject_fasta = resolve_species_family_pep(
ws,
final_dir,
in_dir,
reference_species,
family,
)
if not subject_fasta:
subject_fasta = resolve_species_sequence_file(
ws,
final_dir,
in_dir,
ortholog_scope_data,
reference_species,
family,
"pep",
family_only=False,
)
if not subject_fasta:
return (
f"❌ Ortholog stage requires reference protein FASTA for {reference_species}. "
@@ -12454,11 +12534,14 @@ def run_stage(
ortholog_pairs_has_data,
write_ortholog_run_sidecar,
)
ortholog_owner = str(dependency_owner or "").strip().lower()
if ortholog_owner not in {"", "ppi", "kaks"}:
return f"❌ Unsupported Ortholog dependency owner: {dependency_owner}", manifest
scope_fields: dict[str, Any] = {}
if ortholog_scope_data:
source_scope_mode = str(ortholog_scope_data.get("scope_mode") or "")
preserve_target_only_scope = ppi_owned_producer and source_scope_mode == "target_only"
preserve_target_only_scope = ppi_owned_producer
effective_scope_id = str(ortholog_scope_data.get("scope_id") or "")
if ppi_owned_producer:
effective_scope_id = _effective_ppi_owner_scope_id(
@@ -12468,19 +12551,19 @@ def run_stage(
reference_storage_key=reference_species,
)
scope_fields = {
"scope_file": str(ortholog_scope_path),
"scope_file": str(ortholog_scope_path) if source_scope_mode == "target_only" else "",
"scope_id": effective_scope_id,
"scope_mode": ortholog_scope_data.get("scope_mode", ""),
"scope_mode": "target_only" if ppi_owned_producer else ortholog_scope_data.get("scope_mode", ""),
"target_species": [record[0] for record in run_specs],
"reference_species": (
ortholog_scope_data.get("reference_species", [])
if preserve_target_only_scope
[]
if ppi_owned_producer
else [reference_species] if reference_species else []
),
"comparative_species": ortholog_scope_data.get("comparative_species", []),
"comparative_species": [] if ppi_owned_producer else ortholog_scope_data.get("comparative_species", []),
"selected_species": (
ortholog_scope_data.get("selected_species", [record[0] for record in run_specs])
if preserve_target_only_scope
[record[0] for record in run_specs]
if ppi_owned_producer
else [record[0] for record in run_specs] + ([reference_species] if reference_species else [])
),
"family": family,
@@ -12559,9 +12642,10 @@ def run_stage(
run_identities.append(completed_identity)
try:
pair_count, ortholog_manifest = materialize_ortholog_final_outputs(
final_dir=final_dir,
prefix=prefix,
pair_count, ortholog_manifest = materialize_ortholog_final_outputs(
final_dir=final_dir,
prefix=prefix,
dependency_owner=ortholog_owner,
run_records=run_records,
job_id=job.job_id,
workspace=ws,
@@ -12735,18 +12819,15 @@ def run_stage(
and target_string_taxon_id
and selected_execution_taxon_id == target_string_taxon_id
)
mapping_candidates = []
if not use_target_string_directly:
if scope_id:
mapping_candidates.extend([
final_dir / f"{prefix}.{sanitize_token(scope_id)}.orthologs.txt",
final_dir / f"{prefix}.{sanitize_token(scope_id)}.ortholog_pairs.txt",
])
mapping_candidates.extend([
final_dir / f"{prefix}.orthologs.txt",
final_dir / f"{prefix}.ortholog_pairs.txt",
])
mapping_file = next((p for p in mapping_candidates if p.exists() and p.stat().st_size > 0), None)
mapping_candidates = []
if not use_target_string_directly:
mapping_candidates = ortholog_mapping_candidates(
final_dir,
prefix,
dependency_owner="ppi" if ppi_owner_intent else "",
scope_id=scope_id,
)
mapping_file = next((p for p in mapping_candidates if p.exists() and p.stat().st_size > 0), None)
if mapping_file:
gene_id_file = ws / "ppi" / "analysis" / f"{prefix}.{sanitize_token(scope_id) if scope_id else 'scope'}.ppi_source_ids.txt"
mapped_source_count = build_ppi_gene_ids_from_ortholog_mapping(mapping_file, gene_id_file, ppi_selected_targets)
@@ -12931,7 +13012,7 @@ def run_stage(
if mapping_file:
effective_normalization_label = (
ppi_reference_storage_key
if ppi_owner_auto_fallback and ppi_reference_storage_key
if ppi_reference_storage_key
else ppi_reference_species or ppi_species
)
elif len(ppi_selected_targets) == 1:
@@ -12998,6 +13079,41 @@ def run_stage(
if not ppi_storage_key:
ppi_storage_key = str(ppi_species)
id_map_file = Path(job.input_dir) / ppi_storage_key / f"{ppi_storage_key}.pep_cds_idmap.tsv"
if mapping_file and not id_map_file.is_file():
reference_family = str(
(ppi_scope_data or {}).get("family")
or (prefix.split(".", 1)[1] if "." in prefix else prefix)
)
reference_pep = resolve_species_sequence_file(
ws,
final_dir,
in_dir,
ppi_scope_data or {},
ppi_storage_key,
reference_family,
"pep",
family_only=False,
)
reference_cds = resolve_species_sequence_file(
ws,
final_dir,
in_dir,
ppi_scope_data or {},
ppi_storage_key,
reference_family,
"cds",
family_only=False,
)
if reference_pep and reference_cds:
try:
_build_reference_bundle_id_map(
species_code=ppi_storage_key,
pep_path=reference_pep,
cds_path=reference_cds,
output_path=id_map_file,
)
except (OSError, UnicodeError, csv.Error) as exc:
print(f"[PPI] Could not build species-matched ID map: {exc}")
if mapping_file and not id_map_file.is_file():
return fail_ppi_execution(
f"❌ PPI projected analysis requires species-matched ID map: {id_map_file}"
@@ -13279,22 +13395,23 @@ def run_stage(
f"Target.{target_species_name}.{family}",
])
candidate_prefixes = list(dict.fromkeys(str(x) for x in candidate_prefixes if str(x)))
mapping_candidates = []
if scope_id:
for candidate_prefix in candidate_prefixes:
mapping_candidates.extend([
final_dir / f"{candidate_prefix}.{sanitize_token(scope_id)}.orthologs.txt",
final_dir / f"{candidate_prefix}.{sanitize_token(scope_id)}.ortholog_pairs.txt",
])
for candidate_prefix in candidate_prefixes:
mapping_candidates.extend([
final_dir / f"{candidate_prefix}.orthologs.txt",
final_dir / f"{candidate_prefix}.ortholog_pairs.txt",
])
mapping_candidates.append(in_dir / "ortholog_pairs.txt")
scoped_pairs_source = next((p for p in mapping_candidates if p.exists() and p.stat().st_size > 0), None)
if not scoped_pairs_source:
return "❌ KaKs scope 模式需要 Ortholog 映射文件。请先运行 Ortholog 阶段。", manifest
selected_mapping_owner = str(ortholog_mapping_owner or "").strip().lower()
if selected_mapping_owner not in {"", "ppi", "kaks"}:
return f"❌ KaKs Ortholog 映射所有者无效: {ortholog_mapping_owner}", manifest
mapping_candidates = []
for candidate_prefix in candidate_prefixes:
mapping_candidates.extend(ortholog_mapping_candidates(
final_dir,
candidate_prefix,
dependency_owner=selected_mapping_owner,
scope_id=scope_id,
))
if not selected_mapping_owner:
mapping_candidates.append(in_dir / "ortholog_pairs.txt")
scoped_pairs_source = next((p for p in mapping_candidates if p.exists() and p.stat().st_size > 0), None)
if not scoped_pairs_source:
owner_hint = f"({selected_mapping_owner} 专属)" if selected_mapping_owner else ""
return f"❌ KaKs scope 模式需要{owner_hint} Ortholog 映射文件。", manifest
ref_cds = resolve_species_sequence_file(ws, final_dir, in_dir, kaks_scope_data, ref_species, family, "cds")
ref_pep = resolve_species_sequence_file(ws, final_dir, in_dir, kaks_scope_data, ref_species, family, "pep", family_only=False)
+849 -308
View File
File diff suppressed because it is too large Load Diff
+125 -27
View File
@@ -1479,18 +1479,62 @@ def _canonicalize_species_codes_for_job_strict(job_id: str, species_codes: list[
def _reference_bundle_species_available(job_id: str, species_code: str, require_cds: bool = False) -> tuple[bool, str]:
"""Return whether a downloaded job-local reference bundle can satisfy PPI/KaKs."""
"""Return whether job-local or config-resolved reference data can satisfy PPI/KaKs."""
code = str(species_code or "").strip()
if not job_id or not code:
return False, "missing job_id/species_code"
species_dir = INPUT_ROOT / job_id / code
input_dir = INPUT_ROOT / job_id
species_dir = input_dir / code
pep_path = species_dir / f"{code}.pep.fasta"
cds_path = species_dir / f"{code}.cds.fasta"
if not pep_path.exists() or pep_path.stat().st_size <= 0:
local_pep_ready = pep_path.exists() and pep_path.stat().st_size > 0
local_cds_ready = cds_path.exists() and cds_path.stat().st_size > 0
if local_pep_ready and (not require_cds or local_cds_ready):
return True, str(species_dir)
try:
scope = build_gene_family_scope(
input_dir=input_dir,
workspace=WORKSPACE_ROOT / job_id,
prefix="",
target_species=[],
reference_species=[code],
comparative_species=[],
scope_mode="target_plus_reference",
)
species_files = scope.files.get(code, {})
def has_sequence(file_type: str) -> bool:
return any(
record.get("path")
and Path(str(record["path"])).exists()
and Path(str(record["path"])).stat().st_size > 0
for record in species_files.get(file_type, [])
)
if has_sequence("pep") and (not require_cds or has_sequence("cds")):
return True, str(input_dir)
except Exception:
pass
if not local_pep_ready:
return False, f"missing PEP: {pep_path}"
if require_cds and (not cds_path.exists() or cds_path.stat().st_size <= 0):
return False, f"missing CDS: {cds_path}"
return True, str(species_dir)
return False, f"missing CDS: {cds_path}"
def _technical_reference_bundle_lookup_code(req: Any, species_code: str) -> str:
"""Resolve the local bundle identity without changing the biological species code."""
code = str(species_code or "").strip()
storage_key = str(getattr(req, "ppiReferenceStorageKey", "") or "").strip()
if not storage_key or not bool(getattr(req, "ppiReferenceUserConfirmed", False)):
return code
stage = str(getattr(req, "stage", "") or "").strip().lower()
if stage == "ppi":
return storage_key
mapping_owner = str(getattr(req, "orthologMappingOwner", "") or "").strip().lower()
if stage == "kaks" and mapping_owner == "ppi":
return storage_key
return code
def _normalize_species_alias_for_job(value: str) -> str:
@@ -1682,6 +1726,23 @@ def _looks_like_gene_family_workflow_query(message: str) -> bool:
return any(token in text for token in ["基因家族分析", "家族分析", "完整分析", "全流程", "全部阶段", "完整流程"])
def _full_gene_family_workflow_query_text(message: str, history: list[Any]) -> str:
"""Recover the user's workflow request across the MainAgent confirmation turn."""
current = str(message or "").strip()
if _looks_like_gene_family_workflow_query(current):
return current
for item in reversed(history or []):
if not isinstance(item, dict):
continue
role = str(item.get("role") or item.get("sender") or "").strip().lower()
if role not in {"user", "human"}:
continue
text = str(item.get("content") or item.get("message") or item.get("text") or "").strip()
if _looks_like_gene_family_workflow_query(text):
return text
return ""
def _looks_like_intraspecies_synteny_query(message: str) -> bool:
text = str(message or "").lower()
return (
@@ -1985,7 +2046,7 @@ def _downstream_gene_family_stages_for_context(message: str) -> list[str]:
stages = [
"phylogeny", "motif", "domain", "structure", "combo",
"promoter", "tfbs", "synteny", "interspecies_synteny", "karyotype", "pep",
"ortholog", "kaks", "ppi",
"ppi", "kaks",
]
if any(token in text for token in ["表达", "expression", "rna"]):
stages.append("expression")
@@ -1995,7 +2056,6 @@ def _downstream_gene_family_stages_for_context(message: str) -> list[str]:
if any(token in text for token in ["种间", "比较物种", "interspecies"]):
stages.append("interspecies_synteny")
if any(token in text for token in ["kaks", "ka/ks", "选择压力"]):
stages.append("ortholog")
stages.append("kaks")
if any(token in text for token in ["ppi", "互作"]):
stages.append("ppi")
@@ -2066,6 +2126,9 @@ def _resolve_contextual_gene_family_request(
confirmed = _confirmed_subfamily_species(job_id, family)
confirmed_species = [item["species"] for item in confirmed if item.get("source") == "confirmed"]
mentioned = _extract_species_codes_from_text_for_job(job_id, text)
has_explicit_scope = bool(
mentioned or structured_targets or structured_refs or structured_comps
)
prefix_species = ""
if request_prefix and "." in request_prefix:
prefix_species = request_prefix.split(".", 1)[0].strip()
@@ -2458,7 +2521,7 @@ def _stage_detail_defaults(stage_id: str) -> Dict[str, Any]:
"interspecies_synteny": ["identify"],
"karyotype": ["identify", "synteny"],
"ortholog": ["identify"],
"kaks": ["identify", "ortholog"],
"kaks": ["identify"],
"ppi": ["identify"],
"expression": ["identify"],
}
@@ -2485,18 +2548,16 @@ FULL_GENE_FAMILY_WORKFLOW_STAGES = [
"interspecies_synteny",
"karyotype",
"pep",
"ortholog",
"kaks",
"ppi",
"kaks",
]
FORMAL_GENE_FAMILY_CORE_STAGES = {"phylogeny", "motif", "domain", "structure", "combo"}
FULL_GENE_FAMILY_TARGET_ONLY_STAGES = {"promoter", "tfbs", "synteny", "karyotype", "pep"}
FULL_GENE_FAMILY_TARGET_ONLY_STAGES = {"promoter", "tfbs", "synteny", "karyotype", "pep", "ppi"}
FULL_GENE_FAMILY_REFERENCE_STAGES = FORMAL_GENE_FAMILY_CORE_STAGES | {
"interspecies_synteny",
"ortholog",
"kaks",
"ppi",
}
SCOPE_IDENTITY_PARAM_KEYS = (
"scopeFile",
@@ -2624,6 +2685,8 @@ def _normalize_full_gene_family_workflow_plan(
if sid not in ordered:
ordered.append(sid)
for sid in stage_ids:
if sid == "ortholog":
continue
if sid and sid not in ordered:
ordered.append(sid)
@@ -2652,6 +2715,8 @@ def _normalize_full_gene_family_workflow_plan(
detail["dependencies"] = ["phylogeny", "motif", "domain", "structure"]
detail_params["comboPanels"] = ["motif", "domain", "structure"]
detail_params["comboPanelSource"] = "regular_gene_family"
if sid in {"ppi", "kaks"}:
detail["dependencies"] = ["identify"]
if sid in GENE_FAMILY_SCOPE_STAGES:
detail["params"] = enrich_stage_params_with_context(sid, plan_prefix, detail_params)
else:
@@ -5178,6 +5243,7 @@ def _prepare_execute_technical_reference_scope(
stage == "ortholog"
and str(getattr(req, "dependencyOwner", "") or "").strip().lower() == "ppi"
)
ppi_consumer_intent = stage == "ppi"
if ppi_owner_intent:
missing_fields = []
if not bool(getattr(req, "ppiReferenceUserConfirmed", False)):
@@ -5198,6 +5264,8 @@ def _prepare_execute_technical_reference_scope(
)
ppi_owned_ortholog = ppi_owner_intent
if ppi_owned_ortholog:
req.analysisRoute = "single_species"
req.memberSetId = ""
technical_reference_species = [
str(getattr(req, "ppiReferenceStorageKey", "") or "").strip()
]
@@ -5256,12 +5324,22 @@ def _prepare_execute_technical_reference_scope(
scope_params["scope_id"] = original_scope_id
scope_params["refSpeciesList"] = list(identity.reference_species)
scope_params["_validatedPpiOwnerScopeId"] = original_scope_id
req.memberSetId = f"{original_scope_id}:single_species:all_members"
else:
scope_params["scopeMode"] = "target_only"
scope_params["referenceSpecies"] = []
scope_params["refSpeciesList"] = []
scope_params["comparativeSpecies"] = []
scope_params["scopeFile"] = ""
elif ppi_consumer_intent:
req.analysisRoute = "single_species"
req.memberSetId = ""
technical_reference_species = []
scope_params["scopeMode"] = "target_only"
scope_params["referenceSpecies"] = []
scope_params["refSpeciesList"] = []
scope_params["comparativeSpecies"] = []
scope_params["scopeFile"] = ""
else:
technical_reference_species = []
if stage in {"ortholog", "ppi", "kaks"}:
@@ -6898,6 +6976,7 @@ class ExecuteRequest(BaseModel):
ppiReferenceSelectionMode: str = ""
ppiReferenceUserConfirmed: bool = False
dependencyOwner: str = ""
orthologMappingOwner: str = ""
identifyBlastReferenceSpecies: List[str] = []
primaryReferenceSpecies: List[str] = []
refSpeciesList: List[str] = []
@@ -7802,6 +7881,7 @@ def run_stage_sync(task_id: str, stage: str, job_id: str, params: dict):
ppi_reference_selection_mode=params.get("ppiReferenceSelectionMode") or params.get("ppi_reference_selection_mode") or "",
ppi_reference_user_confirmed=_bool_param("ppiReferenceUserConfirmed", "ppi_reference_user_confirmed", default=False),
dependency_owner=params.get("dependencyOwner") or params.get("dependency_owner") or "",
ortholog_mapping_owner=params.get("orthologMappingOwner") or params.get("ortholog_mapping_owner") or "",
ppi_score=params.get("ppiScore", 200),
# KaKs参数
kaks_neutral_rate=params.get("kaksNeutralRate", 7.08e-9),
@@ -11434,7 +11514,12 @@ async def execute_stage(req: ExecuteRequest):
for raw_value in raw_values:
raw_code = str(raw_value or "").strip()
if raw_code and raw_code.lower() in technical_ref_lookup:
available, reason = _reference_bundle_species_available(job_id, raw_code, require_cds=require_cds)
bundle_lookup_code = _technical_reference_bundle_lookup_code(req, raw_code)
available, reason = _reference_bundle_species_available(
job_id,
bundle_lookup_code,
require_cds=require_cds,
)
if not available:
raise ValueError(
f"PPI/KaKs 技术参考物种 {raw_code} 已选择但参考包不完整:{reason};"
@@ -12123,6 +12208,7 @@ async def execute_stage(req: ExecuteRequest):
"ppiScore": getattr(req, 'ppiScore', 200),
# KaKs参数
"kaksNeutralRate": getattr(req, 'kaksNeutralRate', 7.08e-9),
"orthologMappingOwner": getattr(req, 'orthologMappingOwner', ''),
# Genome Prepare参数
"genomePrepareType": getattr(req, 'genomePrepareType', 'auto'),
# Interspecies species selection
@@ -15246,7 +15332,7 @@ def _apply_reference_bundle_stale_watchdog(task: Dict[str, Any]) -> None:
progress = task.get("progress") if isinstance(task.get("progress"), dict) else {}
progress = {
"phase": progress.get("phase") or status or "running",
"message": progress.get("message") or task.get("message") or "参考物种注释包仍在后台下载",
"message": progress.get("message") or task.get("message") or "PPI and Ka/Ks reference species data are still downloading",
"percentage": progress.get("percentage"),
"indeterminate": progress.get("indeterminate", True),
"pending_seconds": task["pending_seconds"],
@@ -15264,13 +15350,13 @@ def _apply_reference_bundle_stale_watchdog(task: Dict[str, Any]) -> None:
def run_reference_bundle_download_task(task_id: str, payload: Dict[str, Any]) -> None:
tasks[task_id].update({
"status": "running",
"message": "正在后台下载参考物种注释包",
"message": "Downloading PPI and Ka/Ks reference species PEP and CDS",
"start_time": datetime.now().isoformat(),
"timeout_sec": int(payload.get("timeout_sec") or 300),
"payload": dict(payload),
"progress": {
"phase": "queued",
"message": "参考物种注释包下载任务已启动",
"message": "PPI and Ka/Ks reference species data download started",
"percentage": 0,
"indeterminate": True,
},
@@ -15301,7 +15387,7 @@ def run_reference_bundle_download_task(task_id: str, payload: Dict[str, Any]) ->
progress.setdefault("indeterminate", percentage is None)
tasks[task_id].update({
"status": "running",
"message": progress.get("message") or "正在后台下载参考物种注释包",
"message": progress.get("message") or "Downloading PPI and Ka/Ks reference species PEP and CDS",
"progress": progress,
})
_persist_reference_bundle_task(tasks[task_id])
@@ -15320,11 +15406,11 @@ def run_reference_bundle_download_task(task_id: str, payload: Dict[str, Any]) ->
success = result.get("status") == "success"
tasks[task_id].update({
"status": "success" if success else "failed",
"message": "参考物种注释包下载完成" if success else "参考物种注释包下载未满足必需文件",
"message": "PPI and Ka/Ks reference species data are ready" if success else "PPI and Ka/Ks reference species data are incomplete",
"end_time": datetime.now().isoformat(),
"progress": {
"phase": "completed" if success else "failed",
"message": "参考物种注释包下载完成" if success else "参考物种注释包下载未满足必需文件",
"message": "PPI and Ka/Ks reference species data are ready" if success else "PPI and Ka/Ks reference species data are incomplete",
"percentage": 100 if success else None,
"indeterminate": False if success else True,
},
@@ -15367,7 +15453,7 @@ async def download_reference_bundle_api(req: ReferenceBundleDownloadRequest):
"stage": "reference_bundle_download",
"job_id": req.job_id,
"status": "pending",
"message": "参考物种注释包下载任务已提交",
"message": "PPI and Ka/Ks reference species data download submitted",
"created_at": datetime.now().isoformat(),
"species_code": req.speciesCode,
"species_name": req.speciesName,
@@ -17146,7 +17232,11 @@ async def _run_agent_chat(
"session_id": session_id,
"input_paths": list(outcome.get("input_paths") or []),
"handoff_request": (
str((outcome.get("proposal") or {}).get("request") or "").strip()
str(
(outcome.get("proposal") or {}).get("original_request")
or (outcome.get("proposal") or {}).get("request")
or ""
).strip()
if isinstance(outcome.get("proposal"), dict)
else request.message
) or request.message,
@@ -18269,9 +18359,13 @@ def _build_contextual_gene_family_plan_response(request: Any, session_id: str, c
detail["params"] = enrich_stage_params_with_context(stage_id, plan_prefix, detail_params)
stages_detail.append(detail)
if _looks_like_gene_family_workflow_query(getattr(request, "message", "") or "") and not subfamily_prerequisite_mode:
full_workflow_query = _full_gene_family_workflow_query_text(
getattr(request, "message", "") or "",
getattr(request, "history", []) or [],
)
if full_workflow_query and not subfamily_prerequisite_mode:
stage_ids, stages_detail = _normalize_full_gene_family_workflow_plan(
message=getattr(request, "message", "") or "",
message=full_workflow_query,
stage_ids=stage_ids,
stages_detail=stages_detail,
plan_params=plan_params,
@@ -21919,9 +22013,13 @@ async def agent_plan(request: AgentPlanRequest):
intent["gene_family"] = combo_family
full_workflow_normalized = False
if _looks_like_gene_family_workflow_query(request.message or "") and not subfamily_prediction_query:
full_workflow_query = _full_gene_family_workflow_query_text(
request.message or "",
request.history or [],
)
if full_workflow_query and not subfamily_prediction_query:
stage_ids, stages_detail = _normalize_full_gene_family_workflow_plan(
message=request.message or "",
message=full_workflow_query,
stage_ids=stage_ids,
stages_detail=stages_detail,
plan_params=plan_params,
@@ -21953,7 +22051,7 @@ async def agent_plan(request: AgentPlanRequest):
if full_workflow_normalized:
stage_ids, stages_detail = _normalize_full_gene_family_workflow_plan(
message=request.message or "",
message=full_workflow_query,
stage_ids=stage_ids,
stages_detail=stages_detail,
plan_params=plan_params,
@@ -0,0 +1,308 @@
# PPI First, KaKs Reference Choice Implementation Plan
> **Working repository:** `~/remote-projects/5090/teamWorkspace/sj-2/PlantAI-dev`
>
> **Goal:** In the normal full gene-family workflow, show PPI and KaKs but not their internal Ortholog producers. Run PPI first. After PPI reaches a real terminal outcome, open the normal KaKs confirmation dialog and let the user choose either the PPI technical reference or the configured project reference. Keep the two mappings and manifests isolated.
## Task determination
This is a temporary execution-closure task, not the long-term KaKs reference-quality project.
The user-visible workflow is:
```text
normal gene-family plan
-> PPI (direct STRING or hidden PPI-owned Ortholog)
-> KaKs reference confirmation
-> PPI technical reference
-> configured project reference (for the current Ft case: At)
-> hidden KaKs-owned Ortholog only when an exact reusable mapping is absent
-> KaKs
```
The current failure has three connected causes:
1. The deterministic full-workflow normalizer adds `ortholog` to the visible plan, while KaKs also has a hard `ortholog` dependency.
2. The frontend inserts one visible Ortholog stage and lets PPI and KaKs compete for that stage's owner/reference state.
3. All Ortholog producers materialize the same `final/<prefix>.orthologs.txt` and `<prefix>.ortholog.manifest.json`; PPI-near-reference and KaKs-At runs can overwrite each other.
Fixing only the plan list, only the popup order, or only the mapping filename is insufficient. These three seams must change together.
## First-principles invariants
1. PPI chooses a STRING route for PPI; KaKs chooses a comparative reference for KaKs. One choice must not silently mutate the other.
2. Ortholog remains a supported explicit analysis stage, but is an internal producer when required by PPI or KaKs.
3. KaKs waits for PPI sequencing without declaring a hard PPI dependency. PPI failure, explicit skip, or unavailable PPI reference must still release the KaKs dialog.
4. A PPI-owned mapping and a KaKs-owned mapping may coexist for one prefix and different references.
5. A mapping is reusable only when target/member FASTA identity, reference identity, reference PEP checksum, scope identity, and mapping checksum match.
6. PPI remains PEP-only. Missing KaKs CDS must never prevent PPI submission or completion.
7. The configured reference remains unchanged for unrelated stages. A KaKs selection is written only to KaKs-owned parameters/state.
## Scope and assumptions
### In scope
- The normal/default full gene-family plan no longer contains implicit `ortholog`.
- The normal plan orders PPI before KaKs.
- KaKs has no visible/static hard dependency on Ortholog or PPI; runtime orchestration supplies its internal mapping.
- The existing KaKs stage confirmation dialog gains two reference-source choices.
- PPI-owned and KaKs-owned Ortholog outputs use separate namespaces.
- Same-reference exact mapping reuse is supported.
- PPI completion/failure/explicit skip releases KaKs; a reference download still running does not.
- PPI/KaKs failures remain isolated from unrelated stages.
### Preserved behavior
- An explicit user request for “直系同源基因鉴定/Ortholog” still produces and displays the standalone Ortholog stage and canonical standalone outputs.
- Direct-target STRING PPI remains direct and does not create Ortholog.
- Existing PPI STRING species selection, ID normalization, PPI CLI, KaKs CLI, and scientific algorithms remain unchanged.
- Methodology execution, subfamily workflow, main-Agent handoff, intent Agent, PlanningAgentV2 implementation, and analysis YAML are not redesigned.
### Deferred
- The generic high-quality KaKs reference selector using taxonomy, assembly quality, BUSCO, annotation completeness, and PEP/CDS quality.
- Automatic ranking of multiple non-PPI KaKs candidate species.
- Generic scheduler refactoring or frontend component extraction.
## Reference-choice rules
The KaKs dialog appears only after the PPI stage has settled for this batch.
| PPI outcome | PPI-reference option | Config-reference option | KaKs behavior |
|---|---|---|---|
| PPI direct-target STRING | Hidden; there is no separate comparative PPI reference | Show configured reference | Continue with config choice |
| PPI reference selected and PEP/CDS/mapping exact | Enabled | Show configured reference | Reuse PPI mapping if selected |
| PPI reference selected, CDS missing | Enabled with “需补齐 CDS” | Show configured reference | If selected, download/complete only the KaKs bundle, then continue |
| PPI reference selected, mapping missing/stale | Enabled if PEP/CDS can be prepared | Show configured reference | Run hidden KaKs-owned Ortholog |
| PPI failed or was skipped | Show only if a separate PPI reference and usable files exist | Show configured reference | PPI outcome does not block confirmation |
| Neither choice has PEP+CDS | Disabled with exact missing-file reasons | Disabled with exact missing-file reasons | Pause only KaKs |
The reference choice is explicit. No silent fallback from one option to the other.
## Task 1 — Freeze the visible planning contract
**Files:**
- Modify: `apps/web/vue3/main.py`
- Modify: `gfa_core/mas/stage_catalog.py`
- Modify: `apps/web/vue3/index.html`
- Modify: `tests/unit/test_task_status_attachment.py`
- Modify: `tests/unit/test_subfamily_prediction_plan.py` or add one focused full-workflow plan test
- Modify: `tests/unit/test_ppi_frontend_display_contract.py`
### Steps
1. Add failing tests for the normal “执行基因家族分析” response:
- `ortholog` is absent from `plan.stages` and `stages_detail`;
- `ppi` and `kaks` are present;
- `ppi` appears before `kaks`;
- standalone explicit Ortholog remains supported.
2. Remove implicit Ortholog from `_downstream_gene_family_stages_for_context()` and `FULL_GENE_FAMILY_WORKFLOW_STAGES`.
3. Change the normal static KaKs dependency from `identify + ortholog` to `identify` in the normal planning catalog, plan-detail builder, and frontend dependency table.
4. Keep the standalone Ortholog catalog entry and explicit Ortholog intent unchanged.
5. Do not filter Ortholog out in the rendered frontend. The backend plan itself must be correct.
### Acceptance gate
- A normal full plan displays PPI and KaKs, never implicit Ortholog.
- An explicit Ortholog request still displays Ortholog.
- No frontend-only post-processing is used to disguise a wrong backend plan.
## Task 2 — Give internal mappings owner-specific identities
**Files:**
- Modify: `gfa_core/ortholog_materialize.py`
- Modify: `apps/web/pipeline_backend.py`
- Modify: `apps/web/vue3/main.py` only for the narrow execution-request fields needed to carry mapping ownership
- Modify: `tests/unit/test_ppi_ortholog_owner_identity_task5b.py`
- Modify: `tests/unit/test_pipeline_backend_execution_env.py`
### Output contract
Use one path helper rather than repeating filename construction:
```text
standalone: final/<prefix>.orthologs.txt
PPI owner: final/<prefix>.ppi.orthologs.txt
KaKs owner: final/<prefix>.kaks.orthologs.txt
```
Apply the same owner namespace to pairs, raw pairs, BLAST output, sidecar, and Ortholog manifest. Keep `manifest.stage = "ortholog"`; record `dependency_owner` and the effective mapping identity in parameters/provenance.
### Steps
1. Add failing coexistence tests that materialize PPI-owned Spinacia and KaKs-owned At mappings for the same prefix and assert both remain unchanged.
2. Extend `materialize_ortholog_final_outputs()` with a narrow owner/output namespace. Empty owner preserves every current standalone filename.
3. In `run_stage("ortholog")`, accept only `""`, `"ppi"`, or `"kaks"` as mapping owner and pass it to materialization.
4. Centralize owner-aware candidate paths for preflight and execution. Do not let PPI or KaKs fall through to a different owner's mapping merely because that file exists.
5. Add a narrow KaKs mapping-source owner field to `ExecuteRequest` and backend forwarding. Prefer an owner token over a client-supplied arbitrary filesystem path.
6. Validate reuse against the existing Ortholog identity sidecar:
- selected reference storage/species identity;
- target/query FASTA identity;
- member identity;
- reference PEP checksum;
- scope identity;
- mapping checksum.
7. Preserve canonical filenames and resolution for explicit standalone Ortholog.
### Acceptance gate
- PPI Spinacia and KaKs At mappings coexist.
- Rerunning one owner does not change the other owner's file or Manifest checksum.
- A stale/wrong-reference mapping is rejected rather than silently reused.
- Existing standalone Ortholog tests still pass.
## Task 3 — Sequence KaKs after PPI without a hard dependency
**Files:**
- Modify: `apps/web/vue3/index.html`
- Modify: `tests/unit/test_ppi_owner_scheduling_task5.py`
- Modify: `tests/unit/test_ppi_frontend_display_contract.py`
### Steps
1. Add scheduler tests for a batch containing both PPI and KaKs:
- KaKs is not submitted while PPI is pending, running, or paused for an active reference download;
- PPI completed releases KaKs;
- PPI failed releases KaKs;
- PPI explicitly skipped releases KaKs;
- unrelated stages continue while PPI waits.
2. Add one narrow soft sequencing state to the existing batch state. Do not encode PPI as a KaKs dependency in `stageDependencies`.
3. Release the gate only on a real PPI terminal outcome. Generic `paused` is not sufficient because an active background reference download is resumable.
4. Remove the path that inserts/replaces a visible `ortholog` item in `batchExecuteStages` for PPI/KaKs ownership.
5. Reuse the existing direct internal producer submission helper, but track its result under the owner stage rather than `stageExecutionStatus.ortholog`.
6. Ensure PPI internal-producer failure pauses/fails only PPI and still releases KaKs confirmation when terminal.
### Acceptance gate
- There is no Ortholog row or Ortholog confirmation popup in the normal batch.
- KaKs confirmation cannot race ahead of PPI.
- PPI failure cannot cause KaKs to be dependency-failed.
- Other unrelated stages are not serialized behind PPI.
## Task 4 — Add the KaKs reference choice to the existing dialog
**Files:**
- Modify: `apps/web/vue3/index.html`
- Modify: `apps/web/vue3/main.py` only if a read-only candidate-readiness payload is needed
- Modify: `apps/web/pipeline_backend.py` only to return owner-aware readiness/identity data
- Modify: `tests/unit/test_ppi_frontend_display_contract.py`
- Modify: `tests/unit/test_ppi_snapshot_preflight_task3.py`
### Steps
1. Reuse the existing stage-confirmation dialog and its PPI reference card styles. Do not create a second visual system or a synthetic visible stage.
2. When opening the KaKs confirmation dialog, build two candidates:
- `ppi_reference`: the separate PPI technical reference, only when one exists;
- `project_reference`: the configured reference from the confirmed project scope.
3. For each candidate, show species label/code, source, PEP status, CDS status, mapping status, and the reason when unavailable.
4. Store the confirmed choice only in KaKs stage params, for example `kaksReferenceSource`, `referenceSpecies/refSpeciesList`, and `orthologMappingOwner`. Do not update PPI owner fields or unrelated plan-level reference fields.
5. If the PPI candidate lacks CDS, use the existing reference-bundle endpoint with `requestedStages=["kaks"]` to complete that selected species. PPI remains completed and is never reopened or downgraded.
6. If the selected reference lacks an exact mapping:
- submit a hidden `dependencyOwner="kaks"` Ortholog producer;
- on success, submit KaKs with `orthologMappingOwner="kaks"`;
- if the exact PPI-owned mapping is reusable, submit KaKs with `orthologMappingOwner="ppi"` and do not recompute.
7. If neither candidate can be made ready, pause only KaKs and retain a retryable, explicit reason.
### Acceptance gate
- For the Ft case, the dialog can show the PPI near-reference and configured At as separate choices.
- Selecting At cannot change PPI's reference TaxID, mapping, Manifest, or artifacts.
- Selecting the PPI reference cannot change config/project reference for other stages.
- A missing CDS or mapping produces a KaKs-only action/pause.
## Task 5 — Synchronize the minimum contracts
**Files:**
- Modify: `gfa_core/schemas/stages/ppi_contract_v1.md`
- Modify: `gfa_core/schemas/stages/kaks_contract_v1.md`
- Modify: `gfa_core/schemas/stages/ortholog_contract_v1.md`
### Steps
1. Document PPI-owned and KaKs-owned internal Ortholog identities and standalone Ortholog preservation.
2. Document that PPI and KaKs readiness/reference state are independent.
3. Document the KaKs reference-source choice and exact mapping-reuse rule.
4. Document soft sequencing: PPI terminal outcome releases KaKs, but PPI success is not a KaKs prerequisite.
Do not change CLI inputs or the scientific method contracts beyond these execution/provenance facts.
## Task 6 — Verification without polluting the user's real case
### Automated tests
Run the focused tests first:
```bash
/home/xia-lab/miniconda3/envs/test4py311/bin/python -m pytest -q \
tests/unit/test_task_status_attachment.py \
tests/unit/test_ppi_frontend_display_contract.py \
tests/unit/test_ppi_owner_scheduling_task5.py \
tests/unit/test_ppi_ortholog_owner_identity_task5b.py \
tests/unit/test_ppi_snapshot_preflight_task3.py \
tests/unit/test_pipeline_backend_execution_env.py -k 'ppi or kaks or ortholog or full_workflow'
```
Then run the related gene-family planning/API suite discovered from failures. Do not broaden to unrelated WGS/RNA-seq work unless a shared contract test fails.
### Isolated real-data matrix
Create a dedicated test job ID/folder. Do not write PPI, KaKs, or Ortholog outputs into `job_1786184894919_b7d741/final` or any user acceptance job.
Validate these routes:
1. **Ft + PPI near reference + KaKs same reference**
- visible plan has PPI/KaKs and no Ortholog;
- PPI-owned mapping succeeds;
- KaKs dialog appears after PPI;
- choose PPI reference;
- exact mapping is reused;
- PPI and KaKs succeed.
2. **Ft + PPI near reference + KaKs At**
- choose configured At;
- KaKs-owned At mapping is generated;
- PPI near-reference mapping and Manifest checksums remain unchanged;
- KaKs succeeds with At PEP/CDS.
3. **PPI failure/skip isolation**
- force PPI to fail or explicitly skip it;
- KaKs dialog still appears;
- choose At and complete KaKs;
- unrelated runnable stages keep their own statuses.
4. **PPI active download gate**
- while PPI reference download is running, KaKs dialog does not open;
- after PPI settles, it opens once.
5. **Standalone Ortholog regression**
- explicitly request Ortholog;
- visible standalone stage and canonical outputs remain available.
For each route capture request payloads, task statuses, owner-specific Manifests, mapping checksums, and final stage statuses in the dedicated test folder.
### Live service verification
After unit/API verification, restart only the sj-2 service on port 9016. Verify the served HTML contains the new contract, then exercise one isolated frontend job. Do not restart unrelated workers/services unless backend code loading proves it is necessary.
## Stop rules
Stop and re-diagnose if implementation requires any of the following:
- changing PPI/STRING ID mapping or network analysis logic;
- changing KaKs calculation logic or neutral-rate semantics;
- modifying main-Agent/intent-Agent prompts or analysis YAML;
- making PPI wait for CDS;
- writing PPI reference into shared project reference fields;
- overwriting another owner's mapping/Manifest;
- hiding Ortholog only by frontend display filtering while it remains in the normal backend plan.
## Final acceptance criteria
- Normal full gene-family planning shows PPI and KaKs, not implicit Ortholog.
- PPI is processed before KaKs reference confirmation.
- KaKs offers the PPI technical reference and configured reference when valid.
- PPI success is not required for KaKs to continue.
- PPI and KaKs mappings/manifests are owner-isolated and reproducible.
- At selection for KaKs does not mutate PPI; PPI-near-reference selection does not mutate unrelated stages.
- No original user job is polluted by verification outputs.
@@ -0,0 +1,252 @@
# PPI 专属 Ortholog 参考 PEP 身份一致性修订计划
> **执行约束:** 实施时必须继续遵循 `karpathy-guidelines`:先写可失败的回归测试,只做最小生产改动,每一步都用可观察证据验收;不得顺手重构前端、规划层、下载层或身份校验层。
**目标:** 当 PPI 使用已下载近源物种或用户显式确认的 At 作为技术参考物种时,PPI 专属 Ortholog 的生产端与 PPI 二次预检必须使用同一份全蛋白组 PEP,使 Ortholog → PPI → Ka/Ks 能继续调度,同时保持普通独立 Ortholog 的原有 family PEP 语义不变。
**架构边界:** PPI 的技术参考物种只属于 `dependency_owner=ppi` 的隐藏依赖,不进入公共规划阶段,也不改变 `target_only` 生物学 scope。修复点仅位于后端 Ortholog 输入解析分支;严格的路径、SHA256、taxon、storage key、mapping identity 校验继续保留。
**技术栈:** Python 3.11、FastAPI、Celery、pytest;真实验证使用 sj-2 的 9016 Web/API 和对应 Celery workers。
---
## 1. 已确认的问题
### 1.1 当前真实失败链
真实任务 `job_1786243816439_04955b` 已给出完整证据链:
1. 菠菜参考包下载尝试 3 次后因 `IncompleteRead(488265 bytes read)` 失败;重试机制工作正常。
2. 用户显式选择 At 兜底后,PPI 专属 Ortholog 成功生成 24 对映射。
3. 随后的 PPI 二次预检返回 `stale_ortholog_identity_conflict`,PPI 未提交。
4. Ka/Ks 正确识别同一参考准备链,未再次启动下载,但因 PPI 映射未通过身份校验而暂停。
### 1.2 根因不是“身份校验太严格”,而是生产者与消费者解析了不同文件
| 环节 | 实际解析文件 | 结果 |
|---|---|---|
| PPI 专属 Ortholog 生产端 | `input/.../At.SPL.pep.fasta` | family PEP,9,929 bytes,SHA256 `42dca87b...` |
| PPI 二次预检消费端 | `input/.../At/At.pep.fasta` | whole-proteome PEP,34,646,980 bytes,SHA256 `193f9d45...` |
两者虽然都叫 At,但不是同一个输入身份。Ortholog Manifest 和 sidecar 如实记录了 family PEP;PPI 预检按全蛋白组 PEP 构建期望身份,因此拒绝复用是正确行为。
### 1.3 精确代码位置
生产端位于 `apps/web/pipeline_backend.py` 的 `stage == "ortholog"` 分支:
```python
subject_fasta = first_existing_scope_file(
ortholog_scope_data,
reference_species,
["family_pep", "pep"],
)
if not subject_fasta:
subject_fasta = resolve_species_family_pep(...)
if not subject_fasta:
subject_fasta = resolve_species_sequence_file(..., family_only=False)
```
即使 `ppi_owned_producer=True`,它仍优先选 family PEP。
消费端 `_resolve_ppi_only_snapshot_preflight()` 经 `_ppi_mapping_identity_matches_current_inputs()` 使用 `resolve_species_sequence_file(..., family_only=False)`,解析的是技术参考物种的 whole-proteome PEP。
### 1.4 最近三轮修订与本次失败的关系
| 修订轮次 | 已产生的变化 | 当前结论 |
|---|---|---|
| PPI owner/scope 修订 | 隐藏 Ortholog 归 PPI 所有;公共 scope 保持 `target_only`;增加严格身份校验和二次预检 | 设计正确,必须保留 |
| 下载进度展示修订 | Tip 旁显示下载任务、字节数和终态 | 只增强可见性,没有制造数据链问题 |
| PEP+CDS 一次下载修订 | PPI 阶段一次准备 PEP+CDS;`IncompleteRead` 最多重试 3 次;Ka/Ks 不再重复下载 | 当前日志证明已生效;它暴露了此前项目记录中已暂缓的 PEP 身份不一致 |
因此,本次不能回退上述三轮修订,也不能通过删除严格身份校验“让它通过”。
## 2. 冻结的最小修订方案
### 2.1 唯一生产代码改动
**文件:** `apps/web/pipeline_backend.py`
在 `stage == "ortholog"` 的 subject 解析处,仅对 `ppi_owned_producer` 分流:
```python
if ppi_owned_producer:
subject_fasta = resolve_species_sequence_file(
ws,
final_dir,
in_dir,
ortholog_scope_data,
reference_species,
family,
"pep",
family_only=False,
)
else:
subject_fasta = first_existing_scope_file(
ortholog_scope_data,
reference_species,
["family_pep", "pep"],
)
if not subject_fasta:
subject_fasta = resolve_species_family_pep(
ws, final_dir, in_dir, reference_species, family
)
if not subject_fasta:
subject_fasta = resolve_species_sequence_file(
ws,
final_dir,
in_dir,
ortholog_scope_data,
reference_species,
family,
"pep",
family_only=False,
)
```
该分流保证:
- PPI 专属 Ortholog 与 PPI 预检调用同一个 whole-proteome resolver;
- 菠菜缓存参考和 At 兜底都走同一规则,而不是写死 Ft、菠菜或 At;
- 普通独立 Ortholog 仍优先使用 family PEP,不改变既有科研语义;
- Ka/Ks 继续复用 PPI 已确认的 bundle/mapping owner,不新增第二次 Ortholog。
### 2.2 明确禁止的改动
本次不得修改:
- `apps/web/vue3/index.html` 的规划、调度、弹窗和进度状态机;
- `apps/web/vue3/main.py` 的接口契约和下载任务实现;
- PlanningAgentV2、主 Agent、意图 Agent 或任何 YAML 阶段定义;
- `gfa_core/ortholog_materialize.py` 的严格 identity 计算与比较;
- `stage_context.reference_species`、公共 `scope_id` 或 PPI 的 `target_only` 语义;
- PEP+CDS 一次下载、3 次重试、Ka/Ks 禁止二次下载的现有逻辑;
- 全局调整 `first_existing_scope_file(["family_pep", "pep"])` 的优先级。
## 3. 实施任务
### Task 1:先建立能复现真实冲突的 RED 测试
**文件:** `tests/unit/test_ppi_ortholog_owner_identity_task5b.py`
- [ ] 增加 At 同时存在两套 PEP 的 fixture:
- `input/At.SPL.pep.fasta`:family PEP;
- `input/At/At.pep.fasta`:whole-proteome PEP;
- `input/At/At.cds.fasta`:与 whole PEP 使用相同 transcript ID,如 `AT5G16970.1`。
- [ ] 以 `dependency_owner="ppi"`、`ppi_reference_storage_key="At"`、`ppi_reference_taxon_id="3702"` 执行隐藏 Ortholog。
- [ ] 断言当前代码的 `--subject` 错误指向 `At.SPL.pep.fasta`,并使“必须指向 `At/At.pep.fasta`”的新断言先失败。
- [ ] 断言生成映射后再次调用 `resolve_ppi_species_resolution()`;当前代码应因 reference PEP SHA 不同而不是 `ready`。
运行:
```bash
/home/xia-lab/miniconda3/envs/test4py311/bin/python -m pytest -q \
tests/unit/test_ppi_ortholog_owner_identity_task5b.py \
-k "whole_reference_pep"
```
预期 RED:失败信息明确显示 expected `At/At.pep.fasta`,actual `At.SPL.pep.fasta`;不得用其他异常作为“红灯”。
### Task 2:实施 PPI owner 专属的 resolver 分流
**文件:** `apps/web/pipeline_backend.py`
- [ ] 按 2.1 的代码结构修改 subject 解析。
- [ ] 不改变 `references=[ppi_owner_storage_key]`、`_effective_ppi_owner_scope_id()`、`member_set_id=""`、owner-specific 输出命名。
- [ ] 保留找不到 whole PEP 时的明确失败,不允许静默回退到 family PEP;否则生产端和预检会再次形成双重真相。
- [ ] 运行语法检查:
```bash
/home/xia-lab/miniconda3/envs/test4py311/bin/python -m py_compile \
apps/web/pipeline_backend.py
```
### Task 3:补齐身份与非回归测试
**文件:** `tests/unit/test_ppi_ortholog_owner_identity_task5b.py`
- [ ] GREEN 断言 PPI-owned Ortholog 命令的 `--subject` 精确等于 whole-proteome PEP。
- [ ] 断言 PPI owner sidecar 与 Manifest 的 `reference_pep_sha256`、`reference_storage_key`、`reference_taxon_id`、`mapping_path` 全部对应同一 whole PEP。
- [ ] 断言二次 PPI preflight 返回 `status == "ready"`,不再出现 `stale_ortholog_identity_conflict`。
- [ ] 断言 PPI Manifest 仍为 `scope_mode == "target_only"`,`stage_context.reference_species == []`,`selected_species == ["Ft"]`。
- [ ] 新增普通独立 Ortholog 回归用例:当 family PEP 与 whole PEP 同时存在时,非 PPI owner 仍选择 family PEP。
- [ ] 为直接相关的旧 fixture 补充 CDS,使其进入身份校验分支;不改与本问题无关的前端文本断言或 effective-member fixture。
运行:
```bash
/home/xia-lab/miniconda3/envs/test4py311/bin/python -m pytest -q \
tests/unit/test_ppi_ortholog_owner_identity_task5b.py
/home/xia-lab/miniconda3/envs/test4py311/bin/python -m pytest -q \
tests/unit/test_ppi_ortholog_owner_identity_task5b.py \
tests/unit/test_ppi_owner_scheduling_task5.py \
tests/unit/test_ppi_frontend_display_contract.py
```
验收时必须把修订前基线与修订后结果分开报告。已知旧前端字符串断言、旧 fixture 缺 CDS、effective-member 相关失败不得被伪装为本次新增回归,也不得借机修改生产代码。
### Task 4:在独立验证 job 做真实 API 闭环
**位置:** sj-2 独立创建 `job_codex_ppi_kaks_pep_identity_<timestamp>`,不得把验证产物写入用户当前 job 的 `final/`。
- [ ] 从真实 Ft/At 案例只读复用以下输入:Ft 家族成员 PEP、At family PEP、At whole PEP、At whole CDS、scope/config。
- [ ] 初次调用 `POST /api/ppi/species-resolution`,使用:
```json
{
"job_id": "job_codex_ppi_kaks_pep_identity_<timestamp>",
"prefix": "Ft.SPL",
"targetSpecies": ["Ft"],
"referenceSpecies": [],
"geneFamily": "SPL",
"ppiReferenceTaxonId": "3702",
"ppiReferenceStorageKey": "At",
"ppiReferenceSelectionMode": "explicit_fallback",
"ppiReferenceUserConfirmed": true,
"dependencyOwner": "ppi",
"requestedStages": ["ppi"]
}
```
- [ ] 初次预检必须为 `needs_ortholog`,且不再要求下载完整 At bundle。
- [ ] 调用 `POST /api/execute` 执行隐藏 `ortholog`,请求保留 `dependencyOwner="ppi"`、`targetSpecies=["Ft"]`、`referenceSpecies=[]` 和 PPI owner 字段。
- [ ] 轮询任务直至成功,并检查后端命令日志:`--query` 为 Ft family PEP,`--subject` 为 `At/At.pep.fasta`。
- [ ] 检查 PPI-owned Ortholog 产生有效映射、sidecar、Manifest,映射数量大于 0;所有 reference identity 字段一致。
- [ ] 再次调用 `/api/ppi/species-resolution`,必须返回 `ready`,不得返回 stale identity conflict。
- [ ] 调用 `/api/execute` 执行 PPI,轮询到 terminal success;检查 PPI Manifest 和网络产物,不以“任务已提交”代替成功。
- [ ] 进入 Ka/Ks 确认路径,确认它复用 PPI 选择的参考 bundle 和 `orthologMappingOwner=ppi`;不得出现第二次参考下载或第二个 Ortholog producer。
- [ ] 执行 Ka/Ks 至 terminal success,并核对 Ka/Ks Manifest 的 reference/mapping provenance。
- [ ] 最后确认活动任务数为 0、前端计时漏斗停止、PPI/KaKs 的 terminal 状态能正常显示。
### Task 5:覆盖近源物种路径并区分外部下载故障
- [ ] 若 `string_taxon_3562` 已有完整且非损坏的 PEP+CDS bundle,在独立验证 job 重复 Task 4,要求 PPI-owned Ortholog 的 `--subject` 为 `string_taxon_3562` whole PEP,二次预检为 `ready`。
- [ ] 若菠菜下载仍因远端 `IncompleteRead` 失败,只验收重试次数、字节进度、terminal failure 和 At 手动兜底入口;明确记录为外部下载失败,不得把它误判为本次 identity 修订失败。
- [ ] 不为通过测试而在代码中预设 Ft、菠菜或 At;两条路径必须由同一 storage-key + whole-PEP 解析规则驱动。
### Task 6:部署 sj-2 并做用户前端验收
- [ ] 确认修改发生在 `/home/xzq/5090/teamWorkspace/sj-2/PlantAI-dev`,不是 sj 或主项目。
- [ ] 重启 sj-2 的 9016 Web 服务及该项目全部 Celery workers;启动时显式使用远端 Conda 根 `/home/xzq/miniconda3`。
- [ ] 检查 9016 健康接口、Web PID、Celery PID 和日志所指项目根均为 sj-2。
- [ ] 用户前端验证前,只清理独立验证 job;不得删除用户任务或公共 data。
- [ ] 用户用新 job 验收以下两条路径:
1. 近源参考包可用:一次准备 PEP+CDS → 一个隐藏 PPI-owned Ortholog → PPI → Ka/Ks 确认与复用;
2. 近源下载失败:显示进度和失败 → 用户确认 At → 一个隐藏 PPI-owned Ortholog → PPI → Ka/Ks 确认与复用。
## 4. 完成判据
只有同时满足以下条件,才能宣称修复完成:
- PPI-owned Ortholog 的实际 `--subject` 与 PPI 预检解析出的 whole PEP 是同一路径、同一 SHA256;
- Ortholog Manifest、identity sidecar、PPI preflight 的 taxon/storage/path/hash/mapping identity 完全一致;
- PPI 二次预检为 `ready`,PPI 真实执行成功并生成有效产物;
- Ka/Ks 在 PPI 后出现确认,复用已准备的 PEP+CDS 和 PPI mapping owner,不二次下载、不二次生成 Ortholog;
- 公共规划中不出现 Ortholog,PPI 公共 scope 仍为 Ft `target_only`;
- 独立 Ortholog 保持 family PEP 优先,既有分析语义不变;
- 前端无无限旋转,所有 task 到达 terminal 状态;
- 真实验证产物只存在独立测试目录,不污染用户 job。
## 5. 回滚边界
若新回归测试证明 `resolve_species_sequence_file(..., family_only=False)` 在某种 storage key 下不能稳定解析 whole PEP,只回滚本次单一分流并保留 RED 证据;不得回滚 owner/scope、严格 identity、PEP+CDS 一次下载或前端进度显示。此时应先补清 resolver 输入契约,再重新评估,不得用 family PEP 或禁用 SHA 校验作为临时通行证。
@@ -0,0 +1,43 @@
# PPI/KaKs Integration Commit and Push Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Commit the validated PPI → owner-specific Ortholog → Ka/Ks workflow and push it to the main PlantAI repository without including runtime data.
**Architecture:** Preserve the tested cross-layer change as one functional integration: frontend orchestration, API/backend identity resolution, owner-namespaced Ortholog materialization, contracts, and their tests. Keep input, workspace, downloaded bundles, logs, caches, and shared data outside Git.
**Tech Stack:** Vue 3 single-page frontend, FastAPI, Celery, Python, pytest, Git.
## Global Constraints
- Push must be a normal fast-forward update; never force-push.
- Do not stage `input/`, `workspace/`, `final/`, downloaded reference bundles, logs, PID files, caches, or shared `data` content.
- Preserve standalone, PPI-owner, and KaKs-owner Ortholog namespaces.
- Verify the relevant regression suite before committing and re-check the committed tree before pushing.
---
### Task 1: Clean the commit boundary
**Files:** All currently modified tracked source, contract, and test files; the three PPI/KaKs plan/spec documents.
- [ ] Remove whitespace errors without changing runtime behavior.
- [ ] Update stale source-contract assertions to the current unified dialog and producer variable names.
- [ ] Confirm no runtime outputs are staged.
### Task 2: Verify the integrated workflow
**Files:** `tests/unit/test_ppi*.py`, reference-bundle tests, handoff/snapshot tests, and the affected production files.
- [ ] Run JavaScript syntax validation for `apps/web/vue3/index.html`.
- [ ] Run the focused PPI/KaKs, reference download, owner identity, and context regression tests.
- [ ] Run `git diff --check` and inspect the final staged file list.
### Task 3: Commit and push
**Files:** Only the verified source, contracts, tests, and documentation from Tasks 1–2.
- [ ] Fetch `origin/master` and verify the push remains fast-forward.
- [ ] Commit with a PPI/KaKs integration message.
- [ ] Push the committed branch to `origin/master` without force.
- [ ] Verify the remote master commit ID equals the local committed ID.
@@ -0,0 +1,383 @@
# PPI/KaKs Reference Route Handoff Implementation Plan
**Goal:** 修复 PPI 完成后 Ka/Ks 的两条参考路线,使用户确认的参考物种与 Ortholog owner 原样传递到预检和执行,不发生二次推导、重复下载或错误暂停。
**Architecture:** 保留现有规划、后端 API、CLI 和 owner 专属 Manifest 契约。PlanningAgent 只规划 PPI 与 Ka/Ks;前端收集并传递用户选择;后端 resolver 只验证所选路线;隐藏 Ortholog producer 只在所选 owner 缺少映射时执行。
**Tech Stack:** Vue 3 内嵌脚本、FastAPI、pytest、Node 动态行为测试、Celery、现有 `/api/ppi/species-resolution` 与 `/api/execute`。
## Global Constraints
- 修订项目固定为 `/home/xzq/5090/teamWorkspace/sj-2/PlantAI-dev`,服务端口固定为 `9016`。
- 不修改 MainAgent、PlanningAgent、阶段目录、CLI、分析算法、Manifest Schema 或后端 API 契约。
- 规划继续只显示 PPI 和 Ka/Ks,不显示 Ortholog。
- PPI 技术参考选择只属于 PPI;Ka/Ks 必须在 PPI settled 后单独确认 `reuse PPI` 或 `project reference`。
- 用户确认是路线权威来源;前端不得在确认后从 batch/plan 全局状态重新推导另一条路线。
- 真实验证使用隔离 job 与独立输出目录,不写入用户 job 的 `final/`。
- 不实现跨 owner 哈希复用、页面刷新恢复、统一状态机、断点续传或其它扩展。
- 完成修订和验证后再重启 sj-2 Web 与全部 Celery worker;不自动 commit/push。
---
### Task 1: 用精确行为测试锁定两条路线
**Files:**
- Modify: `tests/unit/test_ppi_owner_scheduling_task5.py`
- Inspect only: `tests/unit/test_ppi_frontend_display_contract.py`
**Interfaces:**
- Consumes: `kaksReferenceChoiceParams`、`buildPpiKaksDependencyRequest`、`ppiNeedsNearReferenceDecision`、`prepareKaksSelectedReference`。
- Produces: 精确复现 PPI 映射复用、At 独立映射、确认参数直传和 PPI-only 近源规则的 RED 测试。
- [ ] **Step 1: 添加 PPI 近源复用路线测试**
构造以下确认结果和 PPI owner 状态:
```javascript
const confirmed = kaksReferenceChoiceParams('ppi', 'string_taxon_3562', 'At');
const ppiOwner = {
ppiReferenceTaxonId: '3562',
ppiReferenceStorageKey: 'string_taxon_3562',
ppiReferenceSelectionMode: 'tree_nearest',
ppiReferenceUserConfirmed: true
};
```
断言 `buildPpiKaksDependencyRequest(['kaks'], confirmed)` 的关键结果为:
```javascript
{
requestedStages: ['kaks'],
dependencyOwner: 'ppi',
referenceSpecies: ['string_taxon_3562'],
ppiReferenceTaxonId: '3562',
ppiReferenceStorageKey: 'string_taxon_3562',
ppiReferenceSelectionMode: 'tree_nearest',
ppiReferenceUserConfirmed: true
}
```
- [ ] **Step 2: 添加项目 At 路线测试**
```javascript
const confirmed = kaksReferenceChoiceParams('project', 'string_taxon_3562', 'At');
```
断言 `dependencyOwner='kaks'`、`referenceSpecies=['At']`,且不携带菠菜 PPI owner 身份。
- [ ] **Step 3: 添加 PPI-only 近源规则测试**
```javascript
const resolution = {
status: 'needs_ortholog',
selected_string_species: 'ath',
diagnostics: {
reference_recommendations: [{species_code: 'string_taxon_3562'}]
}
};
```
断言:
```javascript
ppiNeedsNearReferenceDecision(resolution, ['ppi']) === true;
ppiNeedsNearReferenceDecision(resolution, ['kaks']) === false;
```
- [ ] **Step 4: 添加确认参数直传测试**
Stub `ensurePpiKaksDependencyOwner`,执行 `prepareKaksSelectedReference(confirmed)`,断言精确调用:
```javascript
ensurePpiKaksDependencyOwner('kaks', confirmed);
```
- [ ] **Step 5: 执行 RED 验证**
```bash
/home/xzq/miniconda3/envs/test4py311/bin/python -m pytest -q \
tests/unit/test_ppi_owner_scheduling_task5.py \
-k 'kaks and (reuse or project or confirmed or near_reference)'
```
Expected: 新增测试按预期失败;不得因测试夹具或语法错误失败。
---
### Task 2: 让 Ka/Ks 预检消费用户确认后的唯一参数
**Files:**
- Modify: `apps/web/vue3/index.html:14534-14908`
- Test: `tests/unit/test_ppi_owner_scheduling_task5.py`
**Interfaces:**
- Consumes: `confirmedStageParamsForExecution('kaks', stageParams)` 的返回值。
- Produces: `buildPpiKaksDependencyRequest(stageIds, stageParamsOverride)` 与 `ensurePpiKaksDependencyOwner(ownerStageId, stageParamsOverride)`。
- [ ] **Step 1: 让请求构造器接受确认参数,不新增抽象层**
```javascript
const buildPpiKaksDependencyRequest = (stageIds = [], stageParamsOverride = null) => {
const dependencyStage = (batchExecuteStages.value || [])
.find(stage => getStageId(stage) === stageIds[0]) || {};
const storedStageParams = typeof dependencyStage === 'object'
? (dependencyStage.params || {})
: {};
const stageParams = stageParamsOverride && typeof stageParamsOverride === 'object'
? stageParamsOverride
: storedStageParams;
```
- [ ] **Step 2: 按 mapping owner 决定是否保留 PPI 身份**
```javascript
const mappingOwner = stageParams.orthologMappingOwner || ownerStageId;
const usesPpiOwner = mappingOwner === 'ppi';
```
仅 `usesPpiOwner` 时,从 `stageParams → batchParams → planParams` 读取 `ppiReferenceTaxonId`、`ppiReferenceStorageKey`、`ppiReferenceSelectionMode`、`ppiReferenceUserConfirmed`。项目 At 路线保持这些字段为空。
- [ ] **Step 3: 让 owner 预检接受确认参数**
```javascript
const ensurePpiKaksDependencyOwner = async (ownerStageId, stageParamsOverride = null) => {
const requestedStages = [ownerStageId];
const requestBody = buildPpiKaksDependencyRequest(requestedStages, stageParamsOverride);
```
- [ ] **Step 4: Ka/Ks 准备函数传入确认参数**
```javascript
const prepareKaksSelectedReference = async (stageParams = {}) => {
const ready = await ensurePpiKaksDependencyOwner('kaks', stageParams);
```
后续保持:owner 为 `ppi` 时复用已有映射;owner 为 `kaks` 时只生成一次 Ka/Ks 专属 Ortholog。
- [ ] **Step 5: 执行 GREEN 验证**
```bash
/home/xzq/miniconda3/envs/test4py311/bin/python -m pytest -q tests/unit/test_ppi_owner_scheduling_task5.py
```
Expected: 全文件通过;原有 PPI owner single-flight、running 状态、失败不 resume 行为仍通过。
---
### Task 3: 将近源优先规则限制为 PPI,并隔离 At 兜底
**Files:**
- Modify: `apps/web/vue3/index.html:13804-13927`
- Modify: `apps/web/vue3/index.html:14336-14400`
- Test: `tests/unit/test_ppi_owner_scheduling_task5.py`
**Interfaces:**
- Consumes: `requestedStages` 与 PPI fallback 用户确认结果。
- Produces: PPI-only 近源决策和 fail-closed 的 At fallback。
- [ ] **Step 1: 限制近源规则归属**
```javascript
const ppiNeedsNearReferenceDecision = (resolution = {}, requestedStages = []) => {
if (!requestedStages.includes('ppi')) return false;
const selected = String(resolution.selected_string_species || '').toLowerCase();
return resolution.status === 'needs_ortholog'
&& selected === 'ath'
&& ppiHasNearReferenceBeforeArabidopsis(resolution);
};
```
在 `runPpiSpeciesResolutionPreflight` 和 `ensurePpiKaksDependencyOwner` 中均传入当前 `requestedStages`。
- [ ] **Step 2: PPI下载失败后的At兜底不写Ka/Ks参数**
删除 `applyArabidopsisFallbackForPpiKaks` 中针对 `requestedStages.includes('kaks')` 的参数写入;affected stage 只包含 PPI owner 及其隐藏 Ortholog producer。Ka/Ks 保持原状态,等待 PPI settled 后弹出自己的确认框。
- [ ] **Step 3: fallback orchestration 失败时保持暂停**
```javascript
const orchestrated = await orchestratePpiOwnerAfterReferenceReady({
requestBody,
requestedStages: ['ppi'],
runPreflight: runPpiSpeciesResolutionPreflight,
dependencyStatus: dependencyId => stageExecutionStatus.value[dependencyId],
waitForDependency: waitForPpiProducerDependency,
ensureOrtholog: async (nextResolution, nextRequest) => {
const ready = ensureOrthologStageForPpiKaks(
batchExecuteStages.value || [],
['ppi'],
nextRequest,
nextResolution
);
return {
ready: Boolean(ready),
producerStage: getPpiKaksInternalOrthologProducer('ppi')
};
},
executeProducer: executePpiOwnedOrthologProducer,
resume: async () => resumePpiKaksPausedStages(
['ppi'],
'拟南芥兜底参考已确认'
)
});
if (orchestrated !== true) {
const pauseMessage = '拟南芥兜底参考尚未形成可用的 PPI Ortholog 映射;PPI 保持暂停。';
storePpiKaksReferencePause(['ppi'], {}, pauseMessage);
addAgentMessage('assistant', `⏸️ ${pauseMessage}`);
return false;
}
```
不得在该 handler 中调用 `resumePpiKaksPausedStages(['kaks'])`。
- [ ] **Step 4: 执行调度回归**
```bash
/home/xzq/miniconda3/envs/test4py311/bin/python -m pytest -q \
tests/unit/test_ppi_owner_scheduling_task5.py \
tests/unit/test_ppi_frontend_display_contract.py
```
Expected: 全部通过;规划中无 Ortholog,PPI 先于 Ka/Ks,Ka/Ks 仍受 `ppiSettledForKaks` 门控。
---
### Task 4: 后端 API 路线矩阵验证,不预先修改后端
**Files:**
- Inspect only: `apps/web/vue3/main.py:15185-15225`
- Inspect only: `apps/web/pipeline_backend.py:2795-3177`
- Inspect only: `apps/web/pipeline_backend.py:13398-13421`
**Interfaces:**
- Consumes: 前端最终生成的两个 Ka/Ks preflight payload。
- Produces: 后端现有契约是否足够的证据。
- [ ] **Step 1: 菠菜复用路线 API 验证**
关键 payload:
```json
{
"job_id": "job_codex_ppi_kaks_route_20260810",
"prefix": "Ft.SPL",
"targetSpecies": ["Ft"],
"referenceSpecies": ["string_taxon_3562"],
"ppiReferenceTaxonId": "3562",
"ppiReferenceStorageKey": "string_taxon_3562",
"ppiReferenceSelectionMode": "tree_nearest",
"ppiReferenceUserConfirmed": true,
"dependencyOwner": "ppi",
"requestedStages": ["kaks"]
}
```
Expected: `status=ready`,`ortholog_mapping_source` 指向 PPI owner 映射;不产生下载或新 Ortholog。
- [ ] **Step 2: 项目 At 路线 API 验证**
```json
{
"job_id": "job_codex_ppi_kaks_route_20260810",
"prefix": "Ft.SPL",
"targetSpecies": ["Ft"],
"referenceSpecies": ["At"],
"dependencyOwner": "kaks",
"requestedStages": ["kaks"]
}
```
Expected: At PEP/CDS存在且无Ka/Ks owner映射时返回 `status=needs_ortholog`,不是 `needs_reference_data`,也不受菠菜优先规则阻断。
- [ ] **Step 3: 后端范围门禁**
如果两个 API 结果符合预期,后端保持零改动。如果不符合,只记录确切请求与响应并停止,另行制定后端最小修订,不能顺手扩展。
---
### Task 5: 隔离真实数据闭环验证
**Files:**
- Create at runtime: `input/job_codex_ppi_kaks_route_20260810/`
- Create at runtime: `workspace/job_codex_ppi_kaks_route_20260810/`
- Verify only: `final/*.manifest.json`、logs、`/api/stage-status`
**Interfaces:**
- Consumes: Ft/SPL现有成员输入、At与菠菜参考PEP/CDS。
- Produces: 两条Ka/Ks路线的真实结果,不污染用户job。
- [ ] **Step 1: 创建隔离 job**
逐文件只读复用 Ft/SPL 成员输入和参考 FASTA;不得把整个用户 input 目录软链接为可写目录。运行时缓存和结果全部写入隔离 job。
- [ ] **Step 2: 路线 A:复用 PPI 菠菜映射**
顺序:PPI owner preflight ready → Ka/Ks确认复用 → Ka/Ks preflight ready → `/api/execute` Ka/Ks。
Expected: Ka/Ks确认后不下载、不生成第二个Ortholog,请求携带 `orthologMappingOwner=ppi`,Ka/Ks成功生成结果和manifest。
- [ ] **Step 3: 路线 B:项目 At**
顺序:Ka/Ks确认At → preflight needs_ortholog → 一次 Ka/Ks owner Ortholog → 二次 preflight ready → `/api/execute` Ka/Ks。
Expected: At PEP/CDS已存在时不下载,只生成一次Ka/Ks owner Ortholog,请求携带 `orthologMappingOwner=kaks`,Ka/Ks成功生成结果和manifest。
- [ ] **Step 4: 核对闭环身份**
核对确认参数、preflight请求、Ortholog命令日志、Ortholog manifest、Ka/Ks执行请求、Ka/Ks日志、Ka/Ks manifest、`/api/stage-status` 的参考物种与 owner 全部一致。
- [ ] **Step 5: 确认用户目录未被写入**
比较验证前后用户 job `input/` 与 `final/` 文件清单和 mtime。Expected: 无新增 PPI、Ka/Ks、Ortholog 或缓存文件。
---
### Task 6: 范围审计、重启与前端验收
**Files:**
- Verify: git diff
- Runtime: sj-2 Web 9016 and all sj-2 Celery workers
- [ ] **Step 1: 审计改动范围**
```bash
git diff --name-only
git diff --check
```
Expected code/test changes only:
```text
apps/web/vue3/index.html
tests/unit/test_ppi_owner_scheduling_task5.py
docs/superpowers/plans/2026-08-10-ppi-kaks-reference-route-handoff.md
```
如出现 `planning_v2.py`、`main.py`、`pipeline_backend.py`、CLI、Manifest Schema 或 stage catalog,停止并撤回本轮非必要修改。
- [ ] **Step 2: 确认无运行任务后重启全部sj-2服务**
先检查 `/api/tasks` 和 Celery active;确认空闲后重启 sj-2 Web 与 default/heavy/medium/light/rnaseq workers,不影响其它项目端口或队列。
- [ ] **Step 3: 健康检查**
Expected: 9016首页HTTP 200;`/api/celery/health` 返回sj-2五类worker在线;9016返回的 `index.html` hash 与磁盘文件一致。
- [ ] **Step 4: 前端验收标准**
路线 A:PPI菠菜成功 → Ka/Ks选择复用 → 无下载、无第二次Ortholog → Ka/Ks成功。
路线 B:PPI菠菜成功 → Ka/Ks选择At → 无下载 → 一次Ka/Ks专属Ortholog → Ka/Ks成功。
PPI下载失败路线:确认At → PPI At Ortholog和PPI完成 → Ka/Ks随后独立弹窗;失败时保持PPI暂停,不伪报恢复。
## Completion Gate
只有同时满足以下条件才可声明完成:
1. 新增测试先RED后GREEN。
2. 两条后端预检矩阵与预期一致。
3. 两条真实Ka/Ks路线均执行成功。
4. 用户job没有新增测试产物。
5. 改动范围仅限前端调度、对应测试和本计划文档。
6. 9016 Web与全部sj-2 Celery worker重启并健康。
@@ -0,0 +1,7 @@
# PPI/KaKs Unified Reference Dialogs Implementation Plan
1. Add focused frontend contract tests that require both messages to use `showStageConfirmDialog`, ordered candidate lists, grouped reference fields, and their original accept/cancel semantics.
2. Add two display-only stage IDs and initialize their view model from the existing resolution objects.
3. Add the two panels and dedicated footer actions to the existing stage-confirmation dialog.
4. Replace only the two raw `ElMessageBox` calls with queued stage-confirmation calls.
5. Run the focused tests, related PPI frontend regression tests, syntax/diff checks, then verify the served 9016 page after a safe restart.
@@ -0,0 +1,25 @@
# PPI/KaKs Unified Reference Dialogs Design
## Goal
Render the two PPI/KaKs reference-species messages inside the existing stage-confirmation dialog shell so their information is readable and visually consistent with the rest of the application.
## Scope
- Replace the raw STRING reference confirmation message box with a stage-confirmation dialog.
- Replace the raw reference-data/Ortholog warning alert with a read-only stage-confirmation dialog.
- Show reference candidates as an ordered list.
- Group target species, requested stages, required/missing files, current reference species, and Ortholog mapping into the existing PPI reference panel layout.
- Normalize both topology-candidate and downloaded-reference recommendation field names for display only.
## Non-goals
- No changes to PPI/KaKs download, Ortholog generation, identity, scheduling, API payloads, backend validation, or analysis execution.
- No changes to which candidate is selected: the first ranked candidate remains the confirmed candidate.
- No new automatic confirmation behavior.
## Interaction contract
- STRING candidate dialog: `Cancel PPI` resolves false; `Confirm top candidate` resolves true.
- Reference status dialog: `OK` only acknowledges the message; the preflight remains blocked exactly as before.
- Both dialogs use the existing queue, modal shell, title, progress header, and Element Plus components.
+1 -13
View File
@@ -44,7 +44,7 @@ from gfa_core.wgs_upstream_manifest import (
)
GF_TARGET_ONLY_STAGE_PARAMS = {"promoter", "synteny", "karyotype", "pep", "tfbs"}
GF_TARGET_ONLY_STAGE_PARAMS = {"promoter", "synteny", "karyotype", "pep", "tfbs", "ppi"}
GF_REFERENCE_STAGE_PARAMS = {
"phylogeny",
"motif",
@@ -54,7 +54,6 @@ GF_REFERENCE_STAGE_PARAMS = {
"interspecies_synteny",
"ortholog",
"kaks",
"ppi",
}
GF_SCOPE_IDENTITY_KEYS = (
"scopeFile",
@@ -1958,17 +1957,6 @@ class PlanningAgentV2(BaseAgent):
recommended_params = learning_recommendations.get("params", {})
print(f"[PlanningAgentV2] 应用Learning推荐参数: {recommended_params}")
if "ppi" in stage_ids:
intent_params = dict(intent_params or {})
ppi_species = str(intent_params.get("ppiSpecies") or intent_params.get("ppi_species") or "").strip().lower()
if ppi_species and ppi_species != "auto" and not intent_params.get("referenceSpecies"):
ppi_ref_species = {"ath": ["At"], "osa": ["Os"], "zma": ["Zm"], "gma": ["Gm"], "sly": ["Sl"]}.get(ppi_species, [])
if ppi_ref_species:
intent_params["referenceSpecies"] = ppi_ref_species
intent_params["reference_species"] = ppi_ref_species
intent_params["refSpeciesList"] = ppi_ref_species
intent_params.setdefault("scopeMode", "target_plus_reference")
for i, stage_id in enumerate(stage_ids):
info = self.STAGE_INFO.get(stage_id, {"name": stage_id, "estimated_time": 10, "cpu": 8})
deps = self.DEPENDENCIES.get(stage_id, [])
+6
View File
@@ -458,6 +458,9 @@ class RouterRuntime:
}
if not all(proposal.values()):
return None
original_request = str(raw.get("original_request") or "").strip()
if original_request:
proposal["original_request"] = original_request
workflow_id = str(raw.get("workflow_id") or "").strip()
if workflow_id in {
"data_preparation",
@@ -474,6 +477,7 @@ class RouterRuntime:
session_id: str,
job_id: str,
query: str,
original_query: str,
subject: str,
workflow_id: WorkflowId | None = None,
) -> dict[str, str]:
@@ -487,6 +491,7 @@ class RouterRuntime:
proposal = {
"proposal_id": f"proposal-{digest}",
"request": request,
"original_request": str(original_query or "").strip() or request,
"subject": normalized_subject,
}
if workflow_id is not None:
@@ -791,6 +796,7 @@ class RouterRuntime:
session_id=session_id,
job_id=job_id,
query=proposal_query,
original_query=query,
subject=decision.subject,
workflow_id=decision.workflow_id,
)
+30 -6
View File
@@ -21,6 +21,35 @@ def _is_beneath(path: Path, parent: Path) -> bool:
return True
def _resolve_job_layout(root: Path, job_id: str) -> tuple[Path, Path, Path]:
logical_input_job = root / "input" / job_id
logical_workspace = root / "workspace" / job_id
input_root = (root / "input").resolve(strict=False)
workspace_root = (root / "workspace").resolve(strict=False)
input_job = logical_input_job.resolve(strict=False)
job_workspace = logical_workspace.resolve(strict=False)
if _is_beneath(input_job, input_root) and _is_beneath(
job_workspace,
workspace_root,
):
return root, input_job, job_workspace
if not (logical_input_job.is_symlink() and logical_workspace.is_symlink()):
if not _is_beneath(input_job, input_root):
raise ValueError("input job path escapes project root")
raise ValueError("workspace job path escapes project root")
input_job = logical_input_job.resolve(strict=True)
job_workspace = logical_workspace.resolve(strict=True)
linked_root = input_job.parents[1]
if (
input_job != linked_root / "input" / job_id
or job_workspace != linked_root / "workspace" / job_id
):
raise ValueError("linked job paths do not share one project root")
return linked_root, input_job, job_workspace
def _read_manifest(path: Path) -> dict[str, Any]:
payload = json.loads(path.read_text(encoding="utf-8"))
if type(payload) is not dict:
@@ -263,12 +292,7 @@ def build_confirmation_snapshot(
if not isinstance(job_id, str) or not _JOB_ID.fullmatch(job_id):
raise ValueError("invalid job_id")
root = Path(project_root).expanduser().resolve(strict=True)
input_job = (root / "input" / job_id).resolve(strict=False)
job_workspace = (root / "workspace" / job_id).resolve(strict=False)
if not _is_beneath(input_job, (root / "input").resolve(strict=False)):
raise ValueError("input job path escapes project root")
if not _is_beneath(job_workspace, (root / "workspace").resolve(strict=False)):
raise ValueError("workspace job path escapes project root")
root, input_job, job_workspace = _resolve_job_layout(root, job_id)
final_dir = job_workspace / "final"
result: dict[str, Any] = {
+5
View File
@@ -368,6 +368,11 @@ class PersistentConversationStore:
for key in ("proposal_id", "request", "subject")
}
if all(normalized_proposal.values()):
original_request = str(
proposal.get("original_request") or ""
).strip()
if original_request:
normalized_proposal["original_request"] = original_request
workflow_id = str(proposal.get("workflow_id") or "").strip()
if workflow_id in {
"data_preparation",
+1 -1
View File
@@ -159,7 +159,7 @@ STAGE_DEPENDENCIES = {
"interspecies_synteny": ["identify"],
"karyotype": ["identify", "synteny"],
"ppi": ["identify"],
"kaks": ["identify", "ortholog"],
"kaks": ["identify"],
"combo": ["identify", "phylogeny", "motif", "structure", "domain"],
"rnaseq_align": ["rnaseq_qc"],
"rnaseq_quant": ["rnaseq_align"],
+1
View File
@@ -546,6 +546,7 @@ class StageToolExecutor:
ppi_reference_selection_mode=(extra_params or {}).get("ppiReferenceSelectionMode") or (extra_params or {}).get("ppi_reference_selection_mode") or "",
ppi_reference_user_confirmed=bool_param("ppiReferenceUserConfirmed", "ppi_reference_user_confirmed", default=False),
dependency_owner=(extra_params or {}).get("dependencyOwner") or (extra_params or {}).get("dependency_owner") or "",
ortholog_mapping_owner=(extra_params or {}).get("orthologMappingOwner") or (extra_params or {}).get("ortholog_mapping_owner") or "",
ppi_score=(extra_params or {}).get("ppiScore", 200),
genome_prepare_type=(extra_params or {}).get("genomePrepareType", "auto"),
kaks_neutral_rate=(extra_params or {}).get("kaksNeutralRate", 7.08e-9),
+21 -6
View File
@@ -9,6 +9,16 @@ from pathlib import Path
from typing import Any, Optional
ORTHOLOG_DEPENDENCY_OWNERS = {"", "ppi", "kaks"}
def ortholog_output_stem(prefix: str, dependency_owner: str = "") -> str:
owner = str(dependency_owner or "").strip().lower()
if owner not in ORTHOLOG_DEPENDENCY_OWNERS:
raise ValueError(f"unsupported Ortholog dependency owner: {dependency_owner}")
return f"{prefix}.{owner}" if owner else prefix
@dataclass
class OrthologRunRecord:
query_species: str
@@ -151,6 +161,7 @@ def materialize_ortholog_final_outputs(
*,
final_dir: Path,
prefix: str,
dependency_owner: str = "",
run_records: list[OrthologRunRecord],
job_id: str,
workspace: Path,
@@ -170,10 +181,12 @@ def materialize_ortholog_final_outputs(
final_dir.mkdir(parents=True, exist_ok=True)
scope_fields = dict(scope_fields or {})
owner = str(dependency_owner or "").strip().lower()
output_stem = ortholog_output_stem(prefix, owner)
pairs_dst = final_dir / f"{prefix}.ortholog_pairs.txt"
orthologs_dst = final_dir / f"{prefix}.orthologs.txt"
raw_pairs_dst = final_dir / f"{prefix}.ortholog_pairs.raw.txt"
pairs_dst = final_dir / f"{output_stem}.ortholog_pairs.txt"
orthologs_dst = final_dir / f"{output_stem}.orthologs.txt"
raw_pairs_dst = final_dir / f"{output_stem}.ortholog_pairs.raw.txt"
# run_ortholog.py writes subject<TAB>query<TAB>identity.
# Downstream PPI expects query/source -> target/reference.
@@ -203,7 +216,7 @@ def materialize_ortholog_final_outputs(
pairs_handle.write(out_line)
orthologs_handle.write(out_line)
blast_dst = final_dir / f"{prefix}.ortholog_blastp.txt"
blast_dst = final_dir / f"{output_stem}.ortholog_blastp.txt"
with blast_dst.open("w", encoding="utf-8") as blast_handle:
for record in run_records:
blast_src = record.blast_src
@@ -266,7 +279,7 @@ def materialize_ortholog_final_outputs(
"sha256": _sha256_file(blast_dst),
}
ortholog_manifest = final_dir / f"{prefix}.ortholog.manifest.json"
ortholog_manifest = final_dir / f"{output_stem}.ortholog.manifest.json"
scope_file = str(scope_fields.pop("scope_file", "") or "").strip()
scope_data = dict(scope_fields)
@@ -283,6 +296,7 @@ def materialize_ortholog_final_outputs(
"stage": "ortholog",
"status": "success",
"prefix": prefix,
"dependency_owner": owner,
"job_id": job_id,
"workspace": str(workspace),
"workspace_root": str(workspace),
@@ -303,6 +317,7 @@ def materialize_ortholog_final_outputs(
],
"parameters": {
"prefix": prefix,
"dependency_owner": owner,
"blast_evalue": blast_evalue,
"cpu": cpu,
"reference_species": reference_species,
@@ -340,7 +355,7 @@ def materialize_ortholog_final_outputs(
if scope_id:
from gfa_core.gene_family_scope import sanitize_token
scoped_manifest = final_dir / f"{prefix}.{sanitize_token(scope_id)}.ortholog.manifest.json"
scoped_manifest = final_dir / f"{output_stem}.{sanitize_token(scope_id)}.ortholog.manifest.json"
manifest_data["scope_manifest"] = str(scoped_manifest.resolve())
with scoped_manifest.open("w", encoding="utf-8") as handle:
json.dump(manifest_data, handle, ensure_ascii=False, indent=2)
@@ -27,6 +27,11 @@
- 若 reference CDS 在 config/scope 中缺失,可从已知 reference 数据目录解析,例如 At 的 TAIR10 CDS。
- KaKs 是 reference dependency owner:若缺少 ortholog mapping,可以要求调度器先自动插入 Ortholog;但若 reference CDS/PEP 不完整,必须先提示上传/下载完整参考注释包,不能只跑 Ortholog 后再失败。
- 若 PPI 与 KaKs 同批执行,reference resolver 必须按阶段分别检查;KaKs 使用 `requested_stages=["kaks"]` 检查 reference PEP/CDS 与 Ortholog,而其缺失状态不得覆盖 PPI 的独立就绪状态。
- 普通计划不显示隐式 Ortholog,KaKs 也不声明对 Ortholog 或 PPI 的静态硬依赖。执行层仅用软顺序保证 KaKs 参数确认发生在 PPI 真实终态之后。
- KaKs 确认必须显式选择参考来源:复用本次 PPI 技术参考,或使用项目配置参考。选择只写入 KaKs 参数,不得修改 PPI owner 或其它阶段的参考 scope。
- 复用 PPI 参考时,只有 identity 完全匹配的 `final/{prefix}.ppi.orthologs.txt` 可用,并以 `orthologMappingOwner=ppi` 提交。
- 选择项目参考时,缺少精确映射则运行 `dependency_owner=kaks` 的内部 Ortholog,输出 `final/{prefix}.kaks.orthologs.txt`,随后以 `orthologMappingOwner=kaks` 提交。
- 两种 owner 都不得回落到另一 owner 或 standalone 映射。缺少所选参考的 PEP/CDS/映射只暂停 KaKs,不影响已完成 PPI 或其它阶段。
### Legacy 模式
@@ -59,6 +59,19 @@
| `{prefix}.ortholog.manifest.json` | json | 标准 manifest |
| `{prefix}.{scope_id}.ortholog.manifest.json` | json | scope 模式下的 scoped manifest 副本 |
Owner namespace:
| producer 身份 | 映射/manifest stem | 用途 |
|---|---|---|
| standalone (`dependency_owner=""`) | `{prefix}.ortholog*` | 用户显式请求的 Ortholog,保持原契约 |
| PPI (`dependency_owner="ppi"`) | `{prefix}.ppi.ortholog*` | PPI STRING 技术参考专属内部映射 |
| KaKs (`dependency_owner="kaks"`) | `{prefix}.kaks.ortholog*` | KaKs 显式项目参考专属内部映射 |
- owner namespace 同时作用于 orthologs、pairs、raw pairs、BLAST、identity sidecar、标准 manifest 与 scoped manifest。
- manifest 的 `stage` 仍为 `ortholog`;`parameters.dependency_owner` 记录 producer 身份。
- standalone、PPI、KaKs 三套产物可为同一 prefix 并存;重跑一个 owner 不得覆盖或改变另一个 owner。
- 下游按 owner token 解析候选路径,不接受前端传入任意文件路径,也不得跨 owner 回落。
## Manifest 最小字段
```json
@@ -104,6 +117,7 @@
- 单 target + `target_only` scope 时,`analysis_route` 通常为 `single_species`;含 reference 的 comparative scope 通常为 `multi_species`。
- postflight strict identity 诊断依赖上述字段;materialize 落盘时必须补齐,不能只写 `scope_id`。
- PPI-owned producer 的 `reference_taxon_id` 来自已确认 STRING TaxID,`reference_storage_key` 决定本地 PEP 路径及 mapping 的 Target_Species。
- PPI-owned producer 的生物学 scope 必须与 PPI consumer 相同,固定为目标家族 `target_only`;技术参考物种只写入 owner/provenance 参数。
- sidecar 与当前请求必须在 `reference_taxon_id`、`reference_storage_key`、reference PEP SHA256、target/query PEP identity、scope/member identity、mapping path/SHA256 和 `mapping_identity` 上完全一致,才允许跳过 BLAST 并复用中间 pairs。
- sidecar 缺失、无法解析、字段缺失或任一 identity 冲突都视为不可复用;应按原 Ortholog 命令重新生成,不得根据“文件存在”推断身份。
- PPI preflight 不得只读取 final mapping 的 Target_Species 后判定 ready;还必须同时验证 final mapping sidecar、对应 Ortholog manifest,以及当前 reference PEP、target/query PEP、scope/member 和 mapping identity。任一输入变化或身份材料缺失均返回 `needs_ortholog` / stale identity conflict。
@@ -181,6 +181,17 @@ python skills/ppi/run.py \
## 下游阶段
- summary: 可汇总PPI结果
## 内部 Ortholog 身份与调度
- 普通基因家族计划只展示 PPI;PPI 所需的 Ortholog 是执行层内部 producer,不作为可见计划阶段或独立确认弹窗。
- PPI 的生物学分析身份固定为目标家族成员 `target_only`;项目参考物种不得进入 PPI 的 `scope_id`、`analysis_route` 或 `member_set_id`。
- STRING TaxID、技术参考 storage key 和同源映射参考只属于 PPI 技术溯源,不得被当作生物学 `referenceSpecies` 重建 PPI scope。
- 非目标物种直连 STRING 路线使用 `dependency_owner=ppi`,映射输出为 `final/{prefix}.ppi.orthologs.txt`(及同 owner 的 pairs/raw/BLAST/manifest/sidecar)。
- PPI 只能读取 PPI owner 映射;不得因 standalone 或 KaKs owner 文件存在而静默复用。
- PPI 的 STRING 技术参考只属于 PPI。它不得覆盖项目参考物种或 KaKs 的显式参考选择。
- PPI 成功、失败或用户明确跳过都会释放后续 KaKs 确认;正在进行的 PPI 参考包下载不是终态。
- PPI-owned Ortholog 完成后必须用同一 target-only 身份重新执行预检;只有返回 `ready` 才能提交 PPI。
## 网络统计指标
| 指标 | 描述 |
+3 -1
View File
@@ -122,6 +122,7 @@ async def test_agent_chat_persists_router_proposal_without_analysis_intent(
proposal = {
"proposal_id": "proposal-123",
"request": "执行 Ft 的数据准备",
"original_request": "执行数据准备",
"subject": "执行 Ft 的数据准备",
}
_install_router(
@@ -166,6 +167,7 @@ async def test_agent_chat_exposes_confirmed_request_for_original_planner(
proposal = {
"proposal_id": "proposal-next",
"request": "我已确认 config.xlsx,接下来应该做什么分析",
"original_request": "执行kaks分析",
"subject": "启动苦荞SPL基因家族分析",
}
_install_router(
@@ -192,7 +194,7 @@ async def test_agent_chat_exposes_confirmed_request_for_original_planner(
)
assert result["action"] == "propose_analysis"
assert result["handoff_request"] == proposal["request"]
assert result["handoff_request"] == proposal["original_request"]
assert "尚未执行" in result["intent"]["message"]
@@ -0,0 +1,15 @@
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
def test_analysis_plan_handoff_does_not_forward_main_agent_intent() -> None:
source = (PROJECT_ROOT / "apps/web/vue3/index.html").read_text(encoding="utf-8")
start = source.index("const planData = await fetchJsonWithTimeout('/api/agent/plan'")
end = source.index("}, 180000, '多智能体规划');", start)
payload_source = source[start:end]
assert "message: data.handoff_request || userInput" in payload_source
assert "...(data.action === 'propose_utility' ? { intent: data.intent } : {})" in payload_source
assert "intent: data.intent //" not in payload_source
@@ -0,0 +1,88 @@
import importlib.util
import json
from pathlib import Path
import pytest
def _load_confirmation_snapshot_module():
module_path = (
Path(__file__).resolve().parents[2]
/ "gfa_core"
/ "mas"
/ "confirmation_snapshot.py"
)
spec = importlib.util.spec_from_file_location(
"confirmation_snapshot_linked_job_test",
module_path,
)
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def _write_legacy_config_confirmation(source_root: Path, job_id: str) -> Path:
input_job = source_root / "input" / job_id
final_dir = source_root / "workspace" / job_id / "final"
input_job.mkdir(parents=True)
final_dir.mkdir(parents=True)
config_path = input_job / "config.xlsx"
config_path.write_bytes(b"confirmed config")
manifest_path = final_dir / "config_autofill.manifest.json"
manifest_path.write_text(
json.dumps(
{
"manifest_version": "1.0",
"stage": "config_autofill",
"status": "success",
"artifacts": {
"confirmed_config": {
"path": str(config_path),
"type": "xlsx",
}
},
}
),
encoding="utf-8",
)
return config_path
def test_snapshot_accepts_paired_job_links_to_one_project_root(tmp_path):
snapshot_module = _load_confirmation_snapshot_module()
job_id = "job_linked"
project_root = tmp_path / "sj-2"
source_root = tmp_path / "source"
config_path = _write_legacy_config_confirmation(source_root, job_id)
(project_root / "input").mkdir(parents=True)
(project_root / "workspace").mkdir(parents=True)
(project_root / "input" / job_id).symlink_to(source_root / "input" / job_id)
(project_root / "workspace" / job_id).symlink_to(
source_root / "workspace" / job_id
)
snapshot = snapshot_module.build_confirmation_snapshot(project_root, job_id)
assert snapshot["config_confirmed"] is True
assert snapshot["config"]["artifact_path"] == str(config_path.resolve())
def test_snapshot_rejects_job_links_to_different_project_roots(tmp_path):
snapshot_module = _load_confirmation_snapshot_module()
job_id = "job_mismatch"
project_root = tmp_path / "sj-2"
input_source = tmp_path / "input-source"
workspace_source = tmp_path / "workspace-source"
(input_source / "input" / job_id).mkdir(parents=True)
(workspace_source / "workspace" / job_id / "final").mkdir(parents=True)
(project_root / "input").mkdir(parents=True)
(project_root / "workspace").mkdir(parents=True)
(project_root / "input" / job_id).symlink_to(
input_source / "input" / job_id
)
(project_root / "workspace" / job_id).symlink_to(
workspace_source / "workspace" / job_id
)
with pytest.raises(ValueError, match="linked job paths do not share one project root"):
snapshot_module.build_confirmation_snapshot(project_root, job_id)
@@ -0,0 +1,93 @@
from __future__ import annotations
from pathlib import Path
from openpyxl import Workbook
from apps.web.vue3 import main
def _write_config(input_dir: Path, pep: Path, cds: Path) -> None:
workbook = Workbook()
sheet = workbook.active
sheet.title = "config"
sheet.append(
[
"Species_Code",
"Species_Role",
"File_Type",
"Original_File_Name",
"Ref",
]
)
sheet.append(["At", "reference", "pep", pep.name, True])
sheet.append(["At", "reference", "cds", cds.name, True])
workbook.save(input_dir / "config.xlsx")
def test_execute_validation_accepts_config_resolved_reference_pep_and_cds(
tmp_path: Path,
monkeypatch,
) -> None:
input_root = tmp_path / "input"
job_input = input_root / "job_at"
external = tmp_path / "external"
job_input.mkdir(parents=True)
external.mkdir()
pep = external / "Arabidopsis_thaliana.TAIR10.pep.all.fa"
cds = external / "Arabidopsis_thaliana.TAIR10.cds.all.fa"
pep.write_text(">AT1G01010\nMA\n", encoding="utf-8")
cds.write_text(">AT1G01010\nATGGCT\n", encoding="utf-8")
_write_config(job_input, pep, cds)
monkeypatch.setattr(main, "INPUT_ROOT", input_root)
monkeypatch.setenv("PLANTAI_REF_DATA_ROOT", str(external))
available, source = main._reference_bundle_species_available(
"job_at",
"At",
require_cds=True,
)
assert available is True
assert source == str(job_input)
def test_execute_validation_keeps_accepting_downloaded_reference_bundle(
tmp_path: Path,
monkeypatch,
) -> None:
input_root = tmp_path / "input"
species_dir = input_root / "job_spinach" / "string_taxon_3562"
species_dir.mkdir(parents=True)
(species_dir / "string_taxon_3562.pep.fasta").write_text(">S1\nMA\n", encoding="utf-8")
(species_dir / "string_taxon_3562.cds.fasta").write_text(">S1\nATGGCT\n", encoding="utf-8")
monkeypatch.setattr(main, "INPUT_ROOT", input_root)
available, source = main._reference_bundle_species_available(
"job_spinach",
"string_taxon_3562",
require_cds=True,
)
assert available is True
assert source == str(species_dir)
def test_execute_validation_still_rejects_a_truly_incomplete_reference(
tmp_path: Path,
monkeypatch,
) -> None:
input_root = tmp_path / "input"
species_dir = input_root / "job_incomplete" / "At"
species_dir.mkdir(parents=True)
(species_dir / "At.pep.fasta").write_text(">AT1G01010\nMA\n", encoding="utf-8")
monkeypatch.setattr(main, "INPUT_ROOT", input_root)
available, reason = main._reference_bundle_species_available(
"job_incomplete",
"At",
require_cds=True,
)
assert available is False
assert "missing CDS" in reason
@@ -51,3 +51,28 @@ def test_rebind_to_same_job_keeps_conversation_identity(tmp_path: Path) -> None:
)
assert conversation_id == turn.conversation_id
def test_pending_proposal_preserves_original_analysis_request(tmp_path: Path) -> None:
store = _store(tmp_path)
turn = store.begin_turn(
session_id="kaks-session",
job_id="job_kaks",
query="执行kaks分析",
client_history=[],
)
ledger = store.finish_turn(
turn,
assistant_message="是否继续?",
interaction_kind="independent",
action="clarify",
proposal={
"proposal_id": "proposal-kaks",
"request": "执行kaks分析\n已确认上下文:subfamily_confirm 已完成。",
"original_request": "执行kaks分析",
"subject": "执行kaks分析",
},
)
assert ledger.user_terms["pending_proposal"]["original_request"] == "执行kaks分析"
+131 -17
View File
@@ -26,7 +26,7 @@ def _materialize_trusted_ppi_mapping(
pairs = workspace / "ortholog" / f"{prefix}.{target}_to_{storage_key}_pairs.txt"
pairs.parent.mkdir(parents=True, exist_ok=True)
pairs.write_text(f"{subject_id}\t{query_id}\t91\n", encoding="utf-8")
scope_id = build_scope_id(family, [target], [storage_key], [], "target_plus_reference")
scope_id = build_scope_id(family, [target], [], [], "target_only")
identity = build_ortholog_run_identity(
query_species=target, reference_taxon_id=taxon_id,
reference_storage_key=storage_key, query_fasta=query,
@@ -35,14 +35,15 @@ def _materialize_trusted_ppi_mapping(
)
materialize_ortholog_final_outputs(
final_dir=final_dir, prefix=prefix,
dependency_owner="ppi",
run_records=[OrthologRunRecord(target, storage_key, query, reference, pairs)],
job_id=job.job_id, workspace=workspace, reference_species=storage_key,
reference_taxon_id=taxon_id, reference_storage_key=storage_key,
ortholog_identity=identity,
scope_fields={
"scope_id": scope_id, "scope_mode": "target_plus_reference",
"target_species": [target], "reference_species": [storage_key],
"selected_species": [target, storage_key], "family": family,
"scope_id": scope_id, "scope_mode": "target_only",
"target_species": [target], "reference_species": [],
"selected_species": [target], "family": family,
},
)
@@ -125,6 +126,49 @@ def test_reference_bundle_id_map_preserves_loc_and_numeric_geneid(tmp_path):
assert row["ncbi_gene_id"] == "110806180"
def test_reference_bundle_id_map_reads_ensembl_gene_token_for_isoforms(tmp_path):
from gfa_core.id_resolution import ReferenceAliasIndex
pep_path = tmp_path / "At.pep.fasta"
cds_path = tmp_path / "At.cds.fasta"
output_path = tmp_path / "At.pep_cds_idmap.tsv"
pep_path.write_text(
">AT1G02065.1 pep chromosome:TAIR10:1:365209:367443:1 "
"gene:AT1G02065 transcript:AT1G02065.1 gene_symbol:SPL8\nMAAA\n"
">AT1G02065.2 pep chromosome:TAIR10:1:365165:367353:1 "
"gene:AT1G02065 transcript:AT1G02065.2 gene_symbol:SPL8\nMBBB\n",
encoding="utf-8",
)
cds_path.write_text(
">AT1G02065.1 cds chromosome:TAIR10:1:365209:367443:1 "
"gene:AT1G02065 transcript:AT1G02065.1 gene_symbol:SPL8\nATGGCG\n"
">AT1G02065.2 cds chromosome:TAIR10:1:365165:367353:1 "
"gene:AT1G02065 transcript:AT1G02065.2 gene_symbol:SPL8\nATGGCC\n",
encoding="utf-8",
)
backend._build_reference_bundle_id_map(
species_code="At",
pep_path=pep_path,
cds_path=cds_path,
output_path=output_path,
)
with output_path.open(encoding="utf-8", newline="") as handle:
rows = list(csv.DictReader(handle, delimiter="\t"))
assert {row["protein_id"]: row["gene_id"] for row in rows} == {
"AT1G02065.1": "AT1G02065",
"AT1G02065.2": "AT1G02065",
}
candidates = ReferenceAliasIndex.from_tsv(output_path, "At").candidates("AT1G02065")
assert {candidate.value for candidate in candidates} >= {
"AT1G02065",
"AT1G02065.1",
"AT1G02065.2",
}
def test_reference_bundle_download_extracts_ncbi_zip_and_unblocks_ppi_kaks_preflight(tmp_path, monkeypatch):
job = backend.JobInfo(
job_id="job_test_ref_bundle",
@@ -601,7 +645,7 @@ def test_ppi_species_resolution_selected_near_string_species_still_needs_referen
assert resolution["reference_data_source"] == ""
def test_ppi_species_resolution_selected_near_string_species_with_pep_needs_ortholog(tmp_path):
def test_ppi_species_resolution_selected_near_string_species_with_complete_bundle_needs_ortholog(tmp_path):
job = backend.JobInfo(
job_id="job_test",
input_dir=str(tmp_path / "input"),
@@ -615,6 +659,7 @@ def test_ppi_species_resolution_selected_near_string_species_with_pep_needs_orth
final_dir.mkdir(parents=True)
(input_dir / "soe").mkdir()
(input_dir / "soe" / "soe.pep.fasta").write_text(">SoeGene1\nMA\n", encoding="utf-8")
(input_dir / "soe" / "soe.cds.fasta").write_text(">SoeGene1\nATG\n", encoding="utf-8")
resolution = backend.resolve_ppi_species_resolution(
job=job,
@@ -692,7 +737,6 @@ def test_ppi_kaks_dependency_preflight_requires_reference_cds_before_ortholog(tm
final_dir.mkdir(parents=True)
(input_dir / "soe").mkdir()
(input_dir / "soe" / "soe.pep.fasta").write_text(">SoeGene1\nMA\n", encoding="utf-8")
resolution = backend.resolve_ppi_species_resolution(
job=job,
prefix="Ft.SPL",
@@ -790,6 +834,7 @@ def test_ppi_species_resolution_selected_near_string_species_with_mapping_is_rea
final_dir.mkdir(parents=True)
(input_dir / "soe").mkdir()
(input_dir / "soe" / "soe.pep.fasta").write_text(">SoeGene1\nMA\n", encoding="utf-8")
(input_dir / "soe" / "soe.cds.fasta").write_text(">SoeGene1\nATGGCT\n", encoding="utf-8")
_materialize_trusted_ppi_mapping(
job, prefix="Ft.SPL", target="Ft", family="SPL",
storage_key="soe", taxon_id="3562", query_id="FtGene1", subject_id="SoeGene1",
@@ -812,7 +857,7 @@ def test_ppi_species_resolution_selected_near_string_species_with_mapping_is_rea
assert resolution["status"] == "ready"
assert resolution["selected_string_species"] == "soe"
assert resolution["selected_taxon_id"] == "3562"
assert resolution["ortholog_mapping_source"].endswith("Ft.SPL.orthologs.txt")
assert resolution["ortholog_mapping_source"].endswith("Ft.SPL.ppi.orthologs.txt")
assert resolution["decision"] == "ready_reference_mapping"
@@ -830,6 +875,7 @@ def test_ppi_species_resolution_can_ignore_existing_mapping_for_pre_ortholog_che
final_dir.mkdir(parents=True)
(input_dir / "soe").mkdir()
(input_dir / "soe" / "soe.pep.fasta").write_text(">SoeGene1\nMA\n", encoding="utf-8")
(input_dir / "soe" / "soe.cds.fasta").write_text(">SoeGene1\nATGGCT\n", encoding="utf-8")
_materialize_trusted_ppi_mapping(
job, prefix="Ft.SPL", target="Ft", family="SPL",
storage_key="soe", taxon_id="3562", query_id="FtGene1", subject_id="SoeGene1",
@@ -864,7 +910,7 @@ def test_ppi_species_resolution_can_ignore_existing_mapping_for_pre_ortholog_che
)
assert ready["status"] == "ready"
assert ready["ortholog_mapping_source"].endswith("Ft.SPL.orthologs.txt")
assert ready["ortholog_mapping_source"].endswith("Ft.SPL.ppi.orthologs.txt")
assert pre_ortholog["status"] == "needs_ortholog"
assert pre_ortholog["ortholog_mapping_source"] == ""
assert pre_ortholog["selected_string_species"] == "soe"
@@ -970,7 +1016,7 @@ def test_kaks_preflight_recovers_non_ath_reference_and_prioritizes_missing_cds(t
assert resolution["reference_bundle_status"]["missing_required"] == ["cds"]
def test_ppi_species_resolution_reports_needs_ortholog_when_reference_pep_exists(tmp_path):
def test_ppi_species_resolution_requires_cds_when_only_reference_pep_exists(tmp_path):
job = backend.JobInfo(
job_id="job_test",
input_dir=str(tmp_path / "input"),
@@ -999,13 +1045,14 @@ def test_ppi_species_resolution_reports_needs_ortholog_when_reference_pep_exists
dependency_owner="ppi",
)
assert resolution["status"] == "needs_ortholog"
assert resolution["issue_code"] == "ppi_requires_ortholog_mapping"
assert resolution["status"] == "needs_reference_data"
assert resolution["issue_code"] == "ppi_reference_data_missing"
assert resolution["reference_bundle_status"]["missing_required"] == ["cds"]
assert resolution["reference_data_source"].endswith("soe/soe.pep.fasta")
assert resolution["selected_string_species"] == "soe"
def test_ppi_reference_bundle_download_requests_only_pep(tmp_path, monkeypatch):
def test_ppi_reference_bundle_download_requests_pep_and_cds(tmp_path, monkeypatch):
job = backend.JobInfo(
job_id="job_test",
input_dir=str(tmp_path / "input"),
@@ -1039,7 +1086,10 @@ def test_ppi_reference_bundle_download_requests_only_pep(tmp_path, monkeypatch):
monkeypatch.setattr(
backend,
"_extract_ncbi_reference_bundle_zip",
lambda **kwargs: {"pep": str(tmp_path / "input" / "soe" / "soe.pep.fasta")},
lambda **kwargs: {
"pep": str(tmp_path / "input" / "soe" / "soe.pep.fasta"),
"cds": str(tmp_path / "input" / "soe" / "soe.cds.fasta"),
},
)
monkeypatch.setattr(
backend,
@@ -1047,8 +1097,8 @@ def test_ppi_reference_bundle_download_requests_only_pep(tmp_path, monkeypatch):
lambda **kwargs: {
"id_map_path": str(tmp_path / "input" / "soe" / "soe.pep_cds_idmap.tsv"),
"pep_records": 1,
"cds_records": 0,
"pep_cds_overlap": 0,
"cds_records": 1,
"pep_cds_overlap": 1,
},
)
@@ -1060,10 +1110,10 @@ def test_ppi_reference_bundle_download_requests_only_pep(tmp_path, monkeypatch):
requested_stages=["ppi"],
)
assert captured["file_types"] == ["pep"]
assert captured["file_types"] == ["pep", "cds"]
assert result["status"] == "success"
assert result["missing_required"] == []
assert result["requested_file_types"] == ["pep"]
assert result["requested_file_types"] == ["pep", "cds"]
def test_ppi_execution_scope_canonicalizes_full_species_names_before_mapping_filter(tmp_path, monkeypatch):
@@ -1323,6 +1373,70 @@ def test_ppi_projected_soe_rejects_missing_id_map(tmp_path, monkeypatch):
assert message == f"❌ PPI projected analysis requires species-matched ID map: {expected}"
def test_ppi_projected_builds_missing_id_map_from_species_matched_pep_and_cds(tmp_path, monkeypatch):
job = backend.JobInfo(
job_id="job_test",
input_dir=str(tmp_path / "input"),
workspace_dir=str(tmp_path / "workspace"),
logs_dir=str(tmp_path / "workspace" / "logs"),
created_at="",
)
input_dir = tmp_path / "input"
workspace = tmp_path / "workspace"
final_dir = workspace / "final"
logs_dir = workspace / "logs"
soe_dir = input_dir / "soe"
soe_dir.mkdir(parents=True)
final_dir.mkdir(parents=True)
logs_dir.mkdir(parents=True)
(soe_dir / "soe.pep.fasta").write_text(">SoeGene1 [gene=SoeGene1]\nMAAA\n", encoding="utf-8")
(soe_dir / "soe.cds.fasta").write_text(">SoeGene1 [gene=SoeGene1]\nATGGCT\n", encoding="utf-8")
(final_dir / "Os.CRR.final_members.id").write_text("Os01t0810800-01\n", encoding="utf-8")
mapping_file = final_dir / "Os.CRR.ppi.orthologs.txt"
mapping_file.write_text("Os01t0810800-01\tSoeGene1\t90\tOs\tsoe\n", encoding="utf-8")
scope_file = final_dir / "Os.CRR.CRR.target-Os.target_only.scope.json"
scope_file.write_text(json.dumps({
"family": "CRR",
"prefix": "Os.CRR",
"scope_id": "CRR.target-Os.target_only",
"scope_mode": "target_only",
"target_species": ["Os"],
"reference_species": [],
"selected_species": ["Os"],
}), encoding="utf-8")
captured = {}
def fake_run_cmd_logged(cmd, log_path, cwd=None):
captured["cmd"] = cmd
_write_fake_ppi_outputs(workspace / "ppi")
return 0
monkeypatch.setattr(backend, "can_start_new_task", lambda *args, **kwargs: (True, "ok"))
monkeypatch.setattr(backend, "run_cmd_logged", fake_run_cmd_logged)
monkeypatch.setattr(backend, "download_string_network_image", lambda *args, **kwargs: None)
message, manifest = backend.run_stage(
"ppi",
job,
"Os.CRR",
ppi_species="auto",
ppi_reference_storage_key="soe",
ppi_reference_taxon_id="3562",
ppi_reference_user_confirmed=True,
dependency_owner="ppi",
ppi_score=400,
scope_file=str(scope_file),
analysis_route="single_species",
)
expected = soe_dir / "soe.pep_cds_idmap.tsv"
assert "✅ PPI analysis completed" in message
assert manifest and manifest.exists()
assert expected.is_file()
assert captured["cmd"][captured["cmd"].index("--id-map") + 1] == str(expected)
assert captured["cmd"][captured["cmd"].index("--normalization-species-label") + 1] == "soe"
def test_ppi_explicit_absolute_species_rejected_before_outside_id_map_lookup(tmp_path, monkeypatch):
job = backend.JobInfo(
job_id="job_test",
@@ -0,0 +1,54 @@
from gfa_core.mas.agents.planning_v2 import PlanningAgentV2
def test_ppi_species_does_not_change_unrelated_stage_biological_scope() -> None:
planner = PlanningAgentV2.__new__(PlanningAgentV2)
stages = planner._generate_default_stages(
["motif", "ppi"],
"Ft.SPL",
intent_params={
"geneFamily": "SPL",
"targetSpecies": ["Ft"],
"scopeMode": "target_only",
"analysisRoute": "single_species",
"ppiSpecies": "ath",
},
)
stage_by_id = {stage["id"]: stage for stage in stages}
motif_params = stage_by_id["motif"]["params"]
ppi_params = stage_by_id["ppi"]["params"]
assert motif_params["targetSpecies"] == ["Ft"]
assert motif_params["scopeMode"] == "target_only"
assert motif_params["analysisRoute"] == "single_species"
assert not motif_params.get("referenceSpecies")
assert ppi_params["ppiSpecies"] == "ath"
def test_ppi_plan_is_target_only_while_kaks_keeps_project_reference() -> None:
planner = PlanningAgentV2.__new__(PlanningAgentV2)
stages = planner._generate_default_stages(
["ppi", "kaks"],
"Ft.SPL",
intent_params={
"geneFamily": "SPL",
"targetSpecies": ["Ft"],
"referenceSpecies": ["At"],
"scopeMode": "target_plus_reference",
"analysisRoute": "multi_species",
},
)
stage_by_id = {stage["id"]: stage for stage in stages}
ppi_params = stage_by_id["ppi"]["params"]
kaks_params = stage_by_id["kaks"]["params"]
assert ppi_params["targetSpecies"] == ["Ft"]
assert ppi_params["referenceSpecies"] == []
assert ppi_params["scopeMode"] == "target_only"
assert ppi_params["analysisRoute"] == "single_species"
assert kaks_params["referenceSpecies"] == ["At"]
assert kaks_params["scopeMode"] == "target_plus_reference"
+182 -17
View File
@@ -1,5 +1,7 @@
from pathlib import Path
from apps.web.vue3 import main as web_main
REPO_ROOT = Path(__file__).resolve().parents[2]
@@ -37,12 +39,12 @@ def test_ppi_alphafold_links_remain_in_file_list_and_use_table_preview():
assert "tablePreviewFilteredRows" in html
def test_natural_language_ppi_reset_intent_opens_reset_dialog():
def test_ppi_result_action_opens_reset_dialog():
html = _index_html()
assert "isPpiResetIntent" in html
assert "raw.includes('基因id')" in html
assert "openPpiResetDialog()" in html
assert "PPI Reset 基因列表编辑弹窗" in html
assert '@click="openPpiResetDialog(stage)"' in html
assert 'title="PPI Reset 输入基因列表"' in html
def test_ppi_reset_uses_current_stage_prefix_before_default_execution_prefix():
@@ -91,7 +93,28 @@ def test_ppi_execute_path_runs_species_resolution_preflight_before_execute():
assert "Spinacia oleracea" not in html
assert "const shouldRunPpiKaksPreflight =" in html
assert "const currentIsDependencyOwner = ppiKaksDependencyStages.has(currentStageId) || ppiKaksDependencyStages.has(requestBody.stage)" in html
assert "PPI/KaKs 需要先处理参考物种/Ortholog" in html
assert "id: 'ppi_kaks_reference_status'" in html
assert "PPI/KaKs reference data and Ortholog mapping required" in html
def test_ppi_kaks_temporary_breaker_is_disabled_and_stage_failure_keeps_scheduler_alive():
html = _index_html()
assert "const ppiKaksTemporaryCircuitBreakerEnabled = false;" in html
assert "await ensureOrthologProducerForPpiKaks();" in html
assert "executeStageWithConfirmation(stage" in html
assert ".finally(() => scheduleCheck(0));" in html
catch_start = html.index(" } catch (err) {", html.index("const executeStageWithConfirmation = async"))
catch_end = html.index(" } finally {", catch_start)
ordinary_failure = html[catch_start:catch_end]
assert "stageExecutionStatus.value[stageId] = 'failed';" in ordinary_failure
assert "console.error('[ERROR] Stage failed:', stageId, err);" in ordinary_failure
scheduler_start = html.index("const executeStagesWithDependencies = async")
scheduler_end = html.index("const NCBI_SPECIES_DEFAULTS", scheduler_start)
scheduler = html[scheduler_start:scheduler_end]
assert "return deps.some(dep => stageExecutionStatus.value[dep] === 'failed');" in scheduler
assert "stageExecutionStatus.value[stageId] = 'failed';" in scheduler
def test_ortholog_scope_dialog_and_ppi_queue_preflight_are_wired():
@@ -103,16 +126,20 @@ def test_ortholog_scope_dialog_and_ppi_queue_preflight_are_wired():
assert "ppiHasNearReferenceBeforeArabidopsis" in html
def test_ppi_kaks_dependency_owners_are_preflighted_independently():
def test_ppi_is_preflighted_first_and_kaks_is_deferred_to_its_confirmation():
html = _index_html()
assert "const ppiKaksDependencyStages = new Set(['ppi', 'kaks'])" in html
assert "const ensurePpiKaksDependencyOwner = async (ownerStageId) =>" in html
assert "const ensurePpiKaksDependencyOwner = async (ownerStageId, stageParamsOverride = null) =>" in html
assert "const ensureOrthologProducerForPpiKaks = async" in html
assert "for (const ownerStageId of requestedStages)" in html
assert "await ensurePpiKaksDependencyOwner(ownerStageId)" in html
assert "const requestedStages = [ownerStageId];" in html
assert "autoInsertedFor: requestedStages" in html
assert "referencePolicySource: 'ppi_kaks_dependency_preflight'" in html
assert ".filter(stageId => stageId === 'ppi')" in html
assert "const prepareKaksSelectedReference = async" in html
assert "await ensurePpiKaksDependencyOwner('kaks', stageParams)" in html
assert "referencePolicySource: 'ppi_owner_ortholog_producer'" in html
assert "referencePolicySource: 'kaks_owner_ortholog_producer'" in html
assert "await ensureOrthologProducerForPpiKaks()" in html
@@ -125,18 +152,40 @@ def test_ppi_preflight_uses_current_stage_and_releases_direct_target_from_orthol
assert "releaseDirectTargetPpiFromOrtholog(preflight.resolution);" in html
def test_ppi_needing_mapping_attaches_to_an_existing_ortholog_stage():
def test_ppi_needing_mapping_uses_hidden_internal_ortholog_producer():
html = _index_html()
start = html.index("const ensureOrthologStageForPpiKaks =")
end = html.index("const ensurePpiKaksDependencyOwner =", start)
producer = html[start:end]
attach = "attachOrthologDependencyToOwners(requestedStages);"
existing = "if (stageIds.includes('ortholog'))"
assert attach in producer
assert producer.index(attach) < producer.index(existing)
assert "rememberPpiKaksInternalOrthologProducer('ppi'" in producer
assert "attachOrthologDependencyToOwners(requestedStages);" not in producer
assert "nextStages.splice" not in producer
assert "batchExecuteStages.value = nextStages" not in producer
def test_ppi_static_dependencies_exclude_ortholog_while_kaks_keeps_it():
def test_ppi_internal_ortholog_executes_without_visible_stage_scheduler():
html = _index_html()
start = html.index("const executePpiOwnedOrthologProducer =")
end = html.index("const waitForPpiProducerDependency =", start)
executor = html[start:end]
assert "executeStageDirect('ortholog', producerParams)" in executor
assert "executeStagesWithDependencies([producerStage]" not in executor
assert "stageExecutionStatus.value.ortholog" not in executor
def test_kaks_confirmation_chooses_ppi_or_project_reference_and_mapping_owner():
html = _index_html()
assert 'v-model="confirmParams.kaksReferenceSource"' in html
assert 'label="ppi"' in html
assert 'label="project"' in html
assert "orthologMappingOwner: usePpi ? 'ppi' : 'kaks'" in html
assert "requestBody.orthologMappingOwner = stageParams.orthologMappingOwner" in html
def test_ppi_and_kaks_static_dependencies_hide_internal_ortholog():
html = _index_html()
catalog = (REPO_ROOT / "gfa_core/mas/stage_catalog.py").read_text(encoding="utf-8")
main_py = (REPO_ROOT / "apps/web/vue3/main.py").read_text(encoding="utf-8")
@@ -144,11 +193,127 @@ def test_ppi_static_dependencies_exclude_ortholog_while_kaks_keeps_it():
assert "'ppi': ['identify']," in html
assert '"ppi": ["identify"],' in catalog
assert '"ppi": ["identify"],' in main_py
assert "'kaks': ['identify', 'ortholog']," in html
assert '"kaks": ["identify", "ortholog"],' in catalog
assert '"kaks": ["identify", "ortholog"],' in main_py
assert "'kaks': ['identify']," in html
assert '"kaks": ["identify"],' in catalog
assert '"kaks": ["identify"],' in main_py
assert '"ortholog": ["identify"],' in catalog
def test_normal_full_gene_family_plan_excludes_ortholog_and_orders_ppi_before_kaks():
main_py = (REPO_ROOT / "apps/web/vue3/main.py").read_text(encoding="utf-8")
catalog = (REPO_ROOT / "gfa_core/mas/stage_catalog.py").read_text(encoding="utf-8")
workflow_start = main_py.index("FULL_GENE_FAMILY_WORKFLOW_STAGES = [")
workflow_end = main_py.index("]", workflow_start)
workflow_block = main_py[workflow_start:workflow_end]
assert '"ortholog"' not in workflow_block
assert workflow_block.index('"ppi"') < workflow_block.index('"kaks"')
context_start = main_py.index("def _downstream_gene_family_stages_for_context")
context_end = main_py.index("def _resolve_contextual_gene_family_request", context_start)
context_block = main_py[context_start:context_end]
full_workflow_start = context_block.index("if _looks_like_gene_family_workflow_query(text):")
full_workflow_end = context_block.index("return stages", full_workflow_start)
full_workflow_block = context_block[full_workflow_start:full_workflow_end]
assert '"ortholog"' not in full_workflow_block
assert full_workflow_block.index('"ppi"') < full_workflow_block.index('"kaks"')
normalize_start = main_py.index("def _normalize_full_gene_family_workflow_plan")
normalize_end = main_py.index("def _", normalize_start + 4)
normalize_block = main_py[normalize_start:normalize_end]
assert 'if sid == "ortholog":' in normalize_block
def test_confirmed_full_workflow_recovers_original_user_request_from_history():
history = [
{"role": "user", "content": "执行基因家族分析"},
{"role": "assistant", "content": "是否继续?"},
{"role": "user", "content": "继续"},
]
assert web_main._full_gene_family_workflow_query_text("继续", history) == "执行基因家族分析"
assert web_main._full_gene_family_workflow_query_text("执行直系同源基因鉴定", []) == ""
def test_confirmed_full_workflow_removes_visible_ortholog_and_stale_kaks_dependency():
stages = [
"phylogeny", "motif", "domain", "structure", "combo", "promoter",
"synteny", "interspecies_synteny", "karyotype", "pep", "ortholog", "kaks", "ppi",
]
details = [web_main._stage_detail_defaults(stage) for stage in stages]
next(item for item in details if item["id"] == "kaks")["dependencies"] = ["identify", "ortholog"]
normalized_stages, normalized_details = web_main._normalize_full_gene_family_workflow_plan(
message="执行基因家族分析",
stage_ids=stages,
stages_detail=details,
plan_params={"targetSpecies": ["Ft"], "referenceSpecies": ["At"], "geneFamily": "SPL"},
plan_prefix="Ft.SPL",
)
assert "ortholog" not in normalized_stages
assert normalized_stages.index("ppi") < normalized_stages.index("kaks")
detail_by_id = {item["id"]: item for item in normalized_details}
assert detail_by_id["ppi"]["dependencies"] == ["identify"]
assert detail_by_id["kaks"]["dependencies"] == ["identify"]
assert detail_by_id["ppi"]["params"]["targetSpecies"] == ["Ft"]
assert detail_by_id["ppi"]["params"]["referenceSpecies"] == []
assert detail_by_id["ppi"]["params"]["scopeMode"] == "target_only"
assert detail_by_id["ppi"]["params"]["analysisRoute"] == "single_species"
assert detail_by_id["kaks"]["params"]["referenceSpecies"] == ["At"]
def test_deterministic_context_exit_applies_confirmed_full_workflow_contract():
request = web_main.AgentPlanRequest(
message="继续",
prefix="Ft.SPL",
history=[
{"role": "user", "content": "执行基因家族分析"},
{"role": "assistant", "content": "是否继续?"},
{"role": "user", "content": "继续"},
],
intent={"params": {"referenceSpecies": ["At"]}},
)
contextual_request = {
"family": "SPL",
"prefix": "Ft.SPL",
"stages": [
"phylogeny", "motif", "domain", "structure", "combo", "promoter",
"synteny", "interspecies_synteny", "karyotype", "pep", "ortholog", "kaks", "ppi",
],
"targetSpecies": ["Ft"],
"referenceSpecies": ["At"],
"comparativeSpecies": [],
"scopeMode": "target_plus_reference",
"requiresConfirmedSubfamily": True,
"skipSubfamilyLabels": False,
"subfamilyState": {"by_species": {"Ft": {"status": "confirmed"}}},
"contextGate": {"status": "ready", "issues": []},
}
response = web_main._build_contextual_gene_family_plan_response(
request,
"test_confirmed_full_workflow",
contextual_request,
)
assert response["mode"] == "deterministic_context"
assert "ortholog" not in response["plan"]["stages"]
assert response["plan"]["stages"].index("ppi") < response["plan"]["stages"].index("kaks")
detail_by_id = {item["id"]: item for item in response["plan"]["stages_detail"]}
assert detail_by_id["kaks"]["dependencies"] == ["identify"]
def test_kaks_request_carries_selected_ortholog_mapping_owner_to_backend():
main_py = (REPO_ROOT / "apps/web/vue3/main.py").read_text(encoding="utf-8")
assert 'orthologMappingOwner: str = ""' in main_py
assert '"orthologMappingOwner": getattr(req, \'orthologMappingOwner\', \'\')' in main_py
assert (
'ortholog_mapping_owner=params.get("orthologMappingOwner") '
'or params.get("ortholog_mapping_owner") or ""'
) in main_py
def test_ppi_plan_generation_never_inserts_static_ortholog_dependency():
main_py = (REPO_ROOT / "apps/web/vue3/main.py").read_text(encoding="utf-8")
catalog = (REPO_ROOT / "gfa_core/mas/stage_catalog.py").read_text(encoding="utf-8")
@@ -180,7 +345,7 @@ def test_ppi_preflight_recovered_reference_is_written_to_ppi_owner_only():
def test_ready_preflight_propagates_recovered_reference_before_early_return():
html = _index_html()
start = html.index("const ensurePpiKaksDependencyOwner = async (ownerStageId) =>")
start = html.index("const ensurePpiKaksDependencyOwner = async (ownerStageId, stageParamsOverride = null) =>")
end = html.index("const ensureOrthologProducerForPpiKaks = async () =>", start)
producer = html[start:end]
propagation = "applyResolvedPpiReferenceToBatch(preflight.resolution, requestedStages, requestBody);"
@@ -47,6 +47,9 @@ def test_ppi_owned_ortholog_resolves_storage_pep_and_preflight_reuses_matching_m
workbook.save(input_dir / "config.xlsx")
reference_pep = storage_dir / "string_taxon_4555.pep.fasta"
reference_pep.write_text(">Seita.1\nMA\n", encoding="utf-8")
(storage_dir / "string_taxon_4555.cds.fasta").write_text(
">Seita.1\nATGGCT\n", encoding="utf-8"
)
query_pep = final_dir / "Sv.HKL.final_members.pep.fa"
query_pep.write_text(">SvGene1\nMA\n", encoding="utf-8")
scope_file = final_dir / "Sv.HKL.scope.json"
@@ -78,9 +81,17 @@ def test_ppi_owned_ortholog_resolves_storage_pep_and_preflight_reuses_matching_m
assert message.startswith("✅")
assert launched[0][launched[0].index("--subject") + 1] == str(reference_pep)
assert (final_dir / "Sv.HKL.ppi.orthologs.txt").exists()
assert not (final_dir / "Sv.HKL.orthologs.txt").exists()
manifest = json.loads(Path(manifest_path).read_text(encoding="utf-8"))
assert ".ppi." in Path(manifest_path).name
assert manifest["parameters"]["dependency_owner"] == "ppi"
assert manifest["parameters"]["reference_species"] == "string_taxon_4555"
assert manifest["parameters"]["reference_taxon_id"] == "4555"
assert manifest["scope_id"] == "HKL.target-Sv.target_only"
assert manifest["scope_mode"] == "target_only"
assert manifest["stage_context"]["reference_species"] == []
assert manifest["stage_context"]["selected_species"] == ["Sv"]
resolution = backend.resolve_ppi_species_resolution(
job=job, prefix="Sv.HKL", scope_file=str(scope_file),
target_species=["Sv"], family="HKL",
@@ -90,7 +101,6 @@ def test_ppi_owned_ortholog_resolves_storage_pep_and_preflight_reuses_matching_m
)
assert resolution["status"] == "ready"
assert resolution["mapping_reference_species"] == "string_taxon_4555"
reference_pep.write_text(">Seita.1\nMAGA\n", encoding="utf-8")
stale_reference = backend.resolve_ppi_species_resolution(
job=job, prefix="Sv.HKL", target_species=["Sv"], family="HKL",
@@ -111,6 +121,145 @@ def test_ppi_owned_ortholog_resolves_storage_pep_and_preflight_reuses_matching_m
assert stale_target["status"] == "needs_ortholog"
def test_ppi_owner_effective_scope_ignores_project_and_technical_references() -> None:
assert backend._effective_ppi_owner_scope_id(
family="SPL",
selected_targets=["Ft"],
scope_data={
"scope_id": "SPL.target-Ft.ref-At.target_plus_reference",
"scope_mode": "target_plus_reference",
"target_species": ["Ft"],
"reference_species": ["At"],
"comparative_species": ["Os"],
},
reference_storage_key="string_taxon_3562",
) == "SPL.target-Ft.target_only"
def test_ppi_owned_ortholog_uses_whole_reference_pep_when_family_pep_also_exists(
tmp_path: Path, monkeypatch
) -> None:
job = _job(tmp_path)
input_dir = Path(job.input_dir)
final_dir = Path(job.workspace_dir) / "final"
reference_dir = input_dir / "At"
reference_dir.mkdir()
whole_reference_pep = reference_dir / "At.pep.fasta"
whole_reference_pep.write_text(
">AT5G16970.1 gene:AT5G16970\nMAGA\n", encoding="utf-8"
)
(reference_dir / "At.cds.fasta").write_text(
">AT5G16970.1 gene:AT5G16970\nATGGCTGGCGCT\n", encoding="utf-8"
)
family_reference_pep = input_dir / "At.SPL.pep.fasta"
family_reference_pep.write_text(">AT5G16970.1\nMA\n", encoding="utf-8")
query_pep = final_dir / "Ft.SPL.final_members.pep.fa"
query_pep.write_text(">FtSPL1\nMA\n", encoding="utf-8")
scope_file = final_dir / "Ft.SPL.scope.json"
scope_file.write_text(json.dumps({
"family": "SPL",
"scope_id": "SPL.target-Ft.target_only",
"scope_mode": "target_only",
"target_species": ["Ft"],
"reference_species": [],
"comparative_species": [],
"selected_species": ["Ft"],
}), encoding="utf-8")
launched: list[list[str]] = []
def fake_run(cmd, *_args, **_kwargs):
launched.append(list(cmd))
run_prefix = cmd[cmd.index("--prefix") + 1]
pairs = Path(job.workspace_dir) / "ortholog" / f"{run_prefix}_pairs.txt"
pairs.write_text("AT5G16970.1\tFtSPL1\t91\n", encoding="utf-8")
return 0
monkeypatch.setattr(backend, "can_start_new_task", lambda: (True, "ok"))
monkeypatch.setattr(backend, "run_cmd_logged", fake_run)
message, manifest_path = backend.run_stage(
"ortholog",
job,
"Ft.SPL",
scope_file=str(scope_file),
ppi_reference_taxon_id="3702",
ppi_reference_storage_key="At",
ppi_reference_selection_mode="model_fallback",
ppi_reference_user_confirmed=True,
dependency_owner="ppi",
)
assert message.startswith("✅")
assert launched[0][launched[0].index("--subject") + 1] == str(whole_reference_pep)
manifest = json.loads(Path(manifest_path).read_text(encoding="utf-8"))
assert manifest["scope_mode"] == "target_only"
assert manifest["stage_context"]["reference_species"] == []
resolution = backend.resolve_ppi_species_resolution(
job=job,
prefix="Ft.SPL",
scope_file=str(scope_file),
target_species=["Ft"],
family="SPL",
ppi_reference_taxon_id="3702",
ppi_reference_storage_key="At",
ppi_reference_selection_mode="model_fallback",
ppi_reference_user_confirmed=True,
dependency_owner="ppi",
requested_stages=["ppi"],
)
assert resolution["status"] == "ready", resolution
def test_independent_ortholog_keeps_family_reference_pep_priority(
tmp_path: Path, monkeypatch
) -> None:
job = _job(tmp_path)
input_dir = Path(job.input_dir)
final_dir = Path(job.workspace_dir) / "final"
reference_dir = input_dir / "At"
reference_dir.mkdir()
whole_reference_pep = reference_dir / "At.pep.fasta"
whole_reference_pep.write_text(">AT5G16970.1\nMAGA\n", encoding="utf-8")
family_reference_pep = input_dir / "At.SPL.pep.fasta"
family_reference_pep.write_text(">AT5G16970.1\nMA\n", encoding="utf-8")
(final_dir / "Ft.SPL.final_members.pep.fa").write_text(
">FtSPL1\nMA\n", encoding="utf-8"
)
scope_file = final_dir / "Ft.SPL.scope.json"
scope_file.write_text(json.dumps({
"family": "SPL",
"scope_id": "SPL.target-Ft.ref-At.target_plus_reference",
"scope_mode": "target_plus_reference",
"target_species": ["Ft"],
"reference_species": ["At"],
"comparative_species": [],
"selected_species": ["Ft", "At"],
}), encoding="utf-8")
launched: list[list[str]] = []
def fake_run(cmd, *_args, **_kwargs):
launched.append(list(cmd))
run_prefix = cmd[cmd.index("--prefix") + 1]
pairs = Path(job.workspace_dir) / "ortholog" / f"{run_prefix}_pairs.txt"
pairs.write_text("AT5G16970.1\tFtSPL1\t91\n", encoding="utf-8")
return 0
monkeypatch.setattr(backend, "can_start_new_task", lambda: (True, "ok"))
monkeypatch.setattr(backend, "run_cmd_logged", fake_run)
message, _manifest_path = backend.run_stage(
"ortholog",
job,
"Ft.SPL",
scope_file=str(scope_file),
)
assert message.startswith("✅")
assert launched[0][launched[0].index("--subject") + 1] == str(family_reference_pep)
assert launched[0][launched[0].index("--subject") + 1] != str(whole_reference_pep)
@pytest.mark.parametrize("scope_id_input", ["present", "empty", "missing"])
def test_ppi_owned_ortholog_preserves_target_only_biological_scope_identity(
tmp_path: Path, monkeypatch, scope_id_input: str
@@ -128,6 +277,9 @@ def test_ppi_owned_ortholog_preserves_target_only_biological_scope_identity(
workbook.save(input_dir / "config.xlsx")
reference_pep = storage_dir / "string_taxon_4555.pep.fasta"
reference_pep.write_text(">Seita.1\nMA\n", encoding="utf-8")
(storage_dir / "string_taxon_4555.cds.fasta").write_text(
">Seita.1\nATGGCT\n", encoding="utf-8"
)
query_pep = final_dir / "Sv.HKL.final_members.pep.fa"
query_pep.write_text(">SvGene1\nMA\n", encoding="utf-8")
scope_id = "HKL.target-Sv.target_only"
@@ -170,7 +322,7 @@ def test_ppi_owned_ortholog_preserves_target_only_biological_scope_identity(
assert manifest["stage_context"]["scope_mode"] == "target_only"
assert manifest["stage_context"]["reference_species"] == []
mapping = final_dir / "Sv.HKL.orthologs.txt"
mapping = final_dir / "Sv.HKL.ppi.orthologs.txt"
recorded = json.loads(Path(f"{mapping}.identity.json").read_text(encoding="utf-8"))
expected = build_ortholog_run_identity(
query_species="Sv", reference_taxon_id="4555",
@@ -279,3 +431,115 @@ def test_materialized_manifest_persists_owner_and_mapping_identity(tmp_path: Pat
"target_identity", "member_identity", "mapping_identity", "mapping_path",
):
assert manifest["parameters"][key]
def test_ppi_and_kaks_owned_mappings_coexist_without_overwrite(tmp_path: Path) -> None:
final_dir = tmp_path / "final"
query = tmp_path / "Ft.pep.fa"
query.write_text(">Ft1\nMA\n", encoding="utf-8")
def materialize(owner: str, reference_species: str, reference_id: str) -> tuple[Path, Path]:
reference = tmp_path / f"{reference_species}.pep.fasta"
pairs = tmp_path / f"{owner}.pairs.txt"
reference.write_text(f">{reference_id}\nMA\n", encoding="utf-8")
pairs.write_text(f"{reference_id}\tFt1\t90\n", encoding="utf-8")
_, manifest_path = materialize_ortholog_final_outputs(
final_dir=final_dir,
prefix="Ft.SPL",
dependency_owner=owner,
run_records=[OrthologRunRecord("Ft", reference_species, query, reference, pairs)],
job_id="task5b",
workspace=tmp_path,
reference_species=reference_species,
reference_storage_key=reference_species,
scope_fields={
"scope_id": f"SPL.target-Ft.ref-{reference_species}.target_plus_reference",
"target_species": ["Ft"],
"reference_species": [reference_species],
},
)
return final_dir / f"Ft.SPL.{owner}.orthologs.txt", manifest_path
ppi_mapping, ppi_manifest = materialize("ppi", "string_taxon_3562", "Soe1")
ppi_mapping_before = ppi_mapping.read_bytes()
ppi_manifest_before = ppi_manifest.read_bytes()
kaks_mapping, kaks_manifest = materialize("kaks", "At", "AT1G01010")
assert ppi_mapping.exists()
assert kaks_mapping.exists()
assert ppi_mapping != kaks_mapping
assert ppi_mapping.read_bytes() == ppi_mapping_before
assert ppi_manifest.read_bytes() == ppi_manifest_before
assert json.loads(ppi_manifest.read_text(encoding="utf-8"))["parameters"]["dependency_owner"] == "ppi"
assert json.loads(kaks_manifest.read_text(encoding="utf-8"))["parameters"]["dependency_owner"] == "kaks"
def test_owner_specific_mapping_candidates_never_fall_back_to_another_owner(tmp_path: Path) -> None:
final_dir = tmp_path / "final"
final_dir.mkdir()
ppi_candidates = backend.ortholog_mapping_candidates(
final_dir,
"Ft.SPL",
dependency_owner="ppi",
scope_id="scope-v1",
)
assert ppi_candidates == [
final_dir / "Ft.SPL.ppi.scope-v1.orthologs.txt",
final_dir / "Ft.SPL.ppi.scope-v1.ortholog_pairs.txt",
final_dir / "Ft.SPL.ppi.orthologs.txt",
final_dir / "Ft.SPL.ppi.ortholog_pairs.txt",
]
assert all(".kaks." not in path.name for path in ppi_candidates)
assert all(path.name != "Ft.SPL.orthologs.txt" for path in ppi_candidates)
def test_kaks_selected_ppi_owner_consumes_only_ppi_mapping(tmp_path: Path, monkeypatch) -> None:
job = _job(tmp_path)
input_dir = Path(job.input_dir)
final_dir = Path(job.workspace_dir) / "final"
reference = "string_taxon_3562"
scope_file = final_dir / "Ft.SPL.scope.json"
scope_file.write_text(json.dumps({
"family": "SPL",
"scope_id": f"SPL.target-Ft.ref-{reference}.target_plus_reference",
"scope_mode": "target_plus_reference",
"target_species": ["Ft"],
"reference_species": [reference],
"selected_species": ["Ft", reference],
}), encoding="utf-8")
(input_dir / "Ft.cds.fasta").write_text(">Ft1\nATGGCT\n", encoding="utf-8")
(input_dir / f"{reference}.cds.fasta").write_text(">PpiGene\nATGGCT\n", encoding="utf-8")
(input_dir / f"{reference}.pep.fasta").write_text(">PpiGene\nMA\n", encoding="utf-8")
(final_dir / "Ft.SPL.final_members.pep.fa").write_text(">Ft1\nMA\n", encoding="utf-8")
(final_dir / "Ft.SPL.ppi.orthologs.txt").write_text(
f"Ft1\tPpiGene\t91\tFt\t{reference}\n", encoding="utf-8"
)
(final_dir / "Ft.SPL.kaks.orthologs.txt").write_text(
f"Ft1\tWrongKaksGene\t88\tFt\t{reference}\n", encoding="utf-8"
)
observed_pairs: list[str] = []
def fake_run(cmd, *_args, **_kwargs):
pair_path = Path(cmd[cmd.index("--pairs") + 1])
observed_pairs.append(pair_path.read_text(encoding="utf-8"))
return 0
monkeypatch.setattr(backend, "can_start_new_task", lambda: (True, "ok"))
monkeypatch.setattr(backend, "run_cmd_logged", fake_run)
message, manifest_path = backend.run_stage(
"kaks",
job,
"Ft.SPL",
scope_file=str(scope_file),
ortholog_mapping_owner="ppi",
)
assert message.startswith("✅")
assert observed_pairs and "PpiGene" in observed_pairs[0]
assert "WrongKaksGene" not in observed_pairs[0]
manifest = json.loads(Path(manifest_path).read_text(encoding="utf-8"))
assert manifest["parameters"]["ortholog_mapping"].endswith("Ft.SPL.ppi.orthologs.txt")
File diff suppressed because it is too large Load Diff
+37 -1
View File
@@ -65,6 +65,10 @@ def _assert_complete_identity(identity: dict) -> None:
def _prepare_owner_execution_fixture(job: backend.JobInfo) -> Path:
(Path(job.workspace_dir) / "final" / "Sv.HKL.final_members.id").write_text(
"SvGene1\n",
encoding="utf-8",
)
scope = Path(job.input_dir) / "ppi-owner.scope.json"
scope.write_text(
json.dumps(
@@ -79,7 +83,7 @@ def _prepare_owner_execution_fixture(job: backend.JobInfo) -> Path:
),
encoding="utf-8",
)
mapping = Path(job.workspace_dir) / "final" / "Sv.HKL.owner-target-only.orthologs.txt"
mapping = Path(job.workspace_dir) / "final" / "Sv.HKL.ppi.orthologs.txt"
mapping.write_text(
"SvGene1\tSeita.1\t91\tSv\tstring_taxon_4555\n",
encoding="utf-8",
@@ -300,6 +304,7 @@ def test_waiting_preflights_write_decision_records_without_failed_stage_manifest
storage = Path(job.input_dir) / "string_taxon_4555"
storage.mkdir()
(storage / "string_taxon_4555.pep.fasta").write_text(">Seita1\nMA\n", encoding="utf-8")
(storage / "string_taxon_4555.cds.fasta").write_text(">Seita1\nATGGCT\n", encoding="utf-8")
needs_ortholog = _sv_preflight(job, confirmed=True)
assert [confirmation["status"], missing_pep["status"], needs_ortholog["status"]] == [
@@ -316,6 +321,37 @@ def test_waiting_preflights_write_decision_records_without_failed_stage_manifest
assert not (Path(job.workspace_dir) / "final" / "Sv.HKL.ppi.manifest.json").exists()
def test_confirmed_arabidopsis_model_fallback_is_accepted_outside_nearest_candidates(
tmp_path: Path,
) -> None:
job = _job(tmp_path, job_id="ft-arabidopsis-fallback")
storage = Path(job.input_dir) / "At"
storage.mkdir()
(storage / "At.pep.fasta").write_text(">AT1G01010.1\nMA\n", encoding="utf-8")
result = backend.resolve_ppi_species_resolution(
job=job,
prefix="Ft.SPL",
target_species=["Ft"],
family="SPL",
target_scientific_name="Fagopyrum tataricum",
ppi_target_tree_taxon_id="62330",
ppi_reference_taxon_id="3702",
ppi_reference_storage_key="At",
ppi_reference_selection_mode="model_fallback",
ppi_reference_user_confirmed=True,
dependency_owner="ppi",
ignore_existing_ortholog=True,
requested_stages=["ppi"],
)
assert result["status"] == "needs_ortholog"
assert result["selected_taxon_id"] == "3702"
assert result["reference_species"] == "At"
assert result["ppi_provenance"]["selection_mode"] == "model_fallback"
assert result["ppi_provenance"]["user_confirmation"]["confirmed"] is True
@pytest.mark.parametrize(
("selection_mode", "status", "expected"),
[
@@ -0,0 +1,127 @@
from pathlib import Path
import json
import re
import subprocess
ROOT = Path(__file__).resolve().parents[2]
HTML_PATH = ROOT / "apps/web/vue3/index.html"
def _html() -> str:
return HTML_PATH.read_text(encoding="utf-8")
def _function_block(html: str, start: str, end: str) -> str:
return html.split(start, 1)[1].split(end, 1)[0]
def test_both_reference_messages_use_stage_confirm_dialog_shell():
html = _html()
nearest = _function_block(
html,
"const confirmNearestPpiReferenceCandidate = async",
"const ppiReferenceDecisionMessage =",
)
preflight = _function_block(
html,
"const runPpiSpeciesResolutionPreflight = async",
"const ppiKaksDependencyStages =",
)
assert "id: 'ppi_string_reference_confirmation'" in nearest
assert "showStageConfirmDialog" in nearest
assert "ElMessageBox.confirm" not in nearest
assert "id: 'ppi_kaks_reference_status'" in preflight
assert "showStageConfirmDialog" in preflight
assert "'PPI/KaKs reference data and Ortholog mapping required'" not in preflight
def test_stage_confirm_shell_renders_grouped_fields_and_ordered_candidates():
html = _html()
assert "confirmParams.stage === 'ppi_string_reference_confirmation'" in html
assert "confirmParams.stage === 'ppi_kaks_reference_status'" in html
assert html.count('<ol class="ppi-ref-candidate-list">') >= 2
assert 'v-for="(candidate, index) in confirmParams.ppiKaksReferenceCandidates"' in html
for label in (
"Target species",
"Requested stages",
"Required data",
"Missing data",
"Current reference species",
"Ortholog mapping",
"STRING TaxID",
"LCA",
"Topology distance",
):
assert label in html
def test_special_dialogs_preserve_manual_actions_without_batch_controls():
html = _html()
assert "Confirm top candidate" in html
assert "Cancel PPI" in html
assert "Reference status acknowledged" in html
assert "stageConfirmCountdown > 0" in html
def test_candidate_normalizer_supports_topology_and_recommendation_shapes():
html = _html()
match = re.search(
r"const normalizePpiReferenceCandidates = \(source = \{\}, fallbackVersion = ''\) => \{.*?\n\s*\};",
html,
re.S,
)
assert match, "normalizePpiReferenceCandidates helper is missing"
function_source = match.group(0).replace(
"const normalizePpiReferenceCandidates =",
"globalThis.normalizePpiReferenceCandidates =",
1,
)
script = f"""
{function_source}
const topology = normalizePpiReferenceCandidates({{
candidates: [{{
selected_string_name: 'Spinacia oleracea',
selected_string_taxon_id: 3562,
lca_name: 'Caryophyllales',
topology_distance: 4,
string_version: '12.0'
}}]
}});
const recommendation = normalizePpiReferenceCandidates({{
diagnostics: {{ reference_recommendations: [{{
species_name: 'Arabidopsis thaliana',
species_code: 'ath',
taxon_id: 3702,
relationship: 'model_fallback',
source: 'project_reference'
}}] }}
}}, '12.0');
process.stdout.write(JSON.stringify({{ topology, recommendation }}));
"""
completed = subprocess.run(
["node", "-e", script],
cwd=ROOT,
text=True,
capture_output=True,
check=True,
)
data = json.loads(completed.stdout)
assert data["topology"][0] == {
"name": "Spinacia oleracea",
"code": "",
"taxon": "3562",
"lca": "Caryophyllales",
"distance": "4",
"version": "12.0",
"relationship": "",
"source": "",
}
assert data["recommendation"][0]["name"] == "Arabidopsis thaliana"
assert data["recommendation"][0]["taxon"] == "3702"
assert data["recommendation"][0]["relationship"] == "model_fallback"
assert data["recommendation"][0]["source"] == "project_reference"
@@ -58,7 +58,7 @@ def _materialize_trusted_mapping(
pairs = workspace / "ortholog" / f"{prefix}.{target}_to_{storage_key}_pairs.txt"
pairs.parent.mkdir(parents=True, exist_ok=True)
pairs.write_text(f"{subject_id}\t{query_id}\t91\n", encoding="utf-8")
scope_id = build_scope_id(family, [target], [storage_key], [], "target_plus_reference")
scope_id = build_scope_id(family, [target], [], [], "target_only")
identity = build_ortholog_run_identity(
query_species=target, reference_taxon_id=taxon_id,
reference_storage_key=storage_key, query_fasta=query,
@@ -67,14 +67,15 @@ def _materialize_trusted_mapping(
)
materialize_ortholog_final_outputs(
final_dir=final_dir, prefix=prefix,
dependency_owner="ppi",
run_records=[OrthologRunRecord(target, storage_key, query, reference, pairs)],
job_id=job.job_id, workspace=workspace, reference_species=storage_key,
reference_taxon_id=taxon_id, reference_storage_key=storage_key,
ortholog_identity=identity,
scope_fields={
"scope_id": scope_id, "scope_mode": "target_plus_reference",
"target_species": [target], "reference_species": [storage_key],
"selected_species": [target, storage_key], "family": family,
"scope_id": scope_id, "scope_mode": "target_only",
"target_species": [target], "reference_species": [],
"comparative_species": [], "selected_species": [target], "family": family,
},
)
@@ -168,8 +169,8 @@ def test_confirmed_nearest_without_pep_requests_only_reference_data(tmp_path: Pa
assert result["status"] == "needs_reference_data"
assert result["issue_code"] == "ppi_reference_data_missing"
assert result["reference_bundle_status"]["required_files"] == ["pep"]
assert result["reference_bundle_status"]["missing_required"] == ["pep"]
assert result["reference_bundle_status"]["required_files"] == ["pep", "cds"]
assert result["reference_bundle_status"]["missing_required"] == ["pep", "cds"]
assert result["required_next_stage"] == ""
assert result["selected_taxon_id"] == "4555"
@@ -179,6 +180,7 @@ def test_confirmed_nearest_with_pep_needs_ortholog(tmp_path: Path) -> None:
reference_dir = Path(job.input_dir) / "string_taxon_4555"
reference_dir.mkdir()
(reference_dir / "string_taxon_4555.pep.fasta").write_text(">Seita.1\nMA\n", encoding="utf-8")
(reference_dir / "string_taxon_4555.cds.fasta").write_text(">Seita.1\nATGGCT\n", encoding="utf-8")
result = _resolve_sv(
job,
@@ -200,6 +202,7 @@ def test_confirmed_nearest_with_matching_mapping_is_ready(tmp_path: Path) -> Non
reference_dir = Path(job.input_dir) / "string_taxon_4555"
reference_dir.mkdir()
(reference_dir / "string_taxon_4555.pep.fasta").write_text(">Seita.1\nMA\n", encoding="utf-8")
(reference_dir / "string_taxon_4555.cds.fasta").write_text(">Seita.1\nATGGCT\n", encoding="utf-8")
_materialize_trusted_mapping(
job, prefix="Sv.HKL", target="Sv", family="HKL",
storage_key="string_taxon_4555", taxon_id="4555",
@@ -0,0 +1,163 @@
from __future__ import annotations
import json
import subprocess
from pathlib import Path
HTML_PATH = Path("apps/web/vue3/index.html")
def _source_block(source: str, start: str, end: str) -> str:
return source[source.index(start) : source.index(end)]
def _run_node(script: str) -> dict:
completed = subprocess.run(
["node", "-e", script],
check=True,
capture_output=True,
text=True,
)
return json.loads(completed.stdout.strip())
def _progress_state_script(body: str) -> str:
html = HTML_PATH.read_text(encoding="utf-8")
updater = _source_block(
html,
"const updateReferenceBundleDownloadProgress =",
"const pollReferenceBundleDownloadTask =",
)
return f"""
const referenceBundleDownloadState = {{value: {{
visible: false, taskId: '', speciesLabel: '', message: '',
percentage: 0, indeterminate: true, status: '',
bytesDownloaded: 0, totalBytes: 0
}}}};
{updater}
{body}
"""
def test_unknown_total_keeps_indeterminate_progress_and_reports_downloaded_bytes() -> None:
result = _run_node(
_progress_state_script(
"""
updateReferenceBundleDownloadProgress({
id: 'task-download', status: 'running',
progress: {bytes_downloaded: 8388608, total_bytes: 0, indeterminate: true}
}, {species_name: 'Spinacia oleracea'});
console.log(JSON.stringify(referenceBundleDownloadState.value));
"""
)
)
assert result["visible"] is True
assert result["speciesLabel"] == "Spinacia oleracea"
assert result["bytesDownloaded"] == 8388608
assert result["totalBytes"] == 0
assert result["indeterminate"] is True
def test_failure_without_new_progress_preserves_last_downloaded_bytes() -> None:
result = _run_node(
_progress_state_script(
"""
updateReferenceBundleDownloadProgress({
id: 'task-download', status: 'running',
progress: {bytes_downloaded: 8388608, total_bytes: 0, indeterminate: true}
}, {species_name: 'Spinacia oleracea'});
updateReferenceBundleDownloadProgress({
status: 'failed', message: 'IncompleteRead(726589 bytes read)'
}, {species_name: 'Spinacia oleracea'});
console.log(JSON.stringify(referenceBundleDownloadState.value));
"""
)
)
assert result["status"] == "failed"
assert result["message"] == "IncompleteRead(726589 bytes read)"
assert result["bytesDownloaded"] == 8388608
assert result["totalBytes"] == 0
def test_clear_resets_terminal_download_state() -> None:
result = _run_node(
_progress_state_script(
"""
updateReferenceBundleDownloadProgress({
id: 'task-download', status: 'failed', message: 'download failed',
progress: {bytes_downloaded: 4096, total_bytes: 0, indeterminate: true}
}, {species_name: 'Spinacia oleracea'});
clearReferenceBundleDownloadProgress();
console.log(JSON.stringify(referenceBundleDownloadState.value));
"""
)
)
assert result == {
"visible": False,
"taskId": "",
"speciesLabel": "",
"message": "",
"percentage": 0,
"indeterminate": True,
"status": "",
"bytesDownloaded": 0,
"totalBytes": 0,
}
def test_download_indicator_is_beside_tip_and_not_inside_batch_dialog() -> None:
html = HTML_PATH.read_text(encoding="utf-8")
toolbar = _source_block(
html,
"<!-- 使用小Tip hover弹出 -->",
"<!-- Upload hover弹出已上传文件 -->",
)
batch_dialog = _source_block(
html,
"<!-- 批量执行确认对话框 -->",
"<!-- 阶段确认对话框(批量执行用) -->",
)
assert 'class="reference-download-progress"' in toolbar
assert "referenceBundleDownloadState.visible" in toolbar
assert "PPI and Ka/Ks Reference Species Data Download" in toolbar
assert ':show-text="!referenceBundleDownloadState.indeterminate"' in toolbar
assert 'class="reference-download-progress"' not in batch_dialog
assert "referenceBundleDownloadState.visible" not in batch_dialog
def test_download_byte_formatter_uses_binary_units() -> None:
html = HTML_PATH.read_text(encoding="utf-8")
assert "const formatReferenceDownloadBytes =" in html
formatter = _source_block(
html,
"const formatReferenceDownloadBytes =",
"const updateReferenceBundleDownloadProgress =",
)
result = _run_node(
formatter
+ """
console.log(JSON.stringify({
zero: formatReferenceDownloadBytes(0),
mib: formatReferenceDownloadBytes(8388608)
}));
"""
)
assert result == {"zero": "", "mib": "8.0 MB"}
def test_monitor_retains_success_and_passes_failed_task_progress() -> None:
html = HTML_PATH.read_text(encoding="utf-8")
monitor = _source_block(
html,
"const monitorPpiReferenceBundleDownload =",
"const ppiHasNearReferenceBeforeArabidopsis =",
)
assert "clearReferenceBundleDownloadProgress();" not in monitor
assert "err?.task" in monitor
@@ -1,5 +1,6 @@
from __future__ import annotations
import http.client
import zipfile
@@ -73,5 +74,51 @@ def test_reference_bundle_download_reports_progress_events(tmp_path, monkeypatch
assert "validate_ids" in phases
assert phases[-1] == "completed"
assert manifest["requested_stages"] == ["ppi"]
assert manifest["requested_file_types"] == ["pep"]
assert "cds" not in manifest["files"]
assert manifest["requested_file_types"] == ["pep", "cds"]
assert "cds" in manifest["files"]
def test_reference_bundle_download_retries_incomplete_read(tmp_path, monkeypatch):
from apps.web import pipeline_backend as backend
payload = b"complete archive"
calls = {"count": 0}
class FakeResponse:
def __init__(self, *, incomplete: bool):
self.incomplete = incomplete
self.headers = {"Content-Length": str(len(payload))}
self._sent = False
def __enter__(self):
return self
def __exit__(self, *_args):
return False
def read(self, _size):
if self.incomplete:
raise http.client.IncompleteRead(b"partial", len(payload))
if self._sent:
return b""
self._sent = True
return payload
def fake_urlopen(_url, timeout):
calls["count"] += 1
return FakeResponse(incomplete=calls["count"] == 1)
monkeypatch.setattr(backend.urllib.request, "urlopen", fake_urlopen)
monkeypatch.setattr(backend.time, "sleep", lambda _seconds: None)
output_path = tmp_path / "reference.zip"
downloaded = backend._download_url_to_file(
"https://example.invalid/reference.zip",
output_path,
attempts=2,
)
assert calls["count"] == 2
assert downloaded == len(payload)
assert output_path.read_bytes() == payload
assert not output_path.with_suffix(".zip.part").exists()
@@ -71,3 +71,45 @@ def test_reference_bundle_task_can_be_recovered_from_persisted_status(tmp_path,
assert recovered is not None
assert recovered["job_id"] == "job_ref_recover"
assert recovered["status"] == "failed"
def test_reference_bundle_task_uses_english_user_messages(tmp_path, monkeypatch):
from apps.web import pipeline_backend
from apps.web.vue3 import main
monkeypatch.setattr(main, "WORKSPACE_ROOT", tmp_path / "workspace")
monkeypatch.setattr(main, "INPUT_ROOT", tmp_path / "input")
job_id = "job_ref_english"
task_id = "task_ref_english"
(tmp_path / "workspace" / job_id).mkdir(parents=True)
(tmp_path / "input" / job_id).mkdir(parents=True)
main.tasks[task_id] = {
"id": task_id,
"task_id": task_id,
"stage": "reference_bundle_download",
"job_id": job_id,
"status": "pending",
}
monkeypatch.setattr(
pipeline_backend,
"download_reference_annotation_bundle",
lambda **_kwargs: {
"status": "success",
"requested_file_types": ["pep", "cds"],
"files": {"pep": "ref.pep.fasta", "cds": "ref.cds.fasta"},
},
)
main.run_reference_bundle_download_task(
task_id,
{
"job_id": job_id,
"species_code": "string_taxon_3562",
"requested_stages": ["ppi"],
"timeout_sec": 30,
},
)
task = main.tasks.pop(task_id)
assert task["message"] == "PPI and Ka/Ks reference species data are ready"
assert task["progress"]["message"] == "PPI and Ka/Ks reference species data are ready"
@@ -23,6 +23,20 @@ class _PassThroughTools:
return query
class _ContextEnrichingTools:
@staticmethod
def resolve_analysis_handoff_request(
query: str,
*,
workflow_id: str | None = None,
guidance: bool = False,
) -> str:
return (
f"{query}\n已确认上下文:当前 job 的 subfamily_confirm 已成功完成,"
"家族成员和亚家族分类表已确认。"
)
@pytest.mark.parametrize(
("query", "workflow_id", "clarification_question"),
(
@@ -77,10 +91,47 @@ def test_new_analysis_proposal_preserves_original_request_without_main_agent_pla
assert outcome.action == "clarify"
assert outcome.proposal is not None
assert outcome.proposal["request"] == query
assert outcome.proposal["original_request"] == query
assert "workflow_id" not in outcome.proposal
assert "全基因组还是" not in outcome.message
def test_context_enrichment_does_not_replace_original_analysis_handoff() -> None:
runtime = RouterRuntime(object(), _ContextEnrichingTools(), max_iterations=5) # type: ignore[arg-type]
async def decide_route(**_: Any) -> tuple[RouteDecision, int, list[dict[str, Any]]]:
return (
RouteDecision(
goal="new_analysis",
needs=["analysis_intent"],
subject="执行kaks分析",
workflow_id="gene_family",
analysis_request_kind="execution",
),
1,
[],
)
runtime._decide_route = decide_route # type: ignore[method-assign]
outcome = asyncio.run(
runtime.run(
query="执行kaks分析",
history=[],
session_id="kaks-handoff-session",
job_id="job-kaks-handoff",
goal_ledger=GoalLedger(
conversation_id="kaks-handoff-session",
revision=0,
user_terms={"active_job_id": "job-kaks-handoff"},
),
)
)
assert outcome.proposal is not None
assert "subfamily_confirm" in outcome.proposal["request"]
assert outcome.proposal["original_request"] == "执行kaks分析"
def test_new_analysis_decision_discards_main_agent_workflow_and_clarification() -> None:
decision = RouteDecision(
goal="new_analysis",
@@ -87,6 +87,63 @@ def test_subfamily_request_roles_do_not_promote_reference_to_target(monkeypatch)
}
def test_contextual_gene_family_request_handles_explicit_multi_target_scope(monkeypatch):
main = _load_vue3_main()
monkeypatch.setattr(
main,
"_canonicalize_species_codes_for_job",
lambda _job_id, species: list(species),
)
monkeypatch.setattr(
main,
"_reconcile_confirmed_gene_family_species",
lambda *_args, **_kwargs: {"applicable": False, "conflicts": []},
)
monkeypatch.setattr(main, "_load_identify_species_selection", lambda _job_id: {})
monkeypatch.setattr(main, "_gene_family_from_config", lambda _path: "SPL")
monkeypatch.setattr(main, "_confirmed_subfamily_species", lambda *_args: [])
monkeypatch.setattr(
main,
"_extract_species_codes_from_text_for_job",
lambda *_args: [],
)
monkeypatch.setattr(
main,
"_subfamily_state_for_targets",
lambda *_args: {
"all_confirmed": False,
"by_species": {
"Ft": {"status": "not_classified"},
"At": {"status": "not_classified"},
},
"issues": [],
},
)
monkeypatch.setattr(
main,
"_validate_gene_family_stage_context",
lambda **_kwargs: {"valid": True, "issues": [], "evidence": {}},
)
contextual = main._resolve_contextual_gene_family_request(
"job-multi-target",
"执行基因家族分析",
requested_stages=["phylogeny"],
request_intent={
"gene_family": "SPL",
"target_species": ["Ft", "At"],
"reference_species": [],
"params": {
"targetSpecies": ["Ft", "At"],
"referenceSpecies": [],
},
},
)
assert contextual["targetSpecies"] == ["Ft", "At"]
assert contextual["needs_species_confirmation"] is False
def test_generic_target_reference_scope_does_not_guess_subfamily_from_stage_name():
scope = {
"scope_mode": "target_plus_reference",