ai-agent-book 精选快照(<2MB 代码与文档,来自 github.com/bojieli/ai-agent-book)
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
Build latest book artifacts / build (push) Canceled after 0s
dependency resolution / resolve (3.11) (push) Canceled after 0s
dependency resolution / resolve (3.13) (push) Canceled after 0s
deploy-pages / build (push) Canceled after 0s
deploy-pages / deploy (push) Canceled after 0s
i18n consistency check / check (push) Canceled after 0s
provider adoption tests / test (chapter2/context-compression) (push) Canceled after 0s
provider adoption tests / test (chapter2/prompt-injection) (push) Canceled after 0s
provider adoption tests / test (chapter2/system-hint) (push) Canceled after 0s
provider adoption tests / test (chapter3/log-sanitization) (push) Canceled after 0s
web-search-agent tests / test (push) Canceled after 0s
web-search-agent tests / agentbook (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,544 @@
|
||||
"""
|
||||
PubChem chemical compound data tools.
|
||||
Based on AWorld MCP server implementation.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import traceback
|
||||
from typing import Union, Literal
|
||||
from urllib.parse import quote, urlsplit, urlunsplit
|
||||
|
||||
import requests
|
||||
from dotenv import load_dotenv
|
||||
from mcp.types import TextContent
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from base import ActionResponse
|
||||
|
||||
|
||||
load_dotenv()
|
||||
|
||||
|
||||
class CompoundData(BaseModel):
|
||||
"""Structured compound data from PubChem."""
|
||||
|
||||
cid: int | None = None
|
||||
name: str | None = None
|
||||
molecular_formula: str | None = None
|
||||
molecular_weight: float | None = None
|
||||
smiles: str | None = None
|
||||
inchi: str | None = None
|
||||
synonyms: list[str] = []
|
||||
|
||||
|
||||
class PubChemMetadata(BaseModel):
|
||||
"""Metadata for PubChem operation results."""
|
||||
|
||||
query_type: str
|
||||
query_value: str
|
||||
api_endpoint: str
|
||||
response_time: float
|
||||
total_results: int | None = None
|
||||
rate_limit_delay: float | None = None
|
||||
error_type: str | None = None
|
||||
|
||||
|
||||
class PubChemClient:
|
||||
"""PubChem API client with rate limiting."""
|
||||
|
||||
def __init__(self):
|
||||
self.base_url = "https://pubchem.ncbi.nlm.nih.gov/rest/pug"
|
||||
self.request_delay = 0.2 # 200ms delay to stay under 5 req/sec limit
|
||||
self.last_request_time = 0.0
|
||||
self.timeout = 30
|
||||
|
||||
self.session = requests.Session()
|
||||
self.session.headers.update({
|
||||
"User-Agent": "PerceptionToolsMCP/1.0",
|
||||
"Accept": "application/json"
|
||||
})
|
||||
|
||||
def _rate_limit(self) -> float:
|
||||
"""Enforce rate limiting to comply with PubChem usage policy."""
|
||||
current_time = time.time()
|
||||
time_since_last = current_time - self.last_request_time
|
||||
|
||||
if time_since_last < self.request_delay:
|
||||
delay = self.request_delay - time_since_last
|
||||
time.sleep(delay)
|
||||
self.last_request_time = time.time()
|
||||
return delay
|
||||
|
||||
self.last_request_time = current_time
|
||||
return 0.0
|
||||
|
||||
@staticmethod
|
||||
def _listkey_poll_url(url: str, list_key: str) -> str:
|
||||
"""Convert an asynchronous PUG REST request into its ListKey poll URL."""
|
||||
parsed = urlsplit(url)
|
||||
compound_marker = "/compound/"
|
||||
compound_index = parsed.path.find(compound_marker)
|
||||
if compound_index < 0:
|
||||
raise requests.RequestException("PubChem ListKey response for an unsupported endpoint")
|
||||
|
||||
operation_start = compound_index + len(compound_marker)
|
||||
operation_indexes = [
|
||||
index
|
||||
for marker in ("/property/", "/cids/", "/synonyms/")
|
||||
if (index := parsed.path.find(marker, operation_start)) >= 0
|
||||
]
|
||||
if not operation_indexes:
|
||||
raise requests.RequestException("PubChem ListKey response did not identify a poll operation")
|
||||
|
||||
operation_index = min(operation_indexes)
|
||||
poll_path = (
|
||||
parsed.path[:operation_start]
|
||||
+ f"listkey/{quote(list_key, safe='')}"
|
||||
+ parsed.path[operation_index:]
|
||||
)
|
||||
return urlunsplit((parsed.scheme, parsed.netloc, poll_path, parsed.query, parsed.fragment))
|
||||
|
||||
def make_request(
|
||||
self,
|
||||
url: str,
|
||||
params: dict = None,
|
||||
max_retries: int = 12,
|
||||
_origin_url: str | None = None,
|
||||
_started_at: float | None = None,
|
||||
) -> tuple[dict | None, float]:
|
||||
"""Make a rate-limited request to PubChem API with retry for async operations."""
|
||||
origin_url = _origin_url or url
|
||||
started_at = _started_at if _started_at is not None else time.perf_counter()
|
||||
self._rate_limit()
|
||||
|
||||
try:
|
||||
response = self.session.get(url, params=params, timeout=self.timeout)
|
||||
|
||||
if response.status_code == 200:
|
||||
return response.json(), time.perf_counter() - started_at
|
||||
elif response.status_code == 202:
|
||||
# PUG REST returns a ListKey for long-running searches. Polling the
|
||||
# original URL starts a new job, so switch to the documented
|
||||
# list-key endpoint and retain the complete request latency.
|
||||
if max_retries > 0:
|
||||
waiting = response.json().get("Waiting", {})
|
||||
list_key = str(waiting.get("ListKey", "")).strip()
|
||||
if not list_key:
|
||||
raise requests.RequestException("PubChem async response omitted ListKey")
|
||||
poll_url = self._listkey_poll_url(url, list_key)
|
||||
logging.info("PubChem async operation, waiting 2s before polling ListKey")
|
||||
time.sleep(2)
|
||||
return self.make_request(
|
||||
poll_url,
|
||||
params,
|
||||
max_retries - 1,
|
||||
_origin_url=origin_url,
|
||||
_started_at=started_at,
|
||||
)
|
||||
else:
|
||||
raise requests.RequestException("PubChem async operation timeout after retries")
|
||||
elif response.status_code in {429, 500, 502, 503, 504} and max_retries > 0:
|
||||
# A ListKey job can fail independently inside PubChem. Start a
|
||||
# fresh copy of the original query in that case; otherwise
|
||||
# retry the same endpoint. All retries remain bounded.
|
||||
retry_url = origin_url if "/listkey/" in url else url
|
||||
logging.warning(
|
||||
"PubChem transient HTTP %s; retrying %s",
|
||||
response.status_code,
|
||||
"original query" if retry_url == origin_url else "request",
|
||||
)
|
||||
time.sleep(2)
|
||||
return self.make_request(
|
||||
retry_url,
|
||||
params,
|
||||
max_retries - 1,
|
||||
_origin_url=origin_url,
|
||||
_started_at=started_at,
|
||||
)
|
||||
else:
|
||||
raise requests.RequestException(f"HTTP {response.status_code}: {response.text}")
|
||||
|
||||
except requests.Timeout:
|
||||
raise requests.RequestException(f"Request timeout after {self.timeout}s")
|
||||
except requests.RequestException:
|
||||
raise
|
||||
|
||||
|
||||
# Global client instance
|
||||
_client = None
|
||||
|
||||
|
||||
def get_client() -> PubChemClient:
|
||||
"""Get or create the global PubChem client."""
|
||||
global _client
|
||||
if _client is None:
|
||||
_client = PubChemClient()
|
||||
return _client
|
||||
|
||||
|
||||
async def search_compounds(
|
||||
query: str,
|
||||
search_type: Literal["name", "cid", "smiles", "inchi", "formula"] = "name",
|
||||
max_results: int = 10
|
||||
) -> Union[str, TextContent]:
|
||||
"""
|
||||
Search for chemical compounds in PubChem database.
|
||||
|
||||
Args:
|
||||
query: Search term or identifier
|
||||
search_type: Type of search (name, cid, smiles, inchi, formula)
|
||||
max_results: Maximum number of results (1-100)
|
||||
|
||||
Returns:
|
||||
TextContent with compound search results
|
||||
"""
|
||||
try:
|
||||
if not query or not query.strip():
|
||||
raise ValueError("Search query is required")
|
||||
|
||||
max_results = max(1, min(max_results, 100))
|
||||
|
||||
logging.info(f"🔬 Searching PubChem for: {query} (type: {search_type})")
|
||||
|
||||
client = get_client()
|
||||
|
||||
# Build API URL based on search type
|
||||
if search_type == "cid":
|
||||
url = f"{client.base_url}/compound/cid/{quote(str(query))}/property/Title,MolecularFormula,MolecularWeight,CanonicalSMILES,InChI/JSON"
|
||||
elif search_type == "name":
|
||||
url = f"{client.base_url}/compound/name/{quote(query)}/property/Title,MolecularFormula,MolecularWeight,CanonicalSMILES,InChI/JSON"
|
||||
elif search_type == "smiles":
|
||||
url = f"{client.base_url}/compound/smiles/{quote(query)}/property/Title,MolecularFormula,MolecularWeight,CanonicalSMILES,InChI/JSON"
|
||||
elif search_type == "inchi":
|
||||
url = f"{client.base_url}/compound/inchi/{quote(query)}/property/Title,MolecularFormula,MolecularWeight,CanonicalSMILES,InChI/JSON"
|
||||
elif search_type == "formula":
|
||||
url = f"{client.base_url}/compound/formula/{quote(query)}/property/Title,MolecularFormula,MolecularWeight,CanonicalSMILES,InChI/JSON"
|
||||
else:
|
||||
raise ValueError(f"Unsupported search type: {search_type}")
|
||||
|
||||
# Make API request
|
||||
data, response_time = client.make_request(url)
|
||||
|
||||
# Parse results
|
||||
compounds = []
|
||||
if data and "PropertyTable" in data and "Properties" in data["PropertyTable"]:
|
||||
properties_list = data["PropertyTable"]["Properties"][:max_results]
|
||||
|
||||
for prop in properties_list:
|
||||
compound = CompoundData(
|
||||
cid=prop.get("CID"),
|
||||
name=prop.get("Title"),
|
||||
molecular_formula=prop.get("MolecularFormula"),
|
||||
molecular_weight=prop.get("MolecularWeight"),
|
||||
smiles=prop.get("CanonicalSMILES"),
|
||||
inchi=prop.get("InChI")
|
||||
)
|
||||
compounds.append(compound)
|
||||
|
||||
# Format results
|
||||
result = {
|
||||
"query": query,
|
||||
"search_type": search_type,
|
||||
"compounds": [c.model_dump() for c in compounds],
|
||||
"count": len(compounds)
|
||||
}
|
||||
|
||||
metadata = PubChemMetadata(
|
||||
query_type=search_type,
|
||||
query_value=query,
|
||||
api_endpoint=url,
|
||||
response_time=response_time,
|
||||
total_results=len(compounds)
|
||||
)
|
||||
|
||||
logging.info(f"✅ Found {len(compounds)} compounds ({response_time:.2f}s)")
|
||||
|
||||
action_response = ActionResponse(
|
||||
success=True,
|
||||
message=result,
|
||||
metadata=metadata.model_dump()
|
||||
)
|
||||
|
||||
return TextContent(
|
||||
type="text",
|
||||
text=json.dumps(action_response.model_dump())
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
error_msg = f"Invalid input: {str(e)}"
|
||||
logging.error(f"PubChem search error: {error_msg}")
|
||||
|
||||
action_response = ActionResponse(
|
||||
success=False,
|
||||
message=error_msg,
|
||||
metadata={"error_type": "invalid_input"}
|
||||
)
|
||||
|
||||
return TextContent(
|
||||
type="text",
|
||||
text=json.dumps(action_response.model_dump())
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Search failed: {str(e)}"
|
||||
logging.error(f"PubChem error: {traceback.format_exc()}")
|
||||
|
||||
action_response = ActionResponse(
|
||||
success=False,
|
||||
message=error_msg,
|
||||
metadata={"error_type": "api_error"}
|
||||
)
|
||||
|
||||
return TextContent(
|
||||
type="text",
|
||||
text=json.dumps(action_response.model_dump())
|
||||
)
|
||||
|
||||
|
||||
async def get_compound_properties(
|
||||
cid: int,
|
||||
properties: list[str] | None = None
|
||||
) -> Union[str, TextContent]:
|
||||
"""
|
||||
Retrieve detailed chemical properties for a PubChem compound.
|
||||
|
||||
Args:
|
||||
cid: PubChem Compound ID
|
||||
properties: List of property names (e.g., MolecularWeight, XLogP)
|
||||
|
||||
Returns:
|
||||
TextContent with compound properties
|
||||
"""
|
||||
try:
|
||||
if not cid or cid <= 0:
|
||||
raise ValueError("Valid PubChem CID is required")
|
||||
|
||||
if not properties:
|
||||
properties = [
|
||||
"MolecularWeight", "MolecularFormula", "CanonicalSMILES",
|
||||
"InChI", "XLogP", "TPSA", "HBondDonorCount", "HBondAcceptorCount"
|
||||
]
|
||||
|
||||
logging.info(f"🔬 Getting properties for CID: {cid}")
|
||||
|
||||
client = get_client()
|
||||
props_str = ",".join(properties)
|
||||
url = f"{client.base_url}/compound/cid/{cid}/property/{props_str}/JSON"
|
||||
|
||||
data, response_time = client.make_request(url)
|
||||
|
||||
compound_props = {}
|
||||
if data and "PropertyTable" in data and "Properties" in data["PropertyTable"]:
|
||||
props_data = data["PropertyTable"]["Properties"][0]
|
||||
compound_props = {k: v for k, v in props_data.items() if k != "CID"}
|
||||
|
||||
result = {
|
||||
"cid": cid,
|
||||
"properties": compound_props
|
||||
}
|
||||
|
||||
metadata = PubChemMetadata(
|
||||
query_type="properties",
|
||||
query_value=str(cid),
|
||||
api_endpoint=url,
|
||||
response_time=response_time,
|
||||
total_results=len(compound_props)
|
||||
)
|
||||
|
||||
logging.info(f"✅ Retrieved {len(compound_props)} properties")
|
||||
|
||||
action_response = ActionResponse(
|
||||
success=True,
|
||||
message=result,
|
||||
metadata=metadata.model_dump()
|
||||
)
|
||||
|
||||
return TextContent(
|
||||
type="text",
|
||||
text=json.dumps(action_response.model_dump())
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Property retrieval failed: {str(e)}"
|
||||
logging.error(f"PubChem error: {traceback.format_exc()}")
|
||||
|
||||
action_response = ActionResponse(
|
||||
success=False,
|
||||
message=error_msg,
|
||||
metadata={"error_type": "api_error"}
|
||||
)
|
||||
|
||||
return TextContent(
|
||||
type="text",
|
||||
text=json.dumps(action_response.model_dump())
|
||||
)
|
||||
|
||||
|
||||
async def get_compound_synonyms(
|
||||
cid: int,
|
||||
max_synonyms: int = 20
|
||||
) -> Union[str, TextContent]:
|
||||
"""
|
||||
Retrieve synonyms for a PubChem compound.
|
||||
|
||||
Args:
|
||||
cid: PubChem Compound ID
|
||||
max_synonyms: Maximum number of synonyms (1-100)
|
||||
|
||||
Returns:
|
||||
TextContent with compound synonyms
|
||||
"""
|
||||
try:
|
||||
if not cid or cid <= 0:
|
||||
raise ValueError("Valid PubChem CID is required")
|
||||
|
||||
max_synonyms = max(1, min(max_synonyms, 100))
|
||||
|
||||
logging.info(f"🔬 Getting synonyms for CID: {cid}")
|
||||
|
||||
client = get_client()
|
||||
url = f"{client.base_url}/compound/cid/{cid}/synonyms/JSON"
|
||||
|
||||
data, response_time = client.make_request(url)
|
||||
|
||||
synonyms = []
|
||||
if data and "InformationList" in data and "Information" in data["InformationList"]:
|
||||
info_list = data["InformationList"]["Information"]
|
||||
if info_list and "Synonym" in info_list[0]:
|
||||
synonyms = info_list[0]["Synonym"][:max_synonyms]
|
||||
|
||||
result = {
|
||||
"cid": cid,
|
||||
"synonyms": synonyms,
|
||||
"count": len(synonyms)
|
||||
}
|
||||
|
||||
metadata = PubChemMetadata(
|
||||
query_type="synonyms",
|
||||
query_value=str(cid),
|
||||
api_endpoint=url,
|
||||
response_time=response_time,
|
||||
total_results=len(synonyms)
|
||||
)
|
||||
|
||||
logging.info(f"✅ Retrieved {len(synonyms)} synonyms")
|
||||
|
||||
action_response = ActionResponse(
|
||||
success=True,
|
||||
message=result,
|
||||
metadata=metadata.model_dump()
|
||||
)
|
||||
|
||||
return TextContent(
|
||||
type="text",
|
||||
text=json.dumps(action_response.model_dump())
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Synonym retrieval failed: {str(e)}"
|
||||
logging.error(f"PubChem error: {traceback.format_exc()}")
|
||||
|
||||
action_response = ActionResponse(
|
||||
success=False,
|
||||
message=error_msg,
|
||||
metadata={"error_type": "api_error"}
|
||||
)
|
||||
|
||||
return TextContent(
|
||||
type="text",
|
||||
text=json.dumps(action_response.model_dump())
|
||||
)
|
||||
|
||||
|
||||
async def search_similar_compounds(
|
||||
cid: int,
|
||||
similarity_threshold: float = 0.9,
|
||||
max_results: int = 10
|
||||
) -> Union[str, TextContent]:
|
||||
"""
|
||||
Find structurally similar compounds.
|
||||
|
||||
Args:
|
||||
cid: Reference compound CID
|
||||
similarity_threshold: Minimum similarity (0.0-1.0)
|
||||
max_results: Maximum results (1-50)
|
||||
|
||||
Returns:
|
||||
TextContent with similar compounds
|
||||
"""
|
||||
try:
|
||||
if not cid or cid <= 0:
|
||||
raise ValueError("Valid PubChem CID is required")
|
||||
|
||||
similarity_threshold = max(0.0, min(similarity_threshold, 1.0))
|
||||
max_results = max(1, min(max_results, 50))
|
||||
|
||||
logging.info(f"🔬 Searching similar compounds to CID: {cid}")
|
||||
|
||||
client = get_client()
|
||||
threshold_percent = int(similarity_threshold * 100)
|
||||
url = f"{client.base_url}/compound/fastsimilarity_2d/cid/{cid}/property/Title,MolecularFormula,MolecularWeight/JSON"
|
||||
params = {
|
||||
"Threshold": threshold_percent,
|
||||
"MaxRecords": max_results
|
||||
}
|
||||
|
||||
data, response_time = client.make_request(url, params)
|
||||
|
||||
similar_compounds = []
|
||||
if data and "PropertyTable" in data and "Properties" in data["PropertyTable"]:
|
||||
properties_list = data["PropertyTable"]["Properties"]
|
||||
|
||||
for prop in properties_list:
|
||||
if prop.get("CID") != cid:
|
||||
compound = CompoundData(
|
||||
cid=prop.get("CID"),
|
||||
name=prop.get("Title"),
|
||||
molecular_formula=prop.get("MolecularFormula"),
|
||||
molecular_weight=prop.get("MolecularWeight")
|
||||
)
|
||||
similar_compounds.append(compound)
|
||||
|
||||
result = {
|
||||
"reference_cid": cid,
|
||||
"similarity_threshold": similarity_threshold,
|
||||
"similar_compounds": [c.model_dump() for c in similar_compounds],
|
||||
"count": len(similar_compounds)
|
||||
}
|
||||
|
||||
metadata = PubChemMetadata(
|
||||
query_type="similarity",
|
||||
query_value=str(cid),
|
||||
api_endpoint=url,
|
||||
response_time=response_time,
|
||||
total_results=len(similar_compounds)
|
||||
)
|
||||
|
||||
logging.info(f"✅ Found {len(similar_compounds)} similar compounds")
|
||||
|
||||
action_response = ActionResponse(
|
||||
success=True,
|
||||
message=result,
|
||||
metadata=metadata.model_dump()
|
||||
)
|
||||
|
||||
return TextContent(
|
||||
type="text",
|
||||
text=json.dumps(action_response.model_dump())
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
error_msg = f"Similarity search failed: {str(e)}"
|
||||
logging.error(f"PubChem error: {traceback.format_exc()}")
|
||||
|
||||
action_response = ActionResponse(
|
||||
success=False,
|
||||
message=error_msg,
|
||||
metadata={"error_type": "api_error"}
|
||||
)
|
||||
|
||||
return TextContent(
|
||||
type="text",
|
||||
text=json.dumps(action_response.model_dump())
|
||||
)
|
||||
Reference in New Issue
Block a user