#!/usr/bin/env python3 """ PPI Skill - Protein-Protein Interaction Analysis 蛋白互作网络分析技能 (基于STRING数据库) Usage: python run.py --gene-ids genes.txt --species ath --prefix Ft.SPL --output ./ppi python run.py --config config.yaml --output ./ppi python run.py --demo --output ./ppi """ import argparse import hashlib import json import logging import math import os import sys import urllib.parse from dataclasses import dataclass from datetime import datetime from pathlib import Path from typing import Any, Callable, Dict, List, Optional try: import requests HAS_REQUESTS = True except ImportError: HAS_REQUESTS = False import pandas as pd import yaml from gfa_core.id_resolution import IdResolutionError, ReferenceAliasIndex from gfa_core.ppi_string_policy import ( STRING_SPECIES_MAP, normalize_gene_ids_for_string, string_taxon_id, unique_preserve_order, ) from gfa_core.string_rate_gate import ( StringRateGate, is_authoritative_string_https_url, string_https_request_identity, ) from gfa_core.string_species_registry import StringSnapshotError, load_string_snapshot logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) class StringApiError(RuntimeError): """Typed failure from a STRING transport, HTTP, JSON, or protocol boundary.""" def __init__( self, message: str, *, kind: str, status_code: int | None = None, endpoint: str = "", ) -> None: super().__init__(message) self.kind = kind self.status_code = status_code self.endpoint = endpoint class StringAPI: """STRING API client with stable-version discovery and typed failures.""" BASE_URL = "https://string-db.org/api" CALLER_IDENTITY = "PlantAI" SPECIES_MAP = STRING_SPECIES_MAP def __init__( self, species: str = "ath", *, http_client: Any = None, session: Any = None, stable_api_base: str | None = None, rate_gate: StringRateGate | Any | None = None, rate_gate_path: Path | str | None = None, min_interval: float = 1.0, clock: Callable[[], float] | None = None, sleeper: Callable[[float], None] | None = None, ): self.species = species self.species_id = self.resolve_species_id(species) self._http = http_client or session or (requests if HAS_REQUESTS else None) self.stable_api_base = self._normalize_api_base(stable_api_base or "") gate_options: Dict[str, Any] = { "path": rate_gate_path, "min_interval": min_interval, } if clock is not None: gate_options["clock"] = clock if sleeper is not None: gate_options["sleeper"] = sleeper self.rate_gate = rate_gate or StringRateGate(**gate_options) @staticmethod def resolve_species_id(species: str) -> str: raw = str(species or "").strip() if raw.isdigit(): snapshot_root = Path(__file__).resolve().parents[2] / "data" / "string" / "v12.0" try: snapshot = load_string_snapshot(snapshot_root) except StringSnapshotError as exc: raise ValueError(f"STRING snapshot registry unavailable: {exc}") from exc if raw not in snapshot.registry: raise ValueError(f"STRING TaxID {raw} is not in the fixed STRING snapshot registry") return raw species_id = string_taxon_id(species, "") if not species_id: raise ValueError(f"Unsupported or empty STRING species: {species!r}") return species_id @staticmethod def _normalize_api_base(address: str) -> str: value = str(address or "").strip() if not value: return "" try: parsed = urllib.parse.urlsplit(value) except ValueError as exc: raise StringApiError( "Malformed STRING stable address", kind="protocol", endpoint="version" ) from exc hostname = (parsed.hostname or "").lower() if ( not is_authoritative_string_https_url(value) or parsed.query or parsed.fragment or parsed.path not in {"", "/", "/api", "/api/"} ): raise StringApiError( "Untrusted STRING stable address", kind="protocol", endpoint="version" ) return f"https://{hostname}/api" def _request_json( self, method: str, url: str, *, allow_404: bool = False, **kwargs: Any, ) -> Any: if self._http is None: raise StringApiError( "requests module not available", kind="transport", endpoint=url ) self.rate_gate.wait() try: request_method = getattr(self._http, method.lower()) response = request_method( url, timeout=30, allow_redirects=False, **kwargs ) except Exception as exc: raise StringApiError( f"STRING transport failure: {exc}", kind="transport", endpoint=url ) from exc response_url = getattr(response, "url", None) if string_https_request_identity(response_url) != string_https_request_identity( url ): raise StringApiError( f"STRING response URL does not match request: {response_url!r}", kind="protocol", endpoint=url, ) raw_status = getattr(response, "status_code", None) if isinstance(raw_status, bool) or not isinstance(raw_status, int): raise StringApiError( f"Malformed STRING HTTP status: {raw_status!r}", kind="protocol", endpoint=url, ) status_code = raw_status if allow_404 and status_code == 404: return [] if status_code < 200 or status_code >= 300: raise StringApiError( f"STRING HTTP {status_code}", kind="http", status_code=status_code, endpoint=url, ) try: return response.json() except Exception as exc: raise StringApiError( f"STRING returned invalid JSON: {exc}", kind="json", endpoint=url ) from exc def get_version(self) -> Dict[str, Any]: """Discover and retain the stable API address for this STRING version.""" payload = self._request_json( "GET", f"{self.BASE_URL}/json/version", params={"caller_identity": self.CALLER_IDENTITY}, ) if isinstance(payload, list) and len(payload) == 1: version = payload[0] elif isinstance(payload, dict): version = payload else: raise StringApiError( "STRING version response must be one object", kind="protocol", endpoint="version", ) if not isinstance(version, dict): raise StringApiError( "STRING version response contains a non-object", kind="protocol", endpoint="version", ) stable_address = version.get("string_stable_address") or version.get( "stable_address" ) if not isinstance(stable_address, str) or not stable_address.strip(): raise StringApiError( "STRING version response has no stable address", kind="protocol", endpoint="version", ) self.stable_api_base = self._normalize_api_base(stable_address) return dict(version) def _stable_base(self) -> str: if not self.stable_api_base: self.get_version() return self.stable_api_base @classmethod def validate_mapping_rows( cls, payload: Any, identifiers: List[str], species_id: str, ) -> List[Dict[str, Any]]: if not isinstance(payload, list): raise StringApiError( "STRING mapping response must be a list", kind="protocol" ) seen_indices = set() validated = [] expected_taxon = int(species_id) for row in payload: if not isinstance(row, dict): raise StringApiError( "STRING mapping row must be an object", kind="protocol" ) query_index = row.get("queryIndex") if ( isinstance(query_index, bool) or not isinstance(query_index, int) or query_index < 0 or query_index >= len(identifiers) or query_index in seen_indices ): raise StringApiError( f"Invalid STRING queryIndex: {query_index!r}", kind="protocol" ) seen_indices.add(query_index) taxon = row.get("ncbiTaxonId") parsed_taxon = cls._validated_taxon(taxon) if parsed_taxon != expected_taxon: raise StringApiError( f"STRING mapping taxon {parsed_taxon} != {expected_taxon}", kind="protocol", ) cls._validated_string_id(row.get("stringId"), parsed_taxon) if ( not isinstance(row.get("queryItem"), str) or row["queryItem"] != identifiers[query_index] or not isinstance(row.get("stringId"), str) or not row["stringId"].strip() or not isinstance(row.get("preferredName"), str) or not row["preferredName"].strip() ): raise StringApiError("Malformed STRING mapping row", kind="protocol") validated.append(dict(row)) return sorted(validated, key=lambda row: row["queryIndex"]) @staticmethod def _validated_taxon(value: Any) -> int: if isinstance(value, bool): raise StringApiError(f"Invalid STRING taxon: {value!r}", kind="protocol") if isinstance(value, int): return value if isinstance(value, str) and value.isdigit(): return int(value) raise StringApiError(f"Invalid STRING taxon: {value!r}", kind="protocol") @staticmethod def _validated_string_id(value: Any, taxon_id: int) -> str: if not isinstance(value, str) or value != value.strip(): raise StringApiError( f"Invalid STRING identifier: {value!r}", kind="protocol" ) prefix, separator, entity = value.partition(".") if ( separator != "." or prefix != str(taxon_id) or not entity or any(character.isspace() for character in entity) ): raise StringApiError( f"Invalid STRING identifier for taxon {taxon_id}: {value!r}", kind="protocol", ) return value def get_string_ids(self, identifiers: List[str]) -> List[Dict[str, Any]]: """Resolve candidate identifiers, attributing rows only by queryIndex.""" self._validate_identifiers(identifiers) url = f"{self._stable_base()}/json/get_string_ids" payload = self._request_json( "POST", url, allow_404=True, data={ "identifiers": "\r\n".join(identifiers), "species": self.species_id, "echo_query": 1, "caller_identity": self.CALLER_IDENTITY, }, ) return self.validate_mapping_rows(payload, identifiers, self.species_id) @staticmethod def _validate_identifiers(identifiers: List[str]) -> None: if not isinstance(identifiers, list) or not identifiers: raise ValueError("identifiers must be a non-empty list") if any(not isinstance(item, str) or not item.strip() for item in identifiers): raise ValueError("identifiers must contain non-empty strings") def get_network( self, proteins: List[str], required_score: int = 200, network_type: str = "functional", ) -> List[Dict[str, Any]]: """Return a validated STRING network; an empty list is scientifically valid.""" self._validate_identifiers(proteins) if ( isinstance(required_score, bool) or not isinstance(required_score, int) or not 0 <= required_score <= 1000 ): raise ValueError("required_score must be an integer from 0 to 1000") if network_type != "functional": raise ValueError("network_type is fixed to 'functional'") selected_taxon = int(self.species_id) for protein in proteins: self._validated_string_id(protein, selected_taxon) submitted_ids = set(proteins) url = f"{self._stable_base()}/json/network" payload = self._request_json( "POST", url, data={ "identifiers": "\r\n".join(proteins), "species": self.species_id, "required_score": required_score, "network_type": "functional", "add_nodes": 0, "caller_identity": self.CALLER_IDENTITY, }, ) if not isinstance(payload, list): raise StringApiError( "STRING network response must be a list", kind="protocol", endpoint=url, ) for row in payload: if not isinstance(row, dict): raise StringApiError( "Malformed STRING network row", kind="protocol", endpoint=url ) parsed_taxon = self._validated_taxon(row.get("ncbiTaxonId")) preferred_a = row.get("preferredName_A") preferred_b = row.get("preferredName_B") score = row.get("score") if ( parsed_taxon != int(self.species_id) or not isinstance(preferred_a, str) or not preferred_a.strip() or not isinstance(preferred_b, str) or not preferred_b.strip() or isinstance(score, bool) or not isinstance(score, (int, float)) or not math.isfinite(score) or not 0 <= score <= 1 ): raise StringApiError( "Malformed STRING network row", kind="protocol", endpoint=url ) string_id_a = self._validated_string_id( row.get("stringId_A"), parsed_taxon ) string_id_b = self._validated_string_id( row.get("stringId_B"), parsed_taxon ) if ( string_id_a not in submitted_ids or string_id_b not in submitted_ids or score < required_score / 1000 ): raise StringApiError( "STRING network row contradicts submitted identifiers or score threshold", kind="protocol", endpoint=url, ) return payload def generate_string_url(proteins: List[str], species: str = "3702", required_score: int = 200) -> str: """生成STRING网页链接 Args: proteins: 蛋白ID列表 species: STRING物种ID (默认3702=拟南芥) Returns: STRING网络查看URL """ base_url = "https://string-db.org/cgi/network" # 使用实际换行符,urllib.parse.urlencode会自动编码 identifiers = "\r\n".join(proteins) params = { "identifiers": identifiers, "species": species, "required_score": str(required_score), "network_type": "functional" } return f"{base_url}?{urllib.parse.urlencode(params)}" @dataclass(frozen=True, slots=True) class PpiQueryOrigin: canonical_target_id: str ortholog_reference_id: str raw_query_id: str relationship_source: str @dataclass(frozen=True, slots=True) class PpiResolutionRow: canonical_target_id: str ortholog_reference_id: str raw_query_id: str candidate_gene_id: str selected_query_id: str string_id: str string_preferred_name: str mapping_status: str method: str evidence_source: str relationship_source: str species: str taxon_id: str reason: str @dataclass(slots=True) class StringResolutionResult: selected_string_ids: List[str] rows: List[PpiResolutionRow] metrics: Dict[str, Any] status: str = "success" issue_code: str = "" @dataclass(frozen=True, slots=True) class _ResolutionCandidate: value: str namespace: str method: str evidence_source: str def parse_ortholog_mapping(mapping_file: Path) -> List[PpiQueryOrigin]: """Parse every canonical-to-reference relationship without rewriting IDs.""" mapping: List[PpiQueryOrigin] = [] if not mapping_file.exists(): return mapping with open(mapping_file, "r", encoding="utf-8") as f: for line in f: line = line.strip() if not line or line.startswith("#"): continue parts = line.split("\t") if len(parts) >= 2: source_id = parts[0].strip() target_id = parts[1].strip() if source_id and target_id: mapping.append( PpiQueryOrigin( canonical_target_id=source_id, ortholog_reference_id=target_id, raw_query_id=target_id, relationship_source=str(mapping_file), ) ) return mapping class PpiSkill: """PPI分析技能主类""" def __init__( self, config_path: Optional[str] = None, *, string_api_factory: Callable[[str], Any] | None = None, ): self.config = self._load_config(config_path) self.checksums = {} self.start_time = None self.end_time = None self.ortholog_mapping: List[PpiQueryOrigin] | None = None self.string_api_factory = string_api_factory or StringAPI def _load_config(self, config_path: Optional[str]) -> Dict: """加载配置文件""" if config_path and os.path.exists(config_path): with open(config_path) as f: return yaml.safe_load(f) default_config = Path(__file__).parent / "config.yaml" with open(default_config) as f: return yaml.safe_load(f) def _compute_checksum(self, filepath: Path) -> str: """计算文件SHA256校验""" sha256 = hashlib.sha256() with open(filepath, "rb") as f: for chunk in iter(lambda: f.read(8192), b""): sha256.update(chunk) return sha256.hexdigest() def _record_file(self, filepath: Path, file_type: str = "output") -> Dict: """记录文件信息""" if filepath.exists(): return { "path": str(filepath), "sha256": self._compute_checksum(filepath), "size": filepath.stat().st_size, "type": file_type } return {} def load_gene_ids(self, gene_file: Path) -> List[str]: """加载基因ID列表""" gene_ids = [] with open(gene_file, 'r') as f: for line in f: gene_id = line.strip() if gene_id: gene_ids.append(gene_id) return gene_ids def load_ortholog_mapping(self, mapping_file: Path) -> List[PpiQueryOrigin]: """加载同源基因映射""" self.ortholog_mapping = parse_ortholog_mapping(mapping_file) logger.info(f"Loaded {len(self.ortholog_mapping)} ortholog mappings from {mapping_file}") return self.ortholog_mapping @staticmethod def _query_candidates( raw_query_id: str, normalization_species_label: str, alias_index: ReferenceAliasIndex | None, ) -> List[_ResolutionCandidate]: raw = str(raw_query_id or "").strip() candidates: List[_ResolutionCandidate] = [] def add(value: str, namespace: str, method: str, evidence_source: str) -> None: token = str(value or "").strip() if token and all(candidate.value != token for candidate in candidates): candidates.append( _ResolutionCandidate(token, namespace, method, evidence_source) ) add(raw, "raw", "raw_query", "raw_query") normalized = normalize_gene_ids_for_string([raw], normalization_species_label) if normalized: add( normalized[0], "normalized", f"species_rule:{normalization_species_label}", "ppi_string_policy", ) if "." in raw and raw.rsplit(".", 1)[1].isdigit(): add( raw.rsplit(".", 1)[0], "versionless", "versionless_lookup_candidate", "raw_query_id", ) if alias_index is not None: for candidate in alias_index.candidates(raw): add( candidate.value, candidate.namespace, candidate.method, candidate.evidence_source, ) return candidates def resolve_query_ids( self, origins: List[PpiQueryOrigin], species: str, id_map_file: Path | None, *, api: Any = None, canonical_target_ids: List[str] | None = None, normalization_species_label: str = "", ) -> StringResolutionResult: """Resolve relationship queries without collapsing their provenance rows.""" species_id = StringAPI.resolve_species_id(species) if not isinstance(origins, list) or any( not isinstance(item, PpiQueryOrigin) for item in origins ): raise ValueError("origins must be a list of PpiQueryOrigin") alias_index = ( ReferenceAliasIndex.from_tsv( Path(id_map_file), str(normalization_species_label or species).strip(), ) if id_map_file is not None else None ) unique_queries = unique_preserve_order( [origin.raw_query_id for origin in origins if origin.raw_query_id] ) candidates_by_query: Dict[str, List[_ResolutionCandidate]] = {} query_errors: Dict[str, IdResolutionError] = {} normalization_label = str(normalization_species_label or species).strip() for raw_query in unique_queries: try: candidates_by_query[raw_query] = self._query_candidates( raw_query, normalization_label, alias_index ) except IdResolutionError as exc: candidates_by_query[raw_query] = [] query_errors[raw_query] = exc probes = unique_preserve_order( [ candidate.value for raw_query in unique_queries for candidate in candidates_by_query[raw_query] ] ) string_api = api or self.string_api_factory(species) mapping_rows = string_api.get_string_ids(probes) if probes else [] validated_rows = StringAPI.validate_mapping_rows( mapping_rows, probes, species_id ) mapped_by_probe = { probes[row["queryIndex"]]: row for row in validated_rows } query_results: Dict[str, Dict[str, Any]] = {} for raw_query in unique_queries: candidates = candidates_by_query[raw_query] successful = [ (candidate, mapped_by_probe[candidate.value]) for candidate in candidates if candidate.value in mapped_by_probe ] string_ids = unique_preserve_order( [row["stringId"] for _candidate, row in successful] ) if raw_query in query_errors: query_results[raw_query] = { "status": "ambiguous", "reason": query_errors[raw_query].code, "string_id": "", "preferred_name": "", "selected_candidate": None, "raw_resolved": False, "geneid_fallback": False, } elif not successful: query_results[raw_query] = { "status": "unresolved", "reason": "no_string_mapping", "string_id": "", "preferred_name": "", "selected_candidate": None, "raw_resolved": False, "geneid_fallback": False, } elif len(string_ids) > 1: query_results[raw_query] = { "status": "ambiguous", "reason": "candidate_string_ids_conflict", "string_id": "", "preferred_name": "", "selected_candidate": None, "raw_resolved": False, "geneid_fallback": False, } else: selected_candidate, selected_row = successful[0] raw_resolved = any( candidate.namespace in {"raw", "normalized", "versionless"} for candidate, _row in successful ) geneid_resolved = any( candidate.namespace == "ncbi_gene_id" for candidate, _row in successful ) query_results[raw_query] = { "status": "resolved", "reason": "candidates_converged", "string_id": string_ids[0], "preferred_name": str(selected_row.get("preferredName") or ""), "selected_candidate": selected_candidate, "raw_resolved": raw_resolved, "geneid_fallback": geneid_resolved and not raw_resolved, } rows = [ self._resolution_row(origin, query_results[origin.raw_query_id], species) for origin in origins ] rows = self._exclude_conflicting_orthologs(rows) selected_string_ids = unique_preserve_order( [row.string_id for row in rows if row.mapping_status == "resolved"] ) metrics = self._resolution_metrics( origins, rows, unique_queries, query_results, selected_string_ids, canonical_target_ids=canonical_target_ids, ) if selected_string_ids: return StringResolutionResult(selected_string_ids, rows, metrics) return StringResolutionResult( selected_string_ids, rows, metrics, status="id_resolution_failure", issue_code="ppi_string_id_resolution_failed", ) @staticmethod def _resolution_row( origin: PpiQueryOrigin, query_result: Dict[str, Any], species: str, ) -> PpiResolutionRow: selected = query_result.get("selected_candidate") return PpiResolutionRow( canonical_target_id=origin.canonical_target_id, ortholog_reference_id=origin.ortholog_reference_id, raw_query_id=origin.raw_query_id, candidate_gene_id=( selected.value if selected is not None and selected.namespace in {"ncbi_gene_id", "gene_symbol"} else "" ), selected_query_id=selected.value if selected is not None else "", string_id=query_result["string_id"], string_preferred_name=query_result["preferred_name"], mapping_status=query_result["status"], method=selected.method if selected is not None else "", evidence_source=( ( "ortholog_relationship" if origin.ortholog_reference_id else "canonical_member_input" ) if selected is not None and selected.namespace == "raw" else selected.evidence_source if selected is not None else origin.relationship_source ), relationship_source=origin.relationship_source, species=species, taxon_id=StringAPI.resolve_species_id(species), reason=query_result["reason"], ) @staticmethod def _exclude_conflicting_orthologs( rows: List[PpiResolutionRow], ) -> List[PpiResolutionRow]: rows_by_canonical: Dict[str, List[PpiResolutionRow]] = {} for row in rows: rows_by_canonical.setdefault(row.canonical_target_id, []).append(row) conflicting = set() for canonical, canonical_rows in rows_by_canonical.items(): references = {row.raw_query_id for row in canonical_rows} if len(references) < 2: continue string_ids = { row.string_id for row in canonical_rows if row.mapping_status == "resolved" and row.string_id } if ( len(string_ids) != 1 or any(row.mapping_status != "resolved" for row in canonical_rows) ): conflicting.add(canonical) return [ PpiResolutionRow( **{ **{ field: getattr(row, field) for field in row.__dataclass_fields__ }, "candidate_gene_id": "", "selected_query_id": "", "string_id": "", "string_preferred_name": "", "mapping_status": "ambiguous_ortholog", "method": "", "reason": "ortholog_references_do_not_converge", } ) if row.canonical_target_id in conflicting else row for row in rows ] @staticmethod def _resolution_metrics( origins: List[PpiQueryOrigin], rows: List[PpiResolutionRow], unique_queries: List[str], query_results: Dict[str, Dict[str, Any]], selected_string_ids: List[str], *, canonical_target_ids: List[str] | None = None, ) -> Dict[str, Any]: input_members = unique_preserve_order( list(canonical_target_ids or []) + [origin.canonical_target_id for origin in origins] ) resolved_queries = sum( result["status"] == "resolved" for result in query_results.values() ) states_by_member: Dict[str, set[str]] = {} for row in rows: states_by_member.setdefault(row.canonical_target_id, set()).add( row.mapping_status ) resolved_members = sum(states == {"resolved"} for states in states_by_member.values()) ambiguous_members = sum( bool(states.intersection({"ambiguous", "ambiguous_ortholog"})) for states in states_by_member.values() ) unresolved_members = len(input_members) - resolved_members - ambiguous_members input_count = len(input_members) query_count = len(unique_queries) probe_status = ( "complete" if query_count and resolved_queries == query_count else "partial" if resolved_queries else "failed" ) return { "input_member_count": input_count, "relationship_count": len(origins), "unique_reference_query_count": query_count, "raw_resolved_query_count": sum( bool(result["raw_resolved"]) for result in query_results.values() ), "geneid_fallback_resolved_query_count": sum( bool(result["geneid_fallback"]) for result in query_results.values() ), "resolved_reference_query_count": resolved_queries, "selected_string_query_count": len(selected_string_ids), "resolved_member_count": resolved_members, "unresolved_member_count": unresolved_members, "ambiguous_member_count": ambiguous_members, "member_mapping_rate": round(resolved_members / input_count, 6) if input_count else 0, "query_mapping_rate": round(resolved_queries / query_count, 6) if query_count else 0, "probe_status": probe_status, } def write_string_resolution_report( self, resolution: StringResolutionResult, output_dir: Path, prefix: str, ) -> tuple[Path, Dict[str, Any]]: """Serialize one deterministic summary row per input relationship.""" columns = [ "canonical_target_id", "ortholog_reference_id", "raw_query_id", "candidate_gene_id", "selected_query_id", "string_id", "string_preferred_name", "mapping_status", "method", "evidence_source", "relationship_source", "species", "taxon_id", "reason", ] output_file = output_dir / f"{prefix}_id_resolution.tsv" with output_file.open("w", encoding="utf-8") as handle: handle.write("\t".join(columns) + "\n") for row in resolution.rows: values = [ str(getattr(row, column)).replace("\t", " ").replace("\n", " ") for column in columns ] handle.write("\t".join(values) + "\n") return output_file, dict(resolution.metrics) def analyze_network( self, species: str, required_score: int = 200, *, origins: List[PpiQueryOrigin], canonical_target_ids: List[str], id_map_file: Path | None = None, normalization_species_label: str = "", ) -> Dict: """分析互作网络 Returns: Dict with keys: interactions, stats, string_url """ api = self.string_api_factory(species) resolution = self.resolve_query_ids( origins, species, id_map_file, api=api, canonical_target_ids=canonical_target_ids, normalization_species_label=normalization_species_label, ) network_query_ids = resolution.selected_string_ids if resolution.status == "id_resolution_failure": return { "status": "id_resolution_failure", "issue_code": resolution.issue_code, "interactions": [], "stats": {}, "string_url": "", "id_resolution": resolution, } logger.info( f"Querying STRING for {len(network_query_ids)} genes (species: {species})" ) network_data = api.get_network(network_query_ids, required_score) # 生成STRING网页链接 (无论是否有网络数据都生成) string_url = generate_string_url( network_query_ids, api.species_id, required_score ) logger.info(f"STRING URL: {string_url}") if not network_data: logger.info("STRING returned a valid empty network") return { "interactions": [], "stats": { "input_genes": len(network_query_ids), "genes_in_network": 0, "total_interactions": 0, "avg_score": 0, "high_confidence": 0, "medium_confidence": 0, "low_confidence": 0, "network_status": "empty_network", }, "string_url": string_url, "id_resolution": resolution, } # 解析互作数据 interactions = [] proteins_in_network = set() for interaction in network_data: if isinstance(interaction, dict): p1 = interaction.get("preferredName_A", "") p2 = interaction.get("preferredName_B", "") score = interaction.get("score", 0) interactions.append({ "protein1": p1, "protein2": p2, "score": score }) proteins_in_network.add(p1) proteins_in_network.add(p2) # 统计 stats = { "input_genes": len(network_query_ids), "genes_in_network": len(proteins_in_network), "total_interactions": len(interactions), "avg_score": sum(i["score"] for i in interactions) / len(interactions) if interactions else 0, "high_confidence": sum(1 for i in interactions if i["score"] >= 0.7), "medium_confidence": sum(1 for i in interactions if 0.4 <= i["score"] < 0.7), "low_confidence": sum(1 for i in interactions if i["score"] < 0.4), "network_status": "network_detected", } logger.info(f"Network: {stats['genes_in_network']}/{stats['input_genes']} genes, " f"{stats['total_interactions']} interactions") return { "interactions": interactions, "stats": stats, "string_url": string_url, "id_resolution": resolution, } def save_network(self, interactions: List[Dict], output_file: Path) -> Path: """保存网络数据""" df = pd.DataFrame(interactions, columns=["protein1", "protein2", "score"]) df.to_csv(output_file, sep='\t', index=False) logger.info(f"Network saved: {output_file}") return output_file def run( self, gene_ids_file: Path, species: str, prefix: str, output_dir: Path, required_score: int = 200, mapping_file: Optional[Path] = None, id_map_file: Optional[Path] = None, normalization_species_label: str = "", ppi_provenance: Optional[Dict[str, Any]] = None, ) -> Dict: """运行PPI分析 Args: gene_ids_file: 基因ID列表文件 species: 目标物种代码 (用于STRING查询) prefix: 输出前缀 output_dir: 输出目录 required_score: 置信度阈值 mapping_file: 同源映射文件 (可选,用于非模式植物) id_map_file: 当前任务/物种的本地ID证据文件 (可选) normalization_species_label: 仅用于本地ID规范化规则,不作为STRING查询身份 """ self.start_time = datetime.now() logger.info(f"Starting PPI analysis for {prefix}") logger.info(f"Input: {gene_ids_file}") logger.info(f"Species: {species}") logger.info(f"Output: {output_dir}") # 创建输出目录 output_dir.mkdir(parents=True, exist_ok=True) # 加载基因ID gene_ids = self.load_gene_ids(gene_ids_file) logger.info(f"Loaded {len(gene_ids)} gene IDs") if len(gene_ids) == 0: logger.error("No gene IDs loaded") return self._generate_failed_manifest( prefix, output_dir, "No gene IDs", ppi_provenance=ppi_provenance ) # 加载同源映射 (如果有) ortholog_mode = mapping_file is not None self.ortholog_mapping = None if ortholog_mode: if not mapping_file.exists(): return self._generate_failed_manifest( prefix, output_dir, f"Ortholog mapping file does not exist: {mapping_file}", ppi_provenance=ppi_provenance, ) self.load_ortholog_mapping(mapping_file) if ortholog_mode: requested_members = set(gene_ids) origins = [ origin for origin in (self.ortholog_mapping or []) if origin.canonical_target_id in requested_members ] mapped_members = {origin.canonical_target_id for origin in origins} if len(mapped_members) < len(requested_members): logger.warning( "%s/%s canonical members have no Ortholog relationship", len(requested_members) - len(mapped_members), len(requested_members), ) else: origins = [ PpiQueryOrigin( canonical_target_id=gene_id, ortholog_reference_id="", raw_query_id=gene_id, relationship_source=str(gene_ids_file), ) for gene_id in gene_ids ] result = self.analyze_network( species, required_score, origins=origins, id_map_file=id_map_file, canonical_target_ids=gene_ids, normalization_species_label=normalization_species_label, ) resolution = result["id_resolution"] id_resolution_file, id_resolution_metrics = ( self.write_string_resolution_report(resolution, output_dir, prefix) ) if result.get("status") == "id_resolution_failure": return self._generate_failed_manifest( prefix, output_dir, "No identifiers resolved unambiguously in STRING", issue_code=str(result.get("issue_code") or ""), metrics={"id_resolution": id_resolution_metrics}, artifacts={"id_resolution": self._record_file(id_resolution_file, "tsv")}, ppi_provenance=ppi_provenance, ) query_ids = resolution.selected_string_ids interactions = result["interactions"] stats = result["stats"] string_url = result.get("string_url", "") # 保存网络数据 network_file = output_dir / f"{prefix}_network.tsv" self.save_network(interactions, network_file) # 保存STRING URL到文件 if string_url: url_file = output_dir / f"{prefix}_url.txt" with open(url_file, 'w') as f: f.write("# STRING Protein Interaction Network\n\n") f.write(f"**Query**: {prefix}\n") f.write(f"**Species**: {species}\n") f.write(f"**Genes**: {len(query_ids)}\n\n") f.write(f"View online: {string_url}\n") logger.info(f"STRING URL saved: {url_file}") # 生成报告 report_path = self._generate_report(stats, prefix, output_dir, species, string_url) # 生成manifest manifest = self._generate_manifest( gene_ids_file, species, prefix, output_dir, stats, network_file, report_path, string_url, id_resolution_file=id_resolution_file, id_resolution_metrics=id_resolution_metrics, normalization_species_label=normalization_species_label, ppi_provenance=ppi_provenance, ) self.end_time = datetime.now() elapsed = (self.end_time - self.start_time).total_seconds() logger.info(f"PPI analysis completed in {elapsed:.1f}s") return manifest def _generate_report(self, stats: Dict, prefix: str, output_dir: Path, species: str, string_url: str = "") -> Path: """生成Markdown报告""" report_path = output_dir / f"{prefix}_report.md" with open(report_path, 'w') as f: f.write(f"# PPI Network Analysis Report - {prefix}\n\n") f.write(f"**Generated**: {datetime.now().isoformat()}\n") f.write(f"**Species**: {species}\n") f.write(f"**Data Source**: STRING Database\n\n") f.write("## Network Statistics\n\n") f.write(f"- **Input Genes**: {stats.get('input_genes', 0)}\n") f.write(f"- **Genes in Network**: {stats.get('genes_in_network', 0)}\n") f.write(f"- **Total Interactions**: {stats.get('total_interactions', 0)}\n") f.write(f"- **Average Score**: {stats.get('avg_score', 0):.3f}\n") f.write(f"- **High Confidence (≥0.7)**: {stats.get('high_confidence', 0)}\n") f.write(f"- **Medium Confidence (0.4-0.7)**: {stats.get('medium_confidence', 0)}\n") f.write(f"- **Low Confidence (<0.4)**: {stats.get('low_confidence', 0)}\n\n") if string_url: f.write("## STRING Network\n\n") f.write(f"[View Interactive Network]({string_url})\n\n") f.write("## Output Files\n\n") f.write(f"- `{prefix}_network.tsv` - Interaction network data\n") f.write(f"- `{prefix}_report.md` - This report\n") return report_path def _generate_manifest(self, input_file: Path, species: str, prefix: str, output_dir: Path, stats: Dict, network_file: Path, report: Path, string_url: str = "", id_resolution_file: Optional[Path] = None, id_resolution_metrics: Optional[Dict] = None, normalization_species_label: str = "", ppi_provenance: Optional[Dict[str, Any]] = None) -> Dict: """生成manifest""" self.end_time = datetime.now() elapsed = (self.end_time - self.start_time).total_seconds() artifacts = {} if network_file and network_file.exists(): artifacts["network"] = self._record_file(network_file, "tsv") if report and report.exists(): artifacts["report"] = self._record_file(report, "markdown") if id_resolution_file and id_resolution_file.exists(): artifacts["id_resolution"] = self._record_file(id_resolution_file, "tsv") # 添加URL文件到artifacts url_file = output_dir / f"{prefix}_url.txt" if url_file.exists(): artifacts["string_url"] = self._record_file(url_file, "txt") manifest = { "manifest_version": "1.0", "stage": "ppi", "status": "success", "prefix": prefix, "timestamp": self.end_time.isoformat(), "elapsed_sec": round(elapsed, 2), "input": {"gene_ids": str(input_file), "species": species}, "parameters": { "string_taxon_id": StringAPI.resolve_species_id(species), "normalization_species_label": str(normalization_species_label or species).strip(), }, "metrics": { **stats, "id_resolution": id_resolution_metrics or {}, }, "artifacts": artifacts, "string_url": string_url, "ppi_provenance": dict(ppi_provenance or {}), } manifest_path = output_dir / f"{prefix}.manifest.json" with open(manifest_path, 'w') as f: json.dump(manifest, f, indent=2) manifest["manifest_path"] = str(manifest_path) return manifest def _generate_failed_manifest( self, prefix: str, output_dir: Path, message: str, *, issue_code: str = "ppi_execution_failed", metrics: Optional[Dict[str, Any]] = None, artifacts: Optional[Dict[str, Any]] = None, ppi_provenance: Optional[Dict[str, Any]] = None, ) -> Dict[str, Any]: output_dir.mkdir(parents=True, exist_ok=True) manifest = { "manifest_version": "1.0", "stage": "ppi", "status": "failed", "prefix": prefix, "timestamp": datetime.now().isoformat(), "message": message, "issue_code": issue_code, "metrics": dict(metrics or {}), "artifacts": dict(artifacts or {}), "ppi_provenance": dict(ppi_provenance or {}), } manifest_path = output_dir / f"{prefix}.manifest.json" manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8") return {**manifest, "manifest_path": str(manifest_path)} def main(): parser = argparse.ArgumentParser(description="PPI Skill - Protein-Protein Interaction Analysis") parser.add_argument("-i", "--gene-ids", help="Gene ID list file") parser.add_argument("-s", "--species", default="ath", help="Species code (ath/osa/zma)") parser.add_argument( "--normalization-species-label", default="", help="Local species label used only for gene-ID normalization rules", ) parser.add_argument("-p", "--prefix", required=True, help="Output prefix") parser.add_argument("-o", "--output", required=True, help="Output directory") parser.add_argument("--score", type=int, default=200, help="Confidence threshold (0-1000)") parser.add_argument("--mapping", help="Ortholog mapping file (for non-model species)") parser.add_argument("--id-map", help="Species-matched local PEP/CDS ID evidence TSV") parser.add_argument("--provenance-json", help="Authoritative PPI provenance JSON from preflight/backend") parser.add_argument("--config", help="Config file path") parser.add_argument("--demo", action="store_true", help="Demo mode") args = parser.parse_args() # Demo模式 if args.demo: output_path = Path(args.output) output_path.mkdir(parents=True, exist_ok=True) demo_genes = output_path / "demo_genes.txt" with open(demo_genes, 'w') as f: f.write("AT1G01010\nAT1G01020\nAT1G01030\n") args.gene_ids = str(demo_genes) if not args.gene_ids: parser.error("--gene-ids is required (or use --demo)") # 运行技能 skill = PpiSkill(config_path=args.config) provenance = {} if args.provenance_json: provenance = json.loads(Path(args.provenance_json).read_text(encoding="utf-8")) try: result = skill.run( gene_ids_file=Path(args.gene_ids), species=args.species, prefix=args.prefix, output_dir=Path(args.output), required_score=args.score, mapping_file=Path(args.mapping) if args.mapping else None, id_map_file=Path(args.id_map) if args.id_map else None, normalization_species_label=args.normalization_species_label, ppi_provenance=provenance, ) except Exception as exc: result = skill._generate_failed_manifest( args.prefix, Path(args.output), str(exc), ppi_provenance=provenance ) if result.get("status") == "failed": print(f"\n❌ PPI analysis failed: {result.get('message')}") sys.exit(1) print(f"\n✅ PPI analysis completed!") print(f" Manifest: {result.get('manifest_path', 'N/A')}") print(f" Genes in network: {result['metrics'].get('genes_in_network', 0)}/{result['metrics'].get('input_genes', 0)}") print(f" Interactions: {result['metrics'].get('total_interactions', 0)}") sys.exit(0) if __name__ == "__main__": main()