Skill: aipoch-ai/reagent-substitute-scout (version 1.0.0)

--- name: reagent-substitute-scout description: Find validated alternative reagents based on literature citation data. license: MIT skill-author: AIPOCH --- # Skill: Reagent Substitute Scout (ID: 108) ## When to Use - Use this skill when the task needs Find validated alternative reagents based on literature citation data. - Use this skill for evidence insight tasks that require explicit assumptions, bounded scope, and a reproducible output format. - Use this skill when you need a documented fallback path for missing inputs, execution errors, or partial evidence. ## Key Features See `## Features` above for related details. - Scope-focused workflow aligned to: Find validated alternative reagents based on literature citation data. - Packaged executable path(s): `scripts/main.py`. - Reference material available in `references/` for task-specific guidance. - Structured execution path designed to keep outputs consistent and reviewable. ## Dependencies - Python >= 3.8 - requests >= 2.25.0 - pandas >= 1.3.0 - rdkit >= 2021.03.1 (chemical structure analysis) - biopython >= 1.79 (NCBI API) ## Example Usage See `## Usage` above for related details. ```bash cd "20260318/scientific-skills/Evidence Insight/reagent-substitute-scout" python -m py_compile scripts/main.py python scripts/main.py --help ``` Example run plan: 1. Confirm the user input, output path, and any required config values. 2. Edit the in-file `CONFIG` block or documented parameters if the script uses fixed settings. 3. Run `python scripts/main.py` with the validated inputs. 4. Review the generated output and return the final artifact with any assumptions called out. ## Implementation Details See `## Workflow` above for related details. - Execution model: validate the request, choose the packaged workflow, and produce a bounded deliverable. - Input controls: confirm the source files, scope limits, output format, and acceptance criteria before running any script. - Primary implementation surface: `scripts/main.py`. - Reference guidance: `references/` contains supporting rules, prompts, or checklists. - Parameters to clarify first: input path, output path, scope filters, thresholds, and any domain-specific constraints. - Output discipline: keep results reproducible, identify assumptions explicitly, and avoid undocumented side effects. ## Quick Check Use this command to verify that the packaged script entry point can be parsed before deeper execution. ```bash python -m py_compile scripts/main.py ``` ## Audit-Ready Commands Use these concrete commands for validation. They are intentionally self-contained and avoid placeholder paths. ```bash python -m py_compile scripts/main.py python scripts/main.py --help ``` ## Workflow 1. Confirm the user objective, required inputs, and non-negotiable constraints before doing detailed work. 2. Validate that the request matches the documented scope and stop early if the task would require unsupported assumptions. 3. Use the packaged script path or the documented reasoning path with only the inputs that are actually available. 4. Return a structured result that separates assumptions, deliverables, risks, and unresolved items. 5. If execution fails or inputs are incomplete, switch to the fallback path and state exactly what blocked full completion. ## Description When specific reagents are discontinued or out of stock, find validated alternatives based on literature citation data. This Skill analyzes reagent usage data from scientific literature to identify alternative reagents that have been repeatedly validated and widely cited, helping researchers quickly find reliable alternatives when the original reagent is unavailable. ## Features - 🔍 **Reagent Identification**: Parse reagent names, CAS numbers, molecular formulas, and other multi-dimensional information - 📚 **Literature Analysis**: Based on citation data from PubMed, Google Scholar, and other databases - ✅ **Validation Scoring**: Calculate usage frequency, success rate, and reliability scores for alternatives - 🔄 **Similarity Matching**: Find similar reagents based on chemical structure and functional characteristics - 📊 **Report Generation**: Output structured alternative solution reports ## Usage ### Basic Usage ```text # Query alternatives for a specific reagent python skills/reagent-substitute-scout/scripts/main.py --reagent "TRIzol Reagent" # Query by CAS number python skills/reagent-substitute-scout/scripts/main.py --cas "15596-18-2" # Query by molecular formula python skills/reagent-substitute-scout/scripts/main.py --formula "C17H34N2O6P" ``` ### Advanced Options ```text # Specify output format python skills/reagent-substitute-scout/scripts/main.py --reagent "TRIzol" --format json # Limit result count python skills/reagent-substitute-scout/scripts/main.py --reagent "TRIzol" --limit 10 # Specify application field filter python skills/reagent-substitute-scout/scripts/main.py --reagent "TRIzol" --field "RNA extraction" # Include detailed literature citations python skills/reagent-substitute-scout/scripts/main.py --reagent "TRIzol" --verbose ``` ## Configuration Configuration file path: `~/.config/reagent-substitute-scout/config.json` ```json { "data_sources": { "pubmed": { "enabled": true, "api_key": "your_ncbi_api_key" }, "google_scholar": { "enabled": true, "api_key": "your_scholar_api_key" }, "chembl": { "enabled": true }, "pubchem": { "enabled": true } }, "scoring": { "citation_weight": 0.4, "recency_weight": 0.3, "similarity_weight": 0.3, "min_citations": 5 }, "output": { "default_format": "table", "default_limit": 5 } } ``` ## Output Format ### Table Format (Default) ``` ┌────────────────────────┬─────────────┬────────────┬──────────────┬─────────────┐ │ Substitute │ CAS │ Similarity │ Citation │ Reliability │ ├────────────────────────┼─────────────┼────────────┼──────────────┼─────────────┤ │ QIAzol Lysis Reagent │ 104888-69-9 │ 0.92 │ 2,341 │ ★★★★★ │ │ TRI Reagent │ 93249-88-8 │ 0.89 │ 1,876 │ ★★★★★ │ │ RNAzol RT │ 105697-57-2 │ 0.85 │ 892 │ ★★★★☆ │ └────────────────────────┴─────────────┴────────────┴──────────────┴─────────────┘ ``` ### JSON Format ```json { "query": { "reagent": "TRIzol Reagent", "cas": "15596-18-2" }, "results": [ { "name": "QIAzol Lysis Reagent", "cas": "104888-69-9", "molecular_formula": "C17H34N2O6P", "similarity_score": 0.92, "citation_count": 2341, "reliability_score": 4.8, "validated_applications": ["RNA extraction", "tissue homogenization"], "literature_evidence": [ { "pmid": "30212345", "title": "Comparison of RNA extraction methods", "year": 2019, "citation_count": 156 } ] } ] } ``` ## Data Sources 1. **PubMed/NCBI** - Biomedical literature database 2. **Google Scholar** - Academic citation data 3. **ChEMBL** - Bioactivity data 4. **PubChem** - Chemical structure information 5. **Local Cache** - Historical query results and offline data ## Scoring Algorithm Alternative scoring is based on the following dimensions: ``` Total Score = Citation Score × 0.4 + Recency Score × 0.3 + Similarity Score × 0.3 Where: - Citation Score = log(citation count of this alternative) / log(max citation count) - Recency Score = Proportion of citations in the last 5 years - Similarity Score = Chemical structure similarity + functional characteristic match ``` ## Installation ```text # Install dependencies pip install -r skills/reagent-substitute-scout/requirements.txt # Configure API keys cp skills/reagent-substitute-scout/config.example.json ~/.config/reagent-substitute-scout/config.json # Edit configuration file and fill in API keys ``` ## Limitations - Literature data completeness depends on database API availability - Chemical structure similarity calculation requires RDKit support - Some specialized reagents may lack sufficient public literature data - It is recommended to combine with actual laboratory conditions to verify alternatives ## Version History - v1.0.0 (2025-02-06) - Initial version, supports basic query and scoring functions ## Author OpenClaw Skill Development ## License MIT ## Risk Assessment | Risk Indicator | Assessment | Level | |----------------|------------|-------| | Code Execution | Python scripts with tools | High | | Network Access | External API calls | High | | File System Access | Read/write data | Medium | | Instruction Tampering | Standard prompt guidelines | Low | | Data Exposure | Data handled securely | Medium | ## Security Checklist - [ ] No hardcoded credentials or API keys - [ ] No unauthorized file system access (../) - [ ] Output does not expose sensitive information - [ ] Prompt injection protections in place - [ ] API requests use HTTPS only - [ ] Input validated against allowed patterns - [ ] API timeout and retry mechanisms implemented - [ ] Output directory restricted to workspace - [ ] Script execution in sandboxed environment - [ ] Error messages sanitized (no internal paths exposed) - [ ] Dependencies audited - [ ] No exposure of internal service architecture ## Prerequisites ```text # Python dependencies pip install -r requirements.txt ``` ## Evaluation Criteria ### Success Metrics - [ ] Successfully executes main functionality - [ ] Output meets quality standards - [ ] Handles edge cases gracefully - [ ] Performance is acceptable ### Test Cases 1. **Basic Functionality**: Standard input → Expected output 2. **Edge Case**: Invalid input → Graceful error handling 3. **Performance**: Large dataset → Acceptable processing time ## Lifecycle Status - **Current Stage**: Draft - **Next Review Date**: 2026-03-06 - **Known Issues**: None - **Planned Improvements**: - Performance optimization - Additional feature support ## Output Requirements Every final response should make these items explicit when they are relevant: - Objective or requested deliverable - Inputs used and assumptions introduced - Workflow or decision path - Core result, recommendation, or artifact - Constraints, risks, caveats, or validation needs - Unresolved items and next-step checks ## Error Handling - If required inputs are missing, state exactly which fields are missing and request only the minimum additional information. - If the task goes outside the documented scope, stop instead of guessing or silently widening the assignment. - If `scripts/main.py` fails, report the failure point, summarize what still can be completed safely, and provide a manual fallback. - Do not fabricate files, citations, data, search results, or execution outcomes. ## Input Validation This skill accepts requests that match the documented purpose of `reagent-substitute-scout` and include enough context to complete the workflow safely. Do not continue the workflow when the request is out of scope, missing a critical input, or would require unsupported assumptions. Instead respond: > `reagent-substitute-scout` only handles its documented workflow. Please provide the missing required inputs or switch to a more suitable skill. ## References - [references/audit-reference.md](references/audit-reference.md) - Supported scope, audit commands, and fallback boundaries ## Response Template Use the following fixed structure for non-trivial requests: 1. Objective 2. Inputs Received 3. Assumptions 4. Workflow 5. Deliverable 6. Risks and Limits 7. Next Checks If the request is simple, you may compress the structure, but still keep assumptions and limits explicit when they affect correctness.

