Enhancement: Blockchain Document Loader with better Metadata support (#3710)

This PR includes some minor alignment updates, including:

- metadata object extended to support contractAddress, blockchainType,
and tokenId
- notebook doc better aligned to standard langchain format
- startToken changed from int to str to support multiple hex value types
on the Alchemy API

The updated metadata will look like the below. It's possible for a
single contractAddress to exist across multiple blockchains (e.g.
Ethereum, Polygon, etc.) so it's important to include the
blockchainType.

```
 metadata = {"source": self.contract_address, 
                      "blockchain": self.blockchainType,
                      "tokenId": tokenId}
```
fix_agent_callbacks
Jon Saginaw 1 year ago committed by GitHub
parent 220a7076ac
commit f8d69e4e52
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -7,7 +7,7 @@
"id": "vm8vn9t8DvC_" "id": "vm8vn9t8DvC_"
}, },
"source": [ "source": [
"# Blockchain Document Loader" "# Blockchain"
] ]
}, },
{ {
@ -31,7 +31,7 @@
"\n", "\n",
"Initially this Loader supports:\n", "Initially this Loader supports:\n",
"\n", "\n",
"\n", "* Loading NFTs as Documents from NFT Smart Contracts (ERC721 and ERC1155)\n",
"* Ethereum Maninnet, Ethereum Testnet, Polgyon Mainnet, Polygon Testnet (default is eth-mainnet)\n", "* Ethereum Maninnet, Ethereum Testnet, Polgyon Mainnet, Polygon Testnet (default is eth-mainnet)\n",
"* Alchemy's getNFTsForCollection API\n", "* Alchemy's getNFTsForCollection API\n",
"\n", "\n",
@ -39,14 +39,14 @@
"\n", "\n",
"* Additional APIs can be added (e.g. Tranction-related APIs)\n", "* Additional APIs can be added (e.g. Tranction-related APIs)\n",
"\n", "\n",
"To run this notebook, the user will need:\n", "This Document Loader Requires:\n",
"\n",
"\n", "\n",
"* An OpenAI key (for OpenAI models)\n",
"* A free [Alchemy API Key](https://www.alchemy.com/)\n", "* A free [Alchemy API Key](https://www.alchemy.com/)\n",
"\n", "\n",
"The output takes the following format:\n",
"\n", "\n",
"\n" "- pageContent= Individual NFT\n",
"- metadata={'source': '0x1a92f7381b9f03921564a437210bb9396471050c', 'blockchain': 'eth-mainnet', 'tokenId': '0x15'})"
] ]
}, },
{ {
@ -54,55 +54,16 @@
"cell_type": "markdown", "cell_type": "markdown",
"metadata": {}, "metadata": {},
"source": [ "source": [
"## Setup" "## Load NFTs into Document Loader"
]
},
{
"cell_type": "code",
"execution_count": 48,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Note: you may need to restart the kernel to use updated packages.\n"
]
}
],
"source": [
"%pip install langchain -q"
] ]
}, },
{ {
"cell_type": "code", "cell_type": "code",
"execution_count": 49, "execution_count": null,
"metadata": {},
"outputs": [],
"source": [
"from langchain.document_loaders import BlockchainDocumentLoader\n",
"from langchain.document_loaders.blockchain import BlockchainType\n",
"import os"
]
},
{
"cell_type": "code",
"execution_count": 50,
"metadata": {}, "metadata": {},
"outputs": [], "outputs": [],
"source": [ "source": [
"alchemyApiKey = \"get your own key from https://www.alchemy.com/\" \n", "alchemyApiKey = \"get from https://www.alchemy.com/ and set in environment variable ALCHEMY_API_KEY\""
"os.environ[\"ALCHEMY_API_KEY\"] = alchemyApiKey"
]
},
{
"attachments": {},
"cell_type": "markdown",
"metadata": {
"id": "nzuPWRaBNCMx"
},
"source": [
"## Create a Blockchain Document Loader"
] ]
}, },
{ {
@ -115,29 +76,18 @@
}, },
{ {
"cell_type": "code", "cell_type": "code",
"execution_count": 24, "execution_count": null,
"metadata": { "metadata": {
"id": "J3LWHARC-Kn0" "id": "J3LWHARC-Kn0"
}, },
"outputs": [ "outputs": [],
{
"data": {
"text/plain": [
"[Document(page_content=\"{'contract': {'address': '0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d'}, 'id': {'tokenId': '0x0000000000000000000000000000000000000000000000000000000000000000', 'tokenMetadata': {'tokenType': 'ERC721'}}, 'title': '', 'description': '', 'tokenUri': {'gateway': 'https://alchemy.mypinata.cloud/ipfs/QmeSjSinHpPnmXmspMjwiXyN6zS4E9zccariGR3jxcaWtq/0', 'raw': 'ipfs://QmeSjSinHpPnmXmspMjwiXyN6zS4E9zccariGR3jxcaWtq/0'}, 'media': [{'gateway': 'https://nft-cdn.alchemy.com/eth-mainnet/415d618f5fef7bfe683e02d4653c4289', 'thumbnail': 'https://res.cloudinary.com/alchemyapi/image/upload/thumbnailv2/eth-mainnet/415d618f5fef7bfe683e02d4653c4289', 'raw': 'ipfs://QmRRPWG96cmgTn2qSzjwr2qvfNEuhunv6FNeMFGa9bx6mQ', 'format': 'png', 'bytes': 133270}], 'metadata': {'image': 'ipfs://QmRRPWG96cmgTn2qSzjwr2qvfNEuhunv6FNeMFGa9bx6mQ', 'attributes': [{'value': 'Silver Hoop', 'trait_type': 'Earring'}, {'value': 'Orange', 'trait_type': 'Background'}, {'value': 'Robot', 'trait_type': 'Fur'}, {'value': 'Striped Tee', 'trait_type': 'Clothes'}, {'value': 'Discomfort', 'trait_type': 'Mouth'}, {'value': 'X Eyes', 'trait_type': 'Eyes'}]}, 'timeLastUpdated': '2023-04-18T04:05:27.817Z', 'contractMetadata': {'name': 'BoredApeYachtClub', 'symbol': 'BAYC', 'totalSupply': '10000', 'tokenType': 'ERC721', 'contractDeployer': '0xaba7161a7fb69c88e16ed9f455ce62b791ee4d03', 'deployedBlockNumber': 12287507, 'openSea': {'floorPrice': 68.16, 'collectionName': 'Bored Ape Yacht Club', 'safelistRequestStatus': 'verified', 'imageUrl': 'https://i.seadn.io/gae/Ju9CkWtV-1Okvf45wo8UctR-M9He2PjILP0oOvxE89AyiPPGtrR3gysu1Zgy0hjd2xKIgjJJtWIc0ybj4Vd7wv8t3pxDGHoJBzDB?w=500&auto=format', 'description': 'The Bored Ape Yacht Club is a collection of 10,000 unique Bored Ape NFTs— unique digital collectibles living on the Ethereum blockchain. Your Bored Ape doubles as your Yacht Club membership card, and grants access to members-only benefits, the first of which is access to THE BATHROOM, a collaborative graffiti board. Future areas and perks can be unlocked by the community through roadmap activation. Visit www.BoredApeYachtClub.com for more details.', 'externalUrl': 'http://www.boredapeyachtclub.com/', 'twitterUsername': 'BoredApeYC', 'discordUrl': 'https://discord.gg/3P5K3dzgdB', 'lastIngestedAt': '2023-03-21T03:54:33.000Z'}}}\", metadata={'tokenId': '0x0000000000000000000000000000000000000000000000000000000000000000'}),\n",
" Document(page_content=\"{'contract': {'address': '0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d'}, 'id': {'tokenId': '0x0000000000000000000000000000000000000000000000000000000000000001', 'tokenMetadata': {'tokenType': 'ERC721'}}, 'title': '', 'description': '', 'tokenUri': {'gateway': 'https://alchemy.mypinata.cloud/ipfs/QmeSjSinHpPnmXmspMjwiXyN6zS4E9zccariGR3jxcaWtq/1', 'raw': 'ipfs://QmeSjSinHpPnmXmspMjwiXyN6zS4E9zccariGR3jxcaWtq/1'}, 'media': [{'gateway': 'https://nft-cdn.alchemy.com/eth-mainnet/65558a4d0c5b0c56fbc50bf03f55e3fa', 'thumbnail': 'https://res.cloudinary.com/alchemyapi/image/upload/thumbnailv2/eth-mainnet/65558a4d0c5b0c56fbc50bf03f55e3fa', 'raw': 'ipfs://QmPbxeGcXhYQQNgsC6a36dDyYUcHgMLnGKnF8pVFmGsvqi', 'format': 'png', 'bytes': 171425}], 'metadata': {'image': 'ipfs://QmPbxeGcXhYQQNgsC6a36dDyYUcHgMLnGKnF8pVFmGsvqi', 'attributes': [{'value': 'Grin', 'trait_type': 'Mouth'}, {'value': 'Vietnam Jacket', 'trait_type': 'Clothes'}, {'value': 'Orange', 'trait_type': 'Background'}, {'value': 'Blue Beams', 'trait_type': 'Eyes'}, {'value': 'Robot', 'trait_type': 'Fur'}]}, 'timeLastUpdated': '2023-04-24T04:37:37.738Z', 'contractMetadata': {'name': 'BoredApeYachtClub', 'symbol': 'BAYC', 'totalSupply': '10000', 'tokenType': 'ERC721', 'contractDeployer': '0xaba7161a7fb69c88e16ed9f455ce62b791ee4d03', 'deployedBlockNumber': 12287507, 'openSea': {'floorPrice': 68.16, 'collectionName': 'Bored Ape Yacht Club', 'safelistRequestStatus': 'verified', 'imageUrl': 'https://i.seadn.io/gae/Ju9CkWtV-1Okvf45wo8UctR-M9He2PjILP0oOvxE89AyiPPGtrR3gysu1Zgy0hjd2xKIgjJJtWIc0ybj4Vd7wv8t3pxDGHoJBzDB?w=500&auto=format', 'description': 'The Bored Ape Yacht Club is a collection of 10,000 unique Bored Ape NFTs— unique digital collectibles living on the Ethereum blockchain. Your Bored Ape doubles as your Yacht Club membership card, and grants access to members-only benefits, the first of which is access to THE BATHROOM, a collaborative graffiti board. Future areas and perks can be unlocked by the community through roadmap activation. Visit www.BoredApeYachtClub.com for more details.', 'externalUrl': 'http://www.boredapeyachtclub.com/', 'twitterUsername': 'BoredApeYC', 'discordUrl': 'https://discord.gg/3P5K3dzgdB', 'lastIngestedAt': '2023-03-21T03:54:33.000Z'}}}\", metadata={'tokenId': '0x0000000000000000000000000000000000000000000000000000000000000001'})]"
]
},
"execution_count": 24,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [ "source": [
"contractAddress = \"0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d\" # Bored Ape Yacht Club contract address\n", "contractAddress = \"0xbc4ca0eda7647a8ab7c2061c2e118a18a936f13d\" # Bored Ape Yacht Club contract address\n",
"\n", "\n",
"blockchainType = BlockchainType.ETH_MAINNET #default value, optional parameter\n", "blockchainType = BlockchainType.ETH_MAINNET #default value, optional parameter\n",
"\n", "\n",
"blockchainLoader = BlockchainDocumentLoader(contractAddress)\n", "blockchainLoader = BlockchainDocumentLoader(contract_address=contractAddress,\n",
" api_key=alchemyApiKey)\n",
"\n", "\n",
"nfts = blockchainLoader.load()\n", "nfts = blockchainLoader.load()\n",
"\n", "\n",
@ -154,265 +104,22 @@
}, },
{ {
"cell_type": "code", "cell_type": "code",
"execution_count": 36, "execution_count": null,
"metadata": {}, "metadata": {},
"outputs": [ "outputs": [],
{
"data": {
"text/plain": [
"[Document(page_content=\"{'contract': {'address': '0x448676ffcd0adf2d85c1f0565e8dde6924a9a7d9'}, 'id': {'tokenId': '0x01', 'tokenMetadata': {'tokenType': 'ERC1155'}}, 'title': 'Wyatt Horton #0001', 'description': 'A sleepy capybara', 'tokenUri': {'gateway': 'https://storage.googleapis.com/minted-nfts/smoothstack/avatars/metadata/1.json', 'raw': 'https://storage.googleapis.com/minted-nfts/smoothstack/avatars/metadata/1.json'}, 'media': [{'gateway': 'https://nft-cdn.alchemy.com/matic-mainnet/9085e06ff9f6c9074de91801d1c72d26', 'thumbnail': 'https://res.cloudinary.com/alchemyapi/image/upload/thumbnailv2/matic-mainnet/9085e06ff9f6c9074de91801d1c72d26', 'raw': 'https://storage.googleapis.com/minted-nfts/smoothstack/avatars/images/1.png', 'format': 'png', 'bytes': 769622}], 'metadata': {'name': 'Wyatt Horton #0001', 'description': 'A sleepy capybara', 'image': 'https://storage.googleapis.com/minted-nfts/smoothstack/avatars/images/1.png', 'attributes': [{'value': 'Avatar', 'trait_type': 'Type'}, {'value': 'Animal', 'trait_type': 'Category'}, {'value': 'Capybara', 'trait_type': 'Class'}, {'value': 'Fall 2022', 'trait_type': 'Collection'}, {'value': 'Furry', 'trait_type': 'Feature'}]}, 'timeLastUpdated': '2023-04-20T14:38:24.947Z', 'contractMetadata': {'name': 'Smoothstack - Avatars', 'symbol': 'SMTH', 'tokenType': 'ERC1155', 'contractDeployer': '0x23075b2523c6563b06920a302a8be4f90ef6e974', 'deployedBlockNumber': 34752389, 'openSea': {'lastIngestedAt': '2023-04-17T20:59:42.000Z'}}}\", metadata={'tokenId': '0x01'}),\n",
" Document(page_content=\"{'contract': {'address': '0x448676ffcd0adf2d85c1f0565e8dde6924a9a7d9'}, 'id': {'tokenId': '0x02', 'tokenMetadata': {'tokenType': 'ERC1155'}}, 'title': 'Dylan Leisler #0002', 'description': 'A chipper cat with a big, red bowtie', 'tokenUri': {'gateway': 'https://storage.googleapis.com/minted-nfts/smoothstack/avatars/metadata/2.json', 'raw': 'https://storage.googleapis.com/minted-nfts/smoothstack/avatars/metadata/2.json'}, 'media': [{'gateway': 'https://nft-cdn.alchemy.com/matic-mainnet/67c3c7ccef44b32bf2ce758e8e73dbcd', 'thumbnail': 'https://res.cloudinary.com/alchemyapi/image/upload/thumbnailv2/matic-mainnet/67c3c7ccef44b32bf2ce758e8e73dbcd', 'raw': 'https://storage.googleapis.com/minted-nfts/smoothstack/avatars/images/2.png', 'format': 'png', 'bytes': 1187749}], 'metadata': {'name': 'Dylan Leisler #0002', 'description': 'A chipper cat with a big, red bowtie', 'image': 'https://storage.googleapis.com/minted-nfts/smoothstack/avatars/images/2.png', 'attributes': [{'value': 'Avatar', 'trait_type': 'Type'}, {'value': 'Animal', 'trait_type': 'Category'}, {'value': 'Cat', 'trait_type': 'Class'}, {'value': 'Fall 2022', 'trait_type': 'Collection'}, {'value': 'Red Bowtie', 'trait_type': 'Feature'}]}, 'timeLastUpdated': '2023-04-23T13:38:29.316Z', 'contractMetadata': {'name': 'Smoothstack - Avatars', 'symbol': 'SMTH', 'tokenType': 'ERC1155', 'contractDeployer': '0x23075b2523c6563b06920a302a8be4f90ef6e974', 'deployedBlockNumber': 34752389, 'openSea': {'lastIngestedAt': '2023-04-17T20:59:42.000Z'}}}\", metadata={'tokenId': '0x02'})]"
]
},
"execution_count": 36,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [ "source": [
"contractAddress = \"0x448676ffCd0aDf2D85C1f0565e8dde6924A9A7D9\" # Polygon Mainnet contract address\n", "contractAddress = \"0x448676ffCd0aDf2D85C1f0565e8dde6924A9A7D9\" # Polygon Mainnet contract address\n",
"\n", "\n",
"blockchainType = BlockchainType.POLYGON_MAINNET \n", "blockchainType = BlockchainType.POLYGON_MAINNET \n",
"\n", "\n",
"blockchainLoader = BlockchainDocumentLoader(contractAddress, blockchainType)\n", "blockchainLoader = BlockchainDocumentLoader(contract_address=contractAddress, \n",
" blockchainType=blockchainType, \n",
" api_key=alchemyApiKey)\n",
"\n", "\n",
"nfts = blockchainLoader.load()\n", "nfts = blockchainLoader.load()\n",
"\n", "\n",
"nfts[:2]" "nfts[:2]"
] ]
},
{
"attachments": {},
"cell_type": "markdown",
"metadata": {},
"source": [
"## (Optional) Using the Blockchain Document Loader"
]
},
{
"attachments": {},
"cell_type": "markdown",
"metadata": {
"id": "_PGkFfMCB8J3"
},
"source": [
"### Setup Splitter and Index"
]
},
{
"cell_type": "code",
"execution_count": 37,
"metadata": {},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"Note: you may need to restart the kernel to use updated packages.\n"
]
}
],
"source": [
"%pip install sentence_transformers chromadb openai tiktoken -q"
]
},
{
"cell_type": "code",
"execution_count": 38,
"metadata": {
"id": "BwxxopOCCABh"
},
"outputs": [],
"source": [
"from langchain.indexes import VectorstoreIndexCreator\n",
"from langchain.embeddings import HuggingFaceEmbeddings\n",
"from langchain.text_splitter import RecursiveCharacterTextSplitter"
]
},
{
"cell_type": "code",
"execution_count": 39,
"metadata": {
"colab": {
"base_uri": "https://localhost:8080/"
},
"id": "JE_myAulCDSZ",
"outputId": "99e16b6a-03b4-4e67-d4b4-9dd611a866ef"
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"NUMBER OF DOCUMENTS: 424\n"
]
}
],
"source": [
"text_splitter = RecursiveCharacterTextSplitter(chunk_size=400, chunk_overlap=0)\n",
"\n",
"docs = text_splitter.split_documents(nfts)\n",
"print(\"NUMBER OF DOCUMENTS: \", len(docs))"
]
},
{
"cell_type": "code",
"execution_count": 40,
"metadata": {
"id": "d83yFuAuCKQS"
},
"outputs": [
{
"name": "stderr",
"output_type": "stream",
"text": [
"Using embedded DuckDB without persistence: data will be transient\n"
]
}
],
"source": [
"index = VectorstoreIndexCreator(\n",
" embedding=HuggingFaceEmbeddings(),\n",
" text_splitter=text_splitter).from_loaders([blockchainLoader])"
]
},
{
"attachments": {},
"cell_type": "markdown",
"metadata": {
"id": "y0VfObeXDEXB"
},
"source": [
"## Setup Models and Chains"
]
},
{
"cell_type": "code",
"execution_count": 42,
"metadata": {},
"outputs": [],
"source": [
"openAiKey = \"put OpenAI key here\"\n",
"os.environ[\"OPENAI_API_KEY\"] = openAiKey"
]
},
{
"cell_type": "code",
"execution_count": 31,
"metadata": {
"id": "hiNjDzP9C4pA"
},
"outputs": [],
"source": [
"from langchain.chains import RetrievalQA\n",
"from langchain.llms import OpenAI"
]
},
{
"attachments": {},
"cell_type": "markdown",
"metadata": {
"id": "u-xDlKPaC_xg"
},
"source": [
"### Retrieval Chain"
]
},
{
"cell_type": "code",
"execution_count": 43,
"metadata": {
"id": "BqP00JovC9R4"
},
"outputs": [],
"source": [
"llmOpenAI = OpenAI()\n",
"\n",
"chainQA = RetrievalQA.from_chain_type(llm=llmOpenAI, \n",
" chain_type=\"map_reduce\",\n",
" retriever=index.vectorstore.as_retriever(), \n",
" verbose=True,\n",
" input_key=\"question\")"
]
},
{
"cell_type": "code",
"execution_count": 44,
"metadata": {
"colab": {
"base_uri": "https://localhost:8080/",
"height": 122
},
"id": "2Y3cVVKZDVNq",
"outputId": "dfeea416-5193-47cf-e9dc-c17a5c1cd780"
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"\n",
"\n",
"\u001b[1m> Entering new RetrievalQA chain...\u001b[0m\n",
"\n",
"\u001b[1m> Finished chain.\u001b[0m\n"
]
},
{
"data": {
"text/plain": [
"' Popular attributes include \"Avatar\" (Type), \"Character\" (Category), and \"Human\" or \"Wizard\" (Class).'"
]
},
"execution_count": 44,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"chainQA.run(\"What are some of the popular attributes?\")"
]
},
{
"cell_type": "code",
"execution_count": null,
"metadata": {
"colab": {
"base_uri": "https://localhost:8080/",
"height": 122
},
"id": "7o6ArPo9DXbz",
"outputId": "b1f8ad43-27c7-4cdb-95a7-8c8bd6381c5a"
},
"outputs": [
{
"name": "stdout",
"output_type": "stream",
"text": [
"\n",
"\n",
"\u001b[1m> Entering new RetrievalQA chain...\u001b[0m\n",
"\n",
"\u001b[1m> Finished chain.\u001b[0m\n"
]
},
{
"data": {
"application/vnd.google.colaboratory.intrinsic+json": {
"type": "string"
},
"text/plain": [
"' There are 10,000 unique Bored Ape NFTs.'"
]
},
"execution_count": 32,
"metadata": {},
"output_type": "execute_result"
}
],
"source": [
"chainQA.run(\"How many NFTs are there?\")"
]
} }
], ],
"metadata": { "metadata": {

@ -26,10 +26,14 @@ class BlockchainDocumentLoader(BaseLoader):
The Loader uses the Alchemy API to interact with the blockchain. The Loader uses the Alchemy API to interact with the blockchain.
The API returns 100 NFTs per request and can be paginated using the
startToken parameter.
ALCHEMY_API_KEY environment variable must be set to use this loader. ALCHEMY_API_KEY environment variable must be set to use this loader.
Future versions of this loader can: Future versions of this loader can:
- Support additional Alchemy APIs (e.g. getTransactions, etc.) - Support additional Alchemy APIs (e.g. getTransactions, etc.)
- Support additional blockain APIs (e.g. Infura, Opensea, etc.)
""" """
def __init__( def __init__(
@ -37,7 +41,7 @@ class BlockchainDocumentLoader(BaseLoader):
contract_address: str, contract_address: str,
blockchainType: BlockchainType = BlockchainType.ETH_MAINNET, blockchainType: BlockchainType = BlockchainType.ETH_MAINNET,
api_key: str = "docs-demo", api_key: str = "docs-demo",
startToken: int = 0, startToken: str = "",
): ):
self.contract_address = contract_address self.contract_address = contract_address
self.blockchainType = blockchainType.value self.blockchainType = blockchainType.value
@ -75,6 +79,10 @@ class BlockchainDocumentLoader(BaseLoader):
for item in items: for item in items:
content = str(item) content = str(item)
tokenId = item["id"]["tokenId"] tokenId = item["id"]["tokenId"]
metadata = {"tokenId": tokenId} metadata = {
"source": self.contract_address,
"blockchain": self.blockchainType,
"tokenId": tokenId,
}
result.append(Document(page_content=content, metadata=metadata)) result.append(Document(page_content=content, metadata=metadata))
return result return result

@ -26,7 +26,7 @@ def test_get_nfts_with_pagination() -> None:
contract_address = ( contract_address = (
"0x1a92f7381b9f03921564a437210bb9396471050c" # CoolCats contract address "0x1a92f7381b9f03921564a437210bb9396471050c" # CoolCats contract address
) )
startToken = 20 startToken = "20"
result = BlockchainDocumentLoader( result = BlockchainDocumentLoader(
contract_address, contract_address,

Loading…
Cancel
Save