File size: 5,538 Bytes
ba4ad33
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
import os
import requests
from typing import Optional, Dict, List
from rdkit import Chem
from rdkit.Chem import Descriptors, rdMolDescriptors

"""
LLM-based Generation Layer for Chemical Similarity Explanation
Version 2.0: Optimized with RDKit Grounding & Hallucination Guardrails
"""

# 1. RDKit Metadata Engine 
def get_rdkit_metadata(smiles: str) -> Dict:
    """Extracts factual chemical data to anchor the LLM response."""
    try:
        mol = Chem.MolFromSmiles(smiles)
        if not mol:
            return {}
        return {
            "formula": rdMolDescriptors.CalcMolFormula(mol),
            "mw": round(Descriptors.MolWt(mol), 2),
            "heavy_atoms": mol.GetNumHeavyAtoms(),
            "formal_charge": Chem.GetFormalCharge(mol)
        }
    except Exception:
        return {}

# 2. System Prompt
def build_system_prompt() -> str:
    """Build the system prompt with strict chemical constraints."""
    return """Role:
You are an expert Cheminformatics AI Assistant. Your task is to explain the structural similarity between a "Query Compound" and a "Match Compound" based on SMILES notation and provided RDKit metadata.

Strict Guidelines to Prevent Hallucinations:
1. GROUNDING: Use the provided Molecular Formula to verify atom counts. If the formula is C4H9Br, do NOT say it has 4 Bromine atoms.
2. TETRAVALENCY: Carbon is ALWAYS tetravalent (4 bonds). Never describe a carbon atom as "pentavalent" or having more than 4 bonds.
3. SMILES PARSING: Parentheses ( ) denote branches on the same atom. E.g., C(Br)(Br) means two Bromines on the same Carbon.
4. NO GUESSING NAMES: Do not attempt IUPAC naming unless certain. Use descriptive terms like "tri-halogenated methane" or "tert-butyl scaffold".
5. ISOSTERES: Identify isosteric replacements correctly (e.g., swapping Br for Cl or F).
6. CONCISENESS: Keep the explanation to 2-3 precise technical sentences."""

# 3. User Prompt 
def build_user_prompt(query_smiles: str, match_smiles: str, similarity: float) -> str:
    """Combines SMILES with RDKit metadata for the LLM context."""
    q_meta = get_rdkit_metadata(query_smiles)
    m_meta = get_rdkit_metadata(match_smiles)
    
    return f"""Analyze the following compound pair:

Query: {query_smiles}
- Formula: {q_meta.get('formula', 'N/A')}
- Heavy Atoms: {q_meta.get('heavy_atoms', 'N/A')}

Match: {match_smiles}
- Formula: {m_meta.get('formula', 'N/A')}
- Heavy Atoms: {m_meta.get('heavy_atoms', 'N/A')}

Similarity Score: {similarity:.3f}

Task: Explain why these compounds are structurally similar. Focus on the core scaffold and atom substitutions. Reference the formulas to ensure accuracy."""

# 4. Generation Core 
def generate_explanation(
    query_smiles: str,
    compound_smiles: str,
    similarity_score: float
) -> Optional[str]:
    """Generates a fact-checked explanation using Llama-3.1 via HF Router."""
    try:
        api_key = os.environ.get("HF_TOKEN")
        if not api_key:
            return _generate_fallback_explanation(similarity_score)
        
        system_prompt = build_system_prompt()
        user_prompt = build_user_prompt(query_smiles, compound_smiles, similarity_score)
        
        # HuggingFace Router API
        api_url = "https://router.huggingface.co/v1/chat/completions"
        headers = {"Authorization": f"Bearer {api_key}"}
        
        payload = {
            "messages": [
                {"role": "system", "content": system_prompt},
                {"role": "user", "content": user_prompt}
            ],
            "model": "meta-llama/Llama-3.1-8B-Instruct:fastest",
            "temperature": 0.1,  # Low temperature for high factual precision
            "max_tokens": 100
        }
        
        response = requests.post(api_url, headers=headers, json=payload, timeout=10)
        response.raise_for_status()
        result = response.json()
        
        explanation = result["choices"][0]["message"]["content"].strip()
        return explanation if len(explanation) > 10 else _generate_fallback_explanation(similarity_score)
            
    except Exception:
        return _generate_fallback_explanation(similarity_score)

def _generate_fallback_explanation(similarity_score: float) -> str:
    """Heuristic fallback in case of API failure."""
    if similarity_score >= 0.95:
        return "Extremely high structural similarity - compounds share an identical core scaffold with minimal substituent variation."
    return "Significant structural similarity based on shared molecular framework and functional group distribution."

# 5. Batch Processing
def generate_explanations_batch(query_smiles: str, search_results: List[Dict]) -> List[Dict]:
    """Adds an 'explanation' field to each search result."""
    for result in search_results:
        result["explanation"] = generate_explanation(
            query_smiles,
            result["smiles"],
            result["similarity_score"]
        )
    return search_results

# Example Usage:
if __name__ == "__main__":
    # Mock search results
    query = "C(Br)(Br)(Br)Br"
    results = [
        {"smiles": "C(Cl)(Cl)(Cl)Br", "similarity_score": 0.998},
        {"smiles": "CC(C)(C)Br", "similarity_score": 0.998}
    ]
    
    # You need to set your HF_TOKEN in environment variables
    # os.environ["HF_TOKEN"] = "your_token_here"
    
    updated_results = generate_explanations_batch(query, results)
    for res in updated_results:
        print(f"SMILES: {res['smiles']}\nScore: {res['similarity_score']}\nExplanation: {res['explanation']}\n{'-'*30}")