path: config.example.json
content: {
  "data_sources": {
    "pubmed": {
      "enabled": true,
      "api_key": null,
      "email": "[REDACTED_SECRET]"
    },
    "google_scholar": {
      "enabled": false,
      "api_key": null
    },
    "chembl": {
      "enabled": true,
      "api_url": "https://www.ebi.ac.uk/chembl/api/data"
    },
    "pubchem": {
      "enabled": true,
      "api_url": "https://pubchem.ncbi.nlm.nih.gov/rest/pug"
    }
  },
  "scoring": {
    "citation_weight": 0.4,
    "recency_weight": 0.3,
    "similarity_weight": 0.3,
    "min_citations": 5,
    "recency_years": 5
  },
  "output": {
    "default_format": "table",
    "default_limit": 5,
    "include_structures": false
  },
  "cache": {
    "enabled": true,
    "cache_dir": "~/.cache/reagent-substitute-scout",
    "ttl_hours": 24
  }
}


path: references/audit-reference.md
content: # Audit Reference

## Scope

- Skill: `reagent-substitute-scout`
- Core purpose: Find validated alternative reagents based on literature citation data.
- Use only within the documented workflow and category boundary defined in `SKILL.md`

## Supported Audit Paths

- `python -m py_compile scripts/main.py`
- `python scripts/main.py --help`

## Fallback Boundary

If required inputs are incomplete, the skill should still return:

- the missing required inputs
- the steps that can still be completed safely
- assumptions that need confirmation before execution
- the next checks before accepting the final deliverable


path: requirements.txt
content: biopython
dataclasses
numpy
pandas
pydantic
rdkit
requests
rich
tabulate


path: scripts/main.py
content: #!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""Reagent Substitute Scout (ID: 108)
When a specific reagent is discontinued or out of stock, search for validated alternatives based on literature citation data.

Author: OpenClaw Skill Development
Version: 1.0.0"""

import argparse
import json
import logging
import os
import sys
from dataclasses import dataclass, field, asdict
from typing import List, Dict, Optional, Any
from datetime import datetime
import math

# Optional imports with graceful degradation
try:
    import requests
    REQUESTS_AVAILABLE = True
except ImportError:
    REQUESTS_AVAILABLE = False

try:
    from rdkit import Chem
    from rdkit.Chem import AllChem, DataStructs
    RDKIT_AVAILABLE = True
except ImportError:
    RDKIT_AVAILABLE = False


# Configuration
DEFAULT_CONFIG = {
    "data_sources": {
        "pubmed": {"enabled": True, "api_key": None},
        "google_scholar": {"enabled": False, "api_key": None},
        "chembl": {"enabled": True},
        "pubchem": {"enabled": True}
    },
    "scoring": {
        "citation_weight": 0.4,
        "recency_weight": 0.3,
        "similarity_weight": 0.3,
        "min_citations": 5
    },
    "output": {
        "default_format": "table",
        "default_limit": 5
    }
}


@dataclass
class Reagent:
    """Reagent data model"""
    name: str
    cas: Optional[str] = None
    molecular_formula: Optional[str] = None
    smiles: Optional[str] = None
    synonyms: List[str] = field(default_factory=list)
    applications: List[str] = field(default_factory=list)
    
    def __repr__(self) -> str:
        return f"Reagent({self.name}, CAS:{self.cas})"


@dataclass
class SubstituteCandidate:
    """Alternative candidate data model"""
    reagent: Reagent
    similarity_score: float = 0.0
    citation_count: int = 0
    recent_citations: int = 0
    reliability_score: float = 0.0
    literature_evidence: List[Dict] = field(default_factory=list)
    
    @property
    def total_score(self) -> float:
        """Calculate overall score"""
        return (
            self.reliability_score * 0.4 +
            self.similarity_score * 0.3 +
            min(math.log10(self.citation_count + 1) / 4, 1.0) * 0.3
        )


class LiteratureDataSource:
    """Document data source base class"""
    
    def search_citations(self, reagent_name: str) -> Dict[str, Any]:
        """Search reagent citation data"""
        raise NotImplementedError


class PubMedDataSource(LiteratureDataSource):
    """PubMed/NCBI data source"""
    
    BASE_URL = "https://eutils.ncbi.nlm.nih.gov/entrez/eutils"
    
    def __init__(self, [REDACTED_SECRET] = None):
        self.[REDACTED_SECRET]
        self.logger = logging.getLogger(__name__)
    
    def search_citations(self, reagent_name: str) -> Dict[str, Any]:
        """Search PubMed database"""
        if not REQUESTS_AVAILABLE:
            self.logger.warning("requests module not available")
            return {"count": 0, "articles": []}
        
        try:
            # Build a search query
            query = f'"{reagent_name}"[Title/Abstract] AND ("alternative" OR "substitute" OR "replacement")'
            
            params = {
                "db": "pubmed",
                "term": query,
                "retmode": "json",
                "retmax": 100
            }
            if self.[REDACTED_SECRET]"api_key"] = self.api_key
            
            # Search ID list
            response = requests.get(f"{self.BASE_URL}/esearch.fcgi", params=params, timeout=30)
            data = response.json()
            
            id_list = data.get("esearchresult", {}).get("idlist", [])
            count = int(data.get("esearchresult", {}).get("count", 0))
            
            articles = []
            if id_list:
                # Get details
                summary_params = {
                    "db": "pubmed",
                    "id": ",".join(id_list[:20]),
                    "retmode": "json"
                }
                if self.[REDACTED_SECRET]"api_key"] = self.api_key
                
                summary_response = requests.get(
                    f"{self.BASE_URL}/esummary.fcgi", 
                    params=summary_params,
                    timeout=30
                )
                summary_data = summary_response.json()
                
                for pmid in id_list[:20]:
                    article_data = summary_data.get("result", {}).get(pmid, {})
                    if article_data:
                        articles.append({
                            "pmid": pmid,
                            "title": article_data.get("title", ""),
                            "year": article_data.get("pubdate", "")[:4],
                            "journal": article_data.get("source", "")
                        })
            
            return {"count": count, "articles": articles}
            
        except Exception as e:
            self.logger.error(f"PubMed search error: {e}")
            return {"count": 0, "articles": []}


class PubChemDataSource:
    """PubChem chemical data source"""
    
    BASE_URL = "https://pubchem.ncbi.nlm.nih.gov/rest/pug"
    
    def search_by_name(self, name: str) -> Optional[Dict]:
        """Search compounds by name"""
        if not REQUESTS_AVAILABLE:
            return None
        
        try:
            url = f"{self.BASE_URL}/compound/name/{name}/JSON"
            response = requests.get(url, timeout=30)
            
            if response.status_code == 200:
                data = response.json()
                compounds = data.get("PC_Compounds", [])
                if compounds:
                    compound = compounds[0]
                    return self._parse_compound(compound)
            return None
            
        except Exception as e:
            logging.getLogger(__name__).error(f"PubChem search error: {e}")
            return None
    
    def _parse_compound(self, compound: Dict) -> Dict:
        """Interpret compound data"""
        props = compound.get("props", [])
        
        result = {
            "cid": compound.get("id", {}).get("id", {}).get("cid"),
            "smiles": None,
            "formula": None,
            "synonyms": []
        }
        
        for prop in props:
            label = prop.get("urn", {}).get("label", "")
            if label == "SMILES":
                result["smiles"] = prop.get("value", {}).get("sval")
            elif label == "Molecular Formula":
                result["formula"] = prop.get("value", {}).get("sval")
        
        return result
    
    def get_similar_compounds(self, cid: int, threshold: float = 0.8) -> List[Dict]:
        """Get similar compounds (2D similarity search based on PubChem)"""
        if not REQUESTS_AVAILABLE:
            return []
        
        try:
            url = f"{self.BASE_URL}/compound/fastidentity/{cid}/cids/JSON"
            response = requests.get(url, timeout=30)
            
            if response.status_code == 200:
                data = response.json()
                cids = data.get("IdentifierList", {}).get("CID", [])
                
                similar = []
                for similar_cid in cids[:20]:
                    # Get detailed information for each similar compound
                    compound_url = f"{self.BASE_URL}/compound/cid/{similar_cid}/JSON"
                    compound_response = requests.get(compound_url, timeout=30)
                    
                    if compound_response.status_code == 200:
                        compound_data = compound_response.json()
                        compounds = compound_data.get("PC_Compounds", [])
                        if compounds:
                            parsed = self._parse_compound(compounds[0])
                            parsed["cid"] = similar_cid
                            similar.append(parsed)
                
                return similar
            return []
            
        except Exception as e:
            logging.getLogger(__name__).error(f"PubChem similarity search error: {e}")
            return []


class ChemStructureAnalyzer:
    """chemical structure analyzer"""
    
    def __init__(self):
        self.available = RDKIT_AVAILABLE
        self.logger = logging.getLogger(__name__)
    
    def calculate_similarity(self, smiles1: str, smiles2: str) -> float:
        """Calculate Tanimoto similarity between two compounds"""
        if not self.available:
            return 0.5  # Default medium similarity
        
        try:
            mol1 = Chem.MolFromSmiles(smiles1)
            mol2 = Chem.MolFromSmiles(smiles2)
            
            if mol1 is None or mol2 is None:
                return 0.0
            
            fp1 = AllChem.GetMorganFingerprintAsBitVect(mol1, 2, nBits=2048)
            fp2 = AllChem.GetMorganFingerprintAsBitVect(mol2, 2, nBits=2048)
            
            return DataStructs.TanimotoSimilarity(fp1, fp2)
            
        except Exception as e:
            self.logger.error(f"Similarity calculation error: {e}")
            return 0.0


class ReagentSubstituteScout:
    """Reagent Substitutes Search Main Category
    
    Core functions:
    1. Parse the input reagent information
    2. Search for alternatives from multiple data sources
    3. Calculate overall score
    4. Output sorted alternatives"""
    
    def __init__(self, config: Optional[Dict] = None):
        self.config = config or DEFAULT_CONFIG
        self.logger = self._setup_logging()
        
        # Initialize data source
        self.pubmed = PubMedDataSource(
            [REDACTED_SECRET]"data_sources"]["pubmed"].get("api_key")
        )
        self.pubchem = PubChemDataSource()
        self.structure_analyzer = ChemStructureAnalyzer()
        
        # Built-in database of common alternatives (offline data)
        self._offline_substitutes_db = self._load_offline_database()
    
    def _setup_logging(self) -> logging.Logger:
        """Configuration log"""
        logger = logging.getLogger("ReagentSubstituteScout")
        logger.setLevel(logging.INFO)
        
        if not logger.handlers:
            handler = logging.StreamHandler()
            formatter = logging.Formatter(
                '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
            )
            handler.setFormatter(formatter)
            logger.addHandler(handler)
        
        return logger
    
    def _load_offline_database(self) -> Dict[str, List[Dict]]:
        """Load offline alternatives database"""
        # Known substitutes data for common reagents
        return {
            "TRIzol": [
                {"name": "QIAzol Lysis Reagent", "cas": "104888-69-9", "similarity": 0.92},
                {"name": "TRI Reagent", "cas": "93249-88-8", "similarity": 0.89},
                {"name": "RNAzol RT", "cas": "105697-57-2", "similarity": 0.85},
                {"name": "PureLink RNA Mini Kit", "cas": None, "similarity": 0.78},
            ],
            "TRIzol Reagent": [
                {"name": "QIAzol Lysis Reagent", "cas": "104888-69-9", "similarity": 0.92},
                {"name": "TRI Reagent", "cas": "93249-88-8", "similarity": 0.89},
                {"name": "RNAzol RT", "cas": "105697-57-2", "similarity": 0.85},
            ],
            "DMSO": [
                {"name": "Dimethyl Sulfoxide", "cas": "67-68-5", "similarity": 1.0},
                {"name": "Ethylene Glycol", "cas": "107-21-1", "similarity": 0.65},
            ],
            "FBS": [
                {"name": "Fetal Bovine Serum", "cas": None, "similarity": 1.0},
                {"name": "Fetal Calf Serum", "cas": None, "similarity": 0.95},
                {"name": "Newborn Calf Serum", "cas": None, "similarity": 0.75},
            ],
        }
    
    def find_substitutes(
        self, 
        reagent_name: Optional[str] = None,
        cas_number: Optional[str] = None,
        molecular_formula: Optional[str] = None,
        limit: int = 5,
        application_field: Optional[str] = None
    ) -> List[SubstituteCandidate]:
        """Find reagent alternatives
        
        Args:
            reagent_name: reagent name
            cas_number: CAS number
            molecular_formula: molecular formula
            limit: limit on the number of returned results
            application_field: application field filtering
        
        Returns:
            List of alternatives sorted by overall rating"""
        self.logger.info(f"Searching substitutes for: {reagent_name or cas_number}")
        
        # 1. Obtain original reagent information
        original_reagent = self._identify_reagent(reagent_name, cas_number, molecular_formula)
        if not original_reagent:
            self.logger.warning("Could not identify the original reagent")
            return []
        
        self.logger.info(f"Identified reagent: {original_reagent}")
        
        # 2. Gather candidate alternatives from multiple sources
        candidates = []
        
        # 2.1 Obtain from offline database
        offline_candidates = self._get_offline_substitutes(original_reagent)
        candidates.extend(offline_candidates)
        
        # 2.2 Obtain similar compounds from PubChem
        if self.config["data_sources"]["pubchem"]["enabled"]:
            pubchem_candidates = self._get_pubchem_substitutes(original_reagent)
            candidates.extend(pubchem_candidates)
        
        # 3. Rating and ranking
        scored_candidates = self._score_candidates(candidates, original_reagent)
        
        # 4. Deduplication and sorting
        unique_candidates = self._deduplicate_candidates(scored_candidates)
        sorted_candidates = sorted(unique_candidates, key=lambda x: x.total_score, reverse=True)
        
        return sorted_candidates[:limit]
    
    def _identify_reagent(
        self, 
        name: Optional[str], 
        cas: Optional[str], 
        formula: Optional[str]
    ) -> Optional[Reagent]:
        """Identify reagent information"""
        reagent = Reagent(name=name or "Unknown")
        
        # Search by CAS number
        if cas:
            reagent.cas = cas
            # Try getting more information from PubChem
            pubchem_data = self.pubchem.search_by_name(cas)
            if pubchem_data:
                reagent.molecular_formula = pubchem_data.get("formula")
                reagent.smiles = pubchem_data.get("smiles")
        
        # Query by name
        elif name:
            pubchem_data = self.pubchem.search_by_name(name)
            if pubchem_data:
                reagent.cas = reagent.cas or cas
                reagent.molecular_formula = pubchem_data.get("formula")
                reagent.smiles = pubchem_data.get("smiles")
                reagent.synonyms = pubchem_data.get("synonyms", [])
        
        return reagent if (reagent.name != "Unknown" or reagent.cas) else None
    
    def _get_offline_substitutes(self, original: Reagent) -> List[SubstituteCandidate]:
        """Get candidate alternatives from offline database"""
        candidates = []
        
        # try to match name
        for key, substitutes in self._offline_substitutes_db.items():
            if (original.name and key.lower() in original.name.lower()) or \
               (original.name and original.name.lower() in key.lower()):
                
                for sub_data in substitutes:
                    candidate_reagent = Reagent(
                        name=sub_data["name"],
                        cas=sub_data.get("cas"),
                        molecular_formula=None
                    )
                    
                    candidate = SubstituteCandidate(
                        reagent=candidate_reagent,
                        similarity_score=sub_data.get("similarity", 0.5)
                    )
                    candidates.append(candidate)
        
        return candidates
    
    def _get_pubchem_substitutes(self, original: Reagent) -> List[SubstituteCandidate]:
        """Get similar compounds from PubChem as alternatives"""
        candidates = []
        
        if not original.smiles:
            return candidates
        
        # Search for similar compounds
        pubchem_data = self.pubchem.search_by_name(original.name or original.cas or "")
        if pubchem_data and pubchem_data.get("cid"):
            similar_compounds = self.pubchem.get_similar_compounds(pubchem_data["cid"])
            
            for compound in similar_compounds[:10]:
                candidate_reagent = Reagent(
                    name=f"Compound CID:{compound.get('cid')}",
                    cas=None,
                    molecular_formula=compound.get("formula"),
                    smiles=compound.get("smiles")
                )
                
                # Calculate structural similarity
                if compound.get("smiles") and original.smiles:
                    similarity = self.structure_analyzer.calculate_similarity(
                        original.smiles, compound["smiles"]
                    )
                else:
                    similarity = 0.5
                
                candidate = SubstituteCandidate(
                    reagent=candidate_reagent,
                    similarity_score=similarity
                )
                candidates.append(candidate)
        
        return candidates
    
    def _score_candidates(
        self, 
        candidates: List[SubstituteCandidate], 
        original: Reagent
    ) -> List[SubstituteCandidate]:
        """Calculate scores for candidate alternatives"""
        
        for candidate in candidates:
            # 1. Obtain literature citation data
            pubmed_data = self.pubmed.search_citations(candidate.reagent.name)
            candidate.citation_count = pubmed_data.get("count", 0)
            candidate.literature_evidence = pubmed_data.get("articles", [])
            
            # 2. Calculate timeliness score (proportion of citations in the last 5 years)
            recent_count = 0
            current_year = datetime.now().year
            for article in candidate.literature_evidence:
                try:
                    year = int(article.get("year", 0))
                    if current_year - year <= 5:
                        recent_count += 1
                except (ValueError, TypeError):
                    pass
            
            candidate.recent_citations = recent_count
            
            # 3. Calculate reliability score
            if candidate.citation_count >= 100:
                candidate.reliability_score = 5.0
            elif candidate.citation_count >= 50:
                candidate.reliability_score = 4.0
            elif candidate.citation_count >= 20:
                candidate.reliability_score = 3.0
            elif candidate.citation_count >= 5:
                candidate.reliability_score = 2.0
            else:
                candidate.reliability_score = 1.0
        
        return candidates
    
    def _deduplicate_candidates(
        self, 
        candidates: List[SubstituteCandidate]
    ) -> List[SubstituteCandidate]:
        """Deduplication candidate list (based on name)"""
        seen = set()
        unique = []
        
        for candidate in candidates:
            key = candidate.reagent.name.lower()
            if key not in seen:
                seen.add(key)
                unique.append(candidate)
        
        return unique


class OutputFormatter:
    """output formatter"""
    
    @staticmethod
    def format_table(candidates: List[SubstituteCandidate]) -> str:
        """Format to table output"""
        if not candidates:
            return "No replacement found."
        
        # Header
        header = "┌────────────────────────────┬─────────────┬────────────┬────────────┬─────────────┐"
        separator = "├────────────────────────────┼─────────────┼────────────┼────────────┼─────────────┤"
        footer = "└────────────────────────────┴─────────────┴────────────┴────────────┴─────────────┘"
        
        lines = [header]
        lines.append("│ {:<26} │ {:<11} │ {:<10} │ {:<10} │ {:<11} │".format(
            "Substitute", "CAS", "Similarity", "Citations", "Reliability"
        ))
        lines.append(separator)
        
        # data row
        for candidate in candidates:
            name = candidate.reagent.name[:26] if len(candidate.reagent.name) <= 26 else candidate.reagent.name[:23] + "..."
            cas = (candidate.reagent.cas or "N/A")[:11]
            similarity = f"{candidate.similarity_score:.2f}"
            citations = str(candidate.citation_count) if candidate.citation_count > 0 else "N/A"
            reliability = "★" * int(candidate.reliability_score)
            
            lines.append("│ {:<26} │ {:<11} │ {:<10} │ {:<10} │ {:<11} │".format(
                name, cas, similarity, citations, reliability
            ))
        
        lines.append(footer)
        
        return "\n".join(lines)
    
    @staticmethod
    def format_json(candidates: List[SubstituteCandidate], query: Dict) -> str:
        """Formatted as JSON output"""
        result = {
            "query": query,
            "results": []
        }
        
        for candidate in candidates:
            result["results"].append({
                "name": candidate.reagent.name,
                "cas": candidate.reagent.cas,
                "molecular_formula": candidate.reagent.molecular_formula,
                "similarity_score": round(candidate.similarity_score, 3),
                "citation_count": candidate.citation_count,
                "recent_citations": candidate.recent_citations,
                "reliability_score": round(candidate.reliability_score, 1),
                "total_score": round(candidate.total_score, 3),
                "literature_evidence": candidate.literature_evidence[:5]  # Only keep the first 5 items
            })
        
        return json.dumps(result, ensure_ascii=False, indent=2)
    
    @staticmethod
    def format_markdown(candidates: List[SubstituteCandidate], query: Dict) -> str:
        """Formatted as Markdown output"""
        lines = [
            "#Reagent Substitute Search Report",
            "",
            f"**Query reagents**: {query.get('reagent', 'N/A')}",
            f"**query time**: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
            "",
            "## List of recommended alternatives",
            "",
            "| Ranking | Alternative name | CAS number | Similarity | Number of citations | Reliability |",
            "|------|-----------|-------|--------|----------|--------|"
        ]
        
        for i, candidate in enumerate(candidates, 1):
            name = candidate.reagent.name
            cas = candidate.reagent.cas or "N/A"
            similarity = f"{candidate.similarity_score:.2f}"
            citations = str(candidate.citation_count) if candidate.citation_count > 0 else "N/A"
            reliability = "★" * int(candidate.reliability_score)
            
            lines.append(f"| {i} | {name} | {cas} | {similarity} | {citations} | {reliability} |")
        
        lines.extend([
            "",
            "## Rating description",
            "",
            "- **Similarity**: Degree of similarity in chemical structure and functional properties (0-1)",
            "- **Citations**: Number of citations in the literature database",
            "- **Reliability**: Rating (1-5 stars) based on cited data and usage verification",
            "",
            "---",
            "*Generated by Reagent Substitute Scout v1.0.0*"
        ])
        
        return "\n".join(lines)


def main():
    """Main entry function"""
    parser = argparse.ArgumentParser(
        description="Reagent Substitute Scout - Find proven reagent substitutes",
        formatter_class=argparse.RawDescriptionHelpFormatter,
        epilog="""Example:
  %(prog)s --reagent "TRIzol Reagent"
  %(prog)s --cas "15596-18-2" --format json
  %(prog)s --reagent "TRIzol" --limit 10 --verbose"""
    )
    
    # input parameters
    input_group = parser.add_mutually_exclusive_group(required=True)
    input_group.add_argument(
        "--reagent", "-r",
        help="Reagent name"
    )
    input_group.add_argument(
        "--cas", "-c",
        help="CAS number"
    )
    input_group.add_argument(
        "--formula", "-f",
        help="Molecular formula"
    )
    
    # Output parameters
    parser.add_argument(
        "--format", "-F",
        choices=["table", "json", "markdown"],
        default="table",
        help="Output format (default: table)"
    )
    parser.add_argument(
        "--limit", "-l",
        type=int,
        default=5,
        help="Limit on the number of results returned (default: 5)"
    )
    parser.add_argument(
        "--field",
        help="Application field filtering"
    )
    parser.add_argument(
        "--verbose", "-v",
        action="store_true",
        help="Output details (including literature citations)"
    )
    parser.add_argument(
        "--config",
        help="Configuration file path"
    )
    parser.add_argument(
        "--output", "-o",
        help="Output file path"
    )
    
    args = parser.parse_args()
    
    # Load configuration
    config = DEFAULT_CONFIG.copy()
    if args.config and os.path.exists(args.config):
        with open(args.config, 'r') as f:
            config.update(json.load(f))
    
    # Initialize searcher
    scout = ReagentSubstituteScout(config)
    
    # Perform a search
    try:
        candidates = scout.find_substitutes(
            reagent_name=args.reagent,
            cas_number=args.cas,
            molecular_formula=args.formula,
            limit=args.limit,
            application_field=args.field
        )
        
        # Prepare to query information
        query_info = {
            "reagent": args.reagent,
            "cas": args.cas,
            "formula": args.formula,
            "field": args.field
        }
        
        # Formatted output
        formatter = OutputFormatter()
        if args.format == "json":
            output = formatter.format_json(candidates, query_info)
        elif args.format == "markdown":
            output = formatter.format_markdown(candidates, query_info)
        else:
            output = formatter.format_table(candidates)
        
        # Output results
        if args.output:
            with open(args.output, 'w', encoding='utf-8') as f:
                f.write(output)
            print(f"Results have been saved to: {args.output}")
        else:
            print(output)
        
        # If verbose mode is enabled, output detailed literature information
        if args.verbose and args.format == "table":
            print("Detailed documentary evidence:")
            for i, candidate in enumerate(candidates, 1):
                print(f"\n[{i}] {candidate.reagent.name}")
                if candidate.literature_evidence:
                    for article in candidate.literature_evidence[:3]:
                        print(f"  - {article.get('title', 'N/A')} ({article.get('year', 'N/A')})")
                else:
                    print("(no literature data)")
        
        # Return status code
        return 0 if candidates else 1
        
    except KeyboardInterrupt:
        print("Operation canceled")
        return 130
    except Exception as e:
        print(f"mistake: {e}", file=sys.stderr)
        return 1


if __name__ == "__main__":
    sys.exit(main())


Security scan verdicts — ClawScan: clean | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (MEDIUM)