diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..edc058b --- /dev/null +++ b/.dockerignore @@ -0,0 +1,10 @@ +# Ignore all but src/ +* +!src/neuroagent/* +!pyproject.toml + +# Ignore the __pycache__ that are in the whitelisted folders. +*/__pycache__/ +*/*/__pycache__/ +*/*/*/__pycache__/ + diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..e7b68dd --- /dev/null +++ b/.env.example @@ -0,0 +1,54 @@ +# required. +NEUROAGENT_TOOLS__LITERATURE__URL= +NEUROAGENT_KNOWLEDGE_GRAPH__BASE_URL= +NEUROAGENT_GENERATIVE__OPENAI__TOKEN= + +# Important but not required +NEUROAGENT_AGENT__MODEL= +NEUROAGENT_AGENT__CHAT= +NEUROAGENT_KNOWLEDGE_GRAPH__USE_TOKEN= +NEUROAGENT_KNOWLEDGE_GRAPH__TOKEN= +NEUROAGENT_KNOWLEDGE_GRAPH__DOWNLOAD_HIERARCHY= + +# Useful but not required. +NEUROAGENT_DB__PREFIX= +NEUROAGENT_DB__USER= +NEUROAGENT_DB__PASSWORD= +NEUROAGENT_DB__HOST= +NEUROAGENT_DB__PORT= +NEUROAGENT_DB__NAME= +NEUROAGENT_TOOLS__LITERATURE__RETRIEVER_K= +NEUROAGENT_TOOLS__LITERATURE__RERANKER_K= +NEUROAGENT_TOOLS__LITERATURE__USE_RERANKER= + +NEUROAGENT_TOOLS__KNOWLEDGE_GRAPH__SEARCH_SIZE= + +NEUROAGENT_TOOLS__TRACE__SEARCH_SIZE= + +NEUROAGENT_TOOLS__KG_MORPHO__SEARCH_SIZE= + +NEUROAGENT_GENERATIVE__LLM_TYPE= # can only be openai for now +NEUROAGENT_GENERATIVE__OPENAI__MODEL= +NEUROAGENT_GENERATIVE__OPENAI__TEMPERATURE= +NEUROAGENT_GENERATIVE__OPENAI__MAX_TOKENS= + +NEUROAGENT_COHERE__TOKEN= + +NEUROAGENT_LOGGING__LEVEL= +NEUROAGENT_LOGGING__EXTERNAL_PACKAGES= + +NEUROAGENT_KNOWLEDGE_GRAPH__BR_SAVING_PATH= +NEUROAGENT_KNOWLEDGE_GRAPH__CT_SAVING_PATH= + +NEUROAGENT_MISC__APPLICATION_PREFIX= +NEUROAGENT_MISC__CORS_ORIGINS= + +NEUROAGENT_KEYCLOAK__ISSUER= +NEUROAGENT_KEYCLOAK__VALIDATE_TOKEN= +NEUROAGENT_KEYCLOAK__CLIENT_ID= +NEUROAGENT_KEYCLOAK__USERNAME= +NEUROAGENT_KEYCLOAK__PASSWORD= + +# other. +SSL_CERT_FILE=(path to cert file)(etc/ssl/certs/ca-certificates.crt for ubuntu, for mac check Nicolas tutorial) +REQUESTS_CA_BUNDLE=(path to cert file)(etc/ssl/certs/ca-certificates.crt for ubuntu, for mac check Nicolas tutorial) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..db7f261 --- /dev/null +++ b/.gitignore @@ -0,0 +1,154 @@ +# Custom entries +/.idea/ +.DS_Store +.env* +!.env*.example +.python-version +.vscode +.pypirc + +# Version file + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# static files generated from Django application using `collectstatic` +media +static diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..677a169 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,35 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v4.4.0 + hooks: + - id: check-added-large-files + - id: check-json + - id: check-toml + - id: check-yaml + - id: requirements-txt-fixer + - id: trailing-whitespace + - id: end-of-file-fixer + - id: no-commit-to-branch + args: [--branch, master, --branch, main] + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.5.5 + hooks: + - id: ruff + args: [ --fix ] + - id: ruff-format + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.8.0 + hooks: + - id: mypy + files: ^src/ + additional_dependencies: ['pydantic', 'types-requests'] + + - repo: https://github.com/PyCQA/bandit + rev: 1.7.4 + hooks: + - id: bandit + args: [-c, pyproject.toml, -qr] + files: ^src/ + additional_dependencies: ["bandit[toml]"] diff --git a/AUTHORS.txt b/AUTHORS.txt new file mode 100644 index 0000000..f368a7c --- /dev/null +++ b/AUTHORS.txt @@ -0,0 +1,8 @@ + + +- [Boris Bergsma](https://github.com/BoBer78) @ Blue Brain Project, EPFL +- [Emilie Delattre](https://github.com/EmilieDel) @ Blue Brain Project, EPFL +- [Nicolas Frank](https://github.com/WonderPG) @ Blue Brain Project, EPFL +- [Jan Krepl](https://github.com/jankrepl) @ Blue Brain Project, EPFL +- [Kerem Kurban](https://github.com/KeremKurban) @ Blue Brain Project, EPFL +- [Csaba Zsolnai]() @ Blue Brain Project, EPFL \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 0b2e0a2..fd91bc3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,10 +2,13 @@ All notable changes to this project will be documented in this file. -The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ### Removed -- Github action to create the docs. \ No newline at end of file +- Github action to create the docs. + +### Changed +- Migration to pydantic V2. diff --git a/Dockerfile b/Dockerfile index c62bbc9..f6bd061 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,3 +1,16 @@ -FROM python:3.10-slim +FROM python:3.10 -CMD python \ No newline at end of file +ENV PYTHONUNBUFFERED=1 + +RUN apt-get -y update +RUN apt-get -y install curl + +RUN pip install --no-cache-dir --upgrade pip +COPY ./ /code +RUN pip install --no-cache-dir /code[api] +RUN rm -rf /code + +WORKDIR / + +EXPOSE 8078 +CMD neuroagent-api --host 0.0.0.0 --port 8078 diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..f49a4e1 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/README.md b/README.md index 242e836..e684a4f 100644 --- a/README.md +++ b/README.md @@ -1 +1,93 @@ -First version of the neuroagent package. \ No newline at end of file +# Agents + + + +## Getting started + +To make it easy for you to get started with GitLab, here's a list of recommended next steps. + +Already a pro? Just edit this README.md and make it your own. Want to make it easy? [Use the template at the bottom](#editing-this-readme)! + +## Add your files + +- [ ] [Create](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#create-a-file) or [upload](https://docs.gitlab.com/ee/user/project/repository/web_editor.html#upload-a-file) files +- [ ] [Add files using the command line](https://docs.gitlab.com/ee/gitlab-basics/add-file.html#add-a-file-using-the-command-line) or push an existing Git repository with the following command: + +``` +cd existing_repo +git remote add origin https://bbpgitlab.epfl.ch/ml/agents.git +git branch -M main +git push -uf origin main +``` + +## Integrate with your tools + +- [ ] [Set up project integrations](https://bbpgitlab.epfl.ch/ml/agents/-/settings/integrations) + +## Collaborate with your team + +- [ ] [Invite team members and collaborators](https://docs.gitlab.com/ee/user/project/members/) +- [ ] [Create a new merge request](https://docs.gitlab.com/ee/user/project/merge_requests/creating_merge_requests.html) +- [ ] [Automatically close issues from merge requests](https://docs.gitlab.com/ee/user/project/issues/managing_issues.html#closing-issues-automatically) +- [ ] [Enable merge request approvals](https://docs.gitlab.com/ee/user/project/merge_requests/approvals/) +- [ ] [Set auto-merge](https://docs.gitlab.com/ee/user/project/merge_requests/merge_when_pipeline_succeeds.html) + +## Test and Deploy + +Use the built-in continuous integration in GitLab. + +- [ ] [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/index.html) +- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/ee/user/application_security/sast/) +- [ ] [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html) +- [ ] [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/) +- [ ] [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html) + +*** + +# Editing this README + +When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thanks to [makeareadme.com](https://www.makeareadme.com/) for this template. + +## Suggestions for a good README + +Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information. + +## Name +Choose a self-explaining name for your project. + +## Description +Let people know what your project can do specifically. Provide context and add a link to any reference visitors might be unfamiliar with. A list of Features or a Background subsection can also be added here. If there are alternatives to your project, this is a good place to list differentiating factors. + +## Badges +On some READMEs, you may see small images that convey metadata, such as whether or not all the tests are passing for the project. You can use Shields to add some to your README. Many services also have instructions for adding a badge. + +## Visuals +Depending on what you are making, it can be a good idea to include screenshots or even a video (you'll frequently see GIFs rather than actual videos). Tools like ttygif can help, but check out Asciinema for a more sophisticated method. + +## Installation +Within a particular ecosystem, there may be a common way of installing things, such as using Yarn, NuGet, or Homebrew. However, consider the possibility that whoever is reading your README is a novice and would like more guidance. Listing specific steps helps remove ambiguity and gets people to using your project as quickly as possible. If it only runs in a specific context like a particular programming language version or operating system or has dependencies that have to be installed manually, also add a Requirements subsection. + +## Usage +Use examples liberally, and show the expected output if you can. It's helpful to have inline the smallest example of usage that you can demonstrate, while providing links to more sophisticated examples if they are too long to reasonably include in the README. + +## Support +Tell people where they can go to for help. It can be any combination of an issue tracker, a chat room, an email address, etc. + +## Roadmap +If you have ideas for releases in the future, it is a good idea to list them in the README. + +## Contributing +State if you are open to contributions and what your requirements are for accepting them. + +For people who want to make changes to your project, it's helpful to have some documentation on how to get started. Perhaps there is a script that they should run or some environment variables that they need to set. Make these steps explicit. These instructions could also be useful to your future self. + +You can also document commands to lint the code or run tests. These steps help to ensure high code quality and reduce the likelihood that the changes inadvertently break something. Having instructions for running tests is especially helpful if it requires external setup, such as starting a Selenium server for testing in a browser. + +## Authors and acknowledgment +Show your appreciation to those who have contributed to the project. + +## License +For open source projects, say how it is licensed. + +## Project status +If you have run out of energy or time for your project, put a note at the top of the README saying that development has slowed down or stopped completely. Someone may choose to fork your project or volunteer to step in as a maintainer or owner, allowing your project to keep going. You can also make an explicit request for maintainers. diff --git a/pyproject.toml b/pyproject.toml index da0f948..5fc5406 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,11 +23,11 @@ dependencies = [ "langgraph-checkpoint-postgres", "langgraph-checkpoint-sqlite", "neurom", + "psycopg-binary", "psycopg2-binary", "pydantic-settings", "python-dotenv", "python-keycloak", - "python-multipart", "sqlalchemy", "uvicorn", ] @@ -97,5 +97,13 @@ minversion = "6.0" testpaths = [ "tests", ] +filterwarnings = [ + "error", + "ignore:Use get_feature_values:DeprecationWarning", + "ignore:Mean of empty slice:RuntimeWarning", + "ignore:Degrees of freedom:RuntimeWarning", + "ignore:Exception ignored in:pytest.PytestUnraisableExceptionWarning", + "ignore:This API is in beta:langchain_core._api.beta_decorator.LangChainBetaWarning", +] addopts = "--cov=src/ --cov=tests/ -v --cov-report=term-missing --durations=20 --no-cov-on-fail" diff --git a/src/neuroagent/__init__.py b/src/neuroagent/__init__.py index 3ea7d87..6af808a 100644 --- a/src/neuroagent/__init__.py +++ b/src/neuroagent/__init__.py @@ -1,3 +1,3 @@ -"""Neuroagent package.""" +"""Agent package.""" -__version__ = "0.0.0" +__version__ = "0.7.0" diff --git a/src/neuroagent/agents/__init__.py b/src/neuroagent/agents/__init__.py new file mode 100644 index 0000000..382377e --- /dev/null +++ b/src/neuroagent/agents/__init__.py @@ -0,0 +1,13 @@ +"""Agents.""" + +from neuroagent.agents.base_agent import AgentOutput, AgentStep, BaseAgent +from neuroagent.agents.simple_agent import SimpleAgent +from neuroagent.agents.simple_chat_agent import SimpleChatAgent + +__all__ = [ + "AgentOutput", + "AgentStep", + "BaseAgent", + "SimpleChatAgent", + "SimpleAgent", +] diff --git a/src/neuroagent/agents/base_agent.py b/src/neuroagent/agents/base_agent.py new file mode 100644 index 0000000..df0833d --- /dev/null +++ b/src/neuroagent/agents/base_agent.py @@ -0,0 +1,102 @@ +"""Base agent.""" + +from abc import ABC, abstractmethod +from typing import Any, AsyncIterator + +from langchain.chat_models.base import BaseChatModel +from langchain_core.messages import ( + AIMessage, + ChatMessage, + FunctionMessage, + HumanMessage, + SystemMessage, + ToolMessage, +) +from langchain_core.prompts import ( + ChatPromptTemplate, + HumanMessagePromptTemplate, + MessagesPlaceholder, + PromptTemplate, + SystemMessagePromptTemplate, +) +from langchain_core.tools import BaseTool +from pydantic import BaseModel, ConfigDict + +BASE_PROMPT = ChatPromptTemplate( + input_variables=["agent_scratchpad", "input"], + input_types={ + "chat_history": list[ + AIMessage + | HumanMessage + | ChatMessage + | SystemMessage + | FunctionMessage + | ToolMessage + ], + "agent_scratchpad": list[ + AIMessage + | HumanMessage + | ChatMessage + | SystemMessage + | FunctionMessage + | ToolMessage + ], + }, + messages=[ + SystemMessagePromptTemplate( + prompt=PromptTemplate( + input_variables=[], + template="""You are a helpful assistant helping scientists with neuro-scientific questions. + You must always specify in your answers from which brain regions the information is extracted. + Do no blindly repeat the brain region requested by the user, use the output of the tools instead.""", + ) + ), + MessagesPlaceholder(variable_name="chat_history", optional=True), + HumanMessagePromptTemplate( + prompt=PromptTemplate(input_variables=["input"], template="{input}") + ), + MessagesPlaceholder(variable_name="agent_scratchpad"), + ], +) + + +class AgentStep(BaseModel): + """Class for agent decision steps.""" + + tool_name: str + arguments: dict[str, Any] | str + + +class AgentOutput(BaseModel): + """Class for agent response.""" + + response: str + steps: list[AgentStep] + plan: str | None = None + + +class BaseAgent(BaseModel, ABC): + """Base class for services.""" + + llm: BaseChatModel + tools: list[BaseTool] + agent: Any + + model_config = ConfigDict(arbitrary_types_allowed=True) + + @abstractmethod + def run(self, *args: Any, **kwargs: Any) -> AgentOutput: + """Run method of the service.""" + + @abstractmethod + async def arun(self, *args: Any, **kwargs: Any) -> AgentOutput: + """Arun method of the service.""" + + @abstractmethod + async def astream(self, *args: Any, **kwargs: Any) -> AsyncIterator[str]: + """Astream method of the service.""" + + @staticmethod + @abstractmethod + def _process_output(*args: Any, **kwargs: Any) -> AgentOutput: + """Format the output.""" diff --git a/src/neuroagent/agents/simple_agent.py b/src/neuroagent/agents/simple_agent.py new file mode 100644 index 0000000..819bd38 --- /dev/null +++ b/src/neuroagent/agents/simple_agent.py @@ -0,0 +1,123 @@ +"""Simple agent.""" + +import logging +from typing import Any, AsyncIterator + +from langchain_core.messages import AIMessage +from langgraph.prebuilt import create_react_agent +from pydantic import model_validator + +from neuroagent.agents import AgentOutput, AgentStep, BaseAgent + +logger = logging.getLogger(__name__) + + +class SimpleAgent(BaseAgent): + """Simple Agent class.""" + + @model_validator(mode="before") + @classmethod + def create_agent(cls, data: dict[str, Any]) -> dict[str, Any]: + """Instantiate the clients upon class creation.""" + # Initialise the agent with the tools + data["agent"] = create_react_agent( + model=data["llm"], + tools=data["tools"], + state_modifier="""You are a helpful assistant helping scientists with neuro-scientific questions. + You must always specify in your answers from which brain regions the information is extracted. + Do no blindly repeat the brain region requested by the user, use the output of the tools instead.""", + ) + return data + + def run(self, query: str) -> Any: + """Run the agent against a query. + + Parameters + ---------- + query + Query of the user + + Returns + ------- + Processed output of the LLM + """ + return self._process_output(self.agent.invoke({"messages": [("human", query)]})) + + async def arun(self, query: str) -> Any: + """Run the agent against a query. + + Parameters + ---------- + query + Query of the user + + Returns + ------- + Processed output of the LLM + """ + result = await self.agent.ainvoke({"messages": [("human", query)]}) + return self._process_output(result) + + async def astream(self, query: str) -> AsyncIterator[str]: # type: ignore + """Run the agent against a query in streaming way. + + Parameters + ---------- + query + Query of the user + + Returns + ------- + Iterator streaming the processed output of the LLM + """ + streamed_response = self.agent.astream_events({"messages": query}, version="v2") + + async for event in streamed_response: + kind = event["event"] + + # newline everytime model starts streaming. + if kind == "on_chat_model_start": + yield "\n\n" # These \n are to separate AI message from tools. + # check for the model stream. + if kind == "on_chat_model_stream": + # check if we are calling the tools. + data_chunk = event["data"]["chunk"] + if "tool_calls" in data_chunk.additional_kwargs: + tool = data_chunk.additional_kwargs["tool_calls"] + if tool[0]["function"]["name"]: + yield ( + f'\nCalling tool : {tool[0]["function"]["name"]} with' + " arguments : " + ) # This \n is for when there are multiple async tool calls. + if tool[0]["function"]["arguments"]: + yield tool[0]["function"]["arguments"] + + content = data_chunk.content + if content: + yield content + yield "\n" + + @staticmethod + def _process_output(output: Any) -> AgentOutput: + """Format the output. + + Parameters + ---------- + output + Raw output of the LLM + + Returns + ------- + Unified output across different agent type. + """ + # Gather tool name and arguments together + agent_steps = [ + AgentStep( + tool_name=tool_call["name"], + arguments=tool_call["args"], + ) + for step in output["messages"] + if isinstance(step, AIMessage) and step.additional_kwargs + for tool_call in step.tool_calls + ] + return AgentOutput(response=output["messages"][-1].content, steps=agent_steps) diff --git a/src/neuroagent/agents/simple_chat_agent.py b/src/neuroagent/agents/simple_chat_agent.py new file mode 100644 index 0000000..1262a2c --- /dev/null +++ b/src/neuroagent/agents/simple_chat_agent.py @@ -0,0 +1,113 @@ +"""Simple agent.""" + +import logging +from typing import Any, AsyncIterator + +from langchain_core.messages import AIMessage, HumanMessage +from langgraph.checkpoint.base import BaseCheckpointSaver +from langgraph.prebuilt import create_react_agent +from pydantic import model_validator + +from neuroagent.agents import AgentOutput, AgentStep, BaseAgent + +logger = logging.getLogger(__name__) + + +class SimpleChatAgent(BaseAgent): + """Simple Agent class.""" + + memory: BaseCheckpointSaver + + @model_validator(mode="before") + @classmethod + def create_agent(cls, data: dict[str, Any]) -> dict[str, Any]: + """Instantiate the clients upon class creation.""" + data["agent"] = create_react_agent( + model=data["llm"], + tools=data["tools"], + checkpointer=data["memory"], + state_modifier="""You are a helpful assistant helping scientists with neuro-scientific questions. + You must always specify in your answers from which brain regions the information is extracted. + Do no blindly repeat the brain region requested by the user, use the output of the tools instead.""", + ) + return data + + def run(self, session_id: str, query: str) -> Any: + """Run the agent against a query.""" + pass + + async def arun(self, thread_id: str, query: str) -> Any: + """Run the agent against a query.""" + config = {"configurable": {"thread_id": thread_id}} + input_message = HumanMessage(content=query) + result = await self.agent.ainvoke({"messages": [input_message]}, config=config) + return self._process_output(result) + + async def astream(self, thread_id: str, query: str) -> AsyncIterator[str]: # type: ignore + """Run the agent against a query in streaming way. + + Parameters + ---------- + thread_id + ID of the thread of the chat. + query + Query of the user + + Returns + ------- + Iterator streaming the processed output of the LLM + """ + config = {"configurable": {"thread_id": thread_id}} + streamed_response = self.agent.astream_events( + {"messages": query}, version="v2", config=config + ) + + async for event in streamed_response: + kind = event["event"] + + # newline everytime model starts streaming. + if kind == "on_chat_model_start": + yield "\n\n" + # check for the model stream. + if kind == "on_chat_model_stream": + # check if we are calling the tools. + data_chunk = event["data"]["chunk"] + if "tool_calls" in data_chunk.additional_kwargs: + tool = data_chunk.additional_kwargs["tool_calls"] + if tool[0]["function"]["name"]: + yield ( + f'\nCalling tool : {tool[0]["function"]["name"]} with' + " arguments : " + ) + if tool[0]["function"]["arguments"]: + yield tool[0]["function"]["arguments"] + + content = data_chunk.content + if content: + yield content + yield "\n" + + @staticmethod + def _process_output(output: Any) -> AgentOutput: + """Format the output. + + Parameters + ---------- + output + Raw output of the LLM + + Returns + ------- + Unified output across different agent type. + """ + # Gather tool name and arguments together + agent_steps = [ + AgentStep( + tool_name=tool_call["name"], + arguments=tool_call["args"], + ) + for step in output["messages"] + if isinstance(step, AIMessage) and step.additional_kwargs + for tool_call in step.tool_calls + ] + return AgentOutput(response=output["messages"][-1].content, steps=agent_steps) diff --git a/src/neuroagent/app/__init__.py b/src/neuroagent/app/__init__.py new file mode 100644 index 0000000..612fa7d --- /dev/null +++ b/src/neuroagent/app/__init__.py @@ -0,0 +1 @@ +"""Main package for neuroagent deployment.""" diff --git a/src/neuroagent/app/config.py b/src/neuroagent/app/config.py new file mode 100644 index 0000000..f61f5b2 --- /dev/null +++ b/src/neuroagent/app/config.py @@ -0,0 +1,267 @@ +"""Configuration.""" + +import os +import pathlib +from typing import Literal, Optional + +from dotenv import dotenv_values +from fastapi.openapi.models import OAuthFlowPassword, OAuthFlows +from pydantic import BaseModel, ConfigDict, SecretStr, model_validator +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class SettingsAgent(BaseModel): + """Agent setting.""" + + model: str = "simple" + chat: str = "simple" + + model_config = ConfigDict(frozen=True) + + +class SettingsDB(BaseModel): + """DB settings for retrieving history.""" + + prefix: str | None = None + user: str | None = None + password: SecretStr | None = None + host: str | None = None + port: str | None = None + name: str | None = None + + model_config = ConfigDict(frozen=True) + + +class SettingsKeycloak(BaseModel): + """Class retrieving keycloak info for authorization.""" + + issuer: str = "https://openbluebrain.com/auth/realms/SBO" + validate_token: bool = False + # Useful only for service account (dev) + client_id: str | None = None + username: str | None = None + password: SecretStr | None = None + + model_config = ConfigDict(frozen=True) + + @property + def token_endpoint(self) -> str | None: + """Define the token endpoint.""" + if self.validate_token: + return f"{self.issuer}/protocol/openid-connect/token" + else: + return None + + @property + def user_info_endpoint(self) -> str | None: + """Define the user_info endpoint.""" + if self.validate_token: + return f"{self.issuer}/protocol/openid-connect/userinfo" + else: + return None + + @property + def flows(self) -> OAuthFlows: + """Define the flow to override Fastapi's one.""" + return OAuthFlows( + password=OAuthFlowPassword( + tokenUrl=self.token_endpoint, + ), + ) + + @property + def server_url(self) -> str: + """Server url.""" + return self.issuer.split("/auth")[0] + "/auth/" + + @property + def realm(self) -> str: + """Realm.""" + return self.issuer.rpartition("/realms/")[-1] + + +class SettingsLiterature(BaseModel): + """Literature search API settings.""" + + url: str + retriever_k: int = 700 + use_reranker: bool = True + reranker_k: int = 5 + + model_config = ConfigDict(frozen=True) + + +class SettingsTrace(BaseModel): + """Trace tool settings.""" + + search_size: int = 10 + + model_config = ConfigDict(frozen=True) + + +class SettingsKGMorpho(BaseModel): + """KG Morpho settings.""" + + search_size: int = 3 + + model_config = ConfigDict(frozen=True) + + +class SettingsGetMorpho(BaseModel): + """Get Morpho settings.""" + + search_size: int = 10 + + model_config = ConfigDict(frozen=True) + + +class SettingsKnowledgeGraph(BaseModel): + """Knowledge graph API settings.""" + + base_url: str + token: SecretStr | None = None + use_token: bool = False + download_hierarchy: bool = False + br_saving_path: pathlib.Path | str = str( + pathlib.Path(__file__).parent / "data" / "brainregion_hierarchy.json" + ) + ct_saving_path: pathlib.Path | str = str( + pathlib.Path(__file__).parent / "data" / "celltypes_hierarchy.json" + ) + model_config = ConfigDict(frozen=True) + + @property + def url(self) -> str: + """Knowledge graph search url.""" + return f"{self.base_url}/search/query/" + + @property + def sparql_url(self) -> str: + """Knowledge graph view for sparql query.""" + return f"{self.base_url}/views/neurosciencegraph/datamodels/https%3A%2F%2Fbluebrain.github.io%2Fnexus%2Fvocabulary%2FdefaultSparqlIndex/sparql" + + @property + def class_view_url(self) -> str: + """Knowledge graph view for ES class query.""" + return f"{self.base_url}/views/neurosciencegraph/datamodels/https%3A%2F%2Fbbp.epfl.ch%2Fneurosciencegraph%2Fdata%2Fviews%2Fes%2Fdataset/_search" + + @property + def hierarchy_url(self) -> str: + """Knowledge graph url for brainregion/celltype files.""" + return "http://bbp.epfl.ch/neurosciencegraph/ontologies/core" + + +class SettingsTools(BaseModel): + """Database settings.""" + + literature: SettingsLiterature + morpho: SettingsGetMorpho = SettingsGetMorpho() + trace: SettingsTrace = SettingsTrace() + kg_morpho_features: SettingsKGMorpho = SettingsKGMorpho() + + model_config = ConfigDict(frozen=True) + + +class SettingsOpenAI(BaseModel): + """OpenAI settings.""" + + token: Optional[SecretStr] = None + model: str = "gpt-4o-mini" + temperature: float = 0 + max_tokens: Optional[int] = None + + model_config = ConfigDict(frozen=True) + + +class SettingsGenerative(BaseModel): + """Generative QA settings.""" + + llm_type: Literal["fake", "openai"] = "openai" + openai: SettingsOpenAI = SettingsOpenAI() + + model_config = ConfigDict(frozen=True) + + +class SettingsCohere(BaseModel): + """Settings cohere reranker.""" + + token: Optional[SecretStr] = None + + model_config = ConfigDict(frozen=True) + + +class SettingsLogging(BaseModel): + """Metadata settings.""" + + level: Literal["debug", "info", "warning", "error", "critical"] = "info" + external_packages: Literal["debug", "info", "warning", "error", "critical"] = ( + "warning" + ) + + model_config = ConfigDict(frozen=True) + + +class SettingsMisc(BaseModel): + """Other settings.""" + + application_prefix: str = "" + # list is not hashable, the cors_origins have to be provided as a string with + # comma separated entries, i.e. "value_1, value_2, ..." + cors_origins: str = "" + + model_config = ConfigDict(frozen=True) + + +class Settings(BaseSettings): + """All settings.""" + + tools: SettingsTools + knowledge_graph: SettingsKnowledgeGraph + agent: SettingsAgent = SettingsAgent() # has no required + db: SettingsDB = SettingsDB() # has no required + generative: SettingsGenerative = SettingsGenerative() # has no required + cohere: SettingsCohere = SettingsCohere() # has no required + logging: SettingsLogging = SettingsLogging() # has no required + keycloak: SettingsKeycloak = SettingsKeycloak() # has no required + misc: SettingsMisc = SettingsMisc() # has no required + + model_config = SettingsConfigDict( + env_file=".env", + env_prefix="NEUROAGENT_", + env_nested_delimiter="__", + frozen=True, + ) + + @model_validator(mode="after") + def check_consistency(self) -> "Settings": + """Check if consistent. + + ATTENTION: Do not put model validators into the child settings. The + model validator is run during instantiation. + + """ + # generative + if self.generative.llm_type == "openai": + if self.generative.openai.token is None: + raise ValueError("OpenAI token not provided") + if not self.keycloak.password and not self.keycloak.validate_token: + if not self.knowledge_graph.use_token: + raise ValueError("if no password is provided, please use token auth.") + if not self.knowledge_graph.token: + raise ValueError( + "No auth method provided for knowledge graph related queries." + " Please set either a password or use a fixed token." + ) + + return self + + +# Load the remaining variables into the environment +# Necessary for things like SSL_CERT_FILE +config = dotenv_values() +for k, v in config.items(): + if k.lower().startswith("neuroagent_"): + continue + if v is None: + continue + os.environ[k] = os.environ.get(k, v) # environment has precedence diff --git a/src/neuroagent/app/dependencies.py b/src/neuroagent/app/dependencies.py new file mode 100644 index 0000000..84791a4 --- /dev/null +++ b/src/neuroagent/app/dependencies.py @@ -0,0 +1,475 @@ +"""Dependencies.""" + +import logging +from functools import cache +from typing import Annotated, Any, AsyncIterator, Iterator + +from fastapi import Depends, HTTPException, Request +from fastapi.security import OAuth2PasswordBearer +from httpx import AsyncClient, HTTPStatusError +from keycloak import KeycloakOpenID +from langchain_openai import ChatOpenAI +from langgraph.checkpoint.base import BaseCheckpointSaver +from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver +from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver +from sqlalchemy import create_engine +from sqlalchemy.engine import Engine +from sqlalchemy.exc import SQLAlchemyError +from sqlalchemy.orm import Session +from starlette.status import HTTP_401_UNAUTHORIZED + +from neuroagent.agents import ( + BaseAgent, + SimpleAgent, + SimpleChatAgent, +) +from neuroagent.app.config import Settings +from neuroagent.cell_types import CellTypesMeta +from neuroagent.multi_agents import BaseMultiAgent, SupervisorMultiAgent +from neuroagent.tools import ( + ElectrophysFeatureTool, + GetMorphoTool, + GetTracesTool, + KGMorphoFeatureTool, + LiteratureSearchTool, + MorphologyFeatureTool, + ResolveBrainRegionTool, +) +from neuroagent.utils import RegionMeta, get_file_from_KG + +logger = logging.getLogger(__name__) + +auth = OAuth2PasswordBearer( + tokenUrl="/token", # Will be overriden + auto_error=False, +) + + +@cache +def get_settings() -> Settings: + """Load all parameters. + + Note that this function is cached and environment will be read just once. + """ + logger.info("Reading the environment and instantiating settings") + return Settings() + + +async def get_httpx_client(request: Request) -> AsyncIterator[AsyncClient]: + """Manage the httpx client for the request.""" + client = AsyncClient( + timeout=None, + verify=False, + headers={"x-request-id": request.headers["x-request-id"]}, + ) + yield client + + +def get_connection_string( + settings: Annotated[Settings, Depends(get_settings)], +) -> str | None: + """Get the db interacting class for chat agent.""" + if settings.db.prefix: + connection_string = settings.db.prefix + if settings.db.user and settings.db.password: + # Add authentication. + connection_string += ( + f"{settings.db.user}:{settings.db.password.get_secret_value()}@" + ) + if settings.db.host: + # Either in file DB or connect to remote host. + connection_string += settings.db.host + if settings.db.port: + # Add the port if remote host. + connection_string += f":{settings.db.port}" + if settings.db.name: + # Add database name if specified. + connection_string += f"/{settings.db.name}" + return connection_string + else: + return None + + +@cache +def get_engine( + settings: Annotated[Settings, Depends(get_settings)], + connection_string: Annotated[str | None, Depends(get_connection_string)], +) -> Engine | None: + """Get the SQL engine.""" + if connection_string: + engine_kwargs: dict[str, Any] = {"url": connection_string} + if "sqlite" in settings.db.prefix: # type: ignore + # https://fastapi.tiangolo.com/tutorial/sql-databases/#create-the-sqlalchemy-engine + engine_kwargs["connect_args"] = {"check_same_thread": False} + + engine = create_engine(**engine_kwargs) + else: + logger.warning("The SQL db_prefix needs to be set to use the SQL DB.") + return None + try: + engine.connect() + logger.info( + "Successfully connected to the SQL database" + f" {connection_string if not settings.db.password else connection_string.replace(settings.db.password.get_secret_value(), '*****')}." + ) + return engine + except SQLAlchemyError: + logger.warning( + "Failed connection to SQL database" + f" {connection_string if not settings.db.password else connection_string.replace(settings.db.password.get_secret_value(), '*****')}." + ) + return None + + +def get_session( + engine: Annotated[Engine | None, Depends(get_engine)], +) -> Iterator[Session]: + """Yield a session per request.""" + if not engine: + raise HTTPException( + status_code=500, + detail={ + "detail": "Couldn't connect to the SQL DB.", + }, + ) + with Session(engine) as session: + yield session + + +async def get_user_id( + token: Annotated[str, Depends(auth)], + settings: Annotated[Settings, Depends(get_settings)], + httpx_client: Annotated[AsyncClient, Depends(get_httpx_client)], +) -> str: + """Validate JWT token and returns user ID.""" + if settings.keycloak.validate_token and settings.keycloak.user_info_endpoint: + try: + response = await httpx_client.get( + settings.keycloak.user_info_endpoint, + headers={"Authorization": f"Bearer {token}"}, + ) + response.raise_for_status() + user_info = response.json() + return user_info["sub"] + except HTTPStatusError: + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, detail="Invalid token." + ) + else: + return "dev" + + +def get_kg_token( + settings: Annotated[Settings, Depends(get_settings)], + token: Annotated[str | None, Depends(auth)], +) -> str: + """Get a Knowledge graph token using Keycloak.""" + if token: + return token + else: + if not settings.knowledge_graph.use_token: + instance = KeycloakOpenID( + server_url=settings.keycloak.server_url, + realm_name=settings.keycloak.realm, + client_id=settings.keycloak.client_id, + ) + return instance.token( + username=settings.keycloak.username, + password=settings.keycloak.password.get_secret_value(), # type: ignore + )["access_token"] + else: + return settings.knowledge_graph.token.get_secret_value() # type: ignore + + +def get_literature_tool( + token: Annotated[str, Depends(get_kg_token)], + settings: Annotated[Settings, Depends(get_settings)], + httpx_client: Annotated[AsyncClient, Depends(get_httpx_client)], +) -> LiteratureSearchTool: + """Load literature tool.""" + tool = LiteratureSearchTool( + metadata={ + "url": settings.tools.literature.url, + "httpx_client": httpx_client, + "token": token, + "retriever_k": settings.tools.literature.retriever_k, + "reranker_k": settings.tools.literature.reranker_k, + "use_reranker": settings.tools.literature.use_reranker, + } + ) + return tool + + +def get_brain_region_resolver_tool( + token: Annotated[str, Depends(get_kg_token)], + httpx_client: Annotated[AsyncClient, Depends(get_httpx_client)], + settings: Annotated[Settings, Depends(get_settings)], +) -> ResolveBrainRegionTool: + """Load resolve brain region tool.""" + tool = ResolveBrainRegionTool( + metadata={ + "token": token, + "httpx_client": httpx_client, + "kg_sparql_url": settings.knowledge_graph.sparql_url, + "kg_class_view_url": settings.knowledge_graph.class_view_url, + } + ) + return tool + + +def get_morpho_tool( + settings: Annotated[Settings, Depends(get_settings)], + token: Annotated[str, Depends(get_kg_token)], + httpx_client: Annotated[AsyncClient, Depends(get_httpx_client)], +) -> GetMorphoTool: + """Load get morpho tool.""" + tool = GetMorphoTool( + metadata={ + "url": settings.knowledge_graph.url, + "token": token, + "httpx_client": httpx_client, + "search_size": settings.tools.morpho.search_size, + "brainregion_path": settings.knowledge_graph.br_saving_path, + "celltypes_path": settings.knowledge_graph.ct_saving_path, + } + ) + return tool + + +def get_kg_morpho_feature_tool( + settings: Annotated[Settings, Depends(get_settings)], + token: Annotated[str, Depends(get_kg_token)], + httpx_client: Annotated[AsyncClient, Depends(get_httpx_client)], +) -> KGMorphoFeatureTool: + """Load knowledge graph tool.""" + tool = KGMorphoFeatureTool( + metadata={ + "url": settings.knowledge_graph.url, + "token": token, + "httpx_client": httpx_client, + "search_size": settings.tools.kg_morpho_features.search_size, + "brainregion_path": settings.knowledge_graph.br_saving_path, + } + ) + return tool + + +def get_traces_tool( + settings: Annotated[Settings, Depends(get_settings)], + token: Annotated[str, Depends(get_kg_token)], + httpx_client: Annotated[AsyncClient, Depends(get_httpx_client)], +) -> GetTracesTool: + """Load knowledge graph tool.""" + tool = GetTracesTool( + metadata={ + "url": settings.knowledge_graph.url, + "token": token, + "httpx_client": httpx_client, + "search_size": settings.tools.trace.search_size, + "brainregion_path": settings.knowledge_graph.br_saving_path, + } + ) + return tool + + +def get_electrophys_feature_tool( + settings: Annotated[Settings, Depends(get_settings)], + token: Annotated[str, Depends(get_kg_token)], + httpx_client: Annotated[AsyncClient, Depends(get_httpx_client)], +) -> ElectrophysFeatureTool: + """Load morphology features tool.""" + tool = ElectrophysFeatureTool( + metadata={ + "url": settings.knowledge_graph.url, + "token": token, + "httpx_client": httpx_client, + } + ) + return tool + + +def get_morphology_feature_tool( + settings: Annotated[Settings, Depends(get_settings)], + token: Annotated[str, Depends(get_kg_token)], + httpx_client: Annotated[AsyncClient, Depends(get_httpx_client)], +) -> MorphologyFeatureTool: + """Load morphology features tool.""" + tool = MorphologyFeatureTool( + metadata={ + "url": settings.knowledge_graph.url, + "token": token, + "httpx_client": httpx_client, + } + ) + return tool + + +def get_language_model( + settings: Annotated[Settings, Depends(get_settings)], +) -> ChatOpenAI: + """Get the language model.""" + logger.info(f"OpenAI selected. Loading model {settings.generative.openai.model}.") + return ChatOpenAI( + model_name=settings.generative.openai.model, + temperature=settings.generative.openai.temperature, + openai_api_key=settings.generative.openai.token.get_secret_value(), # type: ignore + max_tokens=settings.generative.openai.max_tokens, + seed=78, + streaming=True, + ) + + +async def get_agent_memory( + connection_string: Annotated[str | None, Depends(get_connection_string)], +) -> AsyncIterator[BaseCheckpointSaver | None]: + """Get the agent checkpointer.""" + if connection_string: + if connection_string.startswith("sqlite"): + async with AsyncSqliteSaver.from_conn_string( + connection_string.split("///")[-1] + ) as memory: + await memory.setup() + yield memory + await memory.conn.close() + + elif connection_string.startswith("postgresql"): + async with AsyncPostgresSaver.from_conn_string(connection_string) as memory: + await memory.setup() + yield memory + await memory.conn.close() + else: + raise HTTPException( + status_code=500, + detail={ + "details": ( + f"Database of type {connection_string.split(':')[0]} is not" + " supported." + ) + }, + ) + else: + logger.warning("The SQL db_prefix needs to be set to use the SQL DB.") + yield None + + +def get_agent( + llm: Annotated[ChatOpenAI, Depends(get_language_model)], + literature_tool: Annotated[LiteratureSearchTool, Depends(get_literature_tool)], + br_resolver_tool: Annotated[ + ResolveBrainRegionTool, Depends(get_brain_region_resolver_tool) + ], + morpho_tool: Annotated[GetMorphoTool, Depends(get_morpho_tool)], + morphology_feature_tool: Annotated[ + MorphologyFeatureTool, Depends(get_morphology_feature_tool) + ], + kg_morpho_feature_tool: Annotated[ + KGMorphoFeatureTool, Depends(get_kg_morpho_feature_tool) + ], + electrophys_feature_tool: Annotated[ + ElectrophysFeatureTool, Depends(get_electrophys_feature_tool) + ], + traces_tool: Annotated[GetTracesTool, Depends(get_traces_tool)], +) -> BaseAgent | BaseMultiAgent: + """Get the generative question answering service.""" + tools = [ + literature_tool, + br_resolver_tool, + morpho_tool, + morphology_feature_tool, + kg_morpho_feature_tool, + electrophys_feature_tool, + traces_tool, + ] + logger.info("Load simple agent") + return SimpleAgent(llm=llm, tools=tools) # type: ignore + + +def get_chat_agent( + llm: Annotated[ChatOpenAI, Depends(get_language_model)], + memory: Annotated[BaseCheckpointSaver, Depends(get_agent_memory)], + literature_tool: Annotated[LiteratureSearchTool, Depends(get_literature_tool)], + br_resolver_tool: Annotated[ + ResolveBrainRegionTool, Depends(get_brain_region_resolver_tool) + ], + morpho_tool: Annotated[GetMorphoTool, Depends(get_morpho_tool)], + morphology_feature_tool: Annotated[ + MorphologyFeatureTool, Depends(get_morphology_feature_tool) + ], + kg_morpho_feature_tool: Annotated[ + KGMorphoFeatureTool, Depends(get_kg_morpho_feature_tool) + ], + electrophys_feature_tool: Annotated[ + ElectrophysFeatureTool, Depends(get_electrophys_feature_tool) + ], + traces_tool: Annotated[GetTracesTool, Depends(get_traces_tool)], + settings: Annotated[Settings, Depends(get_settings)], +) -> BaseAgent: + """Get the generative question answering service.""" + if settings.agent.chat == "multi": + logger.info("Load multi-agent chat") + tools_list = [ + ("literature", [literature_tool]), + ( + "morphologies", + [ + br_resolver_tool, + morpho_tool, + morphology_feature_tool, + kg_morpho_feature_tool, + ], + ), + ("traces", [br_resolver_tool, electrophys_feature_tool, traces_tool]), + ] + return SupervisorMultiAgent(llm=llm, agents=tools_list) # type: ignore + else: + logger.info("Load simple chat") + tools = [ + literature_tool, + br_resolver_tool, + morpho_tool, + morphology_feature_tool, + kg_morpho_feature_tool, + electrophys_feature_tool, + traces_tool, + ] + return SimpleChatAgent(llm=llm, tools=tools, memory=memory) # type: ignore + + +async def get_update_kg_hierarchy( + token: Annotated[str, Depends(get_kg_token)], + httpx_client: Annotated[AsyncClient, Depends(get_httpx_client)], + settings: Annotated[Settings, Depends(get_settings)], + file_name: str = "brainregion.json", +) -> None: + """Query file from KG and update the local hierarchy file.""" + file_url = f"<{settings.knowledge_graph.hierarchy_url}/brainregion>" + KG_hierarchy = await get_file_from_KG( + file_url=file_url, + file_name=file_name, + view_url=settings.knowledge_graph.sparql_url, + token=token, + httpx_client=httpx_client, + ) + RegionMeta_temp = RegionMeta.from_KG_dict(KG_hierarchy) + RegionMeta_temp.save_config(settings.knowledge_graph.br_saving_path) + logger.info("Knowledge Graph Brain Regions Hierarchy file updated.") + + +async def get_cell_types_kg_hierarchy( + token: Annotated[str, Depends(get_kg_token)], + httpx_client: Annotated[AsyncClient, Depends(get_httpx_client)], + settings: Annotated[Settings, Depends(get_settings)], + file_name: str = "celltypes.json", +) -> None: + """Query file from KG and update the local hierarchy file.""" + file_url = f"<{settings.knowledge_graph.hierarchy_url}/celltypes>" + hierarchy = await get_file_from_KG( + file_url=file_url, + file_name=file_name, + view_url=settings.knowledge_graph.sparql_url, + token=token, + httpx_client=httpx_client, + ) + celltypesmeta = CellTypesMeta.from_dict(hierarchy) + celltypesmeta.save_config(settings.knowledge_graph.ct_saving_path) + logger.info("Knowledge Graph Cell Types Hierarchy file updated.") diff --git a/src/neuroagent/app/main.py b/src/neuroagent/app/main.py new file mode 100644 index 0000000..4e331ed --- /dev/null +++ b/src/neuroagent/app/main.py @@ -0,0 +1,174 @@ +"""FastAPI for the Agent.""" + +import logging +from contextlib import asynccontextmanager +from logging.config import dictConfig +from typing import Annotated, Any, AsyncContextManager +from uuid import uuid4 + +from asgi_correlation_id import CorrelationIdMiddleware +from fastapi import Depends, FastAPI +from fastapi.middleware.cors import CORSMiddleware +from httpx import AsyncClient +from starlette.middleware.base import BaseHTTPMiddleware + +from neuroagent import __version__ +from neuroagent.app.config import Settings +from neuroagent.app.dependencies import ( + auth, + get_agent_memory, + get_cell_types_kg_hierarchy, + get_connection_string, + get_engine, + get_kg_token, + get_settings, + get_update_kg_hierarchy, +) +from neuroagent.app.middleware import strip_path_prefix +from neuroagent.app.routers import qa +from neuroagent.app.routers.database import threads, tools +from neuroagent.app.routers.database.schemas import Base, Threads # noqa: F401 + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "filters": { + "correlation_id": { + "()": "asgi_correlation_id.CorrelationIdFilter", + "uuid_length": 32, + "default_value": "-", + }, + }, + "formatters": { + "request_id": { + "class": "logging.Formatter", + "format": ( + "[%(levelname)s] %(asctime)s (%(correlation_id)s) %(name)s %(message)s" + ), + }, + }, + "handlers": { + "request_id": { + "class": "logging.StreamHandler", + "filters": ["correlation_id"], + "formatter": "request_id", + }, + }, + "loggers": { + "": { + "handlers": ["request_id"], + "level": "INFO", + "propagate": True, + }, + }, +} +dictConfig(LOGGING) + +logger = logging.getLogger(__name__) + + +@asynccontextmanager # type: ignore +async def lifespan(fastapi_app: FastAPI) -> AsyncContextManager[None]: # type: ignore + """Read environment (settings of the application).""" + # hacky but works: https://github.com/tiangolo/fastapi/issues/425 + app_settings = fastapi_app.dependency_overrides.get(get_settings, get_settings)() + if app_settings.keycloak.validate_token: + auth.model.flows = app_settings.keycloak.flows # type: ignore + + engine = fastapi_app.dependency_overrides.get(get_engine, get_engine)( + app_settings, get_connection_string(app_settings) + ) + # This creates the checkpoints and writes tables. + await anext( + fastapi_app.dependency_overrides.get(get_agent_memory, get_agent_memory)( + get_connection_string(app_settings) + ) + ) + if engine: + Base.metadata.create_all(bind=engine) + + prefix = app_settings.misc.application_prefix + fastapi_app.openapi_url = f"{prefix}/openapi.json" + fastapi_app.servers = [{"url": prefix}] + # Do not rely on the middleware order in the list "fastapi_app.user_middleware" since this is subject to changes. + try: + cors_middleware = filter( + lambda x: x.__dict__["cls"] == CORSMiddleware, fastapi_app.user_middleware + ).__next__() + cors_middleware.kwargs["allow_origins"] = ( + app_settings.misc.cors_origins.replace(" ", "").split(",") + ) + except StopIteration: + pass + + logging.getLogger().setLevel(app_settings.logging.external_packages.upper()) + logging.getLogger("neuroagent").setLevel(app_settings.logging.level.upper()) + logging.getLogger("bluepyefe").setLevel("CRITICAL") + + if app_settings.knowledge_graph.download_hierarchy: + # update KG hierarchy file if requested + await get_update_kg_hierarchy( + token=get_kg_token(app_settings, token=None), + httpx_client=AsyncClient(), + settings=app_settings, + ) + await get_cell_types_kg_hierarchy( + token=get_kg_token(app_settings, token=None), + httpx_client=AsyncClient(), + settings=app_settings, + ) + + yield + + +app = FastAPI( + title="Agents", + summary=( + "Use an AI agent to answer queries based on the knowledge graph, literature" + " search and neuroM." + ), + version=__version__, + swagger_ui_parameters={"tryItOutEnabled": True}, + lifespan=lifespan, +) + +app.add_middleware( + CORSMiddleware, + allow_origin_regex=r"^http:\/\/localhost:(\d+)\/?.*$", + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) +app.add_middleware( + CorrelationIdMiddleware, + header_name="X-Request-ID", + update_request_header=True, + generator=lambda: uuid4().hex, + transformer=lambda a: a, +) +app.add_middleware(BaseHTTPMiddleware, dispatch=strip_path_prefix) + +app.include_router(qa.router) +app.include_router(threads.router) +app.include_router(tools.router) + + +@app.get("/healthz") +def healthz() -> str: + """Check the health of the API.""" + return "200" + + +@app.get("/") +def readyz() -> dict[str, str]: + """Check if the API is ready to accept traffic.""" + return {"status": "ok"} + + +@app.get("/settings") +def settings(settings: Annotated[Settings, Depends(get_settings)]) -> Any: + """Show complete settings of the backend. + + Did not add return model since it pollutes the Swagger UI. + """ + return settings diff --git a/src/neuroagent/app/middleware.py b/src/neuroagent/app/middleware.py new file mode 100644 index 0000000..e2c9a81 --- /dev/null +++ b/src/neuroagent/app/middleware.py @@ -0,0 +1,39 @@ +"""Middleware.""" + +from typing import Any, Callable + +from fastapi import Request, Response + +from neuroagent.app.dependencies import get_settings + + +async def strip_path_prefix( + request: Request, call_next: Callable[[Any], Any] +) -> Response: + """Optionally strip a prefix from a request path. + + Parameters + ---------- + request + Request sent by the user. + call_next + Function executed to get the output of the endpoint. + + Returns + ------- + response: Response of the request after potentially stripping prefix from path and applying other middlewares + """ + if request.base_url in ( + "http://testserver/", + "http://test/", + ) and "healthz" not in str(request.url): + settings = request.app.dependency_overrides[get_settings]() + else: + settings = get_settings() + prefix = settings.misc.application_prefix + if prefix is not None and len(prefix) > 0 and request.url.path.startswith(prefix): + new_path = request.url.path[len(prefix) :] + scope = request.scope + scope["path"] = new_path + request = Request(scope, request.receive) + return await call_next(request) diff --git a/src/neuroagent/app/routers/__init__.py b/src/neuroagent/app/routers/__init__.py new file mode 100644 index 0000000..2802fc7 --- /dev/null +++ b/src/neuroagent/app/routers/__init__.py @@ -0,0 +1 @@ +"""Application routers.""" diff --git a/src/neuroagent/app/routers/database/__init__.py b/src/neuroagent/app/routers/database/__init__.py new file mode 100644 index 0000000..a81230d --- /dev/null +++ b/src/neuroagent/app/routers/database/__init__.py @@ -0,0 +1 @@ +"""Database utilities.""" diff --git a/src/neuroagent/app/routers/database/schemas.py b/src/neuroagent/app/routers/database/schemas.py new file mode 100644 index 0000000..b53dcca --- /dev/null +++ b/src/neuroagent/app/routers/database/schemas.py @@ -0,0 +1,58 @@ +"""Schemas for the chatbot.""" + +import datetime +import uuid +from typing import Any, Literal, Optional + +from pydantic import BaseModel +from sqlalchemy import Column, DateTime, String +from sqlalchemy.orm import declarative_base + +Base = declarative_base() + + +def uuid_to_str() -> str: + """Turn a uuid into a string.""" + return uuid.uuid4().hex + + +class Threads(Base): + """SQL table for the chatbot's user.""" + + __tablename__ = "Threads" + # langgraph's tables work with strings, so we must too + thread_id = Column(String, primary_key=True, default=uuid_to_str) + user_sub = Column(String, default=None, primary_key=True) + title = Column(String, default="title") + timestamp = Column(DateTime, default=datetime.datetime.now) + + +class ThreadsUpdate(BaseModel): + """Class to update the conversation's title in the db.""" + + title: Optional[str] = None + + +class ThreadsRead(BaseModel): + """Data class to read chatbot conversations in the db.""" + + thread_id: str + user_sub: str + title: str = "title" + timestamp: datetime.datetime = datetime.datetime.now() + + +class GetThreadsOutput(BaseModel): + """Output of the conversation listing crud.""" + + message_id: str + entity: Literal["Human", "AI"] + message: str + + +class ToolCallSchema(BaseModel): + """Tool call crud's output.""" + + call_id: str + name: str + arguments: dict[str, Any] diff --git a/src/neuroagent/app/routers/database/sql.py b/src/neuroagent/app/routers/database/sql.py new file mode 100644 index 0000000..7d27a8f --- /dev/null +++ b/src/neuroagent/app/routers/database/sql.py @@ -0,0 +1,44 @@ +"""SQL related functions.""" + +from typing import Annotated + +from fastapi import Depends, HTTPException +from fastapi.security import HTTPBasic +from sqlalchemy.orm import Session + +from neuroagent.app.dependencies import get_session, get_user_id +from neuroagent.app.routers.database.schemas import Threads + +security = HTTPBasic() + + +def get_object( + session: Annotated[Session, Depends(get_session)], + thread_id: str, + user_id: Annotated[str, Depends(get_user_id)], +) -> Threads: + """Get an SQL object. Also useful to correlate user_id to thread_id. + + Parameters + ---------- + session + Session object connected to the SQL instance. + thread_id + ID of the thread, provided by the user. + user_id + ID of the user. + + Returns + ------- + object + Relevant row of the relevant table in the SQL DB. + """ + sql_object = session.get(Threads, (thread_id, user_id)) + if not sql_object: + raise HTTPException( + status_code=404, + detail={ + "detail": "Thread not found.", + }, + ) + return sql_object diff --git a/src/neuroagent/app/routers/database/threads.py b/src/neuroagent/app/routers/database/threads.py new file mode 100644 index 0000000..3e6a4dc --- /dev/null +++ b/src/neuroagent/app/routers/database/threads.py @@ -0,0 +1,218 @@ +"""Conversation related CRUD operations.""" + +import logging +from typing import Annotated + +from fastapi import APIRouter, Depends, HTTPException +from langchain_core.messages import AIMessage, HumanMessage +from langchain_core.runnables import RunnableConfig +from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver +from sqlalchemy import MetaData, select +from sqlalchemy.engine import Engine +from sqlalchemy.orm import Session + +from neuroagent.app.dependencies import ( + get_agent_memory, + get_engine, + get_session, + get_user_id, +) +from neuroagent.app.routers.database.schemas import ( + GetThreadsOutput, + Threads, + ThreadsRead, + ThreadsUpdate, +) +from neuroagent.app.routers.database.sql import get_object + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/threads", tags=["Threads' CRUD"]) + + +@router.post("/") +def create_thread( + session: Annotated[Session, Depends(get_session)], + user_id: Annotated[str, Depends(get_user_id)], + title: str = "title", +) -> ThreadsRead: + """Create thread. + \f + + Parameters + ---------- + session + SQL session to communicate with the db. + user_id + ID of the current user. + title + Title of the thread to create. + + Returns + ------- + thread_dict: {'thread_id': 'thread_name'} + Conversation created. + """ # noqa: D301, D400, D205 + new_thread = Threads(user_sub=user_id, title=title) + session.add(new_thread) + session.commit() + session.refresh(new_thread) + return ThreadsRead(**new_thread.__dict__) + + +@router.get("/") +def get_threads( + session: Annotated[Session, Depends(get_session)], + user_id: Annotated[str, Depends(get_user_id)], +) -> list[ThreadsRead]: + """Get threads for a user. + \f + + Parameters + ---------- + session + SQL session to communicate with the db. + user_id + ID of the current user. + + Returns + ------- + list[ThreadsRead] + List the threads from the given user id. + """ # noqa: D205, D301, D400 + query = select(Threads).where(Threads.user_sub == user_id) + threads = session.execute(query).all() + return [ThreadsRead(**thread[0].__dict__) for thread in threads] + + +@router.get("/{thread_id}") +async def get_thread( + _: Annotated[Threads, Depends(get_object)], + memory: Annotated[AsyncSqliteSaver | None, Depends(get_agent_memory)], + thread_id: str, +) -> list[GetThreadsOutput]: + """Get thread. + \f + + Parameters + ---------- + _ + Thread object returned by SQLAlchemy + memory + Langgraph's checkpointer's instance. + thread_id + ID of the thread. + + Returns + ------- + messages + Conversation with messages. + + Raises + ------ + HTTPException + If the thread is not from the current user. + """ # noqa: D301, D205, D400 + if memory is None: + raise HTTPException( + status_code=404, + detail={ + "detail": "Couldn't connect to the SQL DB.", + }, + ) + config = RunnableConfig({"configurable": {"thread_id": thread_id}}) + messages = await memory.aget(config) + if not messages: + return [] + + output: list[GetThreadsOutput] = [] + # Reconstruct the conversation. Also output message_id for other endpoints + for message in messages["channel_values"]["messages"]: + if isinstance(message, HumanMessage): + output.append( + GetThreadsOutput( + message_id=message.id, + entity="Human", + message=message.content, + ) + ) + if isinstance(message, AIMessage) and message.content: + output.append( + GetThreadsOutput( + message_id=message.id, + entity="AI", + message=message.content, + ) + ) + return output + + +@router.patch("/{thread_id}") +def update_thread_title( + thread: Annotated[Threads, Depends(get_object)], + session: Annotated[Session, Depends(get_session)], + thread_update: ThreadsUpdate, +) -> ThreadsRead: + """Update thread. + \f + + Parameters + ---------- + thread + Thread object returned by SQLAlchemy + session + SQL session + thread_update + Pydantic class containing the required updates + + Returns + ------- + thread_return + Updated thread instance + """ # noqa: D205, D301, D400 + thread_data = thread_update.model_dump(exclude_unset=True) + for key, value in thread_data.items(): + setattr(thread, key, value) + session.add(thread) + session.commit() + session.refresh(thread) + thread_return = ThreadsRead(**thread.__dict__) # For mypy. + return thread_return + + +@router.delete("/{thread_id}") +def delete_thread( + _: Annotated[Threads, Depends(get_object)], + session: Annotated[Session, Depends(get_session)], + engine: Annotated[Engine, Depends(get_engine)], + thread_id: str, +) -> dict[str, str]: + """Delete the specified thread. + \f + + Parameters + ---------- + _ + Thread object returned by SQLAlchemy + session + SQL session + engine + SQL engine + thread_id + ID of the relevant thread + + Returns + ------- + Acknowledgement of the deletion + """ # noqa: D205, D301, D400 + metadata = MetaData() + metadata.reflect(engine) + for table in metadata.tables.values(): + if "thread_id" not in table.c.keys(): + continue + # Delete from the checkpoint table + query = table.delete().where(table.c.thread_id == thread_id) + session.execute(query) + + session.commit() + return {"Acknowledged": "true"} diff --git a/src/neuroagent/app/routers/database/tools.py b/src/neuroagent/app/routers/database/tools.py new file mode 100644 index 0000000..9248c66 --- /dev/null +++ b/src/neuroagent/app/routers/database/tools.py @@ -0,0 +1,200 @@ +"""Conversation related CRUD operations.""" + +import json +import logging +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, HTTPException +from langchain_core.messages import AIMessage, HumanMessage, ToolMessage +from langchain_core.runnables import RunnableConfig +from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver + +from neuroagent.app.dependencies import get_agent_memory +from neuroagent.app.routers.database.schemas import Threads, ToolCallSchema +from neuroagent.app.routers.database.sql import get_object + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/tools", tags=["Tool's CRUD"]) + + +@router.get("/{thread_id}/{message_id}") +async def get_tool_calls( + _: Annotated[Threads, Depends(get_object)], + memory: Annotated[AsyncSqliteSaver | None, Depends(get_agent_memory)], + thread_id: str, + message_id: str, +) -> list[ToolCallSchema]: + """Get tool calls of a specific message. + \f + + Parameters + ---------- + _ + Thread object returned by SQLAlchemy + memory + Langgraph's checkpointer's instance. + thread_id + ID of the thread. + message_id + ID of the message. + + Returns + ------- + tool_calls + tools called to generate a message. + + Raises + ------ + HTTPException + If the thread is not from the current user. + """ # noqa: D301, D400, D205 + if memory is None: + raise HTTPException( + status_code=404, + detail={ + "detail": "Couldn't connect to the SQL DB.", + }, + ) + config = RunnableConfig({"configurable": {"thread_id": thread_id}}) + messages = await memory.aget(config) + if messages is None: + raise HTTPException( + status_code=404, + detail={ + "detail": "Message not found.", + }, + ) + message_list = messages["channel_values"]["messages"] + + # Get the specified message index + try: + relevant_message = next( + (i for i, message in enumerate(message_list) if message.id == message_id) + ) + except StopIteration: + raise HTTPException( + status_code=404, + detail={ + "detail": "Message not found.", + }, + ) + + if isinstance(message_list[relevant_message], HumanMessage): + return [] + + # Get the nearest previous message that has content + previous_content_message = next( + ( + i + for i, message in reversed(list(enumerate(message_list[:relevant_message]))) + if message.content and not isinstance(message, ToolMessage) + ) + ) + + # From sub list, extract tool calls + tool_calls: list[ToolCallSchema] = [] + for message in message_list[previous_content_message + 1 : relevant_message]: + if isinstance(message, AIMessage): + tool_calls.extend( + [ + ToolCallSchema( + call_id=tool["id"], name=tool["name"], arguments=tool["args"] + ) + for tool in message.tool_calls + ] + ) + + return tool_calls + + +@router.get("/output/{thread_id}/{tool_call_id}") +async def get_tool_returns( + _: Annotated[Threads, Depends(get_object)], + memory: Annotated[AsyncSqliteSaver | None, Depends(get_agent_memory)], + thread_id: str, + tool_call_id: str, +) -> list[dict[str, Any] | str]: + """Given a tool id, return its output. + \f + + Parameters + ---------- + _ + Thread object returned by SQLAlchemy + memory + Langgraph's checkpointer's instance. + thread_id + ID of the thread. + tool_call_id + ID of the tool call. + + Returns + ------- + tool_returns + Output of the selected tool call. + + Raises + ------ + HTTPException + If the thread is not from the current user. + """ # noqa: D301, D205, D400 + if memory is None: + raise HTTPException( + status_code=404, + detail={ + "detail": "Couldn't connect to the SQL DB.", + }, + ) + config = RunnableConfig({"configurable": {"thread_id": thread_id}}) + messages = await memory.aget(config) + if messages is None: + raise HTTPException( + status_code=404, + detail={ + "detail": "Message not found.", + }, + ) + message_list = messages["channel_values"]["messages"] + + try: + tool_output_str = next( + ( + message.content + for message in message_list + if isinstance(message, ToolMessage) + and message.tool_call_id == tool_call_id + ) + ) + except StopIteration: + raise HTTPException( + status_code=404, + detail={ + "detail": "Tool call not found.", + }, + ) + if isinstance(tool_output_str, str): + try: + tool_output = json.loads(tool_output_str) + except json.JSONDecodeError: + raise HTTPException( + status_code=500, + detail={ + "detail": "There was an error decoding the tool output.", + }, + ) + + if isinstance(tool_output, list): + return tool_output + else: + return [tool_output] + else: + raise HTTPException( + status_code=500, + detail={ + "detail": ( + "There was an error retrieving the content of the tool output." + " Please forward your request to the ML team." + ), + }, + ) diff --git a/src/neuroagent/app/routers/qa.py b/src/neuroagent/app/routers/qa.py new file mode 100644 index 0000000..447bb28 --- /dev/null +++ b/src/neuroagent/app/routers/qa.py @@ -0,0 +1,64 @@ +"""Endpoints for agent's question answering pipeline.""" + +import logging +from typing import Annotated + +from fastapi import APIRouter, Depends +from fastapi.responses import StreamingResponse + +from neuroagent.agents import ( + AgentOutput, + BaseAgent, + SimpleChatAgent, +) +from neuroagent.app.dependencies import ( + get_agent, + get_chat_agent, + get_user_id, +) +from neuroagent.app.routers.database.schemas import Threads +from neuroagent.app.routers.database.sql import get_object +from neuroagent.app.schemas import AgentRequest + +router = APIRouter( + prefix="/qa", tags=["Run the agent"], dependencies=[Depends(get_user_id)] +) + +logger = logging.getLogger(__name__) + + +@router.post("/run", response_model=AgentOutput) +async def run_agent( + request: AgentRequest, + agent: Annotated[BaseAgent, Depends(get_agent)], +) -> AgentOutput: + """Run agent.""" + logger.info("Running agent query.") + logger.info(f"User's query: {request.inputs}") + return await agent.arun(request.inputs) + + +@router.post("/chat/{thread_id}", response_model=AgentOutput) +async def run_chat_agent( + request: AgentRequest, + _: Annotated[Threads, Depends(get_object)], + agent: Annotated[SimpleChatAgent, Depends(get_chat_agent)], + thread_id: str, +) -> AgentOutput: + """Run chat agent.""" + logger.info("Running agent query.") + logger.info(f"User's query: {request.inputs}") + return await agent.arun(query=request.inputs, thread_id=thread_id) + + +@router.post("/chat_streamed/{thread_id}") +async def run_streamed_chat_agent( + request: AgentRequest, + _: Annotated[Threads, Depends(get_object)], + agent: Annotated[BaseAgent, Depends(get_chat_agent)], + thread_id: str, +) -> StreamingResponse: + """Run agent in streaming mode.""" + logger.info("Running agent query.") + logger.info(f"User's query: {request.inputs}") + return StreamingResponse(agent.astream(query=request.inputs, thread_id=thread_id)) # type: ignore diff --git a/src/neuroagent/app/schemas.py b/src/neuroagent/app/schemas.py new file mode 100644 index 0000000..344a957 --- /dev/null +++ b/src/neuroagent/app/schemas.py @@ -0,0 +1,12 @@ +"""Schemas.""" + +from typing import Any + +from pydantic import BaseModel + + +class AgentRequest(BaseModel): + """Class for agent request.""" + + inputs: str + parameters: dict[str, Any] diff --git a/src/neuroagent/cell_types.py b/src/neuroagent/cell_types.py new file mode 100644 index 0000000..df84d38 --- /dev/null +++ b/src/neuroagent/cell_types.py @@ -0,0 +1,192 @@ +"""Cell types metadata.""" + +import json +import logging +from collections import defaultdict +from pathlib import Path +from typing import Any + +logger = logging.getLogger(__file__) + + +class CellTypesMeta: + """Class holding the hierarchical cell types metadata. + + Typically, such information would be parsed from a `celltypes.json` + file. + """ + + def __init__(self) -> None: + self.name_: dict[str, str] = {} + self.descendants_ids: dict[str, set[str]] = {} + + def descendants(self, ids: str | set[str]) -> set[str]: + """Find all descendants of given cell type. + + The result is inclusive, i.e. the input region IDs will be + included in the result. + + Parameters + ---------- + ids : set or iterable of set + A region ID or a collection of region IDs to collect + descendants for. + + Returns + ------- + set + All descendant region IDs of the given regions, including the input cell type themselves. + """ + if isinstance(ids, str): + unique_ids = {ids} + else: + unique_ids = set(ids) + + descendants = unique_ids.copy() + for id_ in unique_ids: + try: + descendants.update(self.descendants_ids[id_]) + except KeyError: + logger.info(f"{id_} does not have any child in the hierarchy.") + return descendants + + def save_config(self, json_file_path: str | Path) -> None: + """Save the actual configuration in a json file. + + Parameters + ---------- + json_file_path + Path where to save the json file + """ + descendants = {} + for k, v in self.descendants_ids.items(): + descendants[k] = list(v) + + to_save = { + "names": self.name_, + "descendants_ids": descendants, + } + with open(json_file_path, "w") as fs: + fs.write(json.dumps(to_save)) + + @classmethod + def load_config(cls, json_file_path: str | Path) -> "CellTypesMeta": + """Load a configuration in a json file and return a 'CellTypesMeta' instance. + + Parameters + ---------- + json_file_path + Path to the json file containing the brain region hierarchy + + Returns + ------- + RegionMeta class with pre-loaded hierarchy + """ + with open(json_file_path, "r") as fs: + to_load = json.load(fs) + + descendants_ids = {} + for k, v in to_load["descendants_ids"].items(): + descendants_ids[k] = set(v) + + self = cls() + + self.name_ = to_load["names"] + self.descendants_ids = descendants_ids + return self + + @classmethod + def from_dict(cls, hierarchy: dict[str, Any]) -> "CellTypesMeta": + """Load the structure graph from a dict and create a Class instance. + + Parameters + ---------- + hierarchy : dict[str, Any] + Hierarchy in dictionary format. + + Returns + ------- + RegionMeta + The initialized instance of this class. + """ + names = {} + initial_json: dict[str, set[str]] = defaultdict(set) + for i in range(len(hierarchy["defines"])): + cell_type = hierarchy["defines"][i] + names[cell_type["@id"]] = ( + cell_type["label"] if "label" in cell_type else None + ) + if "subClassOf" not in cell_type.keys(): + initial_json[cell_type["@id"]] = set() + continue + parents = cell_type["subClassOf"] + for parent in parents: + initial_json[parent].add(hierarchy["defines"][i]["@id"]) + + current_json = initial_json.copy() + + for i in range(10): # maximum number of attempts + new_json = {} + for k, v in current_json.items(): + new_set = v.copy() + for child in v: + if child in current_json.keys(): + new_set.update(current_json[child]) + new_json[k] = new_set + + if new_json == current_json: + break + + if i == 9: + raise ValueError("Did not manage to create a CellTypesMeta object.") + + current_json = new_json.copy() + + self = cls() + + self.name_ = names + self.descendants_ids = new_json + + return self + + @classmethod + def from_json(cls, json_path: Path | str) -> "CellTypesMeta": + """Load the structure graph from a JSON file and create a Class instance. + + Parameters + ---------- + json_path : str or pathlib.Path + + Returns + ------- + RegionMeta + The initialized instance of this class. + """ + with open(json_path) as fh: + hierarchy = json.load(fh) + + return cls.from_dict(hierarchy) + + +def get_celltypes_descendants(cell_type_id: str, json_path: str | Path) -> set[str]: + """Get all descendant of a brain region id. + + Parameters + ---------- + cell_type_id + Cell type ID for which to find the descendants list. + json_path + Path to the json file containing the Cell Types hierarchy. + + Returns + ------- + Set of descendants of a cell type + """ + try: + region_meta = CellTypesMeta.load_config(json_path) + hierarchy_ids = region_meta.descendants(cell_type_id) + except IOError: + logger.warning(f"The file {json_path} doesn't exist.") + hierarchy_ids = {cell_type_id} + + return hierarchy_ids diff --git a/src/neuroagent/multi_agents/__init__.py b/src/neuroagent/multi_agents/__init__.py new file mode 100644 index 0000000..ace9a8c --- /dev/null +++ b/src/neuroagent/multi_agents/__init__.py @@ -0,0 +1,9 @@ +"""Multi-agents.""" + +from neuroagent.multi_agents.base_multi_agent import BaseMultiAgent +from neuroagent.multi_agents.supervisor_multi_agent import SupervisorMultiAgent + +__all__ = [ + "BaseMultiAgent", + "SupervisorMultiAgent", +] diff --git a/src/neuroagent/multi_agents/base_multi_agent.py b/src/neuroagent/multi_agents/base_multi_agent.py new file mode 100644 index 0000000..8a3f62a --- /dev/null +++ b/src/neuroagent/multi_agents/base_multi_agent.py @@ -0,0 +1,37 @@ +"""Base multi-agent.""" + +from abc import ABC, abstractmethod +from typing import Any, AsyncIterator + +from langchain.chat_models.base import BaseChatModel +from pydantic import BaseModel, ConfigDict + +from neuroagent.agents import AgentOutput +from neuroagent.tools.base_tool import BasicTool + + +class BaseMultiAgent(BaseModel, ABC): + """Base class for multi agents.""" + + llm: BaseChatModel + main_agent: Any + agents: list[tuple[str, list[BasicTool]]] + + model_config = ConfigDict(arbitrary_types_allowed=True) + + @abstractmethod + def run(self, *args: Any, **kwargs: Any) -> AgentOutput: + """Run method of the service.""" + + @abstractmethod + async def arun(self, *args: Any, **kwargs: Any) -> AgentOutput: + """Arun method of the service.""" + + @abstractmethod + async def astream(self, *args: Any, **kwargs: Any) -> AsyncIterator[str]: + """Astream method of the service.""" + + @staticmethod + @abstractmethod + def _process_output(*args: Any, **kwargs: Any) -> AgentOutput: + """Format the output.""" diff --git a/src/neuroagent/multi_agents/supervisor_multi_agent.py b/src/neuroagent/multi_agents/supervisor_multi_agent.py new file mode 100644 index 0000000..15df2e3 --- /dev/null +++ b/src/neuroagent/multi_agents/supervisor_multi_agent.py @@ -0,0 +1,225 @@ +"""Supervisor multi-agent.""" + +import functools +import logging +import operator +from typing import Annotated, Any, AsyncIterator, Hashable, Sequence, TypedDict + +from langchain_core.messages import AIMessage, BaseMessage, HumanMessage +from langchain_core.output_parsers.openai_functions import JsonOutputFunctionsParser +from langchain_core.prompts import ( + ChatPromptTemplate, + MessagesPlaceholder, + PromptTemplate, +) +from langchain_core.runnables import RunnableConfig +from langgraph.graph import END, START, StateGraph +from langgraph.graph.graph import CompiledGraph +from langgraph.prebuilt import create_react_agent +from pydantic import ConfigDict, model_validator + +from neuroagent.agents import AgentOutput, AgentStep +from neuroagent.multi_agents.base_multi_agent import BaseMultiAgent + +logger = logging.getLogger(__file__) + + +class AgentState(TypedDict): + """Base class for agent state.""" + + messages: Annotated[Sequence[BaseMessage], operator.add] + next: str # noqa: A003 + + +class SupervisorMultiAgent(BaseMultiAgent): + """Base class for multi agents.""" + + summarizer: Any + + model_config = ConfigDict(arbitrary_types_allowed=True) + + @model_validator(mode="before") + @classmethod + def create_main_agent(cls, data: dict[str, Any]) -> dict[str, Any]: + """Instantiate the clients upon class creation.""" + logger.info("Creating main agent, supervisor and all the agents with tools.") + system_prompt = ( + "You are a supervisor tasked with managing a conversation between the" + " following workers: {members}. Given the following user request," + " respond with the worker to act next. Each worker will perform a" + " task and respond with their results and status. When finished," + " respond with FINISH." + ) + agents_list = [elem[0] for elem in data["agents"]] + logger.info(f"List of agents name: {agents_list}") + + options = ["FINISH"] + agents_list + function_def = { + "name": "route", + "description": "Select the next role.", + "parameters": { + "title": "routeSchema", + "type": "object", + "properties": { + "next": { + "title": "Next", + "anyOf": [ + {"enum": options}, + ], + } + }, + "required": ["next"], + }, + } + prompt = ChatPromptTemplate.from_messages( + [ + ("system", system_prompt), + MessagesPlaceholder(variable_name="messages"), + ( + "system", + ( + "Given the conversation above, who should act next?" + " Or should we FINISH? Select one of: {options}" + ), + ), + ] + ).partial(options=str(options), members=", ".join(agents_list)) + data["main_agent"] = ( + prompt + | data["llm"].bind_functions( + functions=[function_def], function_call="route" + ) + | JsonOutputFunctionsParser() + ) + data["summarizer"] = ( + PromptTemplate.from_template( + """You are an helpful assistant. Here is the question of the user: {question}. + And here are the results of the different tools used to answer: {responses}. + You must always specify in your answers from which brain regions the information is extracted. + Do no blindly repeat the brain region requested by the user, use the output of the tools instead. + Keep all information that can be useful for the user such as the ids and links. + Please formulate a complete response to give to the user ONLY based on the results. + """ + ) + | data["llm"] + ) + + return data + + @staticmethod + async def agent_node( + state: AgentState, agent: CompiledGraph, name: str + ) -> dict[str, Any]: + """Run the agent node.""" + logger.info(f"Start running the agent: {name}") + result = await agent.ainvoke(state) + + agent_steps = [ + AgentStep( + tool_name=step.tool_calls[0]["name"], + arguments=step.tool_calls[0]["args"], + ) + for step in result["messages"] + if isinstance(step, AIMessage) and step.additional_kwargs + ] + + return { + "messages": [ + AIMessage( + content=result["messages"][-1].content, + name=name, + additional_kwargs={"steps": agent_steps}, + ) + ] + } + + async def summarizer_node(self, state: AgentState) -> dict[str, Any]: + """Create summarizer node.""" + logger.info("Entering the summarizer node") + question = state["messages"][0].content + responses = " \n".join([mes.content for mes in state["messages"][1:]]) # type: ignore + result = await self.summarizer.ainvoke( + {"question": question, "responses": responses} + ) + return { + "messages": [ + HumanMessage( + content=result.content, + name="summarizer", + ) + ] + } + + def create_graph(self) -> CompiledGraph: + """Create graph.""" + workflow = StateGraph(AgentState) + + # Create nodes + for agent_name, tools_list in self.agents: + agent = create_react_agent(model=self.llm, tools=tools_list) + node = functools.partial(self.agent_node, agent=agent, name=agent_name) + workflow.add_node(agent_name, node) + + # Supervisor node + workflow.add_node("Supervisor", self.main_agent) + + # Summarizer node + summarizer_agent = functools.partial(self.summarizer_node) + workflow.add_node("Summarizer", summarizer_agent) + + # Create edges + for agent_name, _ in self.agents: + workflow.add_edge(agent_name, "Supervisor") + + conditional_map: dict[Hashable, str] = {k[0]: k[0] for k in self.agents} + conditional_map["FINISH"] = "Summarizer" + workflow.add_conditional_edges( + "Supervisor", + lambda x: x["next"], + conditional_map, + ) + workflow.add_edge(START, "Supervisor") + workflow.add_edge("Summarizer", END) + graph = workflow.compile() + return graph + + def run(self, query: str, thread_id: str) -> AgentOutput: + """Run graph against a query.""" + graph = self.create_graph() + config = RunnableConfig(configurable={"thread_id": thread_id}) + res = graph.invoke( + input={"messages": [HumanMessage(content=query)]}, config=config + ) + return self._process_output(res) + + async def arun(self, query: str, thread_id: str) -> AgentOutput: + """Arun method of the service.""" + graph = self.create_graph() + config = RunnableConfig(configurable={"thread_id": thread_id}) + res = await graph.ainvoke( + input={"messages": [HumanMessage(content=query)]}, config=config + ) + return self._process_output(res) + + async def astream(self, query: str, thread_id: str) -> AsyncIterator[str]: # type: ignore + """Astream method of the service.""" + graph = self.create_graph() + config = RunnableConfig(configurable={"thread_id": thread_id}) + async for chunk in graph.astream( + input={"messages": [HumanMessage(content=query)]}, config=config + ): + if "Supervisor" in chunk.keys() and chunk["Supervisor"]["next"] != "FINISH": + yield f'\nCalling agent : {chunk["Supervisor"]["next"]}\n' + else: + values = [i for i in chunk.values()] # noqa: C416 + if "messages" in values[0]: + yield f'\n {values[0]["messages"][0].content}' + + @staticmethod + def _process_output(output: Any) -> AgentOutput: + """Format the output.""" + agent_steps = [] + for message in output["messages"][1:]: + if "steps" in message.additional_kwargs: + agent_steps.extend(message.additional_kwargs["steps"]) + return AgentOutput(response=output["messages"][-1].content, steps=agent_steps) diff --git a/src/neuroagent/resolving.py b/src/neuroagent/resolving.py new file mode 100644 index 0000000..27a8c74 --- /dev/null +++ b/src/neuroagent/resolving.py @@ -0,0 +1,448 @@ +"""Utils related to object resolving in the KG.""" + +import asyncio +import logging +import re +from typing import Literal + +from httpx import AsyncClient + +logger = logging.getLogger(__name__) + + +SPARQL_QUERY = """ + PREFIX bmc: + PREFIX bmo: + PREFIX bmoutils: + PREFIX commonshapes: + PREFIX datashapes: + PREFIX dc: + PREFIX dcat: + PREFIX dcterms: + PREFIX mba: + PREFIX nsg: + PREFIX nxv: + PREFIX oa: + PREFIX obo: + PREFIX owl: + PREFIX prov: + PREFIX rdf: + PREFIX rdfs: + PREFIX schema: + PREFIX sh: + PREFIX shsh: + PREFIX skos: + PREFIX vann: + PREFIX void: + PREFIX xml: + PREFIX xsd: + PREFIX : + + CONSTRUCT {{ + ?id a ?type ; + rdfs:label ?label ; + skos:prefLabel ?prefLabel ; + skos:altLabel ?altLabel ; + skos:definition ?definition; + rdfs:subClassOf ?subClassOf ; + rdfs:isDefinedBy ?isDefinedBy ; + skos:notation ?notation ; + skos:definition ?definition ; + nsg:atlasRelease ?atlasRelease ; + schema:identifier ?identifier ; + ?delineatedBy ; + ?hasLayerLocationPhenotype ; + ?representedInAnnotation ; + ?hasLeafRegionPart ; + schema:isPartOf ?isPartOf ; + ?isLayerPartOf . + }} WHERE {{ + GRAPH ?g {{ + ?id a ?type ; + rdfs:label ?label ; + OPTIONAL {{ + ?id rdfs:subClassOf ?subClassOf ; + }} + OPTIONAL {{ + ?id skos:definition ?definition ; + }} + OPTIONAL {{ + ?id skos:prefLabel ?prefLabel . + }} + OPTIONAL {{ + ?id skos:altLabel ?altLabel . + }} + OPTIONAL {{ + ?id rdfs:isDefinedBy ?isDefinedBy . + }} + OPTIONAL {{ + ?id skos:notation ?notation . + }} + OPTIONAL {{ + ?id skos:definition ?definition . + }} + OPTIONAL {{ + ?id nsg:atlasRelease ?atlasRelease . + }} + OPTIONAL {{ + ?id ?hasLayerLocationPhenotype . + }} + OPTIONAL {{ + ?id schema:identifier ?identifier . + }} + OPTIONAL {{ + ?id ?delineatedBy . + }} + OPTIONAL {{ + ?id ?representedInAnnotation . + }} + OPTIONAL {{ + ?id ?hasLeafRegionPart . + }} + OPTIONAL {{ + ?id schema:isPartOf ?isPartOf . + }} + OPTIONAL {{ + ?id ?isLayerPartOf . + }} + OPTIONAL {{ + ?id ?units . + }} + {{ + SELECT * WHERE {{ + {{ ?id "false"^^xsd:boolean ; a owl:Class ; + rdfs:subClassOf* {resource} ; rdfs:label ?label FILTER regex(?label, {keyword}, "i") }} UNION + {{ ?id "false"^^xsd:boolean ; a owl:Class ; + rdfs:subClassOf* {resource} ; skos:notation ?notation FILTER regex(?notation, {keyword}, "i") }} UNION + {{ ?id "false"^^xsd:boolean ; a owl:Class ; + rdfs:subClassOf* {resource} ; skos:prefLabel ?prefLabel FILTER regex(?prefLabel, {keyword}, "i") }} UNION + {{ ?id "false"^^xsd:boolean ; a owl:Class ; + rdfs:subClassOf* {resource} ; skos:altLabel ?altLabel FILTER regex(?altLabel, {keyword}, "i") }} + }} LIMIT {search_size} + }} + }} + }} +""" # ORDER BY ?id + + +async def sparql_exact_resolve( + query: str, + resource_type: str, + sparql_view_url: str, + token: str, + httpx_client: AsyncClient, +) -> list[dict[str, str]] | None: + """Resolve query with the knowledge graph using sparql (exact match). + + Parameters + ---------- + query + Query to resolve (needs to be a brain region). + resource_type + Type of resource to match. + sparql_view_url + URL to the knowledge graph. + token + Token to access the KG. + httpx_client + Async Client. + + Returns + ------- + list[dict[str, str]] | None + List of brain region IDs and names (only one for exact match). + """ + # For exact match query remove punctuation and add ^ + $ for regex + sparql_query_exact = SPARQL_QUERY.format( + keyword=f'"^{escape_punctuation(query)}$"', + search_size=1, + resource=resource_type, + ).replace("\n", "") + + # Send the sparql query + response = await httpx_client.post( + url=sparql_view_url, + content=sparql_query_exact, + headers={ + "Content-Type": "text/plain", + "Accept": "application/sparql-results+json", + "Authorization": f"Bearer {token}", + }, + ) + try: + # Get the BR or mtype ID + object_id = response.json()["results"]["bindings"][0]["subject"]["value"] + + # Get the BR or mtype name + object_name = next( + ( + resp["object"]["value"] + for resp in response.json()["results"]["bindings"] + if "literal" in resp["object"]["type"] + ) + ) + logger.info( + f"Found object {object_name} id {object_id} from the exact" + " match Sparql query." + ) + + # Return a single element (because exact match) + return [{"label": object_name, "id": object_id}] + + # If nothing matched, notify parent function that exact match didn't work + except (IndexError, KeyError): + return None + + +async def sparql_fuzzy_resolve( + query: str, + resource_type: str, + sparql_view_url: str, + token: str, + httpx_client: AsyncClient, + search_size: int = 10, +) -> list[dict[str, str]] | None: + """Resolve query with the knowledge graph using sparql (fuzzy match). + + Parameters + ---------- + query + Query to resolve (needs to be a brain region). + resource_type + Type of resource to match. + sparql_view_url + URL to the knowledge graph. + token + Token to access the KG. + httpx_client + Async Client. + search_size + Number of results to retrieve. + + + Returns + ------- + list[dict[str, str]] | None + List of brain region IDs and names. None if none found. + """ + # Prepare the fuzzy sparql query + sparql_query_fuzzy = SPARQL_QUERY.format( + keyword=f'"{query}"', + search_size=search_size, + resource=resource_type, + ).replace("\n", "") + + # Send it + response = await httpx_client.post( + url=sparql_view_url, + content=sparql_query_fuzzy, + headers={ + "Content-Type": "text/plain", + "Accept": "application/sparql-results+json", + "Authorization": f"Bearer {token}", + }, + ) + + results = None + if response.json()["results"]["bindings"]: + # Define the regex pattern for br ids + pattern = re.compile(r"http:\/\/api\.brain-map\.org\/api\/.*") + + # Dictionary to store unique objects + objects: dict[str, str] = {} + + # Iterate over the response to extract the required information + for entry in response.json()["results"]["bindings"]: + # Test if the subject is of the form of a BR id or if we are looking for mtype + subject = entry["subject"]["value"] + if pattern.match(subject) or "braincelltype" in resource_type.lower(): + # If so, get the predicate value and see if it describes a label + predicate = entry["predicate"]["value"] + if predicate == "http://www.w3.org/2000/01/rdf-schema#label": + label = entry["object"]["value"] + # Append results if seen for the first time + if subject not in objects: + objects[subject] = label + + # Convert to the desired format + results = [ + {"label": label, "id": subject} for subject, label in objects.items() + ] + # Output the result + logger.info(f"Found {len(results)} objects from the fuzzy Sparql query.") + return results + + +async def es_resolve( + query: str, + resource_type: str, + es_view_url: str, + token: str, + httpx_client: AsyncClient, + search_size: int = 1, +) -> list[dict[str, str]] | None: + """Resolve query with the knowlegde graph using Elastic Search. + + Parameters + ---------- + query + Query to resolve (needs to be a brain region). + resource_type + Type of resource to match. + es_view_url + Optional url used to query the class view of the KG. Useful for backup 'match' query. + token + Token to access the KG. + httpx_client + Async Client. + search_size + Number of results to retrieve. + + Returns + ------- + list[dict[str, str]] | None + List of brain region IDs and names. None if none found. + """ + # Match the label of the BR or mtype + es_query = { + "size": search_size, + "query": { + "bool": { + "must": [ + { + "bool": { + "should": [ + {"match": {"label": query}}, + {"match": {"prefLabel": query}}, + {"match": {"altLabel": query}}, + ] + } + }, + {"term": {"@type": "Class"}}, + ] + } + }, + } + + # If matching a BR, add extra regex match to ensure correct form of the id + if "brainregion" in resource_type.lower(): + es_query["query"]["bool"]["must"].append( # type: ignore + {"regexp": {"@id": r"http:\/\/api\.brain-map\.org\/api\/.*"}} + ) + + # Send the actual query + response = await httpx_client.post( + url=es_view_url, + headers={"Authorization": f"Bearer {token}"}, + json=es_query, + ) + + # If there are results + if "hits" in response.json()["hits"]: + logger.info( + f"Found {len(response.json()['hits']['hits'])} objects from the" + " elasticsearch backup query." + ) + # Return all of the results correctly parsed + return [ + {"label": br["_source"]["label"], "id": br["_source"]["@id"]} + for br in response.json()["hits"]["hits"] + ] + logger.info("Didn't find brain region id. Try again next time !") + return None + + +async def resolve_query( + query: str, + sparql_view_url: str, + es_view_url: str, + token: str, + httpx_client: AsyncClient, + resource_type: Literal["nsg:BrainRegion", "bmo:BrainCellType"] = "nsg:BrainRegion", + search_size: int = 1, +) -> list[dict[str, str]]: + """Resolve query using the knowlegde graph, with sparql and ES. + + Parameters + ---------- + query + Query to resolve (needs to be a brain region or an mtype). + sparql_view_url + URL to the knowledge graph. + es_view_url + Optional url used to query the class view of the KG. Useful for backup 'match' query. + token + Token to access the KG. + httpx_client + Async Client. + search_size + Number of results to retrieve. + resource_type + Type of resource to match. + + Returns + ------- + list[dict[str, str]] | None + List of brain region IDs and names. None if none found. + """ + # Create one task per resolve method. They are ordered by 'importance' + tasks = [ + asyncio.create_task( + sparql_exact_resolve( + query=query, + resource_type=resource_type, + sparql_view_url=sparql_view_url, + token=token, + httpx_client=httpx_client, + ) + ), + asyncio.create_task( + sparql_fuzzy_resolve( + query=query, + resource_type=resource_type, + sparql_view_url=sparql_view_url, + token=token, + httpx_client=httpx_client, + search_size=search_size, + ) + ), + asyncio.create_task( + es_resolve( + query=query, + resource_type=resource_type, + es_view_url=es_view_url, + token=token, + httpx_client=httpx_client, + search_size=search_size, + ) + ), + ] + # Send them all async + resolve_results = await asyncio.gather(*tasks) + + # Return the results of the first one that is not None (in descending importance order) + if any(resolve_results): + return next((result for result in resolve_results if result)) + else: + raise ValueError(f"Couldn't find a brain region ID from the query {query}") + + +def escape_punctuation(text: str) -> str: + """Escape punctuation for sparql query. + + Parameters + ---------- + text + Text to escape punctuation from + + Returns + ------- + Escaped text + """ + if not isinstance(text, str): + raise TypeError("Only accepting strings.") + punctuation = '-()"#/@;:<>{}`+=~|.!?,' + for p in punctuation: + if p in text: + text = text.replace(p, f"\\\\{p}") + return text diff --git a/src/neuroagent/schemas.py b/src/neuroagent/schemas.py new file mode 100644 index 0000000..b621775 --- /dev/null +++ b/src/neuroagent/schemas.py @@ -0,0 +1,11 @@ +"""Pydantic Schemas.""" + +from pydantic import BaseModel + + +class KGMetadata(BaseModel): + """Knowledge Graph Metadata.""" + + file_extension: str + brain_region: str + is_lnmc: bool = False diff --git a/src/neuroagent/scripts/neuroagent_api.py b/src/neuroagent/scripts/neuroagent_api.py new file mode 100644 index 0000000..5b37ec6 --- /dev/null +++ b/src/neuroagent/scripts/neuroagent_api.py @@ -0,0 +1,59 @@ +"""Start the Neuroagent API.""" + +import argparse +from pathlib import Path + +import uvicorn + + +def get_parser() -> argparse.ArgumentParser: + """Get parser for command line arguments.""" + parser = argparse.ArgumentParser( + formatter_class=argparse.ArgumentDefaultsHelpFormatter + ) + parser.add_argument( + "--host", + type=str, + default="127.0.0.1", + help="Host used by the app.", + ) + parser.add_argument( + "--port", + type=int, + default=8000, + help="Port used by the app", + ) + parser.add_argument( + "--env", + type=Path, + default=None, + help=( + "Path to the env file for app config. See example at" + " https://github.com/BlueBrain/neuroagent/.env.example. Reads from local" + " '.env' file in cwd by default." + ), + ) + parser.add_argument( + "--workers", + type=int, + default=1, + help="Number of workers used by the app.", + ) + return parser + + +def main() -> None: + """Run main logic.""" + parser = get_parser() + args = parser.parse_args() + uvicorn.run( + "neuroagent.app.main:app", + host=args.host, + port=args.port, + workers=args.workers, + env_file=args.env, + ) + + +if __name__ == "__main__": + main() diff --git a/src/neuroagent/tools/__init__.py b/src/neuroagent/tools/__init__.py new file mode 100644 index 0000000..7ce3aee --- /dev/null +++ b/src/neuroagent/tools/__init__.py @@ -0,0 +1,38 @@ +"""Tools folder.""" + +from neuroagent.tools.electrophys_tool import ElectrophysFeatureTool, FeaturesOutput +from neuroagent.tools.get_morpho_tool import GetMorphoTool, KnowledgeGraphOutput +from neuroagent.tools.kg_morpho_features_tool import ( + KGMorphoFeatureOutput, + KGMorphoFeatureTool, +) +from neuroagent.tools.literature_search_tool import ( + LiteratureSearchTool, + ParagraphMetadata, +) +from neuroagent.tools.morphology_features_tool import ( + MorphologyFeatureOutput, + MorphologyFeatureTool, +) +from neuroagent.tools.resolve_brain_region_tool import ( + BRResolveOutput, + ResolveBrainRegionTool, +) +from neuroagent.tools.traces_tool import GetTracesTool, TracesOutput + +__all__ = [ + "BRResolveOutput", + "ElectrophysFeatureTool", + "FeaturesOutput", + "GetMorphoTool", + "GetTracesTool", + "KGMorphoFeatureOutput", + "KGMorphoFeatureTool", + "KnowledgeGraphOutput", + "LiteratureSearchTool", + "MorphologyFeatureOutput", + "MorphologyFeatureTool", + "ParagraphMetadata", + "ResolveBrainRegionTool", + "TracesOutput", +] diff --git a/src/neuroagent/tools/base_tool.py b/src/neuroagent/tools/base_tool.py new file mode 100644 index 0000000..6bb9003 --- /dev/null +++ b/src/neuroagent/tools/base_tool.py @@ -0,0 +1,84 @@ +"""Base tool (to handle errors).""" + +import json +import logging +from typing import Any + +from langchain_core.tools import BaseTool, ToolException +from pydantic import BaseModel, ValidationError, model_validator + +logger = logging.getLogger(__name__) + + +def process_validation_error(error: ValidationError) -> str: + """Handle validation errors when tool inputs are wrong.""" + error_list = [] + name = error.title + # We have to iterate, in case there are multiple errors. + try: + for err in error.errors(): + if err["type"] == "literal_error": + error_list.append( + { + "Validation error": ( + f'Wrong value: provided {err["input"]} for input' + f' {err["loc"][0]}. Try again and change this problematic' + " input." + ) + } + ) + elif err["type"] == "missing": + error_list.append( + { + "Validation error": ( + f'Missing input : {err["loc"][0]}. Try again and add this' + " input." + ) + } + ) + else: + error_list.append( + {"Validation error": f'{err["loc"][0]}. {err["msg"]}'} + ) + + except (KeyError, IndexError) as e: + error_list.append({"Validation error": f"Error in {name} : {str(e)}"}) + logger.error( + "UNTREATED ERROR !! PLEASE CONTACT ML TEAM AND FOWARD THEM THE REQUEST !!" + ) + + logger.warning(f"VALIDATION ERROR: Wrong input in {name}. {error_list}") + + return json.dumps(error_list) + + +def process_tool_error(error: ToolException) -> str: + """Handle errors inside tools.""" + logger.warning( + f"TOOL ERROR: Error in tool {error.args[1]}. Error: {str(error.args[0])}" + ) + dict_output = {error.args[1]: error.args[0]} + return json.dumps(dict_output) + + +class BasicTool(BaseTool): + """Basic class for tools.""" + + name: str = "base" + description: str = "Base tool from which regular tools should inherit." + + @model_validator(mode="before") + @classmethod + def handle_errors(cls, data: dict[str, Any]) -> dict[str, Any]: + """Instantiate the clients upon class creation.""" + data["handle_validation_error"] = process_validation_error + data["handle_tool_error"] = process_tool_error + return data + + +class BaseToolOutput(BaseModel): + """Base class for tool outputs.""" + + def __repr__(self) -> str: + """Representation method.""" + return self.model_dump_json() diff --git a/src/neuroagent/tools/electrophys_tool.py b/src/neuroagent/tools/electrophys_tool.py new file mode 100644 index 0000000..e3f7018 --- /dev/null +++ b/src/neuroagent/tools/electrophys_tool.py @@ -0,0 +1,334 @@ +"""Electrophys tool.""" + +import logging +import tempfile +from statistics import mean +from typing import Any, Literal, Optional, Type + +from bluepyefe.extract import extract_efeatures +from efel.units import get_unit +from langchain_core.tools import ToolException +from pydantic import BaseModel, Field + +from neuroagent.tools.base_tool import BaseToolOutput, BasicTool +from neuroagent.utils import get_kg_data + +logger = logging.getLogger(__name__) + + +POSSIBLE_PROTOCOLS = { + "idrest": ["idrest"], + "idthresh": ["idthres", "idthresh"], + "iv": ["iv"], + "apwaveform": ["apwaveform"], + "spontaneous": ["spontaneous"], + "step": ["step"], + "spontaps": ["spontaps"], + "firepattern": ["firepattern"], + "sponnohold30": ["sponnohold30", "spontnohold30"], + "sponthold30": ["sponhold30", "sponthold30"], + "starthold": ["starthold"], + "startnohold": ["startnohold"], + "delta": ["delta"], + "sahp": ["sahp"], + "idhyperpol": ["idhyeperpol"], + "irdepol": ["irdepol"], + "irhyperpol": ["irhyperpol"], + "iddepol": ["iddepol"], + "ramp": ["ramp"], + "apthresh": ["apthresh", "ap_thresh"], + "hyperdepol": ["hyperdepol"], + "negcheops": ["negcheops"], + "poscheops": ["poscheops"], + "spikerec": ["spikerec"], + "sinespec": ["sinespec"], +} + + +STIMULI_TYPES = list[ + Literal[ + "spontaneous", + "idrest", + "idthres", + "apwaveform", + "iv", + "step", + "spontaps", + "firepattern", + "sponnohold30", + "sponhold30", + "starthold", + "startnohold", + "delta", + "sahp", + "idhyperpol", + "irdepol", + "irhyperpol", + "iddepol", + "ramp", + "ap_thresh", + "hyperdepol", + "negcheops", + "poscheops", + "spikerec", + "sinespec", + ] +] + +CALCULATED_FEATURES = list[ + Literal[ + "spike_count", + "time_to_first_spike", + "time_to_last_spike", + "inv_time_to_first_spike", + "doublet_ISI", + "inv_first_ISI", + "ISI_log_slope", + "ISI_CV", + "irregularity_index", + "adaptation_index", + "mean_frequency", + "strict_burst_number", + "strict_burst_mean_freq", + "spikes_per_burst", + "AP_height", + "AP_amplitude", + "AP1_amp", + "APlast_amp", + "AP_duration_half_width", + "AHP_depth", + "AHP_time_from_peak", + "AP_peak_upstroke", + "AP_peak_downstroke", + "voltage_base", + "voltage_after_stim", + "ohmic_input_resistance_vb_ssse", + "steady_state_voltage_stimend", + "sag_amplitude", + "decay_time_constant_after_stim", + "depol_block_bool", + ] +] + + +class AmplitudeInput(BaseModel): + """Amplitude class.""" + + min_value: float + max_value: float + + +class InputElectrophys(BaseModel): + """Inputs of the NeuroM API.""" + + trace_id: str = Field( + description=( + "ID of the trace of interest. The trace ID is in the form of an HTTP(S)" + " link such as 'https://bbp.epfl.ch/neurosciencegraph/data/traces...'." + ) + ) + stimuli_types: Optional[STIMULI_TYPES] = Field( + description=( + "Type of stimuli requested by the user. Should be one of 'spontaneous'," + " 'idrest', 'idthres', 'apwaveform', 'iv', 'step', 'spontaps'," + " 'firepattern', 'sponnohold30','sponhold30', 'starthold', 'startnohold'," + " 'delta', 'sahp', 'idhyperpol', 'irdepol', 'irhyperpol','iddepol', 'ramp'," + " 'ap_thresh', 'hyperdepol', 'negcheops', 'poscheops'," + " 'spikerec', 'sinespec'." + ) + ) + calculated_feature: Optional[CALCULATED_FEATURES] = Field( + description=( + "Feature requested by the user. Should be one of 'spike_count'," + "'time_to_first_spike', 'time_to_last_spike'," + "'inv_time_to_first_spike', 'doublet_ISI', 'inv_first_ISI'," + "'ISI_log_slope', 'ISI_CV', 'irregularity_index', 'adaptation_index'," + "'mean_frequency', 'strict_burst_number', 'strict_burst_mean_freq'," + "'spikes_per_burst', 'AP_height', 'AP_amplitude', 'AP1_amp', 'APlast_amp'," + "'AP_duration_half_width', 'AHP_depth', 'AHP_time_from_peak'," + "'AP_peak_upstroke', 'AP_peak_downstroke', 'voltage_base'," + "'voltage_after_stim', 'ohmic_input_resistance_vb_ssse'," + "'steady_state_voltage_stimend', 'sag_amplitude'," + "'decay_time_constant_after_stim', 'depol_block_bool'" + ) + ) + amplitude: Optional[AmplitudeInput] = Field( + description=( + "Amplitude of the protocol (should be specified in nA)." + "Can be a range of amplitudes with min and max values" + "Can be None (if the user does not specify it)" + " and all the amplitudes are going to be taken into account." + ), + ) + + +class FeaturesOutput(BaseToolOutput): + """Output schema for the neurom tool.""" + + brain_region: str + feature_dict: dict[str, Any] + + +class ElectrophysFeatureTool(BasicTool): + """Class defining the Electrophys Featyres Tool.""" + + name: str = "electrophys-features-tool" + description: str = """Given a trace ID, extract features from the trace for certain stimuli types and certain amplitudes. + You can optionally specify which feature to calculate, for which stimuli types and for which amplitudes: + - The calculated features are a list of features that the user requests to compute. + - The stimuli types are the types of input stimuli injected in the cell when measuring the response. + - The amplitude is the total current injected in the cell when measuring the response. + Specify those ONLY if the user specified them. Otherwise leave them as None. + """ + metadata: dict[str, Any] + args_schema: Type[BaseModel] = InputElectrophys + + def _run(self) -> None: # type: ignore + """Not implemented yet.""" + pass + + async def _arun( + self, + trace_id: str, + calculated_feature: CALCULATED_FEATURES | None = None, + stimuli_types: STIMULI_TYPES | None = None, + amplitude: AmplitudeInput | None = None, + ) -> FeaturesOutput | dict[str, str]: + """Give features about trace. + + Parameters + ---------- + trace + ID of the trace of interest (of the form https://bbp.epfl.ch/neurosciencegraph/data/traces...) + calculated_features + List of features one wants to compute + stimuli_types + List of stimuli types that should be taken into account when computing the features + amplitude + Amplitude range of the input stimulus when measuring the cell's response + + Returns + ------- + Dict of feature: value + """ + logger.info( + f"Entering electrophys tool. Inputs: {trace_id=}, {calculated_feature=}," + f" {amplitude=}, {stimuli_types=}" + ) + try: + # Deal with cases where user did not specify stimulus type or/and feature + if not stimuli_types: + # Default to IDRest if protocol not specified + logger.warning("No stimulus type specified. Defaulting to IDRest.") + stimuli_types = ["idrest"] + if not calculated_feature: + # Compute ALL of the available features if not specified + logger.warning("No feature specified. Defaulting to everything.") + calculated_feature = list(CALCULATED_FEATURES.__args__[0].__args__) # type: ignore + + # Download the .nwb file associated to the trace from the KG + trace_content, metadata = await get_kg_data( + object_id=trace_id, + httpx_client=self.metadata["httpx_client"], + url=self.metadata["url"], + token=self.metadata["token"], + preferred_format="nwb", + ) + + # Turn amplitude requirement of user into a bluepyefe compatible representation + if isinstance(amplitude, AmplitudeInput): + # If the user specified amplitude/a range of amplitudes, + # the target amplitude is centered on the range and the + # tolerance is set as half the range + desired_amplitude = mean([amplitude.min_value, amplitude.max_value]) + + # If the range is just one number, use 10% of it as tolerance + if amplitude.min_value == amplitude.max_value: + desired_tolerance = amplitude.max_value * 0.1 + else: + desired_tolerance = amplitude.max_value - desired_amplitude + else: + # If the amplitudes are not specified, take an arbitrarily high tolerance + desired_amplitude = 0 + desired_tolerance = 1e12 + logger.info( + f"target amplitude set to {desired_amplitude} nA. Tolerance is" + f" {desired_tolerance} nA" + ) + + targets = [] + # Create a target for each stimuli_types and their various spellings and for each feature to compute + for stim_type in stimuli_types: + for efeature in calculated_feature: + for protocol in POSSIBLE_PROTOCOLS[stim_type]: + target = { + "efeature": efeature, + "protocol": protocol, + "amplitude": desired_amplitude, + "tolerance": desired_tolerance, + } + targets.append(target) + logger.info(f"Generated {len(targets)} targets.") + + # The trace needs to be opened from a file, no way to hack it + with ( + tempfile.NamedTemporaryFile(suffix=".nwb") as temp_file, + tempfile.TemporaryDirectory() as temp_dir, + ): + temp_file.write(trace_content) + + # LNMC traces need to be adjusted by an output voltage of 14mV due to their experimental protocol + if metadata.is_lnmc: + files_metadata = { + "test": { + stim_type: [ + { + "filepath": temp_file.name, + "protocol": protocol, + "ljp": 14, + } + for protocol in POSSIBLE_PROTOCOLS[stim_type] + ] + for stim_type in stimuli_types + } + } + else: + files_metadata = { + "test": { + stim_type: [ + {"filepath": temp_file.name, "protocol": protocol} + for protocol in POSSIBLE_PROTOCOLS[stim_type] + ] + for stim_type in stimuli_types + } + } + + # Extract the requested features for the requested protocols + efeatures, protocol_definitions, _ = extract_efeatures( + output_directory=temp_dir, + files_metadata=files_metadata, + targets=targets, + absolute_amplitude=True, + ) + output_features = {} + + # Format the extracted features into a readable dict for the model + for protocol_name in protocol_definitions.keys(): + efeatures_values = efeatures[protocol_name] + protocol_def = protocol_definitions[protocol_name] + output_features[protocol_name] = { + f"{f['efeature_name']} (avg on n={f['n']} trace(s))": ( + f"{f['val'][0]} {get_unit(f['efeature_name']) if get_unit(f['efeature_name']) != 'constant' else ''}".strip() + ) + for f in efeatures_values["soma"] + } + + # Add the stimulus current of the protocol to the output + output_features[protocol_name]["stimulus_current"] = ( + f"{protocol_def['step']['amp']} nA" + ) + return FeaturesOutput( + brain_region=metadata.brain_region, feature_dict=output_features + ) + except Exception as e: + raise ToolException(str(e), self.name) diff --git a/src/neuroagent/tools/get_morpho_tool.py b/src/neuroagent/tools/get_morpho_tool.py new file mode 100644 index 0000000..5bd73c4 --- /dev/null +++ b/src/neuroagent/tools/get_morpho_tool.py @@ -0,0 +1,215 @@ +"""Get Morpho tool.""" + +import logging +from typing import Any, Optional, Type + +from langchain_core.tools import ToolException +from pydantic import BaseModel, Field + +from neuroagent.cell_types import get_celltypes_descendants +from neuroagent.tools.base_tool import BaseToolOutput, BasicTool +from neuroagent.utils import get_descendants_id + +logger = logging.getLogger(__name__) + + +class InputGetMorpho(BaseModel): + """Inputs of the knowledge graph API.""" + + brain_region_id: str = Field(description="ID of the brain region of interest.") + mtype_id: Optional[str] = Field( + default=None, description="ID of the M-type of interest." + ) + + +class KnowledgeGraphOutput(BaseToolOutput): + """Output schema for the knowledge graph API.""" + + morphology_id: str + morphology_name: str | None + morphology_description: str | None + mtype: str | None + + brain_region_id: str + brain_region_label: str | None + + subject_species_label: str | None + subject_age: str | None + + +class GetMorphoTool(BasicTool): + """Class defining the Get Morpho logic.""" + + name: str = "get-morpho-tool" + description: str = """Searches a neuroscience based knowledge graph to retrieve neuron morphology names, IDs and descriptions. + Requires a 'brain_region_id' which is the ID of the brain region of interest as registered in the knowledge graph. To get this ID, please use the `resolve-brain-region-tool` first. + The output is a list of morphologies, containing: + - The brain region ID. + - The brain region name. + - The subject species name. + - The subject age. + - The morphology ID. + - The morphology name. + - the morphology description. + The morphology ID is in the form of an HTTP(S) link such as 'https://bbp.epfl.ch/neurosciencegraph/data/neuronmorphologies...'.""" + metadata: dict[str, Any] + args_schema: Type[BaseModel] = InputGetMorpho + + def _run(self) -> None: + pass + + async def _arun( + self, brain_region_id: str, mtype_id: str | None = None + ) -> list[KnowledgeGraphOutput]: + """From a brain region ID, extract morphologies. + + Parameters + ---------- + brain_region_id + ID of the brain region of interest (of the form http://api.brain-map.org/api/v2/data/Structure/...) + mtype_id + ID of the mtype of the morphology + + Returns + ------- + list of KnowledgeGraphOutput to describe the morphology and its metadata, or an error dict. + """ + logger.info( + f"Entering Get Morpho tool. Inputs: {brain_region_id=}, {mtype_id=}" + ) + try: + # From the brain region ID, get the descendants. + hierarchy_ids = get_descendants_id( + brain_region_id, json_path=self.metadata["brainregion_path"] + ) + logger.info( + f"Found {len(list(hierarchy_ids))} children of the brain ontology." + ) + + # Create the ES query to query the KG. + mtype_ids = ( + get_celltypes_descendants(mtype_id, self.metadata["celltypes_path"]) + if mtype_id + else None + ) + entire_query = self.create_query( + brain_regions_ids=hierarchy_ids, mtype_ids=mtype_ids + ) + + # Send the query to get morphologies. + response = await self.metadata["httpx_client"].post( + url=self.metadata["url"], + headers={"Authorization": f"Bearer {self.metadata['token']}"}, + json=entire_query, + ) + + # Process the output and return. + return self._process_output(response.json()) + + except Exception as e: + raise ToolException(str(e), self.name) + + def create_query( + self, brain_regions_ids: set[str], mtype_ids: set[str] | None = None + ) -> dict[str, Any]: + """Create ES query out of the BR and mtype IDs. + + Parameters + ---------- + brain_regions_ids + IDs of the brain region of interest (of the form http://api.brain-map.org/api/v2/data/Structure/...) + mtype_ids + IDs the the mtype of the morphology + + Returns + ------- + dict containing the elasticsearch query to send to the KG. + """ + # At least one of the children brain region should match. + conditions = [ + { + "bool": { + "should": [ + {"term": {"brainRegion.@id.keyword": hierarchy_id}} + for hierarchy_id in brain_regions_ids + ] + } + } + ] + + if mtype_ids: + # The correct mtype should match. For now + # It is a one term should condition, but eventually + # we will resolve the subclasses of the mtypes. + # They will all be appended here. + conditions.append( + { + "bool": { + "should": [ + {"term": {"mType.@id.keyword": mtype_id}} + for mtype_id in mtype_ids + ] + } + } + ) + + # Assemble the query to return morphologies. + entire_query = { + "size": self.metadata["search_size"], + "track_total_hits": True, + "query": { + "bool": { + "must": [ + *conditions, + { + "term": { + "@type.keyword": "https://neuroshapes.org/ReconstructedNeuronMorphology" + } + }, + {"term": {"deprecated": False}}, + {"term": {"curated": True}}, + ] + } + }, + } + return entire_query + + @staticmethod + def _process_output(output: Any) -> list[KnowledgeGraphOutput]: + """Process output to fit the KnowledgeGraphOutput pydantic class defined above. + + Parameters + ---------- + output + Raw output of the _arun method, which comes from the KG + + Returns + ------- + list of KGMorphoFeatureOutput to describe the morphology and its metadata. + """ + formatted_output = [ + KnowledgeGraphOutput( + morphology_id=res["_source"]["@id"], + morphology_name=res["_source"].get("name"), + morphology_description=res["_source"].get("description"), + mtype=( + res["_source"]["mType"].get("label") + if "mType" in res["_source"] + else None + ), + brain_region_id=res["_source"]["brainRegion"]["@id"], + brain_region_label=res["_source"]["brainRegion"].get("label"), + subject_species_label=( + res["_source"]["subjectSpecies"].get("label") + if "subjectSpecies" in res["_source"] + else None + ), + subject_age=( + res["_source"]["subjectAge"].get("label") + if "subjectAge" in res["_source"] + else None + ), + ) + for res in output["hits"]["hits"] + ] + return formatted_output diff --git a/src/neuroagent/tools/kg_morpho_features_tool.py b/src/neuroagent/tools/kg_morpho_features_tool.py new file mode 100644 index 0000000..ba67c0f --- /dev/null +++ b/src/neuroagent/tools/kg_morpho_features_tool.py @@ -0,0 +1,362 @@ +"""KG Morpho Feature tool.""" + +import logging +from typing import Any, Literal, Type + +from langchain_core.tools import ToolException +from pydantic import BaseModel, Field, model_validator + +from neuroagent.tools.base_tool import BaseToolOutput, BasicTool +from neuroagent.utils import get_descendants_id + +LABEL = Literal[ + "Neurite Max Radial Distance", + "Number Of Sections", + "Number Of Bifurcations", + "Number Of Leaves", + "Total Length", + "Total Area", + "Total Volume", + "Section Lengths", + "Section Term Lengths", + "Section Bif Lengths", + "Section Branch Orders", + "Section Bif Branch Orders", + "Section Term Branch Orders", + "Section Path Distances", + "Section Taper Rates", + "Local Bifurcation Angles", + "Remote Bifurcation Angles", + "Partition Asymmetry", + "Partition Asymmetry Length", + "Sibling Ratios", + "Diameter Power Relations", + "Section Radial Distances", + "Section Term Radial Distances", + "Section Bif Radial Distances", + "Terminal Path Lengths", + "Section Volumes", + "Section Areas", + "Section Tortuosity", + "Section Strahler Orders", + "Soma Surface Area", + "Soma Radius", + "Soma Number Of Points", + "Morphology Max Radial Distance", + "Number Of Sections Per Neurite", + "Total Length Per Neurite", + "Total Area Per Neurite", + "Total Height", + "Total Width", + "Total Depth", + "Number Of Neurites", +] + +STATISTICS = { + "Morphology Max Radial Distance": ["raw"], + "Neurite Max Radial Distance": ["raw"], + "Number Of Sections": ["raw"], + "Number Of Bifurcations": ["raw"], + "Number Of Leaves": ["raw"], + "Total Length": ["raw"], + "Total Area": ["raw"], + "Total Volume": ["raw"], + "Section Lengths": ["min", "max", "median", "mean", "std"], + "Section Term Lengths": ["min", "max", "median", "mean", "std"], + "Section Bif Lengths": ["min", "max", "median", "mean", "std"], + "Section Branch Orders": ["min", "max", "median", "mean", "std"], + "Section Bif Branch Orders": ["min", "max", "median", "mean", "std"], + "Section Term Branch Orders": ["min", "max", "median", "mean", "std"], + "Section Path Distances": ["min", "max", "median", "mean", "std"], + "Section Taper Rates": ["min", "max", "median", "mean", "std"], + "Local Bifurcation Angles": ["min", "max", "median", "mean", "std"], + "Remote Bifurcation Angles": ["min", "max", "median", "mean", "std"], + "Partition Asymmetry": ["min", "max", "median", "mean", "std"], + "Partition Asymmetry Length": ["min", "max", "median", "mean", "std"], + "Sibling Ratios": ["min", "max", "median", "mean", "std"], + "Diameter Power Relations": ["min", "max", "median", "mean", "std"], + "Section Radial Distances": ["min", "max", "median", "mean", "std"], + "Section Term Radial Distances": ["min", "max", "median", "mean", "std"], + "Section Bif Radial Distances": ["min", "max", "median", "mean", "std"], + "Terminal Path Lengths": ["min", "max", "median", "mean", "std"], + "Section Volumes": ["min", "max", "median", "mean", "std"], + "Section Areas": ["min", "max", "median", "mean", "std"], + "Section Tortuosity": ["min", "max", "median", "mean", "std"], + "Section Strahler Orders": ["min", "max", "median", "mean", "std"], + "Soma Surface Area": ["raw"], + "Soma Radius": ["raw"], + "Number Of Sections Per Neurite": ["min", "max", "median", "mean", "std"], + "Total Length Per Neurite": ["min", "max", "median", "mean", "std"], + "Total Area Per Neurite": ["min", "max", "median", "mean", "std"], + "Total Height": ["raw"], + "Total Width": ["raw"], + "Total Depth": ["raw"], + "Number Of Neurites": ["raw"], + "Soma Number Of Points": ["N"], +} + +logger = logging.getLogger(__name__) + + +class FeatRangeInput(BaseModel): + """Features Range input class.""" + + min_value: float | int | None = None + max_value: float | int | None = None + + +class FeatureInput(BaseModel): + """Class defining the scheme of inputs the agent should use for the features.""" + + label: LABEL + compartment: ( + Literal["Axon", "BasalDendrite", "ApicalDendrite", "NeuronMorphology", "Soma"] + | None + ) = Field( + default=None, + description=( + "Compartment of the cell. Leave as None if not explicitely stated by the" + " user" + ), + ) + feat_range: FeatRangeInput | None = None + + @model_validator(mode="before") + @classmethod + def check_if_list(cls, data: Any) -> dict[str, str | list[float | int] | None]: + """Validate that the values passed to the constructor are a dictionary.""" + if isinstance(data, list) and len(data) == 1: + data_dict = data[0] + else: + data_dict = data + return data_dict + + +class InputKGMorphoFeatures(BaseModel): + """Inputs of the knowledge graph API when retrieving features of morphologies.""" + + brain_region_id: str = Field(description="ID of the brain region of interest.") + features: FeatureInput = Field( + description="""Definition of the feature and values expected by the user. + The input consists of a dictionary with three keys. The first one is the label (or name) of the feature specified by the user. + The second one is the compartment in which the feature is calculated. It MUST be None if not explicitly specified by the user. + The third one consists of a min_value and a max_value which encapsulate the range of values the user expects for this feature. It can also be None if not specified by the user. + For instance, if the user asks for a morphology with an axon section volume between 1000 and 5000µm, the corresponding tuple should be: {label: 'Section Volumes', compartment: 'Axon', feat_range: FeatRangeInput(min_value=1000, max_value=5000)}.""", + ) + + +class KGMorphoFeatureOutput(BaseToolOutput): + """Output schema for the knowledge graph API.""" + + brain_region_id: str + brain_region_label: str | None = None + + morphology_id: str + morphology_name: str | None = None + + features: dict[str, str] + + +class KGMorphoFeatureTool(BasicTool): + """Class defining the Knowledge Graph logic.""" + + name: str = "kg-morpho-feature-tool" + description: str = """Searches a neuroscience based knowledge graph to retrieve neuron morphology features based on a brain region of interest. + Use this tool if and only if the user specifies explicitely certain features of morphology, and potentially the range of values expected. + Requires a 'brain_region_id' and a dictionary with keys 'label' (and optionally 'compartment' and 'feat_range') describing the feature(s) specified by the user. + The morphology ID is in the form of an HTTP(S) link such as 'https://bbp.epfl.ch/neurosciencegraph/data/neuronmorphologies...'. + The output is a list of morphologies, containing: + - The brain region ID. + - The brain region name. + - The morphology ID. + - The morphology name. + - The list of features of the morphology. + If a given feature has multiple statistics (e.g. mean, min, max, median...), please return only its mean unless specified differently by the user.""" + metadata: dict[str, Any] + args_schema: Type[BaseModel] = InputKGMorphoFeatures + + def _run(self) -> None: + """Not defined yet.""" + pass + + async def _arun( + self, + brain_region_id: str, + features: FeatureInput, + ) -> list[KGMorphoFeatureOutput] | dict[str, str]: + """Run the tool async. + + Parameters + ---------- + brain_region_id + ID of the brain region of interest (of the form http://api.brain-map.org/api/v2/data/Structure/...) + features + Pydantic class describing the features one wants to compute + + Returns + ------- + list of KGMorphoFeatureOutput to describe the morphology and its features, or an error dict. + """ + try: + logger.info( + f"Entering KG morpho feature tool. Inputs: {brain_region_id=}," + f" {features=}" + ) + # Get the descendants of the brain region specified as input + hierarchy_ids = get_descendants_id( + brain_region_id, json_path=self.metadata["brainregion_path"] + ) + logger.info( + f"Found {len(list(hierarchy_ids))} children of the brain ontology." + ) + + # Get the associated ES query + entire_query = self.create_query( + brain_regions_ids=hierarchy_ids, features=features + ) + + # Send the ES query to the KG + response = await self.metadata["httpx_client"].post( + url=self.metadata["url"], + headers={"Authorization": f"Bearer {self.metadata['token']}"}, + json=entire_query, + ) + + return self._process_output(response.json()) + + except Exception as e: + raise ToolException(str(e), self.name) + + def create_query( + self, brain_regions_ids: set[str], features: FeatureInput + ) -> dict[str, Any]: + """Create ES query to query the KG with. + + Parameters + ---------- + brain_regions_ids + IDs of the brain region of interest (of the form http://api.brain-map.org/api/v2/data/Structure/...) + features + Pydantic class describing the features one wants to compute + + Returns + ------- + Dict containing the ES query to send to the KG. + """ + # At least one BR should match in the set of descendants + conditions = [ + { + "bool": { + "should": [ + {"term": {"brainRegion.@id.keyword": hierarchy_id}} + for hierarchy_id in brain_regions_ids + ] + } + } + ] + + # Add condition for the name of the requested feature to be present + sub_conditions: list[dict[str, Any]] = [] + sub_conditions.append( + {"term": {"featureSeries.label.keyword": str(features.label)}} + ) + + # Optionally add a constraint on the compartment if specified + if features.compartment: + sub_conditions.append( + { + "term": { + "featureSeries.compartment.keyword": str(features.compartment) + } + } + ) + + # Optionally add a constraint on the feature values if specified + if features.feat_range: + # Get the correct statistic for the feature + stat = ( + "mean" + if "mean" in STATISTICS[features.label] + else "raw" + if "raw" in STATISTICS[features.label] + else "N" + ) + + # Add constraint on the statistic type + sub_conditions.append({"term": {"featureSeries.statistic.keyword": stat}}) + feat_range = [ + features.feat_range.min_value, + features.feat_range.max_value, + ] + # Add constraint on min and/or max value of the feature + sub_condition = {"range": {"featureSeries.value": {}}} # type: ignore + if feat_range[0]: + sub_condition["range"]["featureSeries.value"]["gte"] = feat_range[0] + if feat_range[1]: + sub_condition["range"]["featureSeries.value"]["lte"] = feat_range[1] + if len(sub_condition["range"]["featureSeries.value"]) > 0: + sub_conditions.append(sub_condition) + + # Nest the entire constrained query in a nested block + feature_nested_query = { + "nested": { + "path": "featureSeries", + "query": {"bool": {"must": sub_conditions}}, + } + } + conditions.append(feature_nested_query) # type: ignore + + # Unwrap all of the conditions in the global query + entire_query = { + "size": self.metadata["search_size"], + "track_total_hits": True, + "query": { + "bool": { + "must": [ + *conditions, + { + "term": { + "@type.keyword": "https://bbp.epfl.ch/ontologies/core/bmo/NeuronMorphologyFeatureAnnotation" + } + }, + {"term": {"deprecated": False}}, + ] + } + }, + } + + return entire_query + + @staticmethod + def _process_output(output: Any) -> list[KGMorphoFeatureOutput]: + """Process output. + + Parameters + ---------- + output + Raw output of the _arun method, which comes from the KG + + Returns + ------- + list of KGMorphoFeatureOutput to describe the morphology and its features. + """ + formatted_output = [] + for morpho in output["hits"]["hits"]: + morpho_source = morpho["_source"] + feature_output = { + f"{dic['compartment']} {dic['label']} ({dic['statistic']})": ( + f"{dic['value']} ({dic['unit']})" + ) + for dic in morpho_source["featureSeries"] + } + formatted_output.append( + KGMorphoFeatureOutput( + brain_region_id=morpho_source["brainRegion"]["@id"], + brain_region_label=morpho_source["brainRegion"].get("label"), + morphology_id=morpho_source["neuronMorphology"]["@id"], + morphology_name=morpho_source["neuronMorphology"].get("name"), + features=feature_output, + ) + ) + + return formatted_output diff --git a/src/neuroagent/tools/literature_search_tool.py b/src/neuroagent/tools/literature_search_tool.py new file mode 100644 index 0000000..b25d9bb --- /dev/null +++ b/src/neuroagent/tools/literature_search_tool.py @@ -0,0 +1,133 @@ +"""Literature Search tool.""" + +import logging +from typing import Any, Type + +from langchain_core.tools import ToolException +from pydantic import BaseModel, Field + +from neuroagent.tools.base_tool import BaseToolOutput, BasicTool + +logger = logging.getLogger(__name__) + + +class InputLiteratureSearch(BaseModel): + """Inputs of the literature search API.""" + + query: str = Field( + description=( + "Query to match against the text of paragraphs coming from scientific" + " articles. The matching is done using the bm25 algorithm, so the query" + " should be based on keywords to ensure maximal efficiency." + ) + ) + + +class ParagraphMetadata(BaseToolOutput, extra="ignore"): + """Metadata for an article.""" + + article_title: str + article_authors: list[str] + paragraph: str + section: str | None = None + article_doi: str | None = None + journal_issn: str | None = None + + +class LiteratureSearchTool(BasicTool): + """Class defining the Literature Search logic.""" + + name: str = "literature-search-tool" + description: str = """Searches the scientific literature. The tool should be used to gather general scientific knowledge. It is best suited for questions about neuroscience and medicine that are not about morphologies. + It returns a list of paragraphs fron scientific papers that match the query (in the sense of the bm25 algorithm), alongside with the metadata of the articles they were extracted from, such as: + - title + - authors + - paragraph_text + - section + - article_doi + - journal_issn""" + metadata: dict[str, Any] + args_schema: Type[BaseModel] = InputLiteratureSearch + + def _run(self, query: str) -> list[ParagraphMetadata]: + """Search the scientific literature and returns citations. + + Parameters + ---------- + query + Query to send to the literature search backend + + Returns + ------- + List of paragraphs and their metadata + """ + # Prepare the request's body + req_body = { + "query": query, + "retriever_k": self.metadata["retriever_k"], + "use_reranker": self.metadata["use_reranker"], + "reranker_k": self.metadata["reranker_k"], + } + + # Send the request + return self._process_output( + self.metadata["httpx_client"] + .get( + self.metadata["url"], + headers={"Authorization": f"Bearer {self.metadata['token']}"}, + json=req_body, + timeout=None, + ) + .json() + ) + + async def _arun(self, query: str) -> list[ParagraphMetadata] | str: + """Async search the scientific literature and returns citations. + + Parameters + ---------- + query + Query to send to the literature search backend + + Returns + ------- + List of paragraphs and their metadata + """ + try: + logger.info(f"Entering literature search tool. Inputs: {query=}") + + # Prepare the request's body + req_body = { + "query": query, + "retriever_k": self.metadata["retriever_k"], + "use_reranker": self.metadata["use_reranker"], + "reranker_k": self.metadata["reranker_k"], + } + + # Send the request + response = await self.metadata["httpx_client"].get( + self.metadata["url"], + headers={"Authorization": f"Bearer {self.metadata['token']}"}, + params=req_body, + timeout=None, + ) + + return self._process_output(response.json()) + except Exception as e: + raise ToolException(str(e), self.name) + + @staticmethod + def _process_output(output: list[dict[str, Any]]) -> list[ParagraphMetadata]: + """Process output.""" + paragraphs_metadata = [ + ParagraphMetadata( + article_title=paragraph["article_title"], + article_authors=paragraph["article_authors"], + paragraph=paragraph["paragraph"], + section=paragraph["section"], + article_doi=paragraph["article_doi"], + journal_issn=paragraph["journal_issn"], + ) + for paragraph in output + ] + return paragraphs_metadata diff --git a/src/neuroagent/tools/morphology_features_tool.py b/src/neuroagent/tools/morphology_features_tool.py new file mode 100644 index 0000000..aee5f72 --- /dev/null +++ b/src/neuroagent/tools/morphology_features_tool.py @@ -0,0 +1,163 @@ +"""Morphology features tool.""" + +import logging +from typing import Any, Type + +import neurom +import numpy as np +from langchain_core.tools import ToolException +from neurom.io.utils import load_morphology +from pydantic import BaseModel, Field + +from neuroagent.tools.base_tool import BaseToolOutput, BasicTool +from neuroagent.utils import get_kg_data + +logger = logging.getLogger(__name__) + + +class InputNeuroM(BaseModel): + """Inputs of the NeuroM API.""" + + morphology_id: str = Field( + description=( + "ID of the morphology of interest. A morphology ID is an HTTP(S) link, it" + " should therefore match the following regex pattern:" + r" 'https?://\S+[a-zA-Z0-9]'" + ) + ) + + +class MorphologyFeatureOutput(BaseToolOutput): + """Output schema for the neurom tool.""" + + brain_region: str + feature_dict: dict[str, Any] + + +class MorphologyFeatureTool(BasicTool): + """Class defining the morphology feature retrieval logic.""" + + name: str = "morpho-features-tool" + description: str = """Given a morphology ID, fetch data about the features of the morphology. You need to know a morphology ID to use this tool and they can only come from the 'get-morpho-tool'. Therefore this tool should only be used if you already called the 'knowledge-graph-tool'. + Here is an exhaustive list of features that can be retrieved with this tool: + Soma radius, Soma surface area, Number of neurites, Number of sections, Number of sections per neurite, Section lengths, Segment lengths, Section radial distance, Section path distance, Local bifurcation angles, Remote bifurcation angles.""" + metadata: dict[str, Any] + args_schema: Type[BaseModel] = InputNeuroM + + def _run(self) -> None: + """Not implemented yet.""" + pass + + async def _arun(self, morphology_id: str) -> list[MorphologyFeatureOutput]: + """Give features about morphology. + + Parameters + ---------- + morphology_id + ID of the morphology of interest + + Returns + ------- + Dict containing feature_name: value. + """ + logger.info(f"Entering morphology feature tool. Inputs: {morphology_id=}") + try: + # Download the .swc file describing the morphology from the KG + morphology_content, metadata = await get_kg_data( + object_id=morphology_id, + httpx_client=self.metadata["httpx_client"], + url=self.metadata["url"], + token=self.metadata["token"], + preferred_format="swc", + ) + + # Extract the features from it + features = self.get_features(morphology_content, metadata.file_extension) + return [ + MorphologyFeatureOutput( + brain_region=metadata.brain_region, feature_dict=features + ) + ] + except Exception as e: + raise ToolException(str(e), self.name) + + def get_features(self, morphology_content: bytes, reader: str) -> dict[str, Any]: + """Get features from a morphology. + + Parameters + ---------- + morphology_content + Bytes of the file containing the morphology info (comes from the KG) + reader + type of file (i.e. its extension) + + Returns + ------- + Dict containing feature_name: value. + """ + # Load the morphology + morpho = load_morphology(morphology_content.decode(), reader=reader) + + # Compute soma radius and soma surface area + features = { + "soma_radius [µm]": neurom.get("soma_radius", morpho), + "soma_surface_area [µm^2]": neurom.get("soma_surface_area", morpho), + } + + # Prepare a list of features that have a unique value (no statistics) + f1 = [ + ("number_of_neurites", "Number of neurites"), + ("number_of_sections", "Number of sections"), + ("number_of_sections_per_neurite", "Number of sections per neurite"), + ] + + # For each neurite type, compute the above features + for neurite_type in neurom.NEURITE_TYPES: + for get_name, name in f1: + features[f"{name} ({neurite_type.name})"] = neurom.get( + get_name, morpho, neurite_type=neurite_type + ) + + # Prepare a list of features that are defined by statistics + f2 = [ + ("section_lengths", "Section lengths [µm]"), + ("segment_lengths", "Segment lengths [µm]"), + ("section_radial_distances", "Section radial distance [µm]"), + ("section_path_distances", "Section path distance [µm]"), + ("local_bifurcation_angles", "Local bifurcation angles [˚]"), + ("remote_bifurcation_angles", "Remote bifurcation angles [˚]"), + ] + + # For each neurite, compute the feature values and return their statistics + for neurite_type in neurom.NEURITE_TYPES: + for get_name, name in f2: + try: + array = neurom.get(get_name, morpho, neurite_type=neurite_type) + if len(array) == 0: + continue + features[f"{name} ({neurite_type.name})"] = self.get_stats(array) + except (IndexError, ValueError): + continue + return features + + @staticmethod + def get_stats(array: list[int | float]) -> dict[str, int | np.float64]: + """Get summary stats for the array. + + Parameters + ---------- + array + Array of feature's statistics of a morphology + + Returns + ------- + Dict with length, mean, sum, standard deviation, min and max of data + """ + return { + "len": len(array), + "mean": np.mean(array), + "sum": np.sum(array), + "std": np.std(array), + "min": np.min(array), + "max": np.max(array), + } diff --git a/src/neuroagent/tools/resolve_brain_region_tool.py b/src/neuroagent/tools/resolve_brain_region_tool.py new file mode 100644 index 0000000..ecd4c4c --- /dev/null +++ b/src/neuroagent/tools/resolve_brain_region_tool.py @@ -0,0 +1,123 @@ +"""Tool to resolve the brain region from natural english to a KG ID.""" + +import logging +from typing import Any, Optional, Type + +from langchain_core.tools import ToolException +from pydantic import BaseModel, Field + +from neuroagent.resolving import resolve_query +from neuroagent.tools.base_tool import BaseToolOutput, BasicTool + +logger = logging.getLogger(__name__) + + +class InputResolveBR(BaseModel): + """Inputs of the Resolve Brain Region tool..""" + + brain_region: str = Field( + description="Brain region of interest specified by the user in natural english." + ) + mtype: Optional[str] = Field( + default=None, + description="M-type of interest specified by the user in natural english.", + ) + + +class BRResolveOutput(BaseToolOutput): + """Output schema for the Brain region resolver.""" + + brain_region_name: str + brain_region_id: str + + +class MTypeResolveOutput(BaseToolOutput): + """Output schema for the Mtype resolver.""" + + mtype_name: str + mtype_id: str + + +class ResolveBrainRegionTool(BasicTool): + """Class defining the Brain Region Resolving logic.""" + + name: str = "resolve-brain-region-tool" + description: str = """From a brain region name written in natural english, search a knowledge graph to retrieve its corresponding ID. + Optionaly resolve the mtype name from natural english to its corresponding ID too. + You MUST use this tool when a brain region is specified in natural english because in that case the output of this tool is essential to other tools. + returns a dictionary containing the brain region name, id and optionaly the mtype name and id. + Brain region related outputs are stored in the class `BRResolveOutput` while the mtype related outputs are stored in the class `MTypeResolveOutput`.""" + metadata: dict[str, Any] + args_schema: Type[BaseModel] = InputResolveBR + + def _run(self) -> None: + """Not implemented yet.""" + pass + + async def _arun( + self, brain_region: str, mtype: str | None = None + ) -> list[BRResolveOutput | MTypeResolveOutput]: + """Given a brain region in natural language, resolve its ID. + + Parameters + ---------- + brain_region + Name of the brain region to resolve (in english) + mtype + Name of the mtype to resolve (in english) + + Returns + ------- + Mapping from BR/mtype name to ID. + """ + logger.info( + f"Entering Brain Region resolver tool. Inputs: {brain_region=}, {mtype=}" + ) + try: + # Prepare the output list. + output: list[BRResolveOutput | MTypeResolveOutput] = [] + + # First resolve the brain regions. + brain_regions = await resolve_query( + sparql_view_url=self.metadata["kg_sparql_url"], + token=self.metadata["token"], + query=brain_region, + resource_type="nsg:BrainRegion", + search_size=10, + httpx_client=self.metadata["httpx_client"], + es_view_url=self.metadata["kg_class_view_url"], + ) + # Extend the resolved BRs. + output.extend( + [ + BRResolveOutput( + brain_region_name=br["label"], brain_region_id=br["id"] + ) + for br in brain_regions + ] + ) + + # Optionally resolve the mtypes. + if mtype is not None: + mtypes = await resolve_query( + sparql_view_url=self.metadata["kg_sparql_url"], + token=self.metadata["token"], + query=mtype, + resource_type="bmo:BrainCellType", + search_size=10, + httpx_client=self.metadata["httpx_client"], + es_view_url=self.metadata["kg_class_view_url"], + ) + # Extend the resolved mtypes. + output.extend( + [ + MTypeResolveOutput( + mtype_name=mtype["label"], mtype_id=mtype["id"] + ) + for mtype in mtypes + ] + ) + + return output + except Exception as e: + raise ToolException(str(e), self.name) diff --git a/src/neuroagent/tools/traces_tool.py b/src/neuroagent/tools/traces_tool.py new file mode 100644 index 0000000..ce3f30c --- /dev/null +++ b/src/neuroagent/tools/traces_tool.py @@ -0,0 +1,265 @@ +"""Traces tool.""" + +import logging +from typing import Any, Literal, Optional, Type + +from langchain_core.tools import ToolException +from pydantic import BaseModel, Field + +from neuroagent.tools.base_tool import BaseToolOutput, BasicTool +from neuroagent.utils import get_descendants_id + +logger = logging.getLogger(__name__) + +ETYPE_IDS = { + "bAC": "http://uri.interlex.org/base/ilx_0738199", + "bIR": "http://uri.interlex.org/base/ilx_0738206", + "bNAC": "http://uri.interlex.org/base/ilx_0738203", + "bSTUT": "http://uri.interlex.org/base/ilx_0738200", + "cAC": "http://uri.interlex.org/base/ilx_0738197", + "cIR": "http://uri.interlex.org/base/ilx_0738204", + "cNAC": "http://uri.interlex.org/base/ilx_0738201", + "cSTUT": "http://uri.interlex.org/base/ilx_0738198", + "dNAC": "http://uri.interlex.org/base/ilx_0738205", + "dSTUT": "http://uri.interlex.org/base/ilx_0738202", +} + + +class InputGetTraces(BaseModel): + """Inputs of the knowledge graph API.""" + + brain_region_id: str = Field(description="ID of the brain region of interest.") + etype: Optional[ + Literal[ + "bAC", + "bIR", + "bNAC", + "bSTUT", + "cAC", + "cIR", + "cNAC", + "cSTUT", + "dNAC", + "dSTUT", + ] + ] = Field( + default=None, + description=( + "E-type of interest specified by the user. Possible values:" + f" {', '.join(list(ETYPE_IDS.keys()))}. The first letter meaning classical," + " bursting or delayed, The other letters in capital meaning accomodating," + " non-accomodating, stuttering or irregular spiking." + ), + ) + + +class TracesOutput(BaseToolOutput): + """Output schema for the traces.""" + + trace_id: str + + brain_region_id: str + brain_region_label: str | None + + etype: str | None + + subject_species_id: str | None + subject_species_label: str | None + subject_age: str | None + + +class GetTracesTool(BasicTool): + """Class defining the logic to obtain traces ids.""" + + name: str = "get-traces-tool" + description: str = """Searches a neuroscience based knowledge graph to retrieve traces names, IDs and descriptions. + Requires a 'brain_region_id' which is the ID of the brain region of interest as registered in the knowledge graph. To get this ID, please use the `resolve-brain-region-tool` first. + The output is a list of traces, containing: + - The trace id. + - The brain region ID. + - The brain region name. + - The etype of the excited cell + - The subject species ID. + - The subject species name. + - The subject age. + The trace ID is in the form of an HTTP(S) link such as 'https://bbp.epfl.ch/neurosciencegraph/data/traces...'.""" + metadata: dict[str, Any] + args_schema: Type[BaseModel] = InputGetTraces + + def _run(self, query: str) -> list[TracesOutput]: # type: ignore + pass + + async def _arun( + self, + brain_region_id: str, + etype: ( + Literal[ + "bAC", + "bIR", + "bNAC", + "bSTUT", + "cAC", + "cIR", + "cNAC", + "cSTUT", + "dAC", + "dIR", + "dNAC", + "dSTUT", + ] + | None + ) = None, + ) -> list[TracesOutput] | dict[str, str]: + """From a brain region ID, extract traces. + + Parameters + ---------- + brain_region_id + ID of the brain region of interest (of the form http://api.brain-map.org/api/v2/data/Structure/...) + etype + Name of the etype of interest (in plain english) + + Returns + ------- + list of TracesOutput to describe the trace and its metadata, or an error dict. + """ + logger.info(f"Entering get trace tool. Inputs: {brain_region_id=}, {etype=}") + try: + # Get descendants of the brain region specified as input + hierarchy_ids = get_descendants_id( + brain_region_id, json_path=self.metadata["brainregion_path"] + ) + logger.info( + f"Found {len(list(hierarchy_ids))} children of the brain ontology." + ) + + # Create the ES query to query the KG with resolved descendants + entire_query = self.create_query( + brain_region_ids=hierarchy_ids, etype=etype + ) + + # Send the query to the KG + response = await self.metadata["httpx_client"].post( + url=self.metadata["url"], + headers={"Authorization": f"Bearer {self.metadata['token']}"}, + json=entire_query, + ) + return self._process_output(response.json()) + except Exception as e: + raise ToolException(str(e), self.name) + + def create_query( + self, + brain_region_ids: set[str], + etype: ( + Literal[ + "bAC", + "bIR", + "bNAC", + "bSTUT", + "cAC", + "cIR", + "cNAC", + "cSTUT", + "dAC", + "dIR", + "dNAC", + "dSTUT", + ] + | None + ) = None, + ) -> dict[str, Any]: + """Create ES query. + + Parameters + ---------- + brain_region_ids + IDs of the brain region of interest (of the form http://api.brain-map.org/api/v2/data/Structure/...) + etype + Name of the etype of interest (in plain english) + + Returns + ------- + dict containing the ES query to send to the KG. + """ + # At least one of the children brain region should match. + conditions = [ + { + "bool": { + "should": [ + {"term": {"brainRegion.@id.keyword": hierarchy_id}} + for hierarchy_id in brain_region_ids + ] + } + } + ] + + # Optionally constraint the output on the etype of the cell + if etype is not None: + etype_id = ETYPE_IDS[etype] + logger.info(f"etype selected: {etype_id}") + conditions.append({"term": {"eType.@id.keyword": etype_id}}) # type: ignore + + # Unwrap everything into the main query + entire_query = { + "size": self.metadata["search_size"], + "track_total_hits": True, + "query": { + "bool": { + "must": [ + *conditions, + { + "term": { + "@type.keyword": "https://bbp.epfl.ch/ontologies/core/bmo/ExperimentalTrace" + } + }, + {"term": {"curated": True}}, + {"term": {"deprecated": False}}, + ] + } + }, + } + return entire_query + + @staticmethod + def _process_output(output: Any) -> list[TracesOutput]: + """Process output to fit the TracesOutput pydantic class defined above. + + Parameters + ---------- + output + Raw output of the _arun method, which comes from the KG + + Returns + ------- + list of TracesOutput to describe the trace and its metadata. + """ + results = [ + TracesOutput( + trace_id=res["_source"]["@id"], + brain_region_id=res["_source"]["brainRegion"]["@id"], + brain_region_label=res["_source"]["brainRegion"]["label"], + etype=( + res["_source"]["eType"].get("label") + if "eType" in res["_source"] + else None + ), + subject_species_id=( + res["_source"]["subjectSpecies"]["@id"] + if "subjectSpecies" in res["_source"] + else None + ), + subject_species_label=( + res["_source"]["subjectSpecies"]["label"] + if "subjectSpecies" in res["_source"] + else None + ), + subject_age=( + f"{res['_source']['subjectAge']['value']} {res['_source']['subjectAge']['unit']}" + if "subjectAge" in res["_source"] + else None + ), + ) + for res in output["hits"]["hits"] + ] + return results diff --git a/src/neuroagent/utils.py b/src/neuroagent/utils.py new file mode 100644 index 0000000..61c4488 --- /dev/null +++ b/src/neuroagent/utils.py @@ -0,0 +1,468 @@ +"""Utilies for neuroagent.""" + +import json +import logging +import numbers +import re +from pathlib import Path +from typing import Any, Iterator + +from httpx import AsyncClient + +from neuroagent.schemas import KGMetadata + +logger = logging.getLogger(__name__) + + +class RegionMeta: + """Class holding the hierarchical region metadata. + + Typically, such information would be parsed from a `brain_regions.json` + file. + + Parameters + ---------- + background_id : int, optional + Override the default ID for the background. + """ + + def __init__(self, background_id: int = 0) -> None: + self.background_id = background_id + self.root_id: int | None = None + + self.name_: dict[int, str] = {self.background_id: "background"} + self.st_level: dict[int, int | None] = {self.background_id: None} + + self.parent_id: dict[int, int] = {self.background_id: background_id} + self.children_ids: dict[int, list[int]] = {self.background_id: []} + + def children(self, region_id: int) -> tuple[int, ...]: + """Get all child region IDs of a given region. + + Note that by children we mean only the direct children, much like + by parent we only mean the direct parent. The cumulative quantities + that span all generations are called ancestors and descendants. + + Parameters + ---------- + region_id : int + The region ID in question. + + Returns + ------- + int + The region ID of a child region. + """ + return tuple(self.children_ids[region_id]) + + def descendants(self, ids: int | list[int]) -> set[int]: + """Find all descendants of given regions. + + The result is inclusive, i.e. the input region IDs will be + included in the result. + + Parameters + ---------- + ids : int or iterable of int + A region ID or a collection of region IDs to collect + descendants for. + + Returns + ------- + set + All descendant region IDs of the given regions, including the input + regions themselves. + """ + if isinstance(ids, numbers.Integral): + unique_ids: set[int] = {ids} + elif isinstance(ids, set): + unique_ids = set(ids) + + def iter_descendants(region_id: int) -> Iterator[int]: + """Iterate over all descendants of a given region ID. + + Parameters + ---------- + region_id + Integer representing the id of the region + + Returns + ------- + Iterator with descendants of the region + """ + yield region_id + for child in self.children(region_id): + yield child + yield from iter_descendants(child) + + descendants = set() + for id_ in unique_ids: + descendants |= set(iter_descendants(id_)) + + return descendants + + def save_config(self, json_file_path: str | Path) -> None: + """Save the actual configuration in a json file. + + Parameters + ---------- + json_file_path + Path where to save the json file + """ + to_save = { + "root_id": self.root_id, + "names": self.name_, + "st_level": self.st_level, + "parent_id": self.parent_id, + "children_ids": self.children_ids, + } + with open(json_file_path, "w") as fs: + fs.write(json.dumps(to_save)) + + @classmethod + def load_config(cls, json_file_path: str | Path) -> "RegionMeta": + """Load a configuration in a json file and return a 'RegionMeta' instance. + + Parameters + ---------- + json_file_path + Path to the json file containing the brain region hierarchy + + Returns + ------- + RegionMeta class with pre-loaded hierarchy + """ + with open(json_file_path, "r") as fs: + to_load = json.load(fs) + + # Needed to convert json 'str' keys to int. + for k1 in to_load.keys(): + if not isinstance(to_load[k1], int): + to_load[k1] = {int(k): v for k, v in to_load[k1].items()} + + self = cls() + + self.root_id = to_load["root_id"] + self.name_ = to_load["names"] + self.st_level = to_load["st_level"] + self.parent_id = to_load["parent_id"] + self.children_ids = to_load["children_ids"] + + return self + + @classmethod + def from_KG_dict(cls, KG_hierarchy: dict[str, Any]) -> "RegionMeta": + """Construct an instance from the json of the Knowledge Graph. + + Parameters + ---------- + KG_hierarchy : dict + The dictionary of the region hierarchy, provided by the KG. + + Returns + ------- + region_meta : RegionMeta + The initialized instance of this class. + """ + self = cls() + + for brain_region in KG_hierarchy["defines"]: + # Filter out wrong elements of the KG. + if "identifier" in brain_region.keys(): + region_id = int(brain_region["identifier"]) + + # Check if we are at root. + if "isPartOf" not in brain_region.keys(): + self.root_id = int(region_id) + self.parent_id[region_id] = self.background_id + else: + # Strip url to only keep ID. + self.parent_id[region_id] = int( + brain_region["isPartOf"][0].rsplit("/")[-1] + ) + self.children_ids[region_id] = [] + + self.name_[region_id] = brain_region["label"] + + if "st_level" not in brain_region.keys(): + self.st_level[region_id] = None + else: + self.st_level[region_id] = brain_region["st_level"] + + # Once every parents are set, we can deduce all childrens. + for child_id, parent_id in self.parent_id.items(): + if parent_id is not None: + self.children_ids[int(parent_id)].append(child_id) + + return self + + @classmethod + def load_json(cls, json_path: Path | str) -> "RegionMeta": + """Load the structure graph from a JSON file and create a Class instance. + + Parameters + ---------- + json_path : str or pathlib.Path + + Returns + ------- + RegionMeta + The initialized instance of this class. + """ + with open(json_path) as fh: + KG_hierarchy = json.load(fh) + + return cls.from_KG_dict(KG_hierarchy) + + +def get_descendants_id(brain_region_id: str, json_path: str | Path) -> set[str]: + """Get all descendant of a brain region id. + + Parameters + ---------- + brain_region_id + Brain region ID to find descendants for. + json_path + Path to the json file containing the BR hierarchy + + Returns + ------- + Set of descendants of a brain region + """ + # Split a brain region ID of the form "http://api.brain-map.org/api/v2/data/Structure/123" into base + id. + id_base, _, brain_region_str = brain_region_id.rpartition("/") + try: + # Convert the id into an int + brain_region_int = int(brain_region_str) + + # Get the descendant ids of this BR (as int). + region_meta = RegionMeta.load_config(json_path) + hierarchy = region_meta.descendants(brain_region_int) + + # Recast the descendants into the form "http://api.brain-map.org/api/v2/data/Structure/123" + hierarchy_ids = {f"{id_base}/{h}" for h in hierarchy} + except ValueError: + logger.info( + f"The brain region {brain_region_id} didn't end with an int. Returning only" + " the parent one." + ) + hierarchy_ids = {brain_region_id} + except IOError: + logger.warning(f"The file {json_path} doesn't exist.") + hierarchy_ids = {brain_region_id} + + return hierarchy_ids + + +async def get_file_from_KG( + file_url: str, + file_name: str, + view_url: str, + token: str, + httpx_client: AsyncClient, +) -> dict[str, Any]: + """Get json file for brain region / cell types from the KG. + + Parameters + ---------- + file_url + URL of the view containing the potential file + file_name + Name of the file to download + view_url + URL of the sparql view where to send the request to get the file url + token + Token used to access the knowledge graph + httpx_client + AsyncClient to send requests + + Returns + ------- + Json contained in the downloaded file + """ + sparql_query = """ + PREFIX schema: + + SELECT DISTINCT ?file_url + WHERE {{ + {file_url} schema:distribution ?json_distribution . + ?json_distribution schema:name "{file_name}" ; + schema:contentUrl ?file_url . + }} + LIMIT 1""".format(file_url=file_url, file_name=file_name) + try: + file_response = None + + # Get the url of the relevant file + url_response = await httpx_client.post( + url=view_url, + content=sparql_query, + headers={ + "Content-Type": "text/plain", + "Accept": "application/sparql-results+json", + "Authorization": f"Bearer {token}", + }, + ) + + # Download the file + file_response = await httpx_client.get( + url=url_response.json()["results"]["bindings"][0]["file_url"]["value"], + headers={ + "Accept": "*/*", + "Authorization": f"Bearer {token}", + }, + ) + + return file_response.json() + + except ValueError: + # Issue with KG + if url_response.status_code != 200: + raise ValueError( + f"Could not find the file url, status code : {url_response.status_code}" + ) + # File not found + elif file_response: + raise ValueError( + f"Could not find the file, status code : {file_response.status_code}" + ) + else: + # Issue when downloading the file + raise ValueError("url_response did not return a Json.") + except IndexError: + # No file url found + raise IndexError("No file url was found.") + except KeyError: + # Json has weird format + raise KeyError("Incorrect json format.") + + +def is_lnmc(contributors: list[dict[str, Any]]) -> bool: + """Extract contributor affiliation out of the contributors.""" + lnmc_contributors = { + "https://www.grid.ac/institutes/grid.5333.6", + "https://bbp.epfl.ch/nexus/v1/realms/bbp/users/yshi", + "https://bbp.epfl.ch/nexus/v1/realms/bbp/users/jyi", + "https://bbp.epfl.ch/neurosciencegraph/data/664380c8-5a22-4974-951c-68ca78c0b1f1", + "https://bbp.epfl.ch/nexus/v1/realms/bbp/users/perin", + "https://bbp.epfl.ch/nexus/v1/realms/bbp/users/rajnish", + "https://bbp.epfl.ch/nexus/v1/realms/bbp/users/ajaquier", + "https://bbp.epfl.ch/nexus/v1/realms/bbp/users/gevaert", + "https://bbp.epfl.ch/nexus/v1/realms/bbp/users/kanari", + } + for contributor in contributors: + if "@id" in contributor and contributor["@id"] in lnmc_contributors: + return True + + return False + + +async def get_kg_data( + object_id: str, + httpx_client: AsyncClient, + url: str, + token: str, + preferred_format: str, +) -> tuple[bytes, KGMetadata]: + """Download any knowledge graph object. + + Parameters + ---------- + object_id + ID of the object to which the file is attached + httpx_client + AsyncClient to send the request + url + URL of the KG view where the object is located + token + Token used to access the knowledge graph + preferred_format + Extension of the file to download + + Returns + ------- + Tuple containing the file's content and the associated metadata + + Raises + ------ + ValueError + If the object ID is not found the knowledge graph. + """ + # Extract the id from the specified input (useful for rewoo) + extracted_id = re.findall(pattern=r"https?://\S+[a-zA-Z0-9]", string=object_id) + if not extracted_id: + raise ValueError(f"The provided ID ({object_id}) is not valid.") + else: + object_id = extracted_id[0] + + # Create ES query to retrieve the object in KG + query = { + "size": 1, + "track_total_hits": True, + "query": { + "bool": { + "must": [ + { + "term": { + "@id.keyword": object_id, + } + } + ] + } + }, + } + + # Retrieve the object of interest from KG + response = await httpx_client.post( + url=url, + headers={"Authorization": f"Bearer {token}"}, + json=query, + ) + + if response.status_code != 200 or len(response.json()["hits"]["hits"]) == 0: + raise ValueError(f"We did not find the object {object_id} you are asking") + + # Get the metadata of the object + response_data = response.json()["hits"]["hits"][0]["_source"] + + # Ensure we got the expected object + if response_data["@id"] != object_id: + raise ValueError(f"We did not find the object {object_id} you are asking") + + metadata: dict[str, Any] = dict() + metadata["brain_region"] = response_data["brainRegion"]["label"] + distributions = response_data["distribution"] + + # Extract the format of the file + has_preferred_format = [ + i + for i, dis in enumerate(distributions) + if dis["encodingFormat"] == f"application/{preferred_format}" + ] + + # Set the file extension accordingly if preferred format found + if len(has_preferred_format) > 0: + chosen_dist = distributions[has_preferred_format[0]] + metadata["file_extension"] = preferred_format + else: + chosen_dist = distributions[0] + metadata["file_extension"] = chosen_dist["encodingFormat"].split("/")[1] + logger.info( + "The format you specified was not available." + f" {metadata['file_extension']} was chosen instead." + ) + + # Check if the object has been added by the LNMC lab (useful for traces) + if "contributors" in response_data: + metadata["is_lnmc"] = is_lnmc(response_data["contributors"]) + + # Download the file + url = chosen_dist["contentUrl"] + content_response = await httpx_client.get( + url=url, + headers={"Authorization": f"Bearer {token}"}, + ) + + # Return its content and the associated metadata + object_content = content_response.content + return object_content, KGMetadata(**metadata) diff --git a/tests/__init__.py b/tests/__init__.py index f53f6a0..e69de29 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -1 +0,0 @@ -"""Neuroagent tests.""" diff --git a/tests/agents/__init__.py b/tests/agents/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/agents/test_simple_agent.py b/tests/agents/test_simple_agent.py new file mode 100644 index 0000000..a31cf91 --- /dev/null +++ b/tests/agents/test_simple_agent.py @@ -0,0 +1,56 @@ +"""Testing agent.""" + +import json +from pathlib import Path + +import pytest +from neuroagent.agents import AgentOutput, AgentStep, SimpleAgent + + +@pytest.mark.asyncio +async def test_simple_agent_arun(fake_llm_with_tools, httpx_mock): + json_path = Path(__file__).resolve().parent.parent / "data" / "knowledge_graph.json" + with open(json_path) as f: + knowledge_graph_response = json.load(f) + + httpx_mock.add_response( + url="http://fake_url", + json=knowledge_graph_response, + ) + + llm, tools, _ = await anext(fake_llm_with_tools) + simple_agent = SimpleAgent(llm=llm, tools=tools) + + response = await simple_agent.arun(query="Call get_morpho with thalamus.") + assert isinstance(response, AgentOutput) + assert response.response == "Great answer" + assert len(response.steps) == 1 + assert isinstance(response.steps[0], AgentStep) + assert response.steps[0].tool_name == "get-morpho-tool" + assert response.steps[0].arguments == { + "brain_region_id": "http://api.brain-map.org/api/v2/data/Structure/549" + } + + +@pytest.mark.asyncio +async def test_simple_agent_astream(fake_llm_with_tools, httpx_mock): + json_path = Path(__file__).resolve().parent.parent / "data" / "knowledge_graph.json" + with open(json_path) as f: + knowledge_graph_response = json.load(f) + + httpx_mock.add_response( + url="http://fake_url", + json=knowledge_graph_response, + ) + + llm, tools, _ = await anext(fake_llm_with_tools) + simple_agent = SimpleAgent(llm=llm, tools=tools) + + response_chunks = simple_agent.astream("Call get_morpho with thalamus.") + response = "".join([el async for el in response_chunks]) + + assert ( + response == "\n\n\nCalling tool : get-morpho-tool with arguments :" + ' {"brain_region_id":"http://api.brain-map.org/api/v2/data/Structure/549"}\n\nGreat' + " answer\n" + ) diff --git a/tests/agents/test_simple_chat_agent.py b/tests/agents/test_simple_chat_agent.py new file mode 100644 index 0000000..6ec5474 --- /dev/null +++ b/tests/agents/test_simple_chat_agent.py @@ -0,0 +1,117 @@ +"""Testing chat agent""" + +import json +from pathlib import Path + +import pytest +from langchain_core.messages import HumanMessage, ToolMessage +from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver +from neuroagent.agents import AgentOutput, AgentStep, SimpleChatAgent + + +@pytest.mark.asyncio +async def test_arun(fake_llm_with_tools, httpx_mock): + llm, tools, fake_responses = await anext(fake_llm_with_tools) + json_path = Path(__file__).resolve().parent.parent / "data" / "knowledge_graph.json" + with open(json_path) as f: + knowledge_graph_response = json.load(f) + + httpx_mock.add_response( + url="http://fake_url", + json=knowledge_graph_response, + ) + async with AsyncSqliteSaver.from_conn_string(":memory:") as memory: + agent = SimpleChatAgent(llm=llm, tools=tools, memory=memory) + + response = await agent.arun( + thread_id="test", query="Call get_morpho with thalamus." + ) + + assert isinstance(response, AgentOutput) + assert response.response == "Great answer" + assert len(response.steps) == 1 + assert isinstance(response.steps[0], AgentStep) + assert response.steps[0].tool_name == "get-morpho-tool" + assert response.steps[0].arguments == { + "brain_region_id": "http://api.brain-map.org/api/v2/data/Structure/549" + } + + messages = memory.alist({"configurable": {"thread_id": "test"}}) + messages_list = [message async for message in messages] + assert len(messages_list) == 5 + + assert messages_list[-1].metadata["writes"]["__start__"]["messages"][ + 0 + ] == HumanMessage(content="Call get_morpho with thalamus.") + assert isinstance( + messages_list[1].metadata["writes"]["tools"]["messages"][0], ToolMessage + ) + assert ( + messages_list[0].metadata["writes"]["agent"]["messages"][0].content + == "Great answer" + ) + + # The ids of the messages have to be unique for them to be added to the graph's state. + for i, response in enumerate(fake_responses): + response.id = str(i) + + llm.messages = iter(fake_responses) + response = await agent.arun( + thread_id="test", query="Call get_morpho with thalamus." + ) + messages = memory.alist({"configurable": {"thread_id": "test"}}) + messages_list = [message async for message in messages] + assert len(messages_list) == 10 + + +@pytest.mark.asyncio +async def test_astream(fake_llm_with_tools, httpx_mock): + llm, tools, fake_responses = await anext(fake_llm_with_tools) + json_path = Path(__file__).resolve().parent.parent / "data" / "knowledge_graph.json" + with open(json_path) as f: + knowledge_graph_response = json.load(f) + + httpx_mock.add_response( + url="http://fake_url", + json=knowledge_graph_response, + ) + async with AsyncSqliteSaver.from_conn_string(":memory:") as memory: + agent = SimpleChatAgent(llm=llm, tools=tools, memory=memory) + + response = agent.astream( + thread_id="test", query="Find morphologies in the thalamus" + ) + + msg_list = "".join([el async for el in response]) + assert ( + msg_list == "\n\n\nCalling tool : get-morpho-tool with arguments :" + ' {"brain_region_id":"http://api.brain-map.org/api/v2/data/Structure/549"}\n\nGreat' + " answer\n" + ) + + messages = memory.alist({"configurable": {"thread_id": "test"}}) + messages_list = [message async for message in messages] + assert len(messages_list) == 5 + assert ( + messages_list[-1].metadata["writes"]["__start__"]["messages"] + == "Find morphologies in the thalamus" + ) + assert isinstance( + messages_list[1].metadata["writes"]["tools"]["messages"][0], ToolMessage + ) + assert ( + messages_list[0].metadata["writes"]["agent"]["messages"][0].content + == "Great answer" + ) + + # The ids of the messages have to be unique for them to be added to the graph's state. + for i, response in enumerate(fake_responses): + response.id = str(i) + llm.messages = iter(fake_responses) + response = agent.astream( + thread_id="test", query="Find morphologies in the thalamus please." + ) + msg_list = "".join([el async for el in response]) # Needed to trigger streaming + messages = memory.alist({"configurable": {"thread_id": "test"}}) + messages_list = [message async for message in messages] + assert len(messages_list) == 10 diff --git a/tests/app/__init__.py b/tests/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/app/database/__init__.py b/tests/app/database/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/app/database/test_threads.py b/tests/app/database/test_threads.py new file mode 100644 index 0000000..0d0f2da --- /dev/null +++ b/tests/app/database/test_threads.py @@ -0,0 +1,161 @@ +"""Test of the thread router.""" + +import pytest +from neuroagent.app.config import Settings +from neuroagent.app.dependencies import get_language_model, get_settings +from neuroagent.app.main import app +from neuroagent.app.routers.database.schemas import GetThreadsOutput +from sqlalchemy import MetaData, create_engine +from sqlalchemy.orm import Session +from sqlalchemy.sql.expression import Select + + +def test_create_thread(patch_required_env, app_client, db_connection): + test_settings = Settings( + db={"prefix": db_connection}, + ) + app.dependency_overrides[get_settings] = lambda: test_settings + with app_client as app_client: + # Create a thread + create_output = app_client.post("/threads/").json() + assert create_output["thread_id"] + assert create_output["user_sub"] == "dev" + assert create_output["title"] == "title" + assert create_output["timestamp"] + + +def test_get_threads(patch_required_env, app_client, db_connection): + test_settings = Settings( + db={"prefix": db_connection}, + ) + app.dependency_overrides[get_settings] = lambda: test_settings + with app_client as app_client: + threads = app_client.get("/threads/").json() + assert not threads + # Create a thread + create_output_1 = app_client.post("/threads/").json() + create_output_2 = app_client.post("/threads/").json() + threads = app_client.get("/threads/").json() + + assert len(threads) == 2 + assert threads[0] == create_output_1 + assert threads[1] == create_output_2 + + +@pytest.mark.asyncio +async def test_get_thread( + patch_required_env, fake_llm_with_tools, app_client, db_connection +): + # Put data in the db + llm, _, _ = await anext(fake_llm_with_tools) + app.dependency_overrides[get_language_model] = lambda: llm + test_settings = Settings( + db={"prefix": db_connection}, + ) + app.dependency_overrides[get_settings] = lambda: test_settings + with app_client as app_client: + wrong_response = app_client.get("/threads/test") + assert wrong_response.status_code == 404 + assert wrong_response.json() == {"detail": {"detail": "Thread not found."}} + + # Create a thread + create_output = app_client.post("/threads/").json() + thread_id = create_output["thread_id"] + + # Fill the thread + app_client.post( + f"/qa/chat/{thread_id}", + json={"inputs": "This is my query", "parameters": {}}, + ) + + create_output = app_client.post("/threads/").json() + empty_thread_id = create_output["thread_id"] + empty_messages = app_client.get(f"/threads/{empty_thread_id}").json() + assert empty_messages == [] + + # Get the messages of the thread + messages = app_client.get(f"/threads/{thread_id}").json() + + assert messages == [ + GetThreadsOutput( + message_id=messages[0]["message_id"], + entity="Human", + message="This is my query", + ).model_dump(), + GetThreadsOutput( + message_id="run-42768b30-044a-4263-8c5c-da61429aa9da-0", + entity="AI", + message="Great answer", + ).model_dump(), + ] + + +def test_update_threads(patch_required_env, app_client, db_connection): + test_settings = Settings( + db={"prefix": db_connection}, + ) + app.dependency_overrides[get_settings] = lambda: test_settings + with app_client as app_client: + wrong_response = app_client.patch("/threads/test", json={"title": "New title"}) + assert wrong_response.status_code == 404 + assert wrong_response.json() == {"detail": {"detail": "Thread not found."}} + + # Create a thread + create_output = app_client.post("/threads/").json() + thread_id = create_output["thread_id"] + app_client.patch(f"/threads/{thread_id}", json={"title": "New title"}) + threads = app_client.get("/threads/").json() + + assert threads[0]["title"] == "New title" + + +@pytest.mark.asyncio +async def test_delete_thread( + patch_required_env, fake_llm_with_tools, app_client, db_connection +): + # Put data in the db + llm, _, _ = await anext(fake_llm_with_tools) + app.dependency_overrides[get_language_model] = lambda: llm + test_settings = Settings( + db={"prefix": db_connection}, + ) + app.dependency_overrides[get_settings] = lambda: test_settings + with app_client as app_client: + wrong_response = app_client.delete("/threads/test") + assert wrong_response.status_code == 404 + assert wrong_response.json() == {"detail": {"detail": "Thread not found."}} + + # Create a thread + create_output = app_client.post("/threads/").json() + thread_id = create_output["thread_id"] + # Fill the thread + app_client.post( + f"/qa/chat/{thread_id}", + json={"inputs": "This is my query", "parameters": {}}, + params={"thread_id": thread_id}, + ) + # Get the messages of the thread + messages = app_client.get(f"/threads/{thread_id}").json() + threads = app_client.get("/threads").json() + assert messages + assert threads + delete_response = app_client.delete(f"/threads/{thread_id}") + assert delete_response.json() == {"Acknowledged": "true"} + messages = app_client.get(f"/threads/{thread_id}").json() + threads = app_client.get("/threads").json() + assert messages == {"detail": {"detail": "Thread not found."}} + assert not threads + + # Double check with pure sqlalchemy + metadata = MetaData() + engine = create_engine(test_settings.db.prefix) + metadata.reflect(engine) + + with Session(engine) as session: + for table in metadata.tables.values(): + if "thread_id" in table.c.keys(): + query = Select(table).where( # type: ignore + table.c.thread_id == thread_id + ) + row = session.execute(query).one_or_none() + assert row is None diff --git a/tests/app/database/test_tools.py b/tests/app/database/test_tools.py new file mode 100644 index 0000000..6c5a7fd --- /dev/null +++ b/tests/app/database/test_tools.py @@ -0,0 +1,156 @@ +"""Test of the tool router.""" + +import pytest +from neuroagent.app.config import Settings +from neuroagent.app.dependencies import get_language_model, get_settings +from neuroagent.app.main import app +from neuroagent.app.routers.database.schemas import ToolCallSchema + + +@pytest.mark.asyncio +async def test_get_tool_calls( + patch_required_env, fake_llm_with_tools, app_client, db_connection +): + # Put data in the db + llm, _, _ = await anext(fake_llm_with_tools) + app.dependency_overrides[get_language_model] = lambda: llm + test_settings = Settings( + db={"prefix": db_connection}, + ) + app.dependency_overrides[get_settings] = lambda: test_settings + with app_client as app_client: + wrong_response = app_client.get("/tools/test/1234") + assert wrong_response.status_code == 404 + assert wrong_response.json() == {"detail": {"detail": "Thread not found."}} + + # Create a thread + create_output = app_client.post("/threads/").json() + thread_id = create_output["thread_id"] + + # Fill the thread + app_client.post( + f"/qa/chat/{thread_id}", + json={"inputs": "This is my query", "parameters": {}}, + params={"thread_id": thread_id}, + ) + + tool_calls = app_client.get(f"/tools/{thread_id}/wrong_id") + assert tool_calls.status_code == 404 + assert tool_calls.json() == {"detail": {"detail": "Message not found."}} + + # Get the messages of the thread + messages = app_client.get(f"/threads/{thread_id}").json() + message_id = messages[-1]["message_id"] + tool_calls = app_client.get(f"/tools/{thread_id}/{message_id}").json() + + assert ( + tool_calls[0] + == ToolCallSchema( + call_id="call_zHhwfNLSvGGHXMoILdIYtDVI", + name="get-morpho-tool", + arguments={ + "brain_region_id": "http://api.brain-map.org/api/v2/data/Structure/549" + }, + ).model_dump() + ) + + +@pytest.mark.asyncio +async def test_get_tool_output( + patch_required_env, + fake_llm_with_tools, + app_client, + httpx_mock, + db_connection, +): + # Put data in the db + llm, _, _ = await anext(fake_llm_with_tools) + app.dependency_overrides[get_language_model] = lambda: llm + + test_settings = Settings( + db={"prefix": db_connection}, + ) + app.dependency_overrides[get_settings] = lambda: test_settings + + httpx_mock.add_response( + url="https://fake_url/api/nexus/v1/search/query/", + json={ + "hits": { + "hits": [ + { + "_source": { + "@id": "https://bbp.epfl.ch/data/bbp/mmb-point-neuron-framework-model/ca1f0e5f-ff08-4476-9b5f-95f3c9d004fd", + "brainRegion": { + "@id": ( + "http://api.brain-map.org/api/v2/data/Structure/629" + ), + "label": ( + "Ventral anterior-lateral complex of the thalamus" + ), + }, + "description": ( + "This is a morphology reconstruction of a mouse" + " thalamus cell that was obtained from the Janelia" + " Mouselight project" + " http://ml-neuronbrowser.janelia.org/ . This" + " morphology is positioned in the Mouselight custom" + " 'CCFv2.5' reference space, instead of the Allen" + " Institute CCFv3 reference space." + ), + "mType": {"label": "VPL_TC"}, + "name": "AA0519", + "subjectAge": { + "label": "60 days Post-natal", + }, + "subjectSpecies": {"label": "Mus musculus"}, + } + } + ] + } + }, + ) + with app_client as app_client: + wrong_response = app_client.get("/tools/output/test/123") + assert wrong_response.status_code == 404 + assert wrong_response.json() == {"detail": {"detail": "Thread not found."}} + + # Create a thread + create_output = app_client.post("/threads/").json() + thread_id = create_output["thread_id"] + + # Fill the thread + app_client.post( + f"/qa/chat/{thread_id}", + json={"inputs": "This is my query", "parameters": {}}, + params={"thread_id": thread_id}, + ) + + tool_output = app_client.get(f"/tools/output/{thread_id}/123") + assert tool_output.status_code == 404 + assert tool_output.json() == {"detail": {"detail": "Tool call not found."}} + + # Get the messages of the thread + messages = app_client.get(f"/threads/{thread_id}").json() + message_id = messages[-1]["message_id"] + tool_calls = app_client.get(f"/tools/{thread_id}/{message_id}").json() + tool_call_id = tool_calls[0]["call_id"] + tool_output = app_client.get(f"/tools/output/{thread_id}/{tool_call_id}") + + assert tool_output.json() == [ + { + "morphology_id": "https://bbp.epfl.ch/data/bbp/mmb-point-neuron-framework-model/ca1f0e5f-ff08-4476-9b5f-95f3c9d004fd", + "morphology_name": "AA0519", + "morphology_description": ( + "This is a morphology reconstruction of a mouse thalamus cell that was" + " obtained from the Janelia Mouselight project" + " http://ml-neuronbrowser.janelia.org/ . This morphology is positioned" + " in the Mouselight custom 'CCFv2.5' reference space, instead of the" + " Allen Institute CCFv3 reference space." + ), + "mtype": "VPL_TC", + "brain_region_id": "http://api.brain-map.org/api/v2/data/Structure/629", + "brain_region_label": "Ventral anterior-lateral complex of the thalamus", + "subject_species_label": "Mus musculus", + "subject_age": "60 days Post-natal", + } + ] diff --git a/tests/app/test_config.py b/tests/app/test_config.py new file mode 100644 index 0000000..b3543b9 --- /dev/null +++ b/tests/app/test_config.py @@ -0,0 +1,73 @@ +"""Test config""" + +import pytest +from neuroagent.app.config import Settings +from pydantic import ValidationError + + +def test_required(monkeypatch, patch_required_env): + settings = Settings() + + assert settings.tools.literature.url == "https://fake_url" + assert settings.knowledge_graph.base_url == "https://fake_url/api/nexus/v1" + assert settings.generative.openai.token.get_secret_value() == "dummy" + assert settings.knowledge_graph.use_token + assert settings.knowledge_graph.token.get_secret_value() == "token" + + # make sure not case sensitive + monkeypatch.delenv("NEUROAGENT_TOOLS__LITERATURE__URL") + monkeypatch.setenv("neuroagent_tools__literature__URL", "https://new_fake_url") + + settings = Settings() + assert settings.tools.literature.url == "https://new_fake_url" + + +def test_no_settings(): + # We get an error when no custom variables provided + with pytest.raises(ValidationError): + Settings() + + +def test_setup_tools(monkeypatch, patch_required_env): + monkeypatch.setenv("NEUROAGENT_TOOLS__TRACE__SEARCH_SIZE", "20") + monkeypatch.setenv("NEUROAGENT_TOOLS__MORPHO__SEARCH_SIZE", "20") + monkeypatch.setenv("NEUROAGENT_TOOLS__KG_MORPHO_FEATURES__SEARCH_SIZE", "20") + + monkeypatch.setenv("NEUROAGENT_KEYCLOAK__USERNAME", "user") + monkeypatch.setenv("NEUROAGENT_KEYCLOAK__PASSWORD", "pass") + + settings = Settings() + + assert settings.tools.morpho.search_size == 20 + assert settings.tools.trace.search_size == 20 + assert settings.tools.kg_morpho_features.search_size == 20 + assert settings.keycloak.username == "user" + assert settings.keycloak.password.get_secret_value() == "pass" + assert settings.knowledge_graph.use_token + assert settings.knowledge_graph.token.get_secret_value() == "token" + + +def test_check_consistency(monkeypatch): + # We get an error when no custom variables provided + url = "https://fake_url" + monkeypatch.setenv("NEUROAGENT_TOOLS__LITERATURE__URL", url) + monkeypatch.setenv("NEUROAGENT_KNOWLEDGE_GRAPH__URL", url) + + with pytest.raises(ValueError): + Settings() + + monkeypatch.setenv("NEUROAGENT_GENERATIVE__OPENAI__TOKEN", "dummy") + monkeypatch.setenv("NEUROAGENT_KNOWLEDGE_GRAPH__USE_TOKEN", "true") + + with pytest.raises(ValueError): + Settings() + + monkeypatch.setenv("NEUROAGENT_KNOWLEDGE_GRAPH__USE_TOKEN", "false") + + with pytest.raises(ValueError): + Settings() + + monkeypatch.setenv("NEUROAGENT_KNOWLEDGE_GRAPH__TOKEN", "Hello") + + with pytest.raises(ValueError): + Settings() diff --git a/tests/app/test_dependencies.py b/tests/app/test_dependencies.py new file mode 100644 index 0000000..4611047 --- /dev/null +++ b/tests/app/test_dependencies.py @@ -0,0 +1,363 @@ +"""Test dependencies.""" + +import json +import os +from pathlib import Path +from typing import AsyncIterator +from unittest.mock import Mock + +import pytest +from httpx import AsyncClient +from langchain_openai import ChatOpenAI +from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver +from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver +from neuroagent.agents import SimpleAgent, SimpleChatAgent +from neuroagent.app.dependencies import ( + Settings, + get_agent, + get_agent_memory, + get_brain_region_resolver_tool, + get_cell_types_kg_hierarchy, + get_chat_agent, + get_electrophys_feature_tool, + get_httpx_client, + get_kg_morpho_feature_tool, + get_language_model, + get_literature_tool, + get_morpho_tool, + get_morphology_feature_tool, + get_traces_tool, + get_update_kg_hierarchy, + get_user_id, +) +from neuroagent.tools import ( + ElectrophysFeatureTool, + GetMorphoTool, + GetTracesTool, + KGMorphoFeatureTool, + LiteratureSearchTool, + MorphologyFeatureTool, +) + + +@pytest.mark.asyncio +async def test_get_httpx_client(): + request = Mock() + request.headers = {"x-request-id": "greatid"} + httpx_client_iterator = get_httpx_client(request=request) + assert isinstance(httpx_client_iterator, AsyncIterator) + async for httpx_client in httpx_client_iterator: + assert isinstance(httpx_client, AsyncClient) + assert httpx_client.headers["x-request-id"] == "greatid" + + +@pytest.mark.asyncio +async def test_get_user(httpx_mock, monkeypatch, patch_required_env): + monkeypatch.setenv("NEUROAGENT_KEYCLOAK__USERNAME", "fake_username") + monkeypatch.setenv("NEUROAGENT_KEYCLOAK__PASSWORD", "fake_password") + monkeypatch.setenv("NEUROAGENT_KEYCLOAK__ISSUER", "https://great_issuer.com") + monkeypatch.setenv("NEUROAGENT_KEYCLOAK__VALIDATE_TOKEN", "true") + + fake_response = { + "sub": "12345", + "email_verified": False, + "name": "Machine Learning Test User", + "groups": [], + "preferred_username": "sbo-ml", + "given_name": "Machine Learning", + "family_name": "Test User", + "email": "email@epfl.ch", + } + httpx_mock.add_response( + url="https://great_issuer.com/protocol/openid-connect/userinfo", + json=fake_response, + ) + + settings = Settings() + client = AsyncClient() + token = "eyJgreattoken" + user_id = await get_user_id(token=token, settings=settings, httpx_client=client) + + assert user_id == fake_response["sub"] + + +def test_get_literature_tool(monkeypatch, patch_required_env): + url = "https://fake_url" + + httpx_client = AsyncClient() + settings = Settings() + token = "fake_token" + + literature_tool = get_literature_tool(token, settings, httpx_client) + assert isinstance(literature_tool, LiteratureSearchTool) + assert literature_tool.metadata["url"] == url + assert literature_tool.metadata["retriever_k"] == 700 + assert literature_tool.metadata["reranker_k"] == 5 + assert literature_tool.metadata["use_reranker"] is True + + monkeypatch.setenv("NEUROAGENT_TOOLS__LITERATURE__RETRIEVER_K", "30") + monkeypatch.setenv("NEUROAGENT_TOOLS__LITERATURE__RERANKER_K", "1") + monkeypatch.setenv("NEUROAGENT_TOOLS__LITERATURE__USE_RERANKER", "false") + settings = Settings() + + literature_tool = get_literature_tool(token, settings, httpx_client) + assert isinstance(literature_tool, LiteratureSearchTool) + assert literature_tool.metadata["url"] == url + assert literature_tool.metadata["retriever_k"] == 30 + assert literature_tool.metadata["reranker_k"] == 1 + assert literature_tool.metadata["use_reranker"] is False + + +@pytest.mark.parametrize( + "tool_call,has_search_size,tool_env_name,expected_tool_class", + ( + [get_morpho_tool, True, "MORPHO", GetMorphoTool], + [get_kg_morpho_feature_tool, True, "KG_MORPHO_FEATURES", KGMorphoFeatureTool], + [get_traces_tool, True, "TRACE", GetTracesTool], + [get_electrophys_feature_tool, False, None, ElectrophysFeatureTool], + [get_morphology_feature_tool, False, None, MorphologyFeatureTool], + ), +) +def test_get_tool( + tool_call, + has_search_size, + tool_env_name, + expected_tool_class, + monkeypatch, + patch_required_env, +): + url = "https://fake_url/api/nexus/v1/search/query/" + token = "fake_token" + + httpx_client = AsyncClient() + settings = Settings() + + tool = tool_call(settings=settings, token=token, httpx_client=httpx_client) + assert isinstance(tool, expected_tool_class) + assert tool.metadata["url"] == url + assert tool.metadata["token"] == "fake_token" + + if has_search_size: + monkeypatch.setenv(f"NEUROAGENT_TOOLS__{tool_env_name}__SEARCH_SIZE", "100") + settings = Settings() + + tool = tool_call(settings=settings, token=token, httpx_client=httpx_client) + assert isinstance(tool, expected_tool_class) + assert tool.metadata["url"] == url + assert tool.metadata["search_size"] == 100 + + +@pytest.mark.asyncio +async def test_get_memory(patch_required_env, db_connection): + conn_string = await anext(get_agent_memory(None)) + + assert conn_string is None + + conn_string = await anext(get_agent_memory(db_connection)) + + if db_connection.startswith("sqlite"): + assert isinstance(conn_string, AsyncSqliteSaver) + if db_connection.startswith("postgresql"): + assert isinstance(conn_string, AsyncPostgresSaver) + await conn_string.conn.close() # Needs to be re-closed for some reasons. + + +def test_language_model(monkeypatch, patch_required_env): + monkeypatch.setenv("NEUROAGENT_GENERATIVE__OPENAI__MODEL", "dummy") + monkeypatch.setenv("NEUROAGENT_GENERATIVE__OPENAI__TEMPERATURE", "99") + monkeypatch.setenv("NEUROAGENT_GENERATIVE__OPENAI__MAX_TOKENS", "99") + + settings = Settings() + + language_model = get_language_model(settings) + + assert isinstance(language_model, ChatOpenAI) + assert language_model.model_name == "dummy" + assert language_model.temperature == 99 + assert language_model.max_tokens == 99 + + +def test_get_agent(monkeypatch, patch_required_env): + monkeypatch.setenv("NEUROAGENT_AGENT__MODEL", "simple") + token = "fake_token" + httpx_client = AsyncClient() + settings = Settings() + + language_model = get_language_model(settings) + literature_tool = get_literature_tool( + token=token, settings=settings, httpx_client=httpx_client + ) + morpho_tool = get_morpho_tool( + settings=settings, token=token, httpx_client=httpx_client + ) + morphology_feature_tool = get_morphology_feature_tool( + settings=settings, token=token, httpx_client=httpx_client + ) + kg_morpho_feature_tool = get_kg_morpho_feature_tool( + settings=settings, token=token, httpx_client=httpx_client + ) + electrophys_feature_tool = get_electrophys_feature_tool( + settings=settings, token=token, httpx_client=httpx_client + ) + traces_tool = get_traces_tool( + settings=settings, token=token, httpx_client=httpx_client + ) + br_resolver_tool = get_brain_region_resolver_tool( + token=token, + httpx_client=httpx_client, + settings=settings, + ) + + agent = get_agent( + llm=language_model, + literature_tool=literature_tool, + br_resolver_tool=br_resolver_tool, + morpho_tool=morpho_tool, + morphology_feature_tool=morphology_feature_tool, + kg_morpho_feature_tool=kg_morpho_feature_tool, + electrophys_feature_tool=electrophys_feature_tool, + traces_tool=traces_tool, + ) + + assert isinstance(agent, SimpleAgent) + + +@pytest.mark.asyncio +async def test_get_chat_agent(monkeypatch, db_connection, patch_required_env): + monkeypatch.setenv("NEUROAGENT_DB__PREFIX", "sqlite://") + + token = "fake_token" + httpx_client = AsyncClient() + settings = Settings() + + language_model = get_language_model(settings) + literature_tool = get_literature_tool( + token=token, settings=settings, httpx_client=httpx_client + ) + morpho_tool = get_morpho_tool( + settings=settings, token=token, httpx_client=httpx_client + ) + morphology_feature_tool = get_morphology_feature_tool( + settings=settings, token=token, httpx_client=httpx_client + ) + kg_morpho_feature_tool = get_kg_morpho_feature_tool( + settings=settings, token=token, httpx_client=httpx_client + ) + electrophys_feature_tool = get_electrophys_feature_tool( + settings=settings, token=token, httpx_client=httpx_client + ) + traces_tool = get_traces_tool( + settings=settings, token=token, httpx_client=httpx_client + ) + br_resolver_tool = get_brain_region_resolver_tool( + token=token, + httpx_client=httpx_client, + settings=settings, + ) + + memory = await anext(get_agent_memory(db_connection)) + + agent = get_chat_agent( + llm=language_model, + literature_tool=literature_tool, + br_resolver_tool=br_resolver_tool, + morpho_tool=morpho_tool, + morphology_feature_tool=morphology_feature_tool, + kg_morpho_feature_tool=kg_morpho_feature_tool, + electrophys_feature_tool=electrophys_feature_tool, + traces_tool=traces_tool, + memory=memory, + settings=settings, + ) + + assert isinstance(agent, SimpleChatAgent) + await memory.conn.close() # Needs to be re-closed for some reasons. + + +@pytest.mark.asyncio +async def test_get_update_kg_hierarchy( + tmp_path, httpx_mock, monkeypatch, patch_required_env +): + token = "fake_token" + file_name = "fake_file" + client = AsyncClient() + + file_url = "https://fake_file_url" + + monkeypatch.setenv( + "NEUROAGENT_KNOWLEDGE_GRAPH__HIERARCHY_URL", "http://fake_hierarchy_url.com" + ) + + settings = Settings( + knowledge_graph={"br_saving_path": tmp_path / "test_brain_region.json"} + ) + + json_response_url = { + "head": {"vars": ["file_url"]}, + "results": {"bindings": [{"file_url": {"type": "uri", "value": file_url}}]}, + } + with open( + Path(__file__).parent.parent.parent + / "tests" + / "data" + / "KG_brain_regions_hierarchy_test.json" + ) as fh: + json_response_file = json.load(fh) + + httpx_mock.add_response( + url=settings.knowledge_graph.sparql_url, json=json_response_url + ) + httpx_mock.add_response(url=file_url, json=json_response_file) + + await get_update_kg_hierarchy( + token, + client, + settings, + file_name, + ) + + assert os.path.exists(settings.knowledge_graph.br_saving_path) + + +@pytest.mark.asyncio +async def test_get_cell_types_kg_hierarchy( + tmp_path, httpx_mock, monkeypatch, patch_required_env +): + token = "fake_token" + file_name = "fake_file" + client = AsyncClient() + + file_url = "https://fake_file_url" + monkeypatch.setenv( + "NEUROAGENT_KNOWLEDGE_GRAPH__HIERARCHY_URL", "http://fake_hierarchy_url.com" + ) + + settings = Settings( + knowledge_graph={"ct_saving_path": tmp_path / "test_cell_types_region.json"} + ) + + json_response_url = { + "head": {"vars": ["file_url"]}, + "results": {"bindings": [{"file_url": {"type": "uri", "value": file_url}}]}, + } + with open( + Path(__file__).parent.parent.parent + / "tests" + / "data" + / "kg_cell_types_hierarchy_test.json" + ) as fh: + json_response_file = json.load(fh) + + httpx_mock.add_response( + url=settings.knowledge_graph.sparql_url, json=json_response_url + ) + httpx_mock.add_response(url=file_url, json=json_response_file) + + await get_cell_types_kg_hierarchy( + token, + client, + settings, + file_name, + ) + + assert os.path.exists(settings.knowledge_graph.ct_saving_path) diff --git a/tests/app/test_main.py b/tests/app/test_main.py new file mode 100644 index 0000000..a21b6fd --- /dev/null +++ b/tests/app/test_main.py @@ -0,0 +1,74 @@ +import logging +from unittest.mock import patch + +from fastapi.testclient import TestClient +from neuroagent.app.dependencies import get_settings +from neuroagent.app.main import app + + +def test_settings_endpoint(app_client, dont_look_at_env_file): + settings = app.dependency_overrides[get_settings]() + response = app_client.get("/settings") + + replace_secretstr = settings.model_dump() + replace_secretstr["keycloak"]["password"] = "**********" + replace_secretstr["generative"]["openai"]["token"] = "**********" + assert response.json() == replace_secretstr + + +def test_startup(caplog, monkeypatch, tmp_path, patch_required_env, db_connection): + get_settings.cache_clear() + caplog.set_level(logging.INFO) + + monkeypatch.setenv("NEUROAGENT_LOGGING__LEVEL", "info") + monkeypatch.setenv("NEUROAGENT_LOGGING__EXTERNAL_PACKAGES", "warning") + monkeypatch.setenv("NEUROAGENT_KNOWLEDGE_GRAPH__DOWNLOAD_HIERARCHY", "true") + monkeypatch.setenv("NEUROAGENT_DB__PREFIX", db_connection) + + save_path_brainregion = tmp_path / "fake.json" + + async def save_dummy(*args, **kwargs): + with open(save_path_brainregion, "w") as f: + f.write("test_text") + + with ( + patch("neuroagent.app.main.get_update_kg_hierarchy", new=save_dummy), + patch("neuroagent.app.main.get_cell_types_kg_hierarchy", new=save_dummy), + ): + # The with statement triggers the startup. + with TestClient(app) as test_client: + test_client.get("/healthz") + # check if the brain region dummy file was created. + assert save_path_brainregion.exists() + + assert caplog.record_tuples[0][::2] == ( + "neuroagent.app.dependencies", + "Reading the environment and instantiating settings", + ) + + assert ( + logging.getLevelName(logging.getLogger("neuroagent").getEffectiveLevel()) + == "INFO" + ) + assert ( + logging.getLevelName(logging.getLogger("httpx").getEffectiveLevel()) + == "WARNING" + ) + assert ( + logging.getLevelName(logging.getLogger("fastapi").getEffectiveLevel()) + == "WARNING" + ) + assert ( + logging.getLevelName(logging.getLogger("bluepyefe").getEffectiveLevel()) + == "CRITICAL" + ) + + +def test_readyz(app_client): + response = app_client.get( + "/", + ) + + body = response.json() + assert isinstance(body, dict) + assert body["status"] == "ok" diff --git a/tests/app/test_middleware.py b/tests/app/test_middleware.py new file mode 100644 index 0000000..2707d4d --- /dev/null +++ b/tests/app/test_middleware.py @@ -0,0 +1,43 @@ +"""Test middleware""" + +from unittest.mock import patch + +import pytest +from fastapi.requests import Request +from fastapi.responses import Response +from neuroagent.app.config import Settings +from neuroagent.app.middleware import strip_path_prefix + + +@pytest.mark.parametrize( + "path,prefix,trimmed_path", + [ + ("/suggestions", "", "/suggestions"), + ("/literature/suggestions", "/literature", "/suggestions"), + ], +) +@pytest.mark.asyncio +async def test_strip_path_prefix(path, prefix, trimmed_path, patch_required_env): + test_settings = Settings(misc={"application_prefix": prefix}) + + scope = { + "type": "http", + "path": path, + "query_string": b"best_query_string_i_have_ever_seen,_woah", + "method": "POST", + "headers": [ + (b"host", b"example.com"), + ], + "scheme": "http", + "server": ("example.com", 80), + } + + request = Request(scope=scope) + + async def async_callable(request): + return Response(content=request.url.path, media_type="text/plain") + + with patch("neuroagent.app.middleware.get_settings", lambda: test_settings): + response = await strip_path_prefix(request, async_callable) + + assert response.body.decode("utf-8") == trimmed_path diff --git a/tests/app/test_qa.py b/tests/app/test_qa.py new file mode 100644 index 0000000..3847764 --- /dev/null +++ b/tests/app/test_qa.py @@ -0,0 +1,129 @@ +from unittest.mock import AsyncMock, Mock + +from neuroagent.agents import AgentOutput, AgentStep +from neuroagent.app.config import Settings +from neuroagent.app.dependencies import get_agent, get_chat_agent, get_settings +from neuroagent.app.main import app + + +def test_run_agent(app_client): + agent_output = AgentOutput( + response="This is my response", + steps=[ + AgentStep(tool_name="tool1", arguments="covid-19"), + AgentStep( + tool_name="tool2", + arguments={"query": "covid-19", "brain_region": "thalamus"}, + ), + ], + ) + agent_mock = AsyncMock() + agent_mock.arun.return_value = agent_output + app.dependency_overrides[get_agent] = lambda: agent_mock + + response = app_client.post( + "/qa/run", json={"inputs": "This is my query", "parameters": {}} + ) + assert response.status_code == 200 + assert response.json() == agent_output.model_dump() + + # Missing inputs + response = app_client.post("/qa/run", json={}) + assert response.status_code == 422 + + +def test_run_chat_agent(app_client, tmp_path, patch_required_env): + agent_output = AgentOutput( + response="This is my response", + steps=[ + AgentStep(tool_name="tool1", arguments="covid-19"), + AgentStep( + tool_name="tool2", + arguments={"query": "covid-19", "brain_region": "thalamus"}, + ), + ], + ) + p = tmp_path / "test_db.db" + test_settings = Settings( + db={"prefix": f"sqlite:///{p}"}, + ) + app.dependency_overrides[get_settings] = lambda: test_settings + agent_mock = AsyncMock() + agent_mock.arun.return_value = agent_output + app.dependency_overrides[get_chat_agent] = lambda: agent_mock + with app_client as app_client: + create_output = app_client.post("/threads/").json() + response = app_client.post( + f"/qa/chat/{create_output['thread_id']}", + json={"inputs": "This is my query", "parameters": {}}, + ) + assert response.status_code == 200 + assert response.json() == agent_output.model_dump() + + # Missing thread_id inputs + response = app_client.post( + "/qa/chat", json={"inputs": "This is my query", "parameters": {}} + ) + assert response.status_code == 404 + + +async def streamed_response(): + response = [ + "Calling ", + "tool ", + ": ", + "resolve_brain_region_tool ", + "with ", + "arguments ", + ": ", + "{", + "brain_region", + ": ", + "thalamus", + "}", + "\n ", + "This", + " is", + " an", + " amazingly", + " well", + " streamed", + " response", + ".", + " I", + " can", + "'t", + " believe", + " how", + " good", + " it", + " is", + "!", + ] + for word in response: + yield word + + +def test_chat_streamed(app_client, tmp_path, patch_required_env): + """Test the generative QA endpoint with a fake LLM.""" + agent_mock = Mock() + agent_mock.astream.return_value = streamed_response() + app.dependency_overrides[get_chat_agent] = lambda: agent_mock + p = tmp_path / "test_db.db" + test_settings = Settings( + db={"prefix": f"sqlite:///{p}"}, + ) + app.dependency_overrides[get_settings] = lambda: test_settings + expected_tokens = ( + b"Calling tool : resolve_brain_region_tool with arguments : {brain_region:" + b" thalamus}\n This is an amazingly well streamed response. I can't believe how" + b" good it is!" + ) + with app_client as app_client: + create_output = app_client.post("/threads/").json() + response = app_client.post( + f"/qa/chat_streamed/{create_output['thread_id']}", + json={"inputs": "This is my query", "parameters": {}}, + ) + assert response.status_code == 200 + assert response.content == expected_tokens diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..0ee31f7 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,183 @@ +"""Test configuration.""" + +import json +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient +from httpx import AsyncClient +from langchain_core.language_models.fake_chat_models import GenericFakeChatModel +from langchain_core.messages import AIMessage +from neuroagent.app.config import Settings +from neuroagent.app.dependencies import get_kg_token, get_settings +from neuroagent.app.main import app +from neuroagent.tools import GetMorphoTool +from sqlalchemy import MetaData, create_engine +from sqlalchemy.exc import OperationalError +from sqlalchemy.orm import Session + + +@pytest.fixture(name="app_client") +def client_fixture(): + """Get client and clear app dependency_overrides.""" + app_client = TestClient(app) + test_settings = Settings( + tools={ + "literature": { + "url": "fake_literature_url", + }, + }, + knowledge_graph={ + "base_url": "https://fake_url/api/nexus/v1", + }, + generative={ + "openai": { + "token": "fake_token", + } + }, + keycloak={ + "username": "fake_username", + "password": "fake_password", + }, + ) + app.dependency_overrides[get_settings] = lambda: test_settings + # mock keycloak authentication + app.dependency_overrides[get_kg_token] = lambda: "fake_token" + yield app_client + app.dependency_overrides.clear() + + +@pytest.fixture(autouse=True, scope="session") +def dont_look_at_env_file(): + """Never look inside of the .env when running unit tests.""" + Settings.model_config["env_file"] = None + + +@pytest.fixture() +def patch_required_env(monkeypatch): + monkeypatch.setenv("NEUROAGENT_TOOLS__LITERATURE__URL", "https://fake_url") + monkeypatch.setenv( + "NEUROAGENT_KNOWLEDGE_GRAPH__BASE_URL", "https://fake_url/api/nexus/v1" + ) + monkeypatch.setenv("NEUROAGENT_GENERATIVE__OPENAI__TOKEN", "dummy") + monkeypatch.setenv("NEUROAGENT_KNOWLEDGE_GRAPH__TOKEN", "token") + monkeypatch.setenv("NEUROAGENT_KNOWLEDGE_GRAPH__USE_TOKEN", "true") + + +@pytest.fixture(params=["sqlite", "postgresql"], name="db_connection") +def setup_sql_db(request, tmp_path): + db_type = request.param + + # To start the postgresql database: + # docker run -it --rm -p 5432:5432 -e POSTGRES_USER=test -e POSTGRES_PASSWORD=password postgres:latest + path = ( + f"sqlite:///{tmp_path / 'test_db.db'}" + if db_type == "sqlite" + else "postgresql://test:password@localhost:5432" + ) + if db_type == "postgresql": + try: + engine = create_engine(path).connect() + except OperationalError: + pytest.skip("Postgres database not connected") + yield path + if db_type == "postgresql": + metadata = MetaData() + engine = create_engine(path) + session = Session(bind=engine) + + metadata.reflect(engine) + metadata.drop_all(bind=engine) + session.commit() + + +@pytest.fixture +def get_resolve_query_output(): + with open("tests/data/resolve_query.json") as f: + outputs = json.loads(f.read()) + return outputs + + +@pytest.fixture +def brain_region_json_path(): + br_path = Path(__file__).parent / "data" / "brainregion_hierarchy.json" + return br_path + + +@pytest.fixture +async def fake_llm_with_tools(brain_region_json_path): + class FakeFuntionChatModel(GenericFakeChatModel): + def bind_tools(self, functions: list): + return self + + # If you need another fake response to use different tools, + # you can do in your test + # ```python + # llm, _ = await anext(fake_llm_with_tools) + # llm.responses = my_fake_responses + # ``` + # and simply bind the corresponding tools + fake_responses = [ + AIMessage( + content="", + additional_kwargs={ + "tool_calls": [ + { + "index": 0, + "id": "call_zHhwfNLSvGGHXMoILdIYtDVI", + "function": { + "arguments": '{"brain_region_id":"http://api.brain-map.org/api/v2/data/Structure/549"}', + "name": "get-morpho-tool", + }, + "type": "function", + } + ] + }, + response_metadata={"finish_reason": "tool_calls"}, + id="run-3828644d-197b-401b-8634-e6ecf01c2e7c-0", + tool_calls=[ + { + "name": "get-morpho-tool", + "args": { + "brain_region_id": ( + "http://api.brain-map.org/api/v2/data/Structure/549" + ) + }, + "id": "call_zHhwfNLSvGGHXMoILdIYtDVI", + } + ], + ), + AIMessage( + content="Great answer", + response_metadata={"finish_reason": "stop"}, + id="run-42768b30-044a-4263-8c5c-da61429aa9da-0", + ), + ] + + # If you use this tool in your test, DO NOT FORGET to mock the url response with the following snippet: + # + # ```python + # json_path = Path(__file__).resolve().parent.parent / "data" / "knowledge_graph.json" + # with open(json_path) as f: + # knowledge_graph_response = json.load(f) + + # httpx_mock.add_response( + # url="http://fake_url", + # json=knowledge_graph_response, + # ) + # ``` + # The http call is not mocked here because one might want to change the responses + # and the tools used. + async_client = AsyncClient() + tool = GetMorphoTool( + metadata={ + "url": "http://fake_url", + "search_size": 2, + "httpx_client": async_client, + "token": "fake_token", + "brainregion_path": brain_region_json_path, + } + ) + + yield FakeFuntionChatModel(messages=iter(fake_responses)), [tool], fake_responses + await async_client.aclose() diff --git a/tests/data/99111002.nwb b/tests/data/99111002.nwb new file mode 100644 index 0000000..ce6791d Binary files /dev/null and b/tests/data/99111002.nwb differ diff --git a/tests/data/KG_brain_regions_hierarchy_test.json b/tests/data/KG_brain_regions_hierarchy_test.json new file mode 100644 index 0000000..c5a336a --- /dev/null +++ b/tests/data/KG_brain_regions_hierarchy_test.json @@ -0,0 +1,165 @@ +{ + "@context": "https://neuroshapes.org", + "@id": "http://bbp.epfl.ch/neurosciencegraph/ontologies/core/brainregion", + "@type": "Ontology", + "versionInfo": "R103", + "hasHierarchyView": [ + { + "@id": "https://bbp.epfl.ch/ontologies/core/bmo/BrainLayer", + "label": "Layer", + "description": "Layer based hierarchy", + "hasParentHierarchyProperty": "isLayerPartOf", + "hasChildrenHierarchyProperty": "hasLayerPart", + "hasLeafHierarchyProperty": "hasLayerLeafRegionPart" + }, + { + "@id": "https://neuroshapes.org/BrainRegion", + "label": "BrainRegion", + "description": "Atlas default brain region hierarchy", + "hasParentHierarchyProperty": "isPartOf", + "hasChildrenHierarchyProperty": "hasPart", + "hasLeafHierarchyProperty": "hasLeafRegionPart" + } + ], + "atlasRelease": { + "@id": "https://bbp.epfl.ch/neurosciencegraph/data/4906ab85-694f-469d-962f-c0174e901885", + "@type": "BrainAtlasRelease", + "_rev": 8 + }, + "defines": [ + { + "@id": "http://api.brain-map.org/api/v2/data/Structure/1", + "@type": "Class", + "atlas_id": 424, + "color_hex_triplet": "FF4C3E", + "graph_order": 775, + "hemisphere_id": 3, + "st_level": 9, + "identifier": "1", + "isPartOf": [ + "http://api.brain-map.org/api/v2/data/Structure/2" + ], + "isDefinedBy": "http://bbp.epfl.ch/neurosciencegraph/ontologies/core/brainregion", + "subClassOf": [ + "https://neuroshapes.org/BrainRegion" + ], + "delineates": [ + "http://purl.obolibrary.org/obo/UBERON_0014594" + ], + "regionVolume": { + "unitCode": "cubic micrometer", + "value": 108296875.0 + }, + "regionVolumeRatioToWholeBrain": { + "unitCode": "cubic micrometer", + "value": 0.00021400307558019888 + }, + "representedInAnnotation": true, + "hasHierarchyView": [ + "https://neuroshapes.org/BrainRegion" + ], + "prefLabel": "Tuberomammillary nucleus, ventral part", + "label": "Tuberomammillary nucleus, ventral part", + "notation": "TMv", + "altLabel": "TMv", + "atlasRelease": { + "@id": "https://bbp.epfl.ch/neurosciencegraph/data/4906ab85-694f-469d-962f-c0174e901885", + "@type": "BrainAtlasRelease", + "_rev": 8 + } + }, + { + "@id": "http://api.brain-map.org/api/v2/data/Structure/2", + "@type": "Class", + "atlas_id": 425, + "color_hex_triplet": "FF90FF", + "graph_order": 834, + "hemisphere_id": 3, + "st_level": 9, + "hasPart": [ + "http://api.brain-map.org/api/v2/data/Structure/503", + "http://api.brain-map.org/api/v2/data/Structure/511", + "http://api.brain-map.org/api/v2/data/Structure/614454425", + "http://api.brain-map.org/api/v2/data/Structure/494" + ], + "identifier": "2", + "isDefinedBy": "http://bbp.epfl.ch/neurosciencegraph/ontologies/core/brainregion", + "subClassOf": [ + "https://neuroshapes.org/BrainRegion" + ], + "regionVolume": { + "unitCode": "cubic micrometer", + "value": 2036828125.0 + }, + "regionVolumeRatioToWholeBrain": { + "unitCode": "cubic micrometer", + "value": 0.004024931311990765 + }, + "representedInAnnotation": true, + "hasLeafRegionPart": [ + "http://api.brain-map.org/api/v2/data/Structure/494", + "http://api.brain-map.org/api/v2/data/Structure/614454425", + "http://api.brain-map.org/api/v2/data/Structure/511", + "http://api.brain-map.org/api/v2/data/Structure/503" + ], + "hasHierarchyView": [ + "https://neuroshapes.org/BrainRegion" + ], + "prefLabel": "Superior colliculus, motor related, intermediate gray layer", + "label": "Superior colliculus, motor related, intermediate gray layer", + "notation": "SCig", + "altLabel": "SCig", + "atlasRelease": { + "@id": "https://bbp.epfl.ch/neurosciencegraph/data/4906ab85-694f-469d-962f-c0174e901885", + "@type": "BrainAtlasRelease", + "_rev": 8 + } + }, + { + "@id": "http://api.brain-map.org/api/v2/data/Structure/3", + "@type": "Class", + "atlas_id": 424, + "color_hex_triplet": "FF4C3E", + "graph_order": 775, + "hemisphere_id": 3, + "st_level": 9, + "identifier": "3", + "isPartOf": [ + "http://api.brain-map.org/api/v2/data/Structure/2" + ], + "isDefinedBy": "http://bbp.epfl.ch/neurosciencegraph/ontologies/core/brainregion", + "subClassOf": [ + "https://neuroshapes.org/BrainRegion" + ], + "delineates": [ + "http://purl.obolibrary.org/obo/UBERON_0014594" + ], + "regionVolume": { + "unitCode": "cubic micrometer", + "value": 108296875.0 + }, + "regionVolumeRatioToWholeBrain": { + "unitCode": "cubic micrometer", + "value": 0.00021400307558019888 + }, + "representedInAnnotation": true, + "hasHierarchyView": [ + "https://neuroshapes.org/BrainRegion" + ], + "prefLabel": "Tuberomammillary nucleus, ventral part", + "label": "Primary Motor Cortex", + "notation": "TMv", + "altLabel": "TMv", + "atlasRelease": { + "@id": "https://bbp.epfl.ch/neurosciencegraph/data/4906ab85-694f-469d-962f-c0174e901885", + "@type": "BrainAtlasRelease", + "_rev": 8 + } + } + ], + "derivation": [ + {}, + {} + ], + "label": "Brain Region Ontology" +} diff --git a/tests/data/brainregion_hierarchy.json b/tests/data/brainregion_hierarchy.json new file mode 100644 index 0000000..e623917 --- /dev/null +++ b/tests/data/brainregion_hierarchy.json @@ -0,0 +1 @@ +{"root_id": 997, "names": {"0": "background", "10": "Superior colliculus, motor related, intermediate gray layer", "1002": "Primary auditory area", "1003": "corticopontine tract", "1005": "Primary auditory area, layer 6b", "1006": "Primary somatosensory area, trunk, layer 1", "1009": "fiber tracts", "1010": "Visceral area, layer 4", "1012": "corticorubral tract", "1014": "Geniculate group, ventral thalamus", "1015": "Anterior cingulate area, dorsal part, layer 5", "1018": "Ventral auditory area", "1021": "Secondary motor area, layer 6a", "1023": "Ventral auditory area, layer 5", "1024": "grooves", "1026": "Primary somatosensory area, upper limb, layer 6b", "1027": "Posterior auditory area", "1029": "Posterior limiting nucleus of the thalamus", "1030": "Primary somatosensory area, lower limb, layer 1", "1032": "grooves of the cerebral cortex", "1034": "Taenia tecta, dorsal part, layer 1", "1035": "Supplemental somatosensory area, layer 4", "1037481934": "Orbital area, lateral part, layer 3", "1037502706": "Anterior cingulate area, layer 3", "104": "Agranular insular area, dorsal part", "1040": "grooves of the cerebellar cortex", "1042": "Taenia tecta, dorsal part, layer 2", "1043": "crossed tectospinal pathway", "1045": "Ectorhinal area, layer 6b", "1046": "Anteromedial visual area, layer 6a", "1050": "Taenia tecta, dorsal part, layer 3", "1051": "direct tectospinal pathway", "1053": "Anterior cingulate area, layer 2/3", "1054": "Infralimbic area, layer 6a", "1055": "endorhinal groove", "1057": "Gustatory areas", "1058": "Visceral area, layer 5", "1059": "Taenia tecta, dorsal part, layer 4", "1060": "doral tegmental decussation", "1062": "Primary somatosensory area, barrel field, layer 6b", "1066": "Anteromedial visual area, layer 2/3", "1067": "Taenia tecta, ventral part, layer 1", "10672": "Simple lobule, granular layer", "10673": "Simple lobule, Purkinje layer", "10674": "Simple lobule, molecular layer", "10675": "Crus 1, granular layer", "10676": "Crus 1, Purkinje layer", "10677": "Crus 1, molecular layer", "10678": "Crus 2, granular layer", "10679": "Crus 2, Purkinje layer", "1068": "dorsal thalamus related", "10680": "Crus 2, molecular layer", "10681": "Paramedian lobule, granular layer", "10682": "Paramedian lobule, Purkinje layer", "10683": "Paramedian lobule, molecular layer", "10684": "Copula pyramidis, granular layer", "10685": "Copula pyramidis, Purkinje layer", "10686": "Copula pyramidis, molecular layer", "10687": "Paraflocculus, granular layer", "10688": "Paraflocculus, Purkinje layer", "10689": "Paraflocculus, molecular layer", "1069": "Parapyramidal nucleus", "10690": "Flocculus, granular layer", "10691": "Flocculus, Purkinje layer", "10692": "Flocculus, molecular layer", "10693": "Parasubiculum, layer 1", "10694": "Parasubiculum, layer 2", "10695": "Parasubiculum, layer 3", "10696": "Postsubiculum, layer 1", "10697": "Postsubiculum, layer 2", "10698": "Postsubiculum, layer 3", "10699": "Presubiculum, layer 1", "107": "Somatomotor areas, layer 1", "10700": "Presubiculum, layer 2", "10701": "Presubiculum, layer 3", "10705": "Lingula (I), granular layer", "10706": "Lingula (I), Purkinje layer", "10707": "Lingula (I), molecular layer", "10708": "Lobule II, granular layer", "10709": "Lobule II, Purkinje layer", "10710": "Lobule II, molecular layer", "10711": "Lobule III, granular layer", "10712": "Lobule III, Purkinje layer", "10713": "Lobule III, molecular layer", "10714": "Lobule IV, granular layer", "10715": "Lobule IV, Purkinje layer", "10716": "Lobule IV, molecular layer", "10717": "Lobule V, granular layer", "10718": "Lobule V, Purkinje layer", "10719": "Lobule V, molecular layer", "10720": "Lobules IV-V, granular layer", "10721": "Lobules IV-V, Purkinje layer", "10722": "Lobules IV-V, molecular layer", "10723": "Declive (VI), granular layer", "10724": "Declive (VI), Purkinje layer", "10725": "Declive (VI), molecular layer", "10726": "Folium-tuber vermis (VII), granular layer", "10727": "Folium-tuber vermis (VII), Purkinje layer", "10728": "Folium-tuber vermis (VII), molecular layer", "10729": "Pyramus (VIII), granular layer", "10730": "Pyramus (VIII), Purkinje layer", "10731": "Pyramus (VIII), molecular layer", "10732": "Uvula (IX), granular layer", "10733": "Uvula (IX), Purkinje layer", "10734": "Uvula (IX), molecular layer", "10735": "Nodulus (X), granular layer", "10736": "Nodulus (X), Purkinje layer", "10737": "Nodulus (X), molecular layer", "1074": "Anterolateral visual area, layer 1", "1075": "Taenia tecta, ventral part, layer 2", "1076": "efferent cochleovestibular bundle", "1077": "Perireunensis nucleus", "1080": "Hippocampal region", "1081": "Infralimbic area, layer 6b", "1082": "Taenia tecta, ventral part, layer 3", "1083": "epithalamus related", "1085": "Secondary motor area, layer 6b", "1086": "Primary somatosensory area, trunk, layer 4", "1087129144": "Laterolateral anterior visual area, layer 2", "109": "nigrothalamic fibers", "1090": "Supplemental somatosensory area, layer 5", "1091": "Lobules IV-V", "1094": "Primary somatosensory area, lower limb, layer 4", "1096": "Anteromedial nucleus, dorsal part", "1098": "Medullary reticular nucleus, dorsal part", "1099": "fornix system", "110": "Paraventricular hypothalamic nucleus, parvicellular division, periventricular part", "1101": "Agranular insular area, dorsal part, layer 5", "1102": "Primary somatosensory area, mouth, layer 6a", "1104": "Anteromedial nucleus, ventral part", "1106": "Visceral area, layer 2/3", "1107": "Medullary reticular nucleus, ventral part", "1109": "Parastrial nucleus", "111": "Agranular insular area, posterior part", "1110": "Supramammillary nucleus, lateral part", "1111": "Primary somatosensory area, trunk, layer 5", "1114": "Anterolateral visual area, layer 4", "1117": "Pons, behavioral state related", "1118": "Supramammillary nucleus, medial part", "112": "Granular lamina of the cochlear nuclei", "1120": "Interanteromedial nucleus of the thalamus", "1121": "Entorhinal area, lateral part, layer 1", "1124": "Suprachiasmatic preoptic nucleus", "1125": "Orbital area, ventrolateral part, layer 5", "1127": "Temporal association areas, layer 2/3", "1128": "Primary somatosensory area, lower limb, layer 5", "113": "Primary somatosensory area, lower limb, layer 2/3", "1132": "Pons, sensory related", "1133": "Entorhinal area, medial part, ventral zone, layer 5/6", "1139": "Nucleus of the lateral olfactory tract, layer 3", "1140": "Postpiriform transition area, layers 1", "1141": "Postpiriform transition area, layers 2", "1142": "Postpiriform transition area, layers 3", "1160721590": "Anterior area, layer 3", "1165809076": "Primary auditory area, layer 3", "119": "Agranular insular area, ventral part", "120": "Agranular insular area, posterior part, layer 1", "121": "Lateral visual area, layer 6b", "123": "Koelliker-Fuse subnucleus", "12993": "Somatosensory areas, layer 1", "12994": "Somatosensory areas, layer 2/3", "12995": "Somatosensory areas, layer 4", "12996": "Somatosensory areas, layer 5", "12997": "Somatosensory areas, layer 6a", "12998": "Somatosensory areas, layer 6b", "130": "Superior central nucleus raphe, medial part", "1307372013": "Posterior auditory area, layer 2", "132": "Prelimbic area, layer 6b", "1355885073": "Somatosensory areas, layer 2", "137": "Superior central nucleus raphe, lateral part", "139": "Entorhinal area, lateral part, layer 5", "141": "Periventricular region", "142": "pallidothalamic pathway", "143": "Nucleus ambiguus, ventral division", "1430875964": "Rostrolateral visual area, layer 2", "1431942459": "Posterolateral visual area, layer 3", "144": "Olfactory tubercle, layers 1-3", "1454256797": "Primary somatosensory area, lower limb, layer 2", "1463157755": "Anterolateral visual area, layer 2", "148": "Gustatory areas, layer 4", "150": "periventricular bundle of the hypothalamus", "152": "Piriform area, layers 1-3", "154": "Perihypoglossal nuclei", "156": "Dorsal auditory area, layer 6a", "1598869030": "Posterior auditory area, layer 3", "16": "Layer 6b, isocortex", "160": "Anterior olfactory nucleus, layer 1", "1624848466": "Primary somatosensory area, nose, layer 3", "163": "Agranular insular area, posterior part, layer 2/3", "1645194511": "Anteromedial visual area, layer 2", "166": "premammillary commissure", "167": "Anterior olfactory nucleus, dorsal part", "1672280517": "Agranular insular area, posterior part, layer 2", "168": "Anterior olfactory nucleus, layer 2", "1695203883": "Anterior area, layer 2", "17": "Superior colliculus, motor related, intermediate white layer", "171": "Prelimbic area, layer 1", "1720700944": "Rostrolateral lateral visual area, layer 2", "175": "Anterior olfactory nucleus, external part", "1758306548": "Primary motor area, layer 3", "179": "Anterior cingulate area, layer 6a", "18": "nodular fissure", "180": "Gustatory areas, layer 2/3", "182": "propriohypothalamic pathways", "182305689": "Primary somatosensory area, unassigned", "182305693": "Primary somatosensory area, unassigned, layer 1", "182305697": "Primary somatosensory area, unassigned, layer 2/3", "182305701": "Primary somatosensory area, unassigned, layer 4", "182305705": "Primary somatosensory area, unassigned, layer 5", "182305709": "Primary somatosensory area, unassigned, layer 6a", "182305713": "Primary somatosensory area, unassigned, layer 6b", "183": "Anterior olfactory nucleus, lateral part", "185": "Parapyramidal nucleus, deep part", "187": "Gustatory areas, layer 5", "1890964946": "Primary somatosensory area, upper limb, layer 3", "1896413216": "Laterolateral anterior visual area, layer 3", "190": "pyramid", "191": "Anterior olfactory nucleus, medial part", "192": "Cortical amygdalar area, anterior part, layer 1", "193": "Parapyramidal nucleus, superficial part", "1942628671": "Ventral auditory area, layer 2", "195": "Prelimbic area, layer 2", "199": "Anterior olfactory nucleus, posteroventral part", "2": "Primary somatosensory area, mouth, layer 6b", "20": "Entorhinal area, lateral part, layer 2", "200": "Cortical amygdalar area, anterior part, layer 2", "2012716980": "Orbital area, medial part, layer 3", "2026216612": "Mediomedial anterior visual area, layer 2", "203": "Linear nucleus of the medulla", "205": "retriculospinal tract, lateral part", "2078623765": "Infralimbic area, layer 3", "208": "Cortical amygdalar area, anterior part, layer 3", "21": "lateral olfactory tract, general", "2102386393": "Primary somatosensory area, mouth, layer 2", "211": "Anterior cingulate area, dorsal part, layer 2/3", "213": "retriculospinal tract, medial part", "215": "Anterior pretectal nucleus", "2153924985": "Posterolateral visual area, layer 2", "216": "Cortical amygdalar area, posterior part, lateral zone, layer 1", "2167613582": "Ventral auditory area, layer 3", "2186168811": "Dorsal auditory area, layer 3", "2189208794": "Visceral area, layer 3", "219": "Somatomotor areas, layer 2/3", "2208057363": "Primary somatosensory area, unassigned, layer 3", "221": "rubroreticular tract", "2218254883": "Ectorhinal area, layer 2", "2224619882": "Agranular insular area, ventral part, layer 2", "224": "Cortical amygdalar area, posterior part, lateral zone, layer 2", "2260827822": "Primary somatosensory area, trunk, layer 3", "227": "Anterior cingulate area, layer 6b", "2292194787": "Anteromedial visual area, layer 3", "2300544548": "Posterior parietal association areas, layer 2", "232": "Cortical amygdalar area, posterior part, lateral zone, layer 3", "2336071181": "Supplemental somatosensory area, layer 2", "234": "Temporal association areas, layer 4", "2341154899": "Mediomedial posterior visual area, layer 2", "236": "Main olfactory bulb, mitral layer", "2361776473": "Retrosplenial area, dorsal part, layer 2", "240": "Cortical amygdalar area, posterior part, medial zone, layer 1", "241": "Posterior parietal association areas, layer 2/3", "2413172686": "Orbital area, ventrolateral part, layer 3", "2414821463": "Agranular insular area, posterior part, layer 3", "243": "Dorsal auditory area, layer 6b", "2430059008": "Visual areas, layer 3", "2439179873": "Temporal association areas, layer 2", "244": "Main olfactory bulb, outer plexiform layer", "245": "spinocervical tract", "248": "Cortical amygdalar area, posterior part, medial zone, layer 2", "249": "Posterior auditory area, layer 6a", "25": "simple fissure", "250": "Lateral septal nucleus, caudal (caudodorsal) part", "251": "Primary auditory area, layer 2/3", "2511156654": "Secondary motor area, layer 3", "252": "Dorsal auditory area, layer 5", "253": "spinohypothalamic pathway", "2536061413": "Frontal pole, layer 3", "2542216029": "Supplemental somatosensory area, layer 3", "2544082156": "posteromedial visual area, layer 2", "2546495501": "Somatomotor areas, layer 2", "256": "Cortical amygdalar area, posterior part, medial zone, layer 3", "258": "Lateral septal nucleus, rostral (rostroventral) part", "259": "Entorhinal area, medial part, ventral zone, layer 1", "2598818153": "Primary somatosensory area, barrel field, layer 3", "26": "Superior colliculus, motor related, deep gray layer", "260": "Nucleus of the lateral olfactory tract, molecular layer", "264": "Orbital area, layer 1", "2646114338": "Frontal pole, layer 2", "266": "Lateral septal nucleus, ventral part", "2668242174": "Perirhinal area, layer 3", "267": "Dorsal peduncular area, layer 6a", "268": "Nucleus of the lateral olfactory tract, pyramidal layer", "2683995601": "Primary visual area, layer 2", "269": "Posterolateral visual area, layer 2/3", "2691358660": "Primary somatosensory area, layer 2", "270": "spinoreticular pathway", "271": "Nucleus sagulum", "274": "Retrosplenial area, dorsal part, layer 6a", "276": "Piriform area, molecular layer", "278": "Striatum-like amygdalar nuclei", "2782023316": "Postrhinal area, layer 2", "279": "Retrosplenial area, lateral agranular part, layer 6b", "2790124484": "Prelimbic area, layer 3", "28": "Entorhinal area, lateral part, layer 6a", "281": "Anteromedial visual area, layer 1", "283": "Lateral tegmental nucleus", "2835688982": "Primary somatosensory area, barrel field, layer 2", "284": "Piriform area, pyramidal layer", "2845253318": "Anterolateral visual area, layer 3", "285": "spinotelenchephalic pathway", "2854337283": "Temporal association areas, layer 3", "2862362532": "Anterior cingulate area, dorsal part, layer 3", "288": "Orbital area, ventrolateral part, layer 2/3", "2887815719": "Medial visual area, layer 3", "289": "Temporal association areas, layer 5", "2892558637": "Retrosplenial area, lateral agranular part, layer 3", "2897348183": "Anterior cingulate area, ventral part, layer 2", "29": "lateral spinothalamic tract", "2906756445": "Somatomotor areas, layer 3", "291": "Piriform area, polymorph layer", "2927119608": "Primary auditory area, layer 2", "293": "spinovestibular pathway", "294": "Superior colliculus, motor related", "2949903222": "Anterior cingulate area, layer 2", "2951747260": "Primary somatosensory area, lower limb, layer 3", "296": "Anterior cingulate area, ventral part, layer 2/3", "297": "Taenia tecta, dorsal part, layers 1-4", "298": "Magnocellular nucleus", "2985091592": "Laterointermediate area, layer 3", "299": "Somatomotor areas, layer 5", "300": "Ventral part of the lateral geniculate complex, lateral zone", "302": "Superior colliculus, sensory related", "303": "Basolateral amygdalar nucleus, anterior part", "304": "Prelimbic area, layer 2/3", "3049552521": "Primary somatosensory area, mouth, layer 3", "305": "Primary visual area, layer 6b", "306": "Taenia tecta, ventral part, layers 1-3", "307": "Magnocellular reticular nucleus", "308": "Posterior parietal association areas, layer 6a", "3088876178": "Agranular insular area, dorsal part, layer 3", "309": "striatonigral pathway", "3095364455": "Anterior cingulate area, dorsal part, layer 2", "3099716140": "Primary somatosensory area, layer 3", "311": "Basolateral amygdalar nucleus, posterior part", "3114287561": "Medial visual area, layer 2", "312": "Entorhinal area, lateral part, layer 4/5", "312782546": "Anterior area", "312782550": "Anterior area, layer 1", "312782554": "Anterior area, layer 2/3", "312782558": "Anterior area, layer 4", "312782562": "Anterior area, layer 5", "312782566": "Anterior area, layer 6a", "312782570": "Anterior area, layer 6b", "312782574": "Laterointermediate area", "312782578": "Laterointermediate area, layer 1", "312782582": "Laterointermediate area, layer 2/3", "312782586": "Laterointermediate area, layer 4", "312782590": "Laterointermediate area, layer 5", "312782594": "Laterointermediate area, layer 6a", "312782598": "Laterointermediate area, layer 6b", "312782604": "Rostrolateral visual area, layer 1", "312782608": "Rostrolateral visual area, layer 2/3", "312782612": "Rostrolateral visual area, layer 4", "312782620": "Rostrolateral visual area, layer 6a", "312782624": "Rostrolateral visual area, layer 6b", "312782632": "Postrhinal area, layer 1", "312782636": "Postrhinal area, layer 2/3", "312782644": "Postrhinal area, layer 5", "312782648": "Postrhinal area, layer 6a", "312782652": "Postrhinal area, layer 6b", "3132124329": "Perirhinal area, layer 2", "314": "Agranular insular area, posterior part, layer 6a", "316": "Ventral part of the lateral geniculate complex, medial zone", "318": "Supragenual nucleus", "3192952047": "Retrosplenial area, lateral agranular part, layer 2", "320": "Primary motor area, layer 1", "3206763505": "Mediomedial anterior visual area, layer 3", "321": "Subgeniculate nucleus", "323": "Midbrain, motor related", "324": "Entorhinal area, medial part, ventral zone, layer 2", "3250982806": "Agranular insular area, dorsal part, layer 2", "3269661528": "Orbital area, layer 2", "327": "Basomedial amygdalar nucleus, anterior part", "328": "Agranular insular area, dorsal part, layer 2/3", "330": "Retrosplenial area, dorsal part, layer 6b", "3314370483": "Retrosplenial area, ventral part, layer 3", "332": "Accessory supraoptic group", "334": "Basomedial amygdalar nucleus, posterior part", "335": "Perirhinal area, layer 6a", "3360392253": "Posterior parietal association areas, layer 3", "337": "Primary somatosensory area, lower limb", "3376791707": "Agranular insular area, ventral part, layer 3", "339": "Midbrain, sensory related", "34": "intercrural fissure", "340": "Posterior parietal association areas, layer 6b", "3403314552": "Mediomedial posterior visual area, layer 3", "3412423041": "Secondary motor area, layer 2", "344": "Agranular insular area, posterior part, layer 5", "345": "Primary somatosensory area, mouth", "346": "Primary somatosensory area, layer 2/3", "348": "Midbrain, behavioral state related", "349": "supraoptic commissures", "3516629919": "Ectorhinal area, layer 3", "352": "Orbital area, layer 5", "353": "Primary somatosensory area, nose", "355": "Agranular insular area, posterior part, layer 6b", "356": "Preparasubthalamic nucleus", "3562104832": "Primary somatosensory area, trunk, layer 2", "358": "Sublaterodorsal nucleus", "3582239403": "Anterior cingulate area, ventral part, layer 3", "3582777032": "Rostrolateral lateral visual area, layer 3", "3591549811": "Primary somatosensory area, nose, layer 2", "36": "Gustatory areas, layer 1", "360": "Dorsal peduncular area, layer 2/3", "361": "Primary somatosensory area, trunk", "363": "Prelimbic area, layer 5", "364": "Parasubthalamic nucleus", "3653590473": "Orbital area, ventrolateral part, layer 2", "368": "Perirhinal area, layer 6b", "3683796018": "Gustatory areas, layer 2", "369": "Primary somatosensory area, upper limb", "3693772975": "Primary somatosensory area, upper limb, layer 2", "37": "longitudinal association bundle", "370": "Medulla, motor related", "371": "Entorhinal area, medial part, ventral zone, layer 3", "3710667749": "posteromedial visual area, layer 3", "3714509274": "Rostrolateral visual area, layer 3", "3718675619": "Primary motor area, layer 2", "372": "Infracerebellar nucleus", "3724992631": "Visual areas, layer 2", "373": "trigeminocerebellar tract", "376": "Cortical amygdalar area, posterior part, lateral zone, layers 1-3", "377": "Posterolateral visual area, layer 6a", "3781663036": "Dorsal auditory area, layer 2", "379": "Medulla, behavioral state related", "3803368771": "Orbital area, lateral part, layer 2", "3808183566": "Laterointermediate area, layer 2", "3808433473": "Primary somatosensory area, unassigned, layer 2", "383": "Cortical amygdalar area, posterior part, medial zone, layers 1-3", "386": "Medulla, sensory related", "387": "Entorhinal area, lateral part, layer 5/6", "3880005807": "Orbital area, layer 3", "389": "ventral spinothalamic tract", "3893800328": "Gustatory areas, layer 3", "3894563657": "Primary visual area, layer 3", "39": "Anterior cingulate area, dorsal part", "392": "Nucleus of the lateral olfactory tract, layers 1-3", "3920533696": "Postrhinal area, layer 3", "3927629261": "Lateral visual area, layer 2", "393": "Posterolateral visual area, layer 6b", "3937412080": "Somatosensory areas, layer 3", "3956191525": "Retrosplenial area, dorsal part, layer 3", "3962734174": "Lateral visual area, layer 3", "3964792502": "Visceral area, layer 2", "400": "Piriform-amygdalar area, layers 1-3", "401": "Anteromedial visual area, layer 4", "405": "ventrolateral hypothalamic tract", "408": "Piriform-amygdalar area, molecular layer", "41": "posteromedial visual area, layer 2/3", "410": "reticulocerebellar tract", "411": "Medial amygdalar nucleus, anterodorsal part", "412": "Orbital area, lateral part, layer 2/3", "414": "Subparafascicular nucleus, magnocellular part", "416": "Piriform-amygdalar area, pyramidal layer", "418": "Medial amygdalar nucleus, anteroventral part", "419": "Entorhinal area, medial part, ventral zone, layer 4", "42": "Superior colliculus, motor related, deep white layer", "420": "precommissural fornix diagonal band", "421": "Lateral visual area, layer 1", "422": "Subparafascicular nucleus, parvicellular part", "424": "Piriform-amygdalar area, polymorph layer", "426": "Medial amygdalar nucleus, posterodorsal part", "427": "Ectorhinal area, layer 2/3", "428": "medial corticohypothalamic tract", "430": "Retrosplenial area, ventral part, layer 2/3", "434": "Retrosplenial area, dorsal part, layer 2/3", "435": "Medial amygdalar nucleus, posteroventral part", "44": "Infralimbic area", "440": "Orbital area, lateral part, layer 6a", "441": "Anteromedial visual area, layer 6b", "442": "Retrosplenial area, dorsal part, layer 1", "443": "dorsal hippocampal commissure", "444": "Medial group of the dorsal thalamus", "448": "Orbital area, lateral part, layer 1", "449": "ventral hippocampal commissure", "45": "Spinal nucleus of the trigeminal, oral part, rostral dorsomedial part", "450": "Primary somatosensory area, upper limb, layer 1", "451": "Basolateral amygdalar nucleus, ventral part", "456": "Posterior auditory area, layer 6b", "457": "Visual areas, layer 6a", "458": "Olfactory tubercle, molecular layer", "459": "accessory olfactory tract", "46": "mammillary related", "461": "Primary somatosensory area, trunk, layer 6b", "465": "Olfactory tubercle, pyramidal layer", "468": "Entorhinal area, medial part, dorsal zone, layer 2a", "469": "posteromedial visual area, layer 6b", "472": "Medial amygdalar nucleus, posterodorsal part, sublayer a", "473": "Olfactory tubercle, polymorph layer", "474": "angular path", "476": "Orbital area, layer 6a", "478": "Primary somatosensory area, lower limb, layer 6a", "48": "Anterior cingulate area, ventral part", "480": "Medial amygdalar nucleus, posterodorsal part, sublayer b", "480149202": "Rostrolateral lateral visual area", "480149206": "Rostrolateral lateral visual area, layer 1", "480149210": "Rostrolateral lateral visual area, layer 2/3", "480149214": "Rostrolateral lateral visual area, layer 4", "480149218": "Rostrolateral lateral visual area, layer 5", "480149222": "Rostrolateral lateral visual area, layer 6a", "480149226": "Rostrolateral lateral visual area, layer 6b", "480149230": "Laterolateral anterior visual area", "480149234": "Laterolateral anterior visual area, layer 1", "480149238": "Laterolateral anterior visual area, layer 2/3", "480149242": "Laterolateral anterior visual area, layer 4", "480149246": "Laterolateral anterior visual area, layer 5", "480149250": "Laterolateral anterior visual area, layer 6a", "480149254": "Laterolateral anterior visual area, layer 6b", "480149258": "Mediomedial anterior visual area", "480149262": "Mediomedial anterior visual area, layer 1", "480149266": "Mediomedial anterior visual area, layer 2/3", "480149270": "Mediomedial anterior visual area, layer 4", "480149274": "Mediomedial anterior visual area, layer 5", "480149278": "Mediomedial anterior visual area, layer 6a", "480149282": "Mediomedial anterior visual area, layer 6b", "480149286": "Mediomedial posterior visual area", "480149290": "Mediomedial posterior visual area, layer 1", "480149294": "Mediomedial posterior visual area, layer 2/3", "480149298": "Mediomedial posterior visual area, layer 4", "480149302": "Mediomedial posterior visual area, layer 5", "480149306": "Mediomedial posterior visual area, layer 6a", "480149310": "Mediomedial posterior visual area, layer 6b", "480149314": "Medial visual area", "480149318": "Medial visual area, layer 1", "480149322": "Medial visual area, layer 2/3", "480149326": "Medial visual area, layer 4", "480149330": "Medial visual area, layer 5", "480149334": "Medial visual area, layer 6a", "480149338": "Medial visual area, layer 6b", "484": "Orbital area, medial part, layer 1", "484682470": "Prosubiculum", "484682475": "Prosubiculum, dorsal part", "484682479": "Prosubiculum, dorsal part, molecular layer", "484682483": "Prosubiculum, dorsal part, pyramidal layer", "484682487": "Prosubiculum, dorsal part, stratum radiatum", "484682492": "Prosubiculum, ventral part", "484682496": "Prosubiculum, ventral part, molecular layer", "484682500": "Prosubiculum, ventral part, pyramidal layer", "484682504": "Prosubiculum, ventral part, stratum radiatum", "484682508": "Area prostriata", "484682512": "supra-callosal cerebral white matter", "484682516": "corpus callosum, body", "484682520": "optic radiation", "484682524": "auditory radiation", "484682528": "commissural branch of stria terminalis", "487": "Medial amygdalar nucleus, posterodorsal part, sublayer c", "488": "Orbital area, lateral part, layer 6b", "49": "intraparafloccular fissure", "490": "bulbocerebellar tract", "492": "Orbital area, layer 2/3", "494": "Superior colliculus, motor related, intermediate gray layer, sublayer a", "496": "Dorsal peduncular area, layer 1", "496345664": "Dorsal part of the lateral geniculate complex, shell", "496345668": "Dorsal part of the lateral geniculate complex, core", "496345672": "Dorsal part of the lateral geniculate complex, ipsilateral zone", "497": "Visual areas, layer 6b", "498": "Bed nuclei of the stria terminalis, anterior division, anteromedial area", "50": "Precommissural nucleus", "500": "Somatomotor areas", "503": "Superior colliculus, motor related, intermediate gray layer, sublayer b", "505": "Bed nuclei of the stria terminalis, anterior division, dorsomedial nucleus", "508": "Entorhinal area, medial part, dorsal zone, layer 2b", "509": "Subiculum, dorsal part", "510": "Primary somatosensory area, lower limb, layer 6b", "511": "Superior colliculus, motor related, intermediate gray layer, sublayer c", "516": "Orbital area, layer 6b", "517": "Postpiriform transition area, layers 1-3", "518": "Subiculum, ventral part", "52": "Entorhinal area, lateral part, layer 3", "520": "Ventral auditory area, layer 6a", "522": "dorsal commissure of the spinal cord", "524": "Orbital area, medial part, layer 2", "526": "Entorhinal area, medial part, dorsal zone, layer 1", "526157192": "Frontal pole, layer 5", "526157196": "Frontal pole, layer 6a", "526322264": "Frontal pole, layer 6b", "527": "Dorsal auditory area, layer 1", "527696977": "Orbital area, medial part, layer 6b", "529": "Bed nuclei of the stria terminalis, anterior division, ventral nucleus", "53": "Spinal nucleus of the trigeminal, oral part, middle dorsomedial part, dorsal zone", "530": "dorsal fornix", "531": "Medial pretectal area", "532": "Posterior parietal association areas, layer 1", "534": "Supratrigeminal nucleus", "535": "Dorsal peduncular area, layer 2", "537": "Bed nuclei of the stria terminalis, anterior division, anterolateral area", "538": "dorsal limb", "539": "Midbrain reticular nucleus, magnocellular part", "540": "Perirhinal area, layer 1", "542": "Retrosplenial area, ventral part, layer 1", "543": "Entorhinal area, medial part, dorsal zone, layer 2", "544": "Central amygdalar nucleus, capsular part", "545": "Retrosplenial area, dorsal part, layer 4", "546": "Bed nuclei of the stria terminalis, anterior division, juxtacapsular nucleus", "548": "Midbrain reticular nucleus, magnocellular part, general", "549009199": "Lateral strip of striatum", "549009203": "Retroparafascicular nucleus", "549009207": "Intercollicular nucleus", "549009211": "Medial accesory oculomotor nucleus", "549009215": "Peritrigeminal zone", "549009219": "Accessory trigeminal nucleus", "549009223": "Parvicellular motor 5 nucleus", "549009227": "Intertrigeminal nucleus", "55": "Paraventricular hypothalamic nucleus, parvicellular division, anterior parvicellular part", "550": "Entorhinal area, medial part, dorsal zone, layer 5/6", "551": "Central amygdalar nucleus, lateral part", "555": "Midbrain reticular nucleus, parvicellular part", "556": "Infralimbic area, layer 2/3", "558": "Primary somatosensory area, nose, layer 1", "559": "Central amygdalar nucleus, medial part", "560": "Cochlear nucleus, subpedunclular granular region", "560581551": "Ethmoid nucleus of the thalamus", "560581555": "Retroethmoid nucleus", "560581559": "Xiphoid thalamic nucleus", "560581563": "Posterior intralaminar thalamic nucleus", "561": "Visual areas, layer 2/3", "562": "Bed nuclei of the stria terminalis, anterior division, rhomboid nucleus", "563": "dorsal tegmental tract", "563807435": "Posterior triangular thalamic nucleus", "563807439": "Intermediate geniculate nucleus", "565": "posteromedial visual area, layer 5", "566": "Postpiriform transition area", "569": "Bed nuclei of the stria terminalis, posterior division, dorsal nucleus", "57": "paramedian sulcus", "570": "dorsolateral fascicle", "572": "Anterior cingulate area, layer 1", "576": "Accessory facial motor nucleus", "576073699": "Ventromedial preoptic nucleus", "576073704": "Perifornical nucleus", "577": "Primary somatosensory area, upper limb, layer 4", "582": "Orbital area, medial part, layer 2/3", "584": "Cortical amygdalar area, posterior part, lateral zone, layers 1-2", "585": "Bed nuclei of the stria terminalis, posterior division, interfascicular nucleus", "586": "fasciculus proprius", "588": "Anterior cingulate area, ventral part, layer 1", "589508447": "Hippocampo-amygdalar transition area", "589508451": "Paratrigeminal nucleus", "589508455": "Vestibulocerebellar nucleus", "59": "Intermediodorsal nucleus of the thalamus", "590": "Retrosplenial area, ventral part, layer 6a", "591": "Central linear nucleus raphe", "592": "Cortical amygdalar area, posterior part, medial zone, layers 1-2", "593": "Primary visual area, layer 1", "597": "Taenia tecta, dorsal part", "598": "Ventral auditory area, layer 6b", "599626923": "Subcommissural organ", "599626927": "Posterodorsal tegmental nucleus", "60": "Entorhinal area, lateral part, layer 6b", "600": "Dorsal auditory area, layer 2/3", "601": "Anterolateral visual area, layer 6a", "602": "Bed nuclei of the stria terminalis, posterior division, strial extension", "605": "Taenia tecta, ventral part", "606": "Retrosplenial area, ventral part, layer 2", "606826647": "Medial mammillary nucleus, lateral part", "606826651": "Medial mammillary nucleus, medial part", "606826655": "Medial mammillary nucleus, posterior part", "606826659": "Medial mammillary nucleus, dorsal part", "606826663": "Paratrochlear nucleus", "607344830": "Paranigral nucleus", "607344834": "Interpeduncular nucleus, rostral", "607344838": "Interpeduncular nucleus, caudal", "607344842": "Interpeduncular nucleus, apical", "607344846": "Interpeduncular nucleus, lateral", "607344850": "Interpeduncular nucleus, intermediate", "607344854": "Interpeduncular nucleus, dorsomedial", "607344858": "Interpeduncular nucleus, dorsolateral", "607344862": "Interpeduncular nucleus, rostrolateral", "608": "Orbital area, ventrolateral part, layer 6a", "609": "Subparafascicular area", "61": "Spinal nucleus of the trigeminal, oral part, middle dorsomedial part, ventral zone", "610": "Retrosplenial area, dorsal part, layer 5", "614454277": "Supraoculomotor periaqueductal gray", "620": "Orbital area, medial part, layer 5", "622": "Retrosplenial area, ventral part, layer 6b", "624": "Interpeduncular fossa", "625": "Primary somatosensory area, upper limb, layer 5", "627": "hypothalamohypophysial tract", "629": "Ventral anterior-lateral complex of the thalamus", "630": "Orbital area, lateral part, layer 5", "635": "Posterior parietal association areas, layer 4", "638": "Gustatory areas, layer 6a", "639": "Cortical amygdalar area, anterior part", "640": "Efferent vestibular nucleus", "643": "Posterior auditory area, layer 2/3", "644": "Somatomotor areas, layer 6a", "646": "Dorsal peduncular area, layer 5", "647": "Cortical amygdalar area, posterior part", "648": "Primary motor area, layer 5", "649": "Anterolateral visual area, layer 6b", "65": "parafloccular sulcus", "652": "Paraventricular hypothalamic nucleus, magnocellular division, posterior magnocellular part, lateral zone", "654": "Primary somatosensory area, nose, layer 4", "655": "Cortical amygdalar area, posterior part, lateral zone", "656": "Secondary motor area, layer 1", "657": "Primary somatosensory area, mouth, layer 2/3", "659": "Nucleus of the solitary tract, central part", "660": "Paraventricular hypothalamic nucleus, magnocellular division, posterior magnocellular part, medial zone", "662": "Gustatory areas, layer 6b", "663": "Cortical amygdalar area, posterior part, medial zone", "664": "Entorhinal area, medial part, dorsal zone, layer 3", "666": "Nucleus of the solitary tract, commissural part", "667": "Frontal pole, layer 2/3", "668": "Dorsomedial nucleus of the hypothalamus, anterior part", "670": "Primary somatosensory area, trunk, layer 2/3", "671": "Retrosplenial area, lateral agranular part, layer 1", "675": "Agranular insular area, ventral part, layer 6a", "676": "Dorsomedial nucleus of the hypothalamus, posterior part", "677": "Visceral area", "68": "Frontal pole, layer 1", "680": "Orbital area, ventrolateral part, layer 6b", "682": "Nucleus of the solitary tract, lateral part", "683": "Posterior parietal association areas, layer 5", "684": "Dorsomedial nucleus of the hypothalamus, ventral part", "686": "Primary somatosensory area, layer 6a", "687": "Retrosplenial area, ventral part, layer 5", "69": "Spinal nucleus of the trigeminal, oral part, ventrolateral part", "690": "mammillothalamic tract", "692": "Perirhinal area, layer 5", "694": "Agranular insular area, ventral part, layer 2/3", "696": "Posterior auditory area, layer 1", "698": "Olfactory areas", "699": "Agranular insular area, ventral part, layer 6b", "70": "midbrain related", "702": "Primary somatosensory area, nose, layer 5", "703": "Cortical subplate", "704": "Agranular insular area, ventral part, layer 1", "707": "Infralimbic area, layer 1", "712": "Entorhinal area, medial part, dorsal zone, layer 4", "714": "Orbital area", "715": "Entorhinal area, lateral part, layer 2a", "719": "Primary somatosensory area, layer 6b", "72": "Anterodorsal preoptic nucleus", "722": "periventricular bundle of the thalamus", "723": "Orbital area, lateral part", "725": "Ventral posterolateral nucleus of the thalamus, parvicellular part", "727": "Entorhinal area, medial part, dorsal zone, layer 5", "728": "arbor vitae", "729": "Temporal association areas, layer 6a", "731": "Orbital area, medial part", "734": "Dentate gyrus crest", "735": "Primary auditory area, layer 1", "736": "central tegmental bundle", "738": "Orbital area, ventral part", "739": "Anterior cingulate area, layer 5", "740": "Medial preoptic nucleus, central part", "742": "Dentate gyrus crest, molecular layer", "743": "Entorhinal area, medial part, dorsal zone, layer 6", "745": "precommissural fornix, general", "746": "Orbital area, ventrolateral part", "747": "Infralimbic area, layer 2", "748": "Medial preoptic nucleus, lateral part", "750": "Posterolateral visual area, layer 1", "751": "Dentate gyrus crest, polymorph layer", "755": "Ventral auditory area, layer 2/3", "756": "Medial preoptic nucleus, medial part", "758": "Dentate gyrus crest, granule cell layer", "759": "Posterior auditory area, layer 4", "76": "Interstitial nucleus of the vestibular nerve", "760": "cerebral nuclei related", "761": "Ventromedial hypothalamic nucleus, anterior part", "762": "propriohypothalamic pathways, dorsal", "764": "Entorhinal area, lateral part, layer 2b", "765": "Nucleus x", "766": "Dentate gyrus lateral blade", "767": "Secondary motor area, layer 5", "768": "cerebrum related", "769": "Ventromedial hypothalamic nucleus, central part", "77": "Spinal nucleus of the trigeminal, oral part, caudal dorsomedial part", "770": "propriohypothalamic pathways, lateral", "772": "Anterior cingulate area, ventral part, layer 5", "774": "Retrosplenial area, lateral agranular part, layer 5", "775": "Dentate gyrus lateral blade, molecular layer", "777": "Ventromedial hypothalamic nucleus, dorsomedial part", "779": "propriohypothalamic pathways, medial", "781": "Nucleus y", "782": "Dentate gyrus lateral blade, polymorph layer", "783": "Agranular insular area, dorsal part, layer 6a", "785": "Ventromedial hypothalamic nucleus, ventrolateral part", "786": "Temporal association areas, layer 6b", "787": "propriohypothalamic pathways, ventral", "788": "Piriform-amygdalar area", "789": "Nucleus z", "790": "Dentate gyrus lateral blade, granule cell layer", "791": "Posterior auditory area, layer 5", "792": "dorsal roots", "793": "Primary somatosensory area, layer 1", "799": "Dentate gyrus medial blade", "8": "Basic cell groups and regions", "80": "Anterior hypothalamic area", "800": "Agranular insular area, ventral part, layer 5", "801": "Visual areas, layer 1", "804": "Fields of Forel", "805": "posteromedial visual area, layer 1", "806": "Supplemental somatosensory area, layer 2/3", "807": "Dentate gyrus medial blade, molecular layer", "809": "Pallidum, caudal region", "810": "Anterior cingulate area, ventral part, layer 6a", "814": "Dorsal peduncular area", "815": "Dentate gyrus medial blade, polymorph layer", "816": "Primary auditory area, layer 4", "817": "supraoptic commissures, anterior", "819": "Anterior cingulate area, ventral part, layer 6b", "823": "Dentate gyrus medial blade, granule cell layer", "824": "hypothalamus related", "826": "Pallidum, medial region", "827": "Infralimbic area, layer 5", "829": "Subiculum, dorsal part, molecular layer", "831": "Agranular insular area, dorsal part, layer 6b", "836": "Ectorhinal area, layer 1", "837": "Subiculum, dorsal part, stratum radiatum", "838": "Primary somatosensory area, nose, layer 2/3", "84": "Prelimbic area, layer 6a", "844": "Primary motor area, layer 6a", "845": "Subiculum, dorsal part, pyramidal layer", "847": "Primary auditory area, layer 5", "849": "Visceral area, layer 6b", "850": "uncinate fascicle", "853": "Subiculum, ventral part, molecular layer", "854": "Primary somatosensory area, upper limb, layer 2/3", "855": "retriculospinal tract", "856": "Thalamus, polymodal association cortex related", "857": "Visceral area, layer 6a", "86": "middle thalamic commissure", "860": "Parabrachial nucleus, lateral division, central lateral part", "861": "Subiculum, ventral part, stratum radiatum", "862": "Supplemental somatosensory area, layer 6a", "864": "Thalamus, sensory-motor cortex related", "865": "Primary somatosensory area, layer 4", "868": "Parabrachial nucleus, lateral division, dorsal lateral part", "87": "Paraventricular hypothalamic nucleus, parvicellular division, medial parvicellular part, dorsal zone", "870": "Subiculum, ventral part, pyramidal layer", "873": "Supplemental somatosensory area, layer 1", "875": "Parabrachial nucleus, lateral division, external lateral part", "878": "Primary somatosensory area, mouth, layer 1", "879": "Retrosplenial area, dorsal part", "882": "Primary motor area, layer 6b", "883": "Parabrachial nucleus, lateral division, superior lateral part", "884": "amygdalar capsule", "887": "Efferent cochlear group", "888": "Perirhinal area, layer 2/3", "889": "Primary somatosensory area, nose, layer 6a", "89": "rhinocele", "891": "Parabrachial nucleus, lateral division, ventral lateral part", "893": "Supplemental somatosensory area, layer 6b", "894": "Retrosplenial area, lateral agranular part", "895": "Ectorhinal area", "896": "thalamus related", "897": "Visceral area, layer 1", "899": "Parabrachial nucleus, medial division, external medial part", "9": "Primary somatosensory area, trunk, layer 6a", "902": "Posterolateral visual area, layer 5", "905": "Anterolateral visual area, layer 2/3", "906": "Retrosplenial area, lateral agranular part, layer 6a", "91": "Interposed nucleus", "910": "Orbital area, medial part, layer 6a", "913": "Visual areas, layer 4", "914": "Posterodorsal preoptic nucleus", "915": "Parabrachial nucleus, medial division, medial medial part", "919": "Anterior cingulate area, dorsal part, layer 6a", "92": "Entorhinal area, lateral part, layer 4", "921": "Primary somatosensory area, layer 5", "923": "Parabrachial nucleus, medial division, ventral medial part", "925": "ventral roots", "927": "Anterior cingulate area, dorsal part, layer 6b", "929": "Primary somatosensory area, nose, layer 6b", "932": "cervicothalamic tract", "935": "Anterior cingulate area, dorsal part, layer 1", "937": "Visual areas, layer 5", "939": "Nucleus ambiguus, dorsal division", "943": "Primary motor area, layer 2/3", "945": "Primary somatosensory area, upper limb, layer 6a", "947": "Somatomotor areas, layer 6b", "95": "Agranular insular area", "950": "Primary somatosensory area, mouth, layer 4", "952": "Endopiriform nucleus, dorsal part", "954": "Primary auditory area, layer 6a", "955": "Lateral reticular nucleus, magnocellular part", "956": "corpus callosum, anterior forceps", "959": "Ventral auditory area, layer 1", "960": "cerebellum related fiber tracts", "962": "Secondary motor area, layer 2/3", "963": "Lateral reticular nucleus, parvicellular part", "964": "corpus callosum, extreme capsule", "965": "Retrosplenial area, lateral agranular part, layer 2/3", "966": "Endopiriform nucleus, ventral part", "969": "Orbital area, ventrolateral part, layer 1", "97": "Temporal association areas, layer 1", "971": "corpus callosum, posterior forceps", "973": "Lateral visual area, layer 2/3", "974": "Primary somatosensory area, mouth, layer 5", "977": "Ectorhinal area, layer 6a", "983": "lateral forebrain bundle system", "987": "Pons, motor related", "988": "Ectorhinal area, layer 5", "990": "Ventral auditory area, layer 4", "991": "medial forebrain bundle system", "996": "Agranular insular area, dorsal part, layer 1", "999": "Entorhinal area, lateral part, layer 2/3", "1": "Tuberomammillary nucleus, ventral part", "100": "Interpeduncular nucleus", "1000": "extrapyramidal fiber systems", "1001": "Lobule V", "1004": "Ventral premammillary nucleus", "1007": "Simple lobule", "1008": "Geniculate group, dorsal thalamus", "101": "Ventral cochlear nucleus", "1011": "Dorsal auditory area", "1016": "olfactory nerve layer of main olfactory bulb", "1017": "Ansiform lobule", "1019": "corticospinal tract, crossed", "102": "nigrostriatal tract", "1020": "Posterior complex of the thalamus", "1022": "Globus pallidus, external segment", "1025": "Paramedian lobule", "1028": "corticospinal tract, uncrossed", "103": "Paraventricular hypothalamic nucleus, magnocellular division, posterior magnocellular part", "1031": "Globus pallidus, internal segment", "1033": "Copula pyramidis", "1036": "corticotectal tract", "1037": "Postsubiculum", "1038": "Primary somatosensory area, barrel field, layer 6a", "1039": "Gracile nucleus", "1041": "Paraflocculus", "1044": "Peripeduncular nucleus", "1047": "Primary somatosensory area, barrel field, layer 4", "1048": "Gigantocellular reticular nucleus", "1049": "Flocculus", "105": "Superior olivary complex, medial part", "1052": "Pedunculopontine nucleus", "1056": "Crus 1", "106": "Inferior salivatory nucleus", "1061": "Posterior pretectal nucleus", "1063": "hippocampal fissure", "1064": "Crus 2", "1065": "Hindbrain", "10671": "Median eminence", "1070": "Primary somatosensory area, barrel field, layer 5", "10702": "Dentate gyrus, subgranular zone", "10703": "Dentate gyrus, molecular layer", "10704": "Dentate gyrus, polymorph layer", "1071": "rhinal fissure", "1072": "Medial geniculate complex, dorsal part", "1073": "Hemispheric regions", "1078": "rhinal incisure", "1079": "Medial geniculate complex, ventral part", "108": "choroid plexus", "1084": "Presubiculum", "1087": "precentral fissure", "1088": "Medial geniculate complex, medial part", "1089": "Hippocampal formation", "1092": "external medullary lamina of the thalamus", "1093": "Pontine reticular nucleus, caudal part", "1095": "preculminate fissure", "1097": "Hypothalamus", "11": "posterolateral fissure", "1100": "Pretectal region", "1103": "primary fissure", "1105": "Intercalated amygdalar nucleus", "1108": "genu of corpus callosum", "1112": "posterior superior fissure", "1113": "Interanterodorsal nucleus of the thalamus", "1116": "genu of the facial nerve", "1119": "prepyramidal fissure", "1123": "inferior cerebellar peduncle", "1126": "Tuberomammillary nucleus, dorsal part", "1129": "Interbrain", "1131": "intermediate nerve", "114": "Superior olivary complex, lateral part", "1143": "Cerebellar cortex, granular layer", "1144": "Cerebellar cortex, molecular layer", "1145": "Cerebellar cortex, Purkinje layer", "115": "Trochlear nucleus", "116": "choroid fissure", "117": "optic chiasm", "118": "Periventricular hypothalamic nucleus, intermediate part", "12": "Interfascicular nucleus raphe", "122": "Superior olivary complex, periolivary region", "124": "interventricular foramen", "125": "optic tract", "126": "Periventricular hypothalamic nucleus, posterior part", "127": "Anteromedial nucleus", "128": "Midbrain reticular nucleus", "129": "third ventricle", "131": "Lateral amygdalar nucleus", "133": "Periventricular hypothalamic nucleus, preoptic part", "134": "pallidotegmental fascicle", "135": "Nucleus ambiguus", "136": "Intermediate reticular nucleus", "138": "Lateral group of the dorsal thalamus", "14": "internal medullary lamina of the thalamus", "140": "cerebral aqueduct", "145": "fourth ventricle", "146": "Pontine reticular nucleus", "147": "Locus ceruleus", "149": "Paraventricular nucleus of the thalamus", "15": "Parataenial nucleus", "151": "Accessory olfactory bulb", "153": "lateral recess", "155": "Lateral dorsal nucleus of thalamus", "157": "Periventricular zone", "158": "posterior commissure", "159": "Anterior olfactory nucleus", "161": "Nucleus intercalatus", "162": "Laterodorsal tegmental nucleus", "164": "central canal, spinal cord/medulla", "165": "Midbrain raphe nuclei", "169": "Nucleus prepositus", "170": "Dorsal part of the lateral geniculate complex", "173": "Retrochiasmatic area", "174": "preoptic commissure", "177": "Nucleus of Roller", "178": "Ventral part of the lateral geniculate complex", "181": "Nucleus of reuniens", "184": "Frontal pole, cerebral cortex", "186": "Lateral habenula", "188": "Accessory olfactory bulb, glomerular layer", "189": "Rhomboid nucleus", "19": "Induseum griseum", "194": "Lateral hypothalamic area", "196": "Accessory olfactory bulb, granular layer", "197": "Rostral linear nucleus raphe", "198": "pyramidal decussation", "201": "Primary somatosensory area, barrel field, layer 2/3", "202": "Medial vestibular nucleus", "204": "Accessory olfactory bulb, mitral layer", "206": "Nucleus raphe magnus", "207": "Area postrema", "209": "Lateral vestibular nucleus", "210": "Lateral mammillary nucleus", "212": "Main olfactory bulb, glomerular layer", "214": "Red nucleus", "217": "Superior vestibular nucleus", "218": "Lateral posterior nucleus of the thalamus", "22": "Posterior parietal association areas", "220": "Main olfactory bulb, granule layer", "222": "Nucleus raphe obscurus", "223": "Arcuate hypothalamic nucleus", "225": "Spinal vestibular nucleus", "226": "Lateral preoptic area", "228": "Main olfactory bulb, inner plexiform layer", "229": "sensory root of the trigeminal nerve", "23": "Anterior amygdalar area", "230": "Nucleus raphe pallidus", "231": "Anterior tegmental nucleus", "233": "Anterolateral visual area, layer 5", "235": "Lateral reticular nucleus", "237": "solitary tract", "238": "Nucleus raphe pontis", "239": "Anterior group of the dorsal thalamus", "242": "Lateral septal nucleus", "246": "Midbrain reticular nucleus, retrorubral area", "247": "Auditory areas", "254": "Retrosplenial area", "255": "Anteroventral nucleus of thalamus", "257": "posteromedial visual area, layer 6a", "261": "spino-olivary pathway", "262": "Reticular nucleus of the thalamus", "263": "Anteroventral preoptic nucleus", "27": "Intergeniculate leaflet of the lateral geniculate complex", "272": "Anteroventral periventricular nucleus", "275": "Lateral septal complex", "277": "spinotectal pathway", "280": "Barrington's nucleus", "286": "Suprachiasmatic nucleus", "287": "Bed nucleus of the anterior commissure", "290": "Hypothalamic lateral zone", "292": "Bed nucleus of the accessory olfactory tract", "295": "Basolateral amygdalar nucleus", "3": "secondary fissure", "30": "Periventricular hypothalamic nucleus, anterior part", "301": "stria terminalis", "304325711": "retina", "31": "Anterior cingulate area", "310": "Septofimbrial nucleus", "312782616": "Rostrolateral visual area, layer 5", "312782628": "Postrhinal area", "312782640": "Postrhinal area, layer 4", "313": "Midbrain", "317": "subthalamic fascicle", "319": "Basomedial amygdalar nucleus", "325": "Suprageniculate nucleus", "326": "superior cerebelar peduncles", "329": "Primary somatosensory area, barrel field", "33": "Primary visual area, layer 6a", "331": "Mammillary body", "333": "Septohippocampal nucleus", "336": "superior colliculus commissure", "338": "Subfornical organ", "341": "supramammillary decussation", "342": "Substantia innominata", "343": "Brain stem", "347": "Subparaventricular zone", "35": "Oculomotor nucleus", "350": "Subceruleus nucleus", "351": "Bed nuclei of the stria terminalis", "354": "Medulla", "357": "tectothalamic pathway", "359": "Bed nuclei of the stria terminalis, anterior division", "362": "Mediodorsal nucleus of thalamus", "365": "thalamic peduncles", "366": "Submedial nucleus of the thalamus", "367": "Bed nuclei of the stria terminalis, posterior division", "374": "Substantia nigra, compact part", "378": "Supplemental somatosensory area", "38": "Paraventricular hypothalamic nucleus", "380": "cuneate fascicle", "381": "Substantia nigra, reticular part", "382": "Field CA1", "384": "trochlear nerve decussation", "385": "Primary visual area", "388": "gracile fascicle", "390": "Supraoptic nucleus", "391": "Field CA1, stratum lacunosum-moleculare", "394": "Anteromedial visual area", "395": "Medullary reticular nucleus", "396": "internal arcuate fibers", "397": "ventral tegmental decussation", "398": "Superior olivary complex", "399": "Field CA1, stratum oriens", "4": "Inferior colliculus", "402": "Anterolateral visual area", "404": "olivocerebellar tract", "406": "Subparafascicular nucleus", "407": "Field CA1, pyramidal layer", "409": "Lateral visual area", "413": "vestibular nerve", "415": "Field CA1, stratum radiatum", "417": "Rostrolateral visual area", "423": "Field CA2", "425": "Posterolateral visual area", "429": "Spinal nucleus of the trigeminal, caudal part", "43": "ansoparamedian fissure", "431": "Field CA2, stratum lacunosum-moleculare", "432": "Nucleus circularis", "433": "Anteromedial visual area, layer 5", "436": "columns of the fornix", "437": "Spinal nucleus of the trigeminal, interpolar part", "438": "Field CA2, stratum oriens", "439": "Paraventricular hypothalamic nucleus, descending division, dorsal parvicellular part", "445": "Spinal nucleus of the trigeminal, oral part", "446": "Field CA2, pyramidal layer", "447": "Paraventricular hypothalamic nucleus, descending division, forniceal part", "452": "Median preoptic nucleus", "453": "Somatosensory areas", "454": "Field CA2, stratum radiatum", "455": "Paraventricular hypothalamic nucleus, descending division, lateral parvicellular part", "460": "Midbrain trigeminal nucleus", "462": "Superior salivatory nucleus", "463": "Field CA3", "464": "Paraventricular hypothalamic nucleus, descending division, medial parvicellular part, ventral zone", "466": "alveus", "467": "Hypothalamic medial zone", "47": "Paraventricular hypothalamic nucleus, magnocellular division, anterior magnocellular part", "470": "Subthalamic nucleus", "471": "Field CA3, stratum lacunosum-moleculare", "475": "Medial geniculate complex", "477": "Striatum", "479": "Field CA3, stratum lucidum", "481": "Islands of Calleja", "482": "brachium of the inferior colliculus", "483": "Medial habenula", "485": "Striatum dorsal region", "486": "Field CA3, stratum oriens", "489": "Major island of Calleja", "491": "Medial mammillary nucleus", "493": "Striatum ventral region", "495": "Field CA3, pyramidal layer", "499": "cuneocerebellar tract", "501": "posteromedial visual area, layer 4", "502": "Subiculum", "504": "Field CA3, stratum radiatum", "506": "dorsal acoustic stria", "507": "Main olfactory bulb", "51": "Intralaminar nuclei of the dorsal thalamus", "512": "Cerebellum", "513": "Bed nuclei of the stria terminalis, anterior division, fusiform nucleus", "514": "dorsal column", "515": "Medial preoptic nucleus", "519": "Cerebellar nuclei", "521": "Bed nuclei of the stria terminalis, anterior division, magnocellular nucleus", "523": "Medial preoptic area", "525": "Supramammillary nucleus", "528": "Cerebellar cortex", "533": "posteromedial visual area", "536": "Central amygdalar nucleus", "54": "medial forebrain bundle", "541": "Temporal association areas", "547": "dorsal longitudinal fascicle", "549": "Thalamus", "552": "Pontine reticular nucleus, ventral part", "553": "dorsal spinocerebellar tract", "554": "Bed nuclei of the stria terminalis, anterior division, oval nucleus", "557": "Tuberomammillary nucleus", "56": "Nucleus accumbens", "564": "Medial septal nucleus", "567": "Cerebrum", "568": "Accessory abducens nucleus", "571": "Midline group of the dorsal thalamus", "573": "Lateral visual area, layer 4", "574": "Tegmental reticular nucleus", "575": "Central lateral nucleus of the thalamus", "578": "Bed nuclei of the stria terminalis, posterior division, principal nucleus", "579": "external capsule", "58": "Medial terminal nucleus of the accessory optic tract", "580": "Nucleus of the brachium of the inferior colliculus", "581": "Triangular nucleus of septum", "583": "Claustrum", "587": "Nucleus of Darkschewitsch", "589": "Taenia tecta", "594": "Bed nuclei of the stria terminalis, posterior division, transverse nucleus", "595": "fasciculus retroflexus", "596": "Diagonal band nucleus", "599": "Central medial nucleus of the thalamus", "6": "internal capsule", "603": "fimbria", "604": "Nucleus incertus", "607": "Cochlear nuclei", "611": "habenular commissure", "612": "Nucleus of the lateral lemniscus", "613": "Lateral visual area, layer 5", "614": "Tuberal nucleus", "615": "Substantia nigra, lateral part", "616": "Cuneiform nucleus", "617": "Mediodorsal nucleus of the thalamus, central part", "618": "hippocampal commissures", "619": "Nucleus of the lateral olfactory tract", "62": "medial longitudinal fascicle", "621": "Motor nucleus of trigeminal", "623": "Cerebral nuclei", "626": "Mediodorsal nucleus of the thalamus, lateral part", "628": "Nucleus of the optic tract", "63": "Paraventricular hypothalamic nucleus, descending division", "631": "Cortical amygdalar area", "632": "Dentate gyrus, granule cell layer", "633": "inferior colliculus commissure", "634": "Nucleus of the posterior commissure", "636": "Mediodorsal nucleus of the thalamus, medial part", "637": "Ventral group of the dorsal thalamus", "64": "Anterodorsal nucleus", "641": "intermediate acoustic stria", "642": "Nucleus of the trapezoid body", "645": "Vermal regions", "650": "juxtarestiform body", "651": "Nucleus of the solitary tract", "653": "Abducens nucleus", "658": "lateral lemniscus", "66": "Lateral terminal nucleus of the accessory optic tract", "661": "Facial motor nucleus", "665": "lateral olfactory tract, body", "669": "Visual areas", "67": "Interstitial nucleus of Cajal", "672": "Caudoputamen", "673": "mammillary peduncle", "674": "Nucleus of the solitary tract, gelatinous part", "678": "Dorsal auditory area, layer 4", "679": "Superior central nucleus raphe", "681": "mammillotegmental tract", "685": "Ventral medial nucleus of the thalamus", "688": "Cerebral cortex", "689": "Ventrolateral preoptic nucleus", "691": "Nucleus of the solitary tract, medial part", "693": "Ventromedial hypothalamic nucleus", "695": "Cortical plate", "697": "medial lemniscus", "7": "Principal sensory nucleus of the trigeminal", "700": "Anterior hypothalamic nucleus, anterior part", "701": "Vestibular nuclei", "705": "midbrain tract of the trigeminal nerve", "706": "Olivary pretectal nucleus", "708": "Anterior hypothalamic nucleus, central part", "709": "Ventral posterior complex of the thalamus", "71": "Paraventricular hypothalamic nucleus, magnocellular division", "710": "abducens nerve", "711": "Cuneate nucleus", "713": "perforant path", "716": "Anterior hypothalamic nucleus, dorsal part", "717": "accessory spinal nerve", "718": "Ventral posterolateral nucleus of the thalamus", "720": "Dorsal column nuclei", "721": "Primary visual area, layer 4", "724": "Anterior hypothalamic nucleus, posterior part", "726": "Dentate gyrus", "73": "ventricular systems", "730": "pineal stalk", "732": "Medial mammillary nucleus, median part", "733": "Ventral posteromedial nucleus of the thalamus", "737": "postcommissural fornix", "74": "Lateral visual area, layer 6a", "741": "Ventral posteromedial nucleus of the thalamus, parvicellular part", "744": "cerebellar commissure", "749": "Ventral tegmental area", "75": "Dorsal terminal nucleus of the accessory optic tract", "753": "principal mammillary tract", "754": "Olfactory tubercle", "757": "Ventral tegmental nucleus", "763": "Vascular organ of the lamina terminalis", "771": "Pons", "773": "Hypoglossal nucleus", "776": "corpus callosum", "778": "Primary visual area, layer 5", "78": "middle cerebellar peduncle", "780": "Posterior amygdalar nucleus", "784": "corticospinal tract", "79": "Paraventricular hypothalamic nucleus, magnocellular division, medial magnocellular part", "794": "spinal tract of the trigeminal nerve", "795": "Periaqueductal gray", "796": "Dopaminergic A13 group", "797": "Zona incerta", "798": "facial nerve", "802": "stria medullaris", "803": "Pallidum", "808": "glossopharyngeal nerve", "81": "lateral ventricle", "811": "Inferior colliculus, central nucleus", "812": "superior cerebellar peduncle decussation", "813": "hypoglossal nerve", "818": "Pallidum, dorsal region", "82": "Nucleus of the lateral lemniscus, dorsal part", "820": "Inferior colliculus, dorsal nucleus", "821": "Primary visual area, layer 2/3", "822": "Retrohippocampal region", "825": "supraoptic commissures, dorsal", "828": "Inferior colliculus, external nucleus", "83": "Inferior olivary complex", "830": "Dorsomedial nucleus of the hypothalamus", "832": "oculomotor nerve", "833": "supraoptic commissures, ventral", "834": "Superior colliculus, zonal layer", "835": "Pallidum, ventral region", "839": "Dorsal motor nucleus of the vagus nerve", "840": "olfactory nerve", "841": "trapezoid body", "842": "Superior colliculus, superficial gray layer", "843": "Parasubiculum", "846": "Dentate nucleus", "848": "optic nerve", "85": "spinocerebellar tract", "851": "Superior colliculus, optic layer", "852": "Parvicellular reticular nucleus", "858": "ventral commissure of the spinal cord", "859": "Parasolitary nucleus", "863": "rubrospinal tract", "866": "ventral spinocerebellar tract", "867": "Parabrachial nucleus", "869": "Posterolateral visual area, layer 4", "871": "spinothalamic tract", "872": "Dorsal nucleus raphe", "874": "Parabigeminal nucleus", "876": "accessory optic tract", "877": "tectospinal pathway", "88": "Anterior hypothalamic nucleus", "880": "Dorsal tegmental nucleus", "881": "Parabrachial nucleus, lateral division", "885": "terminal nerve", "886": "Retrosplenial area, ventral part", "890": "Parabrachial nucleus, medial division", "892": "ansa peduncularis", "898": "Pontine central gray", "90": "Nucleus of the lateral lemniscus, horizontal part", "900": "anterior commissure, olfactory limb", "901": "trigeminal nerve", "903": "External cuneate nucleus", "904": "Medial septal complex", "907": "Paracentral nucleus", "908": "anterior commissure, temporal limb", "909": "Entorhinal area", "911": "trochlear nerve", "912": "Lingula (I)", "916": "brachium of the superior colliculus", "917": "vagus nerve", "918": "Entorhinal area, lateral part", "920": "Central lobule", "922": "Perirhinal area", "924": "cerebal peduncle", "926": "Entorhinal area, medial part, dorsal zone", "928": "Culmen", "93": "motor root of the trigeminal nerve", "930": "Parafascicular nucleus", "931": "Pontine gray", "933": "vestibulocochlear nerve", "934": "Entorhinal area, medial part, ventral zone", "936": "Declive (VI)", "938": "Paragigantocellular reticular nucleus", "94": "Paraventricular hypothalamic nucleus, parvicellular division", "940": "cingulum bundle", "941": "vestibulospinal pathway", "942": "Endopiriform nucleus", "944": "Folium-tuber vermis (VII)", "946": "Posterior hypothalamic nucleus", "948": "cochlear nerve", "949": "vomeronasal nerve", "951": "Pyramus (VIII)", "953": "Pineal body", "957": "Uvula (IX)", "958": "Epithalamus", "96": "Dorsal cochlear nucleus", "961": "Piriform area", "967": "cranial nerves", "968": "Nodulus (X)", "970": "Paragigantocellular reticular nucleus, dorsal part", "972": "Prelimbic area", "975": "Edinger-Westphal nucleus", "976": "Lobule II", "978": "Paragigantocellular reticular nucleus, lateral part", "979": "corpus callosum, rostrum", "98": "subependymal zone", "980": "Dorsal premammillary nucleus", "981": "Primary somatosensory area, barrel field, layer 1", "982": "Fasciola cinerea", "984": "Lobule III", "985": "Primary motor area", "986": "corpus callosum, splenium", "989": "Fastigial nucleus", "99": "Nucleus of the lateral lemniscus, ventral part", "992": "Lobule IV", "993": "Secondary motor area", "994": "corticobulbar tract", "995": "Paramedian reticular nucleus", "997": "root", "998": "Fundus of striatum", "375": "Ammon's horn", "403": "Medial amygdalar nucleus", "752": "cerebellar peduncles", "315": "Isocortex", "322": "Primary somatosensory area", "1370229894": "Primary somatosensory area, barrel field, A1 barrel", "1344105173": "Primary somatosensory area, barrel field, A1 barrel layer 1", "2615618683": "Primary somatosensory area, barrel field, A1 barrel layer 2/3", "3116469840": "Primary somatosensory area, barrel field, A1 barrel layer 2", "3379356047": "Primary somatosensory area, barrel field, A1 barrel layer 3", "1315119484": "Primary somatosensory area, barrel field, A1 barrel layer 4", "2436888515": "Primary somatosensory area, barrel field, A1 barrel layer 5", "3577346235": "Primary somatosensory area, barrel field, A1 barrel layer 6a", "3902978127": "Primary somatosensory area, barrel field, A1 barrel layer 6b", "3651721123": "Primary somatosensory area, barrel field, A2 barrel", "1310126712": "Primary somatosensory area, barrel field, A2 barrel layer 1", "1446874462": "Primary somatosensory area, barrel field, A2 barrel layer 2/3", "3324056088": "Primary somatosensory area, barrel field, A2 barrel layer 2", "2593521448": "Primary somatosensory area, barrel field, A2 barrel layer 3", "3685934448": "Primary somatosensory area, barrel field, A2 barrel layer 4", "3575805529": "Primary somatosensory area, barrel field, A2 barrel layer 5", "1210837267": "Primary somatosensory area, barrel field, A2 barrel layer 6a", "1258169895": "Primary somatosensory area, barrel field, A2 barrel layer 6b", "2732283703": "Primary somatosensory area, barrel field, A3 barrel", "1994494334": "Primary somatosensory area, barrel field, A3 barrel layer 1", "1447791371": "Primary somatosensory area, barrel field, A3 barrel layer 2/3", "2590882612": "Primary somatosensory area, barrel field, A3 barrel layer 2", "3761146439": "Primary somatosensory area, barrel field, A3 barrel layer 3", "3139552203": "Primary somatosensory area, barrel field, A3 barrel layer 4", "2692580507": "Primary somatosensory area, barrel field, A3 barrel layer 5", "1677451927": "Primary somatosensory area, barrel field, A3 barrel layer 6a", "3379749055": "Primary somatosensory area, barrel field, A3 barrel layer 6b", "3896406483": "Primary somatosensory area, barrel field, Alpha barrel", "2835342929": "Primary somatosensory area, barrel field, Alpha barrel layer 1", "1897248316": "Primary somatosensory area, barrel field, Alpha barrel layer 2/3", "3173729836": "Primary somatosensory area, barrel field, Alpha barrel layer 2", "3926962776": "Primary somatosensory area, barrel field, Alpha barrel layer 3", "2168807353": "Primary somatosensory area, barrel field, Alpha barrel layer 4", "3137025327": "Primary somatosensory area, barrel field, Alpha barrel layer 5", "2406188897": "Primary somatosensory area, barrel field, Alpha barrel layer 6a", "3670777223": "Primary somatosensory area, barrel field, Alpha barrel layer 6b", "2525641171": "Primary somatosensory area, barrel field, B1 barrel", "1516851569": "Primary somatosensory area, barrel field, B1 barrel layer 1", "3913053667": "Primary somatosensory area, barrel field, B1 barrel layer 2/3", "2196657368": "Primary somatosensory area, barrel field, B1 barrel layer 2", "3986345576": "Primary somatosensory area, barrel field, B1 barrel layer 3", "3495145594": "Primary somatosensory area, barrel field, B1 barrel layer 4", "1644849336": "Primary somatosensory area, barrel field, B1 barrel layer 5", "3289019263": "Primary somatosensory area, barrel field, B1 barrel layer 6a", "2194674250": "Primary somatosensory area, barrel field, B1 barrel layer 6b", "1673450198": "Primary somatosensory area, barrel field, B2 barrel", "3853526235": "Primary somatosensory area, barrel field, B2 barrel layer 1", "3456985752": "Primary somatosensory area, barrel field, B2 barrel layer 2/3", "1311366798": "Primary somatosensory area, barrel field, B2 barrel layer 2", "1126601402": "Primary somatosensory area, barrel field, B2 barrel layer 3", "3966633210": "Primary somatosensory area, barrel field, B2 barrel layer 4", "2812530569": "Primary somatosensory area, barrel field, B2 barrel layer 5", "1641347046": "Primary somatosensory area, barrel field, B2 barrel layer 6a", "3416776496": "Primary somatosensory area, barrel field, B2 barrel layer 6b", "1626685236": "Primary somatosensory area, barrel field, B3 barrel", "3565367498": "Primary somatosensory area, barrel field, B3 barrel layer 1", "2657138906": "Primary somatosensory area, barrel field, B3 barrel layer 2/3", "1881029055": "Primary somatosensory area, barrel field, B3 barrel layer 2", "3080022137": "Primary somatosensory area, barrel field, B3 barrel layer 3", "1547817274": "Primary somatosensory area, barrel field, B3 barrel layer 4", "2369238059": "Primary somatosensory area, barrel field, B3 barrel layer 5", "2478012832": "Primary somatosensory area, barrel field, B3 barrel layer 6a", "2739084189": "Primary somatosensory area, barrel field, B3 barrel layer 6b", "3347675430": "Primary somatosensory area, barrel field, B4 barrel", "2006047173": "Primary somatosensory area, barrel field, B4 barrel layer 1", "2180527067": "Primary somatosensory area, barrel field, B4 barrel layer 2/3", "1456682260": "Primary somatosensory area, barrel field, B4 barrel layer 2", "3562601313": "Primary somatosensory area, barrel field, B4 barrel layer 3", "1970686062": "Primary somatosensory area, barrel field, B4 barrel layer 4", "3890169311": "Primary somatosensory area, barrel field, B4 barrel layer 5", "2936441103": "Primary somatosensory area, barrel field, B4 barrel layer 6a", "3215542274": "Primary somatosensory area, barrel field, B4 barrel layer 6b", "1521759875": "Primary somatosensory area, barrel field, Beta barrel", "3486673188": "Primary somatosensory area, barrel field, Beta barrel layer 1", "3783583602": "Primary somatosensory area, barrel field, Beta barrel layer 2/3", "3970522306": "Primary somatosensory area, barrel field, Beta barrel layer 2", "1054221329": "Primary somatosensory area, barrel field, Beta barrel layer 3", "3895794866": "Primary somatosensory area, barrel field, Beta barrel layer 4", "1496257237": "Primary somatosensory area, barrel field, Beta barrel layer 5", "2152572352": "Primary somatosensory area, barrel field, Beta barrel layer 6a", "3048883337": "Primary somatosensory area, barrel field, Beta barrel layer 6b", "1013068637": "Primary somatosensory area, barrel field, C1 barrel", "1337935688": "Primary somatosensory area, barrel field, C1 barrel layer 1", "1667660763": "Primary somatosensory area, barrel field, C1 barrel layer 2/3", "1558550786": "Primary somatosensory area, barrel field, C1 barrel layer 2", "2563782304": "Primary somatosensory area, barrel field, C1 barrel layer 3", "3219108088": "Primary somatosensory area, barrel field, C1 barrel layer 4", "1420546517": "Primary somatosensory area, barrel field, C1 barrel layer 5", "1945434117": "Primary somatosensory area, barrel field, C1 barrel layer 6a", "2866280389": "Primary somatosensory area, barrel field, C1 barrel layer 6b", "2072239244": "Primary somatosensory area, barrel field, C2 barrel", "1082141991": "Primary somatosensory area, barrel field, C2 barrel layer 1", "2157537321": "Primary somatosensory area, barrel field, C2 barrel layer 2/3", "2525505631": "Primary somatosensory area, barrel field, C2 barrel layer 2", "1714311201": "Primary somatosensory area, barrel field, C2 barrel layer 3", "2930307508": "Primary somatosensory area, barrel field, C2 barrel layer 4", "3188993656": "Primary somatosensory area, barrel field, C2 barrel layer 5", "1843338795": "Primary somatosensory area, barrel field, C2 barrel layer 6a", "3291535006": "Primary somatosensory area, barrel field, C2 barrel layer 6b", "2937437636": "Primary somatosensory area, barrel field, C3 barrel", "3835740469": "Primary somatosensory area, barrel field, C3 barrel layer 1", "1125438717": "Primary somatosensory area, barrel field, C3 barrel layer 2/3", "2629778705": "Primary somatosensory area, barrel field, C3 barrel layer 2", "3581771805": "Primary somatosensory area, barrel field, C3 barrel layer 3", "3877358805": "Primary somatosensory area, barrel field, C3 barrel layer 4", "1667278413": "Primary somatosensory area, barrel field, C3 barrel layer 5", "2743616995": "Primary somatosensory area, barrel field, C3 barrel layer 6a", "1093211310": "Primary somatosensory area, barrel field, C3 barrel layer 6b", "3404738524": "Primary somatosensory area, barrel field, C4 barrel", "2151167540": "Primary somatosensory area, barrel field, C4 barrel layer 1", "2460702429": "Primary somatosensory area, barrel field, C4 barrel layer 2/3", "3167765177": "Primary somatosensory area, barrel field, C4 barrel layer 2", "1639524986": "Primary somatosensory area, barrel field, C4 barrel layer 3", "1549069626": "Primary somatosensory area, barrel field, C4 barrel layer 4", "3085221154": "Primary somatosensory area, barrel field, C4 barrel layer 5", "2659044087": "Primary somatosensory area, barrel field, C4 barrel layer 6a", "2700046659": "Primary somatosensory area, barrel field, C4 barrel layer 6b", "2062992388": "Primary somatosensory area, barrel field, C5 barrel", "1246610280": "Primary somatosensory area, barrel field, C5 barrel layer 1", "3880590912": "Primary somatosensory area, barrel field, C5 barrel layer 2/3", "1035739465": "Primary somatosensory area, barrel field, C5 barrel layer 2", "1105483506": "Primary somatosensory area, barrel field, C5 barrel layer 3", "1792980078": "Primary somatosensory area, barrel field, C5 barrel layer 4", "3556494715": "Primary somatosensory area, barrel field, C5 barrel layer 5", "1706307657": "Primary somatosensory area, barrel field, C5 barrel layer 6a", "1869881498": "Primary somatosensory area, barrel field, C5 barrel layer 6b", "1261138116": "Primary somatosensory area, barrel field, C6 barrel", "2933568634": "Primary somatosensory area, barrel field, C6 barrel layer 1", "2013207018": "Primary somatosensory area, barrel field, C6 barrel layer 2/3", "1805611561": "Primary somatosensory area, barrel field, C6 barrel layer 2", "3719447735": "Primary somatosensory area, barrel field, C6 barrel layer 3", "2371017187": "Primary somatosensory area, barrel field, C6 barrel layer 4", "3985188708": "Primary somatosensory area, barrel field, C6 barrel layer 5", "3796365620": "Primary somatosensory area, barrel field, C6 barrel layer 6a", "1714819828": "Primary somatosensory area, barrel field, C6 barrel layer 6b", "1171261412": "Primary somatosensory area, barrel field, D1 barrel", "3724099631": "Primary somatosensory area, barrel field, D1 barrel layer 1", "2833279579": "Primary somatosensory area, barrel field, D1 barrel layer 2/3", "2558258359": "Primary somatosensory area, barrel field, D1 barrel layer 2", "3859877696": "Primary somatosensory area, barrel field, D1 barrel layer 3", "2108774369": "Primary somatosensory area, barrel field, D1 barrel layer 4", "3320050311": "Primary somatosensory area, barrel field, D1 barrel layer 5", "3628159968": "Primary somatosensory area, barrel field, D1 barrel layer 6a", "3638507875": "Primary somatosensory area, barrel field, D1 barrel layer 6b", "3329043535": "Primary somatosensory area, barrel field, D2 barrel", "1743009264": "Primary somatosensory area, barrel field, D2 barrel layer 1", "1884779226": "Primary somatosensory area, barrel field, D2 barrel layer 2/3", "3623254419": "Primary somatosensory area, barrel field, D2 barrel layer 2", "1926976537": "Primary somatosensory area, barrel field, D2 barrel layer 3", "2047390011": "Primary somatosensory area, barrel field, D2 barrel layer 4", "2798287336": "Primary somatosensory area, barrel field, D2 barrel layer 5", "2987319910": "Primary somatosensory area, barrel field, D2 barrel layer 6a", "3872485424": "Primary somatosensory area, barrel field, D2 barrel layer 6b", "2036081150": "Primary somatosensory area, barrel field, D3 barrel", "1781030954": "Primary somatosensory area, barrel field, D3 barrel layer 1", "2841658580": "Primary somatosensory area, barrel field, D3 barrel layer 2/3", "3521164295": "Primary somatosensory area, barrel field, D3 barrel layer 2", "1876807310": "Primary somatosensory area, barrel field, D3 barrel layer 3", "1501393228": "Primary somatosensory area, barrel field, D3 barrel layer 4", "1972094100": "Primary somatosensory area, barrel field, D3 barrel layer 5", "3302405705": "Primary somatosensory area, barrel field, D3 barrel layer 6a", "1099096371": "Primary somatosensory area, barrel field, D3 barrel layer 6b", "3202423327": "Primary somatosensory area, barrel field, D4 barrel", "1117257996": "Primary somatosensory area, barrel field, D4 barrel layer 1", "1453537399": "Primary somatosensory area, barrel field, D4 barrel layer 2/3", "2567067139": "Primary somatosensory area, barrel field, D4 barrel layer 2", "2427348802": "Primary somatosensory area, barrel field, D4 barrel layer 3", "3859818063": "Primary somatosensory area, barrel field, D4 barrel layer 4", "1588504257": "Primary somatosensory area, barrel field, D4 barrel layer 5", "3571205574": "Primary somatosensory area, barrel field, D4 barrel layer 6a", "1096265790": "Primary somatosensory area, barrel field, D4 barrel layer 6b", "1412541198": "Primary somatosensory area, barrel field, D5 barrel", "1326886999": "Primary somatosensory area, barrel field, D5 barrel layer 1", "1787178465": "Primary somatosensory area, barrel field, D5 barrel layer 2/3", "2341450864": "Primary somatosensory area, barrel field, D5 barrel layer 2", "2551618170": "Primary somatosensory area, barrel field, D5 barrel layer 3", "1170723867": "Primary somatosensory area, barrel field, D5 barrel layer 4", "1038004598": "Primary somatosensory area, barrel field, D5 barrel layer 5", "1149652689": "Primary somatosensory area, barrel field, D5 barrel layer 6a", "1582478571": "Primary somatosensory area, barrel field, D5 barrel layer 6b", "1588741938": "Primary somatosensory area, barrel field, D6 barrel", "1315950883": "Primary somatosensory area, barrel field, D6 barrel layer 1", "1947266943": "Primary somatosensory area, barrel field, D6 barrel layer 2/3", "3990698322": "Primary somatosensory area, barrel field, D6 barrel layer 2", "3301183793": "Primary somatosensory area, barrel field, D6 barrel layer 3", "1464978040": "Primary somatosensory area, barrel field, D6 barrel layer 4", "2387503636": "Primary somatosensory area, barrel field, D6 barrel layer 5", "2023633893": "Primary somatosensory area, barrel field, D6 barrel layer 6a", "1913328693": "Primary somatosensory area, barrel field, D6 barrel layer 6b", "3920024588": "Primary somatosensory area, barrel field, D7 barrel", "1877482733": "Primary somatosensory area, barrel field, D7 barrel layer 1", "2358987890": "Primary somatosensory area, barrel field, D7 barrel layer 2/3", "3673895945": "Primary somatosensory area, barrel field, D7 barrel layer 2", "1393608993": "Primary somatosensory area, barrel field, D7 barrel layer 3", "2978179471": "Primary somatosensory area, barrel field, D7 barrel layer 4", "3338653017": "Primary somatosensory area, barrel field, D7 barrel layer 5", "2384899589": "Primary somatosensory area, barrel field, D7 barrel layer 6a", "2710463424": "Primary somatosensory area, barrel field, D7 barrel layer 6b", "3055000922": "Primary somatosensory area, barrel field, D8 barrel", "1406402073": "Primary somatosensory area, barrel field, D8 barrel layer 1", "1373744894": "Primary somatosensory area, barrel field, D8 barrel layer 2/3", "1156116970": "Primary somatosensory area, barrel field, D8 barrel layer 2", "3453175542": "Primary somatosensory area, barrel field, D8 barrel layer 3", "3652474151": "Primary somatosensory area, barrel field, D8 barrel layer 4", "2236457933": "Primary somatosensory area, barrel field, D8 barrel layer 5", "3277826222": "Primary somatosensory area, barrel field, D8 barrel layer 6a", "1005899076": "Primary somatosensory area, barrel field, D8 barrel layer 6b", "2438939909": "Primary somatosensory area, barrel field, Delta barrel", "1691306271": "Primary somatosensory area, barrel field, Delta barrel layer 1", "3434166213": "Primary somatosensory area, barrel field, Delta barrel layer 2/3", "1275601165": "Primary somatosensory area, barrel field, Delta barrel layer 2", "3946289800": "Primary somatosensory area, barrel field, Delta barrel layer 3", "2004775342": "Primary somatosensory area, barrel field, Delta barrel layer 4", "1456398198": "Primary somatosensory area, barrel field, Delta barrel layer 5", "3561503481": "Primary somatosensory area, barrel field, Delta barrel layer 6a", "1901850664": "Primary somatosensory area, barrel field, Delta barrel layer 6b", "1071521092": "Primary somatosensory area, barrel field, E1 barrel", "3807479791": "Primary somatosensory area, barrel field, E1 barrel layer 1", "2803418480": "Primary somatosensory area, barrel field, E1 barrel layer 2/3", "2980820846": "Primary somatosensory area, barrel field, E1 barrel layer 2", "3188360247": "Primary somatosensory area, barrel field, E1 barrel layer 3", "1477785742": "Primary somatosensory area, barrel field, E1 barrel layer 4", "2964598138": "Primary somatosensory area, barrel field, E1 barrel layer 5", "3093795446": "Primary somatosensory area, barrel field, E1 barrel layer 6a", "1507784331": "Primary somatosensory area, barrel field, E1 barrel layer 6b", "3054551821": "Primary somatosensory area, barrel field, E2 barrel", "3748961581": "Primary somatosensory area, barrel field, E2 barrel layer 1", "3128223634": "Primary somatosensory area, barrel field, E2 barrel layer 2/3", "2185403483": "Primary somatosensory area, barrel field, E2 barrel layer 2", "1433026796": "Primary somatosensory area, barrel field, E2 barrel layer 3", "1104248884": "Primary somatosensory area, barrel field, E2 barrel layer 4", "3545403109": "Primary somatosensory area, barrel field, E2 barrel layer 5", "1536696383": "Primary somatosensory area, barrel field, E2 barrel layer 6a", "3527105324": "Primary somatosensory area, barrel field, E2 barrel layer 6b", "2811301625": "Primary somatosensory area, barrel field, E3 barrel", "1897015494": "Primary somatosensory area, barrel field, E3 barrel layer 1", "3331790659": "Primary somatosensory area, barrel field, E3 barrel layer 2/3", "2795738785": "Primary somatosensory area, barrel field, E3 barrel layer 2", "2768475141": "Primary somatosensory area, barrel field, E3 barrel layer 3", "2658097375": "Primary somatosensory area, barrel field, E3 barrel layer 4", "2157528000": "Primary somatosensory area, barrel field, E3 barrel layer 5", "3309772165": "Primary somatosensory area, barrel field, E3 barrel layer 6a", "1928393658": "Primary somatosensory area, barrel field, E3 barrel layer 6b", "3840818183": "Primary somatosensory area, barrel field, E4 barrel", "3841505448": "Primary somatosensory area, barrel field, E4 barrel layer 1", "3999683881": "Primary somatosensory area, barrel field, E4 barrel layer 2/3", "3325173834": "Primary somatosensory area, barrel field, E4 barrel layer 2", "1798728430": "Primary somatosensory area, barrel field, E4 barrel layer 3", "3299719941": "Primary somatosensory area, barrel field, E4 barrel layer 4", "2360313730": "Primary somatosensory area, barrel field, E4 barrel layer 5", "3043750963": "Primary somatosensory area, barrel field, E4 barrel layer 6a", "2641148319": "Primary somatosensory area, barrel field, E4 barrel layer 6b", "1468793762": "Primary somatosensory area, barrel field, E5 barrel", "1427961626": "Primary somatosensory area, barrel field, E5 barrel layer 1", "1643593739": "Primary somatosensory area, barrel field, E5 barrel layer 2/3", "3092405473": "Primary somatosensory area, barrel field, E5 barrel layer 2", "1181035221": "Primary somatosensory area, barrel field, E5 barrel layer 3", "3118601025": "Primary somatosensory area, barrel field, E5 barrel layer 4", "2374653061": "Primary somatosensory area, barrel field, E5 barrel layer 5", "3026302666": "Primary somatosensory area, barrel field, E5 barrel layer 6a", "2197459620": "Primary somatosensory area, barrel field, E5 barrel layer 6b", "1965375801": "Primary somatosensory area, barrel field, E6 barrel", "1666373161": "Primary somatosensory area, barrel field, E6 barrel layer 1", "3620340000": "Primary somatosensory area, barrel field, E6 barrel layer 2/3", "2815501138": "Primary somatosensory area, barrel field, E6 barrel layer 2", "2091848107": "Primary somatosensory area, barrel field, E6 barrel layer 3", "2658756176": "Primary somatosensory area, barrel field, E6 barrel layer 4", "2097438884": "Primary somatosensory area, barrel field, E6 barrel layer 5", "2868822451": "Primary somatosensory area, barrel field, E6 barrel layer 6a", "3331415743": "Primary somatosensory area, barrel field, E6 barrel layer 6b", "3618095278": "Primary somatosensory area, barrel field, E7 barrel", "2613674898": "Primary somatosensory area, barrel field, E7 barrel layer 1", "1951878763": "Primary somatosensory area, barrel field, E7 barrel layer 2/3", "3413451609": "Primary somatosensory area, barrel field, E7 barrel layer 2", "2225157452": "Primary somatosensory area, barrel field, E7 barrel layer 3", "2842134861": "Primary somatosensory area, barrel field, E7 barrel layer 4", "2064317417": "Primary somatosensory area, barrel field, E7 barrel layer 5", "2123772309": "Primary somatosensory area, barrel field, E7 barrel layer 6a", "1510133109": "Primary somatosensory area, barrel field, E7 barrel layer 6b", "1624778115": "Primary somatosensory area, barrel field, E8 barrel", "1094902124": "Primary somatosensory area, barrel field, E8 barrel layer 1", "3134535128": "Primary somatosensory area, barrel field, E8 barrel layer 2/3", "3312222592": "Primary somatosensory area, barrel field, E8 barrel layer 2", "1518704958": "Primary somatosensory area, barrel field, E8 barrel layer 3", "1475527815": "Primary somatosensory area, barrel field, E8 barrel layer 4", "1612593605": "Primary somatosensory area, barrel field, E8 barrel layer 5", "2915675742": "Primary somatosensory area, barrel field, E8 barrel layer 6a", "2644357350": "Primary somatosensory area, barrel field, E8 barrel layer 6b", "1171320182": "Primary somatosensory area, barrel field, Gamma barrel", "2810081477": "Primary somatosensory area, barrel field, Gamma barrel layer 1", "3513655281": "Primary somatosensory area, barrel field, Gamma barrel layer 2/3", "2790136061": "Primary somatosensory area, barrel field, Gamma barrel layer 2", "2667261098": "Primary somatosensory area, barrel field, Gamma barrel layer 3", "2302833148": "Primary somatosensory area, barrel field, Gamma barrel layer 4", "3278290071": "Primary somatosensory area, barrel field, Gamma barrel layer 5", "2688781451": "Primary somatosensory area, barrel field, Gamma barrel layer 6a", "1848522986": "Primary somatosensory area, barrel field, Gamma barrel layer 6b", "2358040414": "Main olfactory bulb: Other", "2561915647": "Anterior olfactory nucleus: Other", "3389528505": "Taenia tecta, dorsal part: Other", "1860102496": "Taenia tecta, ventral part: Other", "1953921139": "Dorsal peduncular area: Other", "1668688439": "Piriform area: Other", "1466095084": "Cortical amygdalar area, anterior part: Other", "1992072790": "Cortical amygdalar area, posterior part, lateral zone: Other", "1375046773": "Cortical amygdalar area, posterior part, medial zone: Other", "1203939479": "Piriform-amygdalar area: Other", "1209357605": "Postpiriform transition area: Other", "1024543562": "Olfactory areas: Other", "2952544119": "Parasubiculum: Other", "2063775638": "Postsubiculum: Other", "1580329576": "Presubiculum: Other", "1792026161": "Subiculum: Other", "2449182232": "Prosubiculum: Other", "3263488087": "Hippocampal formation: Other", "2416897036": "Cortical subplate: Other", "3672106733": "Olfactory tubercle: Other", "2445320853": "Medial amygdalar nucleus: Other", "3034756217": "Striatum: Other", "2791423253": "Bed nuclei of the stria terminalis: Other", "2165415682": "Pallidum: Other", "3009745967": "Mediodorsal nucleus of thalamus: Other", "1043765183": "Ventral part of the lateral geniculate complex: Other", "2614168502": "Thalamus: Other", "2218808594": "Accessory supraoptic group: Other", "2869757686": "Paraventricular hypothalamic nucleus: Other", "1463730273": "Dorsomedial nucleus of the hypothalamus: Other", "1690235425": "Anterior hypothalamic nucleus: Other", "3449035628": "Supramammillary nucleus: Other", "2254557934": "Medial preoptic nucleus: Other", "3467149620": "Paraventricular hypothalamic nucleus, descending division: Other", "2723065947": "Ventromedial hypothalamic nucleus: Other", "1171543751": "Zona incerta: Other", "1842735199": "Hypothalamus: Other", "1040222935": "Midbrain reticular nucleus: Other", "3654510924": "Superior colliculus, motor related, intermediate gray layer: Other", "2956165934": "Periaqueductal gray: Other", "2183090366": "Interpeduncular nucleus: Other", "3101970431": "Midbrain: Other", "2127067043": "Nucleus of the lateral lemniscus: Other", "3409505442": "Parabrachial nucleus: Other", "2557684018": "Superior central nucleus raphe: Other", "1140764290": "Pons: Other", "2316153360": "Nucleus of the solitary tract: Other", "1593308392": "Spinal nucleus of the trigeminal, oral part: Other", "2114704803": "Parapyramidal nucleus: Other", "1557651847": "Medulla: Other", "3092369320": "Cerebellum: Other", "1166850207": "optic nerve: Other", "3944974149": "oculomotor nerve: Other", "3537828992": "trochlear nerve: Other", "2176156825": "sensory root of the trigeminal nerve: Other", "3283016083": "facial nerve: Other", "2434751741": "superior cerebellar peduncle decussation: Other", "2692485271": "superior cerebelar peduncles: Other", "3140724988": "inferior cerebellar peduncle: Other", "3228324150": "corpus callosum, anterior forceps: Other", "2718688460": "corticospinal tract: Other", "1428498274": "rubrospinal tract: Other", "2923485783": "stria terminalis: Other", "1060511842": "supraoptic commissures: Other", "2500193001": "fiber tracts: Other", "1744978404": "lateral ventricle: Other", "3774104740": "fourth ventricle: Other", "1811993763": "root: Other"}, "st_level": {"0": null, "10": 9, "1002": 8, "1003": 9, "1005": 11, "1006": 11, "1009": 1, "1010": 11, "1012": 9, "1014": 7, "1015": 11, "1018": 8, "1021": 11, "1023": 11, "1024": 1, "1026": 11, "1027": 8, "1029": 8, "1030": 11, "1032": 7, "1034": 11, "1035": 11, "1037481934": null, "1037502706": null, "104": 9, "1040": 7, "1042": 11, "1043": 9, "1045": 11, "1046": 11, "1050": 11, "1051": 9, "1053": 11, "1054": 11, "1055": 8, "1057": 6, "1058": 11, "1059": 11, "1060": 9, "1062": 11, "1066": 11, "1067": 11, "10672": 11, "10673": 11, "10674": 11, "10675": 11, "10676": 11, "10677": 11, "10678": 11, "10679": 11, "1068": 8, "10680": 11, "10681": 11, "10682": 11, "10683": 11, "10684": 11, "10685": 11, "10686": 11, "10687": 11, "10688": 11, "10689": 11, "1069": 8, "10690": 11, "10691": 11, "10692": 11, "10693": 11, "10694": 11, "10695": 11, "10696": 11, "10697": 11, "10698": 11, "10699": 11, "107": 11, "10700": 11, "10701": 11, "10705": 11, "10706": 11, "10707": 11, "10708": 11, "10709": 11, "10710": 11, "10711": 11, "10712": 11, "10713": 11, "10714": 11, "10715": 11, "10716": 11, "10717": 11, "10718": 11, "10719": 11, "10720": 11, "10721": 11, "10722": 11, "10723": 11, "10724": 11, "10725": 11, "10726": 11, "10727": 11, "10728": 11, "10729": 11, "10730": 11, "10731": 11, "10732": 11, "10733": 11, "10734": 11, "10735": 11, "10736": 11, "10737": 11, "1074": 11, "1075": 11, "1076": 9, "1077": 8, "1080": 6, "1081": 11, "1082": 11, "1083": 8, "1085": 11, "1086": 11, "1087129144": null, "109": 8, "1090": 11, "1091": 8, "1094": 11, "1096": 9, "1098": 9, "1099": 8, "110": 10, "1101": 11, "1102": 11, "1104": 9, "1106": 11, "1107": 9, "1109": 8, "111": 9, "1110": 9, "1111": 11, "1114": 11, "1117": 6, "1118": 9, "112": 8, "1120": 8, "1121": 11, "1124": 8, "1125": 11, "1127": 11, "1128": 11, "113": 11, "1132": 6, "1133": 11, "1139": 11, "1140": 11, "1141": 11, "1142": 11, "1160721590": null, "1165809076": null, "119": 9, "120": 11, "121": 11, "123": 9, "12993": 11, "12994": 11, "12995": 11, "12996": 11, "12997": 11, "12998": 11, "130": 9, "1307372013": null, "132": 11, "1355885073": null, "137": 9, "139": 11, "141": 6, "142": 8, "143": 9, "1430875964": null, "1431942459": null, "144": 11, "1454256797": null, "1463157755": null, "148": 11, "150": 8, "152": 11, "154": 7, "156": 11, "1598869030": null, "16": 11, "160": 11, "1624848466": null, "163": 11, "1645194511": null, "166": 8, "167": 9, "1672280517": null, "168": 11, "1695203883": null, "17": 9, "171": 11, "1720700944": null, "175": 9, "1758306548": null, "179": 11, "18": 8, "180": 11, "182": 8, "182305689": 9, "182305693": 11, "182305697": 11, "182305701": 11, "182305705": 11, "182305709": 11, "182305713": 11, "183": 9, "185": 9, "187": 11, "1890964946": null, "1896413216": null, "190": 9, "191": 9, "192": 11, "193": 9, "1942628671": null, "195": 11, "199": 9, "2": 11, "20": 11, "200": 11, "2012716980": null, "2026216612": null, "203": 8, "205": 9, "2078623765": null, "208": 11, "21": 9, "2102386393": null, "211": 11, "213": 9, "215": 9, "2153924985": null, "216": 11, "2167613582": null, "2186168811": null, "2189208794": null, "219": 11, "2208057363": null, "221": 9, "2218254883": null, "2224619882": null, "224": 11, "2260827822": null, "227": 11, "2292194787": null, "2300544548": null, "232": 11, "2336071181": null, "234": 11, "2341154899": null, "236": 11, "2361776473": null, "240": 11, "241": 11, "2413172686": null, "2414821463": null, "243": 11, "2430059008": null, "2439179873": null, "244": 11, "245": 9, "248": 11, "249": 11, "25": 8, "250": 9, "251": 11, "2511156654": null, "252": 11, "253": 9, "2536061413": null, "2542216029": null, "2544082156": null, "2546495501": null, "256": 11, "258": 9, "259": 11, "2598818153": null, "26": 9, "260": 11, "264": 11, "2646114338": null, "266": 9, "2668242174": null, "267": 11, "268": 11, "2683995601": null, "269": 11, "2691358660": null, "270": 9, "271": 8, "274": 11, "276": 11, "278": 6, "2782023316": null, "279": 11, "2790124484": null, "28": 11, "281": 11, "283": 8, "2835688982": null, "284": 11, "2845253318": null, "285": 9, "2854337283": null, "2862362532": null, "288": 11, "2887815719": null, "289": 11, "2892558637": null, "2897348183": null, "29": 9, "2906756445": null, "291": 11, "2927119608": null, "293": 9, "294": 8, "2949903222": null, "2951747260": null, "296": 11, "297": 11, "298": 8, "2985091592": null, "299": 11, "300": 9, "302": 8, "303": 9, "304": 11, "3049552521": null, "305": 11, "306": 11, "307": 8, "308": 11, "3088876178": null, "309": 8, "3095364455": null, "3099716140": null, "311": 9, "3114287561": null, "312": 11, "312782546": 8, "312782550": 11, "312782554": 11, "312782558": 11, "312782562": 11, "312782566": 11, "312782570": 11, "312782574": 8, "312782578": 11, "312782582": 11, "312782586": 11, "312782590": 11, "312782594": 11, "312782598": 11, "312782604": 11, "312782608": 11, "312782612": 11, "312782620": 11, "312782624": 11, "312782632": 11, "312782636": 11, "312782644": 11, "312782648": 11, "312782652": 11, "3132124329": null, "314": 11, "316": 9, "318": 8, "3192952047": null, "320": 11, "3206763505": null, "321": 8, "323": 6, "324": 11, "3250982806": null, "3269661528": null, "327": 9, "328": 11, "330": 11, "3314370483": null, "332": 7, "334": 9, "335": 11, "3360392253": null, "337": 9, "3376791707": null, "339": 6, "34": 8, "340": 11, "3403314552": null, "3412423041": null, "344": 11, "345": 9, "346": 11, "348": 6, "349": 8, "3516629919": null, "352": 11, "353": 9, "355": 11, "356": 8, "3562104832": null, "358": 8, "3582239403": null, "3582777032": null, "3591549811": null, "36": 11, "360": 11, "361": 9, "363": 11, "364": 8, "3653590473": null, "368": 11, "3683796018": null, "369": 9, "3693772975": null, "37": 8, "370": 6, "371": 11, "3710667749": null, "3714509274": null, "3718675619": null, "372": 8, "3724992631": null, "373": 8, "376": 11, "377": 11, "3781663036": null, "379": 6, "3803368771": null, "3808183566": null, "3808433473": null, "383": 11, "386": 6, "387": 11, "3880005807": null, "389": 9, "3893800328": null, "3894563657": null, "39": 9, "392": 11, "3920533696": null, "3927629261": null, "393": 11, "3937412080": null, "3956191525": null, "3962734174": null, "3964792502": null, "400": 11, "401": 11, "405": 8, "408": 11, "41": 11, "410": 10, "411": 9, "412": 11, "414": 9, "416": 11, "418": 9, "419": 11, "42": 9, "420": 10, "421": 11, "422": 9, "424": 11, "426": 9, "427": 11, "428": 10, "430": 11, "434": 11, "435": 9, "44": 8, "440": 11, "441": 11, "442": 11, "443": 10, "444": 7, "448": 11, "449": 10, "45": 10, "450": 11, "451": 9, "456": 11, "457": 11, "458": 11, "459": 10, "46": 8, "461": 11, "465": 11, "468": 11, "469": 11, "472": 11, "473": 11, "474": 9, "476": 11, "478": 11, "48": 9, "480": 11, "480149202": 10, "480149206": 11, "480149210": 11, "480149214": 11, "480149218": 11, "480149222": 11, "480149226": 11, "480149230": 10, "480149234": 11, "480149238": 11, "480149242": 11, "480149246": 11, "480149250": 11, "480149254": 11, "480149258": 10, "480149262": 11, "480149266": 11, "480149270": 11, "480149274": 11, "480149278": 11, "480149282": 11, "480149286": 10, "480149290": 11, "480149294": 11, "480149298": 11, "480149302": 11, "480149306": 11, "480149310": 11, "480149314": 10, "480149318": 11, "480149322": 11, "480149326": 11, "480149330": 11, "480149334": 11, "480149338": 11, "484": 11, "484682470": 8, "484682475": 9, "484682479": 11, "484682483": 11, "484682487": 11, "484682492": 9, "484682496": 11, "484682500": 11, "484682504": 11, "484682508": 8, "484682512": 2, "484682516": 9, "484682520": 8, "484682524": 8, "484682528": 9, "487": 11, "488": 11, "49": 8, "490": 9, "492": 11, "494": 10, "496": 11, "496345664": 9, "496345668": 9, "496345672": 9, "497": 11, "498": 10, "50": 9, "500": 6, "503": 10, "505": 10, "508": 11, "509": 9, "510": 11, "511": 10, "516": 11, "517": 11, "518": 9, "52": 11, "520": 11, "522": 9, "524": 11, "526": 11, "526157192": 11, "526157196": 11, "526322264": 11, "527": 11, "527696977": 11, "529": 10, "53": 10, "530": 9, "531": 9, "532": 11, "534": 8, "535": 11, "537": 10, "538": 10, "539": 9, "540": 11, "542": 11, "543": 11, "544": 9, "545": 11, "546": 10, "548": 9, "549009199": 8, "549009203": 9, "549009207": 8, "549009211": 8, "549009215": 8, "549009219": 8, "549009223": 8, "549009227": 8, "55": 10, "550": 11, "551": 9, "555": 9, "556": 11, "558": 11, "559": 9, "560": 8, "560581551": 8, "560581555": 8, "560581559": 8, "560581563": 8, "561": 11, "562": 10, "563": 9, "563807435": 8, "563807439": 8, "565": 11, "566": 8, "569": 10, "57": 8, "570": 9, "572": 11, "576": 8, "576073699": 8, "576073704": 8, "577": 11, "582": 11, "584": 11, "585": 10, "586": 9, "588": 11, "589508447": 8, "589508451": 8, "589508455": 8, "59": 8, "590": 11, "591": 8, "592": 11, "593": 11, "597": 9, "598": 11, "599626923": 8, "599626927": 8, "60": 11, "600": 11, "601": 11, "602": 10, "605": 9, "606": 11, "606826647": 9, "606826651": 9, "606826655": 9, "606826659": 9, "606826663": 8, "607344830": 8, "607344834": 9, "607344838": 9, "607344842": 9, "607344846": 9, "607344850": 9, "607344854": 9, "607344858": 9, "607344862": 9, "608": 11, "609": 8, "61": 10, "610": 11, "614454277": 9, "620": 11, "622": 11, "624": 7, "625": 11, "627": 10, "629": 8, "630": 11, "635": 11, "638": 11, "639": 9, "640": 8, "643": 11, "644": 11, "646": 11, "647": 9, "648": 11, "649": 11, "65": 8, "652": 11, "654": 11, "655": 10, "656": 11, "657": 11, "659": 9, "660": 11, "662": 11, "663": 10, "664": 11, "666": 9, "667": 11, "668": 9, "670": 11, "671": 11, "675": 11, "676": 9, "677": 8, "68": 11, "680": 11, "682": 9, "683": 11, "684": 9, "686": 11, "687": 11, "69": 10, "690": 9, "692": 11, "694": 11, "696": 11, "698": 5, "699": 11, "70": 8, "702": 11, "703": 5, "704": 11, "707": 11, "712": 11, "714": 8, "715": 11, "719": 11, "72": 8, "722": 9, "723": 9, "725": 9, "727": 11, "728": 8, "729": 11, "731": 9, "734": 9, "735": 11, "736": 8, "738": 9, "739": 11, "740": 9, "742": 11, "743": 11, "745": 9, "746": 9, "747": 11, "748": 9, "750": 11, "751": 11, "755": 11, "756": 9, "758": 11, "759": 11, "76": 8, "760": 7, "761": 9, "762": 9, "764": 11, "765": 8, "766": 9, "767": 11, "768": 7, "769": 9, "77": 10, "770": 9, "772": 11, "774": 11, "775": 11, "777": 9, "779": 9, "781": 8, "782": 11, "783": 11, "785": 9, "786": 11, "787": 9, "788": 8, "789": 8, "790": 11, "791": 11, "792": 7, "793": 11, "799": 9, "8": 1, "80": 8, "800": 11, "801": 11, "804": 9, "805": 11, "806": 11, "807": 11, "809": 6, "810": 11, "814": 8, "815": 11, "816": 11, "817": 9, "819": 11, "823": 11, "824": 7, "826": 6, "827": 11, "829": 11, "831": 11, "836": 11, "837": 11, "838": 11, "84": 11, "844": 11, "845": 11, "847": 11, "849": 11, "850": 9, "853": 11, "854": 11, "855": 8, "856": 6, "857": 11, "86": 8, "860": 10, "861": 11, "862": 11, "864": 6, "865": 11, "868": 10, "87": 10, "870": 11, "873": 11, "875": 10, "878": 11, "879": 9, "882": 11, "883": 10, "884": 8, "887": 8, "888": 11, "889": 11, "89": 9, "891": 10, "893": 11, "894": 9, "895": 8, "896": 7, "897": 11, "899": 10, "9": 11, "902": 11, "905": 11, "906": 11, "91": 8, "910": 11, "913": 11, "914": 8, "915": 10, "919": 11, "92": 11, "921": 11, "923": 10, "925": 7, "927": 11, "929": 11, "932": 8, "935": 11, "937": 11, "939": 9, "943": 11, "945": 11, "947": 11, "95": 8, "950": 11, "952": 9, "954": 11, "955": 9, "956": 9, "959": 11, "960": 2, "962": 11, "963": 9, "964": 9, "965": 11, "966": 9, "969": 11, "97": 11, "971": 9, "973": 11, "974": 11, "977": 11, "983": 2, "987": 6, "988": 11, "990": 11, "991": 2, "996": 11, "999": 11, "1": 9, "100": 8, "1000": 2, "1001": 8, "1004": 8, "1007": 8, "1008": 7, "101": 8, "1011": 8, "1016": 9, "1017": 7, "1019": 9, "102": 8, "1020": 8, "1022": 8, "1025": 8, "1028": 9, "103": 10, "1031": 8, "1033": 8, "1036": 9, "1037": 8, "1038": 11, "1039": 8, "1041": 8, "1044": 8, "1047": 11, "1048": 8, "1049": 8, "105": 9, "1052": 8, "1056": 8, "106": 8, "1061": 9, "1063": 8, "1064": 8, "1065": 3, "10671": 8, "1070": 11, "10702": 11, "10703": 11, "10704": 11, "1071": 8, "1072": 9, "1073": 6, "1078": 8, "1079": 9, "108": 9, "1084": 8, "1087": 8, "1088": 9, "1089": 5, "1092": 8, "1093": 8, "1095": 8, "1097": 5, "11": 8, "1100": 8, "1103": 8, "1105": 8, "1108": 9, "1112": 8, "1113": 8, "1116": 9, "1119": 8, "1123": 8, "1126": 9, "1129": 3, "1131": 9, "114": 9, "1143": 11, "1144": 11, "1145": 11, "115": 8, "116": 9, "117": 9, "118": 9, "12": 8, "122": 9, "124": 8, "125": 9, "126": 8, "127": 8, "128": 8, "129": 8, "131": 8, "133": 8, "134": 8, "135": 8, "136": 8, "138": 7, "14": 8, "140": 8, "145": 8, "146": 8, "147": 8, "149": 8, "15": 8, "151": 8, "153": 9, "155": 8, "157": 6, "158": 9, "159": 8, "161": 8, "162": 8, "164": 8, "165": 7, "169": 8, "170": 8, "173": 8, "174": 8, "177": 8, "178": 8, "181": 8, "184": 8, "186": 8, "188": 11, "189": 8, "19": 8, "194": 8, "196": 11, "197": 8, "198": 9, "201": 11, "202": 8, "204": 11, "206": 8, "207": 8, "209": 8, "210": 8, "212": 11, "214": 8, "217": 8, "218": 8, "22": 6, "220": 11, "222": 8, "223": 8, "225": 8, "226": 8, "228": 11, "229": 9, "23": 8, "230": 8, "231": 8, "233": 11, "235": 8, "237": 9, "238": 8, "239": 7, "242": 8, "246": 8, "247": 6, "254": 8, "255": 8, "257": 11, "261": 9, "262": 8, "263": 8, "27": 8, "272": 8, "275": 6, "277": 9, "280": 8, "286": 8, "287": 8, "290": 6, "292": 8, "295": 8, "3": 8, "30": 9, "301": 8, "304325711": 1, "31": 8, "310": 8, "312782616": 11, "312782628": 8, "312782640": 11, "313": 5, "317": 8, "319": 8, "325": 8, "326": 8, "329": 9, "33": 11, "331": 7, "333": 8, "336": 9, "338": 8, "341": 8, "342": 8, "343": 2, "347": 8, "35": 8, "350": 8, "351": 8, "354": 5, "357": 9, "359": 9, "362": 8, "365": 8, "366": 8, "367": 9, "374": 8, "378": 8, "38": 8, "380": 10, "381": 8, "382": 9, "384": 9, "385": 8, "388": 10, "390": 8, "391": 11, "394": 8, "395": 8, "396": 10, "397": 9, "398": 8, "399": 11, "4": 8, "402": 8, "404": 10, "406": 8, "407": 11, "409": 8, "413": 9, "415": 11, "417": 8, "423": 9, "425": 8, "429": 9, "43": 8, "431": 11, "432": 8, "433": 11, "436": 10, "437": 9, "438": 11, "439": 9, "445": 9, "446": 11, "447": 9, "452": 8, "453": 6, "454": 11, "455": 9, "460": 8, "462": 8, "463": 9, "464": 9, "466": 9, "467": 6, "47": 10, "470": 8, "471": 11, "475": 8, "477": 5, "479": 11, "481": 9, "482": 10, "483": 8, "485": 6, "486": 11, "489": 9, "491": 8, "493": 6, "495": 11, "499": 9, "501": 11, "502": 8, "504": 11, "506": 10, "507": 8, "51": 7, "512": 2, "513": 10, "514": 9, "515": 8, "519": 5, "521": 10, "523": 8, "525": 8, "528": 5, "533": 8, "536": 8, "54": 8, "541": 6, "547": 9, "549": 5, "552": 8, "553": 9, "554": 10, "557": 8, "56": 8, "564": 9, "567": 2, "568": 8, "571": 7, "573": 11, "574": 8, "575": 8, "578": 10, "579": 10, "58": 8, "580": 8, "581": 8, "583": 8, "587": 9, "589": 8, "594": 10, "595": 9, "596": 9, "599": 8, "6": 9, "603": 9, "604": 8, "607": 7, "611": 9, "612": 8, "613": 11, "614": 8, "615": 8, "616": 8, "617": 9, "618": 9, "619": 8, "62": 9, "621": 8, "623": 3, "626": 9, "628": 9, "63": 8, "631": 8, "632": 11, "633": 10, "634": 9, "636": 9, "637": 7, "64": 8, "641": 10, "642": 8, "645": 6, "650": 9, "651": 8, "653": 8, "658": 10, "66": 8, "661": 8, "665": 10, "669": 6, "67": 9, "672": 8, "673": 9, "674": 9, "678": 11, "679": 8, "681": 9, "685": 8, "688": 3, "689": 8, "691": 9, "693": 8, "695": 4, "697": 9, "7": 8, "700": 9, "701": 7, "705": 10, "706": 9, "708": 9, "709": 8, "71": 9, "710": 8, "711": 8, "713": 9, "716": 9, "717": 8, "718": 9, "720": 7, "721": 11, "724": 9, "726": 8, "73": 1, "730": 9, "732": 9, "733": 9, "737": 9, "74": 11, "741": 9, "744": 8, "749": 8, "75": 8, "753": 9, "754": 8, "757": 8, "763": 8, "771": 5, "773": 8, "776": 8, "778": 11, "78": 8, "780": 8, "784": 8, "79": 10, "794": 10, "795": 8, "796": 9, "797": 8, "798": 8, "802": 9, "803": 5, "808": 8, "81": 8, "811": 9, "812": 9, "813": 8, "818": 6, "82": 9, "820": 9, "821": 11, "822": 6, "825": 9, "828": 9, "83": 8, "830": 8, "832": 8, "833": 9, "834": 9, "835": 6, "839": 8, "840": 8, "841": 10, "842": 9, "843": 8, "846": 8, "848": 8, "85": 10, "851": 9, "852": 8, "858": 9, "859": 8, "863": 8, "866": 9, "867": 8, "869": 11, "871": 8, "872": 8, "874": 8, "876": 9, "877": 8, "88": 8, "880": 8, "881": 9, "885": 8, "886": 9, "890": 9, "892": 8, "898": 8, "90": 9, "900": 9, "901": 8, "903": 8, "904": 8, "907": 8, "908": 8, "909": 8, "911": 8, "912": 8, "916": 9, "917": 8, "918": 9, "920": 7, "922": 8, "924": 9, "926": 9, "928": 7, "93": 9, "930": 8, "931": 8, "933": 8, "934": 9, "936": 8, "938": 8, "94": 9, "940": 8, "941": 8, "942": 8, "944": 8, "946": 8, "948": 9, "949": 8, "951": 8, "953": 8, "957": 8, "958": 7, "96": 8, "961": 8, "967": 2, "968": 8, "970": 9, "972": 8, "975": 8, "976": 8, "978": 9, "979": 9, "98": 9, "980": 8, "981": 11, "982": 8, "984": 8, "985": 8, "986": 9, "989": 8, "99": 9, "992": 8, "993": 8, "994": 9, "995": 8, "997": 0, "998": 8, "375": 8, "403": 8, "752": 7, "315": 5, "322": 8, "1370229894": null, "1344105173": null, "2615618683": null, "3116469840": null, "3379356047": null, "1315119484": null, "2436888515": null, "3577346235": null, "3902978127": null, "3651721123": null, "1310126712": null, "1446874462": null, "3324056088": null, "2593521448": null, "3685934448": null, "3575805529": null, "1210837267": null, "1258169895": null, "2732283703": null, "1994494334": null, "1447791371": null, "2590882612": null, "3761146439": null, "3139552203": null, "2692580507": null, "1677451927": null, "3379749055": null, "3896406483": null, "2835342929": null, "1897248316": null, "3173729836": null, "3926962776": null, "2168807353": null, "3137025327": null, "2406188897": null, "3670777223": null, "2525641171": null, "1516851569": null, "3913053667": null, "2196657368": null, "3986345576": null, "3495145594": null, "1644849336": null, "3289019263": null, "2194674250": null, "1673450198": null, "3853526235": null, "3456985752": null, "1311366798": null, "1126601402": null, "3966633210": null, "2812530569": null, "1641347046": null, "3416776496": null, "1626685236": null, "3565367498": null, "2657138906": null, "1881029055": null, "3080022137": null, "1547817274": null, "2369238059": null, "2478012832": null, "2739084189": null, "3347675430": null, "2006047173": null, "2180527067": null, "1456682260": null, "3562601313": null, "1970686062": null, "3890169311": null, "2936441103": null, "3215542274": null, "1521759875": null, "3486673188": null, "3783583602": null, "3970522306": null, "1054221329": null, "3895794866": null, "1496257237": null, "2152572352": null, "3048883337": null, "1013068637": null, "1337935688": null, "1667660763": null, "1558550786": null, "2563782304": null, "3219108088": null, "1420546517": null, "1945434117": null, "2866280389": null, "2072239244": null, "1082141991": null, "2157537321": null, "2525505631": null, "1714311201": null, "2930307508": null, "3188993656": null, "1843338795": null, "3291535006": null, "2937437636": null, "3835740469": null, "1125438717": null, "2629778705": null, "3581771805": null, "3877358805": null, "1667278413": null, "2743616995": null, "1093211310": null, "3404738524": null, "2151167540": null, "2460702429": null, "3167765177": null, "1639524986": null, "1549069626": null, "3085221154": null, "2659044087": null, "2700046659": null, "2062992388": null, "1246610280": null, "3880590912": null, "1035739465": null, "1105483506": null, "1792980078": null, "3556494715": null, "1706307657": null, "1869881498": null, "1261138116": null, "2933568634": null, "2013207018": null, "1805611561": null, "3719447735": null, "2371017187": null, "3985188708": null, "3796365620": null, "1714819828": null, "1171261412": null, "3724099631": null, "2833279579": null, "2558258359": null, "3859877696": null, "2108774369": null, "3320050311": null, "3628159968": null, "3638507875": null, "3329043535": null, "1743009264": null, "1884779226": null, "3623254419": null, "1926976537": null, "2047390011": null, "2798287336": null, "2987319910": null, "3872485424": null, "2036081150": null, "1781030954": null, "2841658580": null, "3521164295": null, "1876807310": null, "1501393228": null, "1972094100": null, "3302405705": null, "1099096371": null, "3202423327": null, "1117257996": null, "1453537399": null, "2567067139": null, "2427348802": null, "3859818063": null, "1588504257": null, "3571205574": null, "1096265790": null, "1412541198": null, "1326886999": null, "1787178465": null, "2341450864": null, "2551618170": null, "1170723867": null, "1038004598": null, "1149652689": null, "1582478571": null, "1588741938": null, "1315950883": null, "1947266943": null, "3990698322": null, "3301183793": null, "1464978040": null, "2387503636": null, "2023633893": null, "1913328693": null, "3920024588": null, "1877482733": null, "2358987890": null, "3673895945": null, "1393608993": null, "2978179471": null, "3338653017": null, "2384899589": null, "2710463424": null, "3055000922": null, "1406402073": null, "1373744894": null, "1156116970": null, "3453175542": null, "3652474151": null, "2236457933": null, "3277826222": null, "1005899076": null, "2438939909": null, "1691306271": null, "3434166213": null, "1275601165": null, "3946289800": null, "2004775342": null, "1456398198": null, "3561503481": null, "1901850664": null, "1071521092": null, "3807479791": null, "2803418480": null, "2980820846": null, "3188360247": null, "1477785742": null, "2964598138": null, "3093795446": null, "1507784331": null, "3054551821": null, "3748961581": null, "3128223634": null, "2185403483": null, "1433026796": null, "1104248884": null, "3545403109": null, "1536696383": null, "3527105324": null, "2811301625": null, "1897015494": null, "3331790659": null, "2795738785": null, "2768475141": null, "2658097375": null, "2157528000": null, "3309772165": null, "1928393658": null, "3840818183": null, "3841505448": null, "3999683881": null, "3325173834": null, "1798728430": null, "3299719941": null, "2360313730": null, "3043750963": null, "2641148319": null, "1468793762": null, "1427961626": null, "1643593739": null, "3092405473": null, "1181035221": null, "3118601025": null, "2374653061": null, "3026302666": null, "2197459620": null, "1965375801": null, "1666373161": null, "3620340000": null, "2815501138": null, "2091848107": null, "2658756176": null, "2097438884": null, "2868822451": null, "3331415743": null, "3618095278": null, "2613674898": null, "1951878763": null, "3413451609": null, "2225157452": null, "2842134861": null, "2064317417": null, "2123772309": null, "1510133109": null, "1624778115": null, "1094902124": null, "3134535128": null, "3312222592": null, "1518704958": null, "1475527815": null, "1612593605": null, "2915675742": null, "2644357350": null, "1171320182": null, "2810081477": null, "3513655281": null, "2790136061": null, "2667261098": null, "2302833148": null, "3278290071": null, "2688781451": null, "1848522986": null, "2358040414": null, "2561915647": null, "3389528505": null, "1860102496": null, "1953921139": null, "1668688439": null, "1466095084": null, "1992072790": null, "1375046773": null, "1203939479": null, "1209357605": null, "1024543562": null, "2952544119": null, "2063775638": null, "1580329576": null, "1792026161": null, "2449182232": null, "3263488087": null, "2416897036": null, "3672106733": null, "2445320853": null, "3034756217": null, "2791423253": null, "2165415682": null, "3009745967": null, "1043765183": null, "2614168502": null, "2218808594": null, "2869757686": null, "1463730273": null, "1690235425": null, "3449035628": null, "2254557934": null, "3467149620": null, "2723065947": null, "1171543751": null, "1842735199": null, "1040222935": null, "3654510924": null, "2956165934": null, "2183090366": null, "3101970431": null, "2127067043": null, "3409505442": null, "2557684018": null, "1140764290": null, "2316153360": null, "1593308392": null, "2114704803": null, "1557651847": null, "3092369320": null, "1166850207": null, "3944974149": null, "3537828992": null, "2176156825": null, "3283016083": null, "2434751741": null, "2692485271": null, "3140724988": null, "3228324150": null, "2718688460": null, "1428498274": null, "2923485783": null, "1060511842": null, "2500193001": null, "1744978404": null, "3774104740": null, "1811993763": null}, "parent_id": {"0": 0, "10": 294, "1002": 247, "1003": 784, "1005": 1002, "1006": 361, "1009": 997, "1010": 677, "1012": 784, "1014": 856, "1015": 39, "1018": 247, "1021": 993, "1023": 1018, "1024": 997, "1026": 369, "1027": 247, "1029": 138, "1030": 337, "1032": 1024, "1034": 597, "1035": 378, "1037481934": 412, "1037502706": 1053, "104": 95, "1040": 1024, "1042": 597, "1043": 877, "1045": 895, "1046": 394, "1050": 597, "1051": 877, "1053": 31, "1054": 44, "1055": 1032, "1057": 315, "1058": 677, "1059": 597, "1060": 877, "1062": 329, "1066": 394, "1067": 605, "10672": 1007, "10673": 1007, "10674": 1007, "10675": 1056, "10676": 1056, "10677": 1056, "10678": 1064, "10679": 1064, "1068": 824, "10680": 1064, "10681": 1025, "10682": 1025, "10683": 1025, "10684": 1033, "10685": 1033, "10686": 1033, "10687": 1041, "10688": 1041, "10689": 1041, "1069": 370, "10690": 1049, "10691": 1049, "10692": 1049, "10693": 843, "10694": 843, "10695": 843, "10696": 1037, "10697": 1037, "10698": 1037, "10699": 1084, "107": 500, "10700": 1084, "10701": 1084, "10705": 912, "10706": 912, "10707": 912, "10708": 976, "10709": 976, "10710": 976, "10711": 984, "10712": 984, "10713": 984, "10714": 992, "10715": 992, "10716": 992, "10717": 1001, "10718": 1001, "10719": 1001, "10720": 1091, "10721": 1091, "10722": 1091, "10723": 936, "10724": 936, "10725": 936, "10726": 944, "10727": 944, "10728": 944, "10729": 951, "10730": 951, "10731": 951, "10732": 957, "10733": 957, "10734": 957, "10735": 968, "10736": 968, "10737": 968, "1074": 402, "1075": 605, "1076": 933, "1077": 444, "1080": 1089, "1081": 44, "1082": 605, "1083": 824, "1085": 993, "1086": 361, "1087129144": 480149238, "109": 760, "1090": 378, "1091": 928, "1094": 337, "1096": 127, "1098": 395, "1099": 768, "110": 94, "1101": 104, "1102": 345, "1104": 127, "1106": 677, "1107": 395, "1109": 141, "111": 95, "1110": 525, "1111": 361, "1114": 402, "1117": 771, "1118": 525, "112": 607, "1120": 239, "1121": 918, "1124": 141, "1125": 746, "1127": 541, "1128": 337, "113": 337, "1132": 771, "1133": 934, "1139": 619, "1140": 566, "1141": 566, "1142": 566, "1160721590": 312782554, "1165809076": 251, "119": 95, "120": 111, "121": 409, "123": 867, "12993": 453, "12994": 453, "12995": 453, "12996": 453, "12997": 453, "12998": 453, "130": 679, "1307372013": 643, "132": 972, "1355885073": 12994, "137": 679, "139": 918, "141": 1097, "142": 760, "143": 135, "1430875964": 312782608, "1431942459": 269, "144": 754, "1454256797": 113, "1463157755": 905, "148": 1057, "150": 824, "152": 961, "154": 370, "156": 1011, "1598869030": 643, "16": 703, "160": 159, "1624848466": 838, "163": 111, "1645194511": 1066, "166": 824, "167": 159, "1672280517": 163, "168": 159, "1695203883": 312782554, "17": 294, "171": 972, "1720700944": 480149210, "175": 159, "1758306548": 943, "179": 31, "18": 1040, "180": 1057, "182": 824, "182305689": 322, "182305693": 182305689, "182305697": 182305689, "182305701": 182305689, "182305705": 182305689, "182305709": 182305689, "182305713": 182305689, "183": 159, "185": 1069, "187": 1057, "1890964946": 854, "1896413216": 480149238, "190": 784, "191": 159, "192": 639, "193": 1069, "1942628671": 755, "195": 304, "199": 159, "2": 345, "20": 918, "200": 639, "2012716980": 582, "2026216612": 480149266, "203": 370, "205": 855, "2078623765": 556, "208": 639, "21": 840, "2102386393": 657, "211": 39, "213": 855, "215": 1100, "2153924985": 269, "216": 655, "2167613582": 755, "2186168811": 600, "2189208794": 1106, "219": 500, "2208057363": 182305697, "221": 863, "2218254883": 427, "2224619882": 694, "224": 655, "2260827822": 670, "227": 31, "2292194787": 1066, "2300544548": 241, "232": 655, "2336071181": 806, "234": 541, "2341154899": 480149294, "236": 507, "2361776473": 434, "240": 663, "241": 22, "2413172686": 288, "2414821463": 163, "243": 1011, "2430059008": 561, "2439179873": 1127, "244": 507, "245": 871, "248": 663, "249": 1027, "25": 1040, "250": 242, "251": 1002, "2511156654": 962, "252": 1011, "253": 871, "2536061413": 667, "2542216029": 806, "2544082156": 41, "2546495501": 219, "256": 663, "258": 242, "259": 934, "2598818153": 201, "26": 294, "260": 619, "264": 714, "2646114338": 667, "266": 242, "2668242174": 888, "267": 814, "268": 619, "2683995601": 821, "269": 425, "2691358660": 346, "270": 871, "271": 339, "274": 879, "276": 961, "278": 477, "2782023316": 312782636, "279": 894, "2790124484": 304, "28": 918, "281": 394, "283": 987, "2835688982": 201, "284": 961, "2845253318": 905, "285": 871, "2854337283": 1127, "2862362532": 211, "288": 746, "2887815719": 480149322, "289": 541, "2892558637": 965, "2897348183": 296, "29": 871, "2906756445": 219, "291": 961, "2927119608": 251, "293": 871, "294": 323, "2949903222": 1053, "2951747260": 113, "296": 48, "297": 597, "298": 835, "2985091592": 312782582, "299": 500, "300": 178, "302": 339, "303": 295, "304": 972, "3049552521": 657, "305": 385, "306": 605, "307": 370, "308": 22, "3088876178": 328, "309": 760, "3095364455": 211, "3099716140": 346, "311": 295, "3114287561": 480149322, "312": 918, "312782546": 22, "312782550": 312782546, "312782554": 312782546, "312782558": 312782546, "312782562": 312782546, "312782566": 312782546, "312782570": 312782546, "312782574": 669, "312782578": 312782574, "312782582": 312782574, "312782586": 312782574, "312782590": 312782574, "312782594": 312782574, "312782598": 312782574, "312782604": 417, "312782608": 417, "312782612": 417, "312782620": 417, "312782624": 417, "312782632": 312782628, "312782636": 312782628, "312782644": 312782628, "312782648": 312782628, "312782652": 312782628, "3132124329": 888, "314": 111, "316": 178, "318": 987, "3192952047": 965, "320": 985, "3206763505": 480149266, "321": 1014, "323": 313, "324": 934, "3250982806": 328, "3269661528": 492, "327": 319, "328": 104, "330": 879, "3314370483": 430, "332": 157, "334": 319, "335": 922, "3360392253": 241, "337": 322, "3376791707": 694, "339": 313, "34": 1040, "340": 22, "3403314552": 480149294, "3412423041": 962, "344": 111, "345": 322, "346": 322, "348": 313, "349": 824, "3516629919": 427, "352": 714, "353": 322, "355": 111, "356": 290, "3562104832": 670, "358": 1117, "3582239403": 296, "3582777032": 480149210, "3591549811": 838, "36": 1057, "360": 814, "361": 322, "363": 972, "364": 290, "3653590473": 288, "368": 922, "3683796018": 180, "369": 322, "3693772975": 854, "37": 768, "370": 354, "371": 934, "3710667749": 41, "3714509274": 312782608, "3718675619": 943, "372": 370, "3724992631": 561, "373": 752, "376": 655, "377": 425, "3781663036": 600, "379": 354, "3803368771": 412, "3808183566": 312782582, "3808433473": 182305697, "383": 663, "386": 354, "387": 918, "3880005807": 492, "389": 871, "3893800328": 180, "3894563657": 821, "39": 31, "392": 619, "3920533696": 312782636, "3927629261": 973, "393": 425, "3937412080": 12994, "3956191525": 434, "3962734174": 973, "3964792502": 1106, "400": 788, "401": 394, "405": 824, "408": 788, "41": 533, "410": 490, "411": 403, "412": 723, "414": 406, "416": 788, "418": 403, "419": 934, "42": 294, "420": 745, "421": 409, "422": 406, "424": 788, "426": 403, "427": 895, "428": 737, "430": 886, "434": 879, "435": 403, "44": 315, "440": 723, "441": 394, "442": 879, "443": 618, "444": 856, "448": 723, "449": 618, "45": 445, "450": 369, "451": 295, "456": 1027, "457": 669, "458": 754, "459": 21, "46": 824, "461": 361, "465": 754, "468": 926, "469": 533, "472": 426, "473": 754, "474": 1099, "476": 714, "478": 337, "48": 31, "480": 426, "480149202": 329, "480149206": 480149202, "480149210": 480149202, "480149214": 480149202, "480149218": 480149202, "480149222": 480149202, "480149226": 480149202, "480149230": 1011, "480149234": 480149230, "480149238": 480149230, "480149242": 480149230, "480149246": 480149230, "480149250": 480149230, "480149254": 480149230, "480149258": 894, "480149262": 480149258, "480149266": 480149258, "480149270": 480149258, "480149274": 480149258, "480149278": 480149258, "480149282": 480149258, "480149286": 894, "480149290": 480149286, "480149294": 480149286, "480149298": 480149286, "480149302": 480149286, "480149306": 480149286, "480149310": 480149286, "480149314": 894, "480149318": 480149314, "480149322": 480149314, "480149326": 480149314, "480149330": 480149314, "480149334": 480149314, "480149338": 480149314, "484": 731, "484682470": 822, "484682475": 484682470, "484682479": 484682475, "484682483": 484682475, "484682487": 484682475, "484682492": 484682470, "484682496": 484682492, "484682500": 484682492, "484682504": 484682492, "484682508": 822, "484682512": 1009, "484682516": 776, "484682520": 896, "484682524": 896, "484682528": 301, "487": 426, "488": 723, "49": 1040, "490": 1123, "492": 714, "494": 10, "496": 814, "496345664": 170, "496345668": 170, "496345672": 170, "497": 669, "498": 359, "50": 795, "500": 315, "503": 10, "505": 359, "508": 926, "509": 502, "510": 337, "511": 10, "516": 714, "517": 566, "518": 502, "52": 918, "520": 1018, "522": 932, "524": 582, "526": 926, "526157192": 184, "526157196": 184, "526322264": 184, "527": 1011, "527696977": 731, "529": 359, "53": 445, "530": 1099, "531": 1100, "532": 22, "534": 987, "535": 814, "537": 359, "538": 21, "539": 128, "540": 922, "542": 886, "543": 926, "544": 536, "545": 879, "546": 359, "548": 128, "549009199": 493, "549009203": 1100, "549009207": 323, "549009211": 323, "549009215": 987, "549009219": 987, "549009223": 987, "549009227": 987, "55": 94, "550": 926, "551": 536, "555": 128, "556": 44, "558": 353, "559": 536, "560": 607, "560581551": 138, "560581555": 138, "560581559": 571, "560581563": 51, "561": 669, "562": 359, "563": 70, "563807435": 637, "563807439": 1014, "565": 533, "566": 698, "569": 367, "57": 1040, "570": 932, "572": 31, "576": 370, "576073699": 141, "576073704": 290, "577": 369, "582": 731, "584": 655, "585": 367, "586": 932, "588": 48, "589508447": 822, "589508451": 386, "589508455": 519, "59": 444, "590": 886, "591": 165, "592": 663, "593": 385, "597": 589, "598": 1018, "599626923": 339, "599626927": 987, "60": 918, "600": 1011, "601": 402, "602": 367, "605": 589, "606": 430, "606826647": 491, "606826651": 491, "606826655": 491, "606826659": 491, "606826663": 323, "607344830": 323, "607344834": 100, "607344838": 100, "607344842": 100, "607344846": 100, "607344850": 100, "607344854": 100, "607344858": 100, "607344862": 100, "608": 746, "609": 864, "61": 445, "610": 879, "614454277": 795, "620": 731, "622": 886, "624": 1024, "625": 369, "627": 285, "629": 637, "630": 723, "635": 22, "638": 1057, "639": 631, "640": 370, "643": 1027, "644": 500, "646": 814, "647": 631, "648": 985, "649": 402, "65": 1040, "652": 103, "654": 353, "655": 647, "656": 993, "657": 345, "659": 651, "660": 103, "662": 1057, "663": 647, "664": 926, "666": 651, "667": 184, "668": 830, "670": 361, "671": 894, "675": 119, "676": 830, "677": 315, "68": 184, "680": 746, "682": 651, "683": 22, "684": 830, "686": 322, "687": 886, "69": 445, "690": 46, "692": 922, "694": 119, "696": 1027, "698": 695, "699": 119, "70": 824, "702": 353, "703": 688, "704": 119, "707": 44, "712": 926, "714": 315, "715": 918, "719": 322, "72": 141, "722": 1068, "723": 714, "725": 709, "727": 926, "728": 960, "729": 541, "731": 714, "734": 726, "735": 1002, "736": 1000, "738": 714, "739": 31, "740": 515, "742": 734, "743": 926, "745": 1099, "746": 714, "747": 556, "748": 515, "750": 425, "751": 734, "755": 1018, "756": 515, "758": 734, "759": 1027, "76": 370, "760": 1000, "761": 693, "762": 182, "764": 918, "765": 370, "766": 726, "767": 993, "768": 991, "769": 693, "77": 445, "770": 182, "772": 48, "774": 894, "775": 766, "777": 693, "779": 182, "781": 370, "782": 766, "783": 104, "785": 693, "786": 541, "787": 182, "788": 698, "789": 386, "790": 766, "791": 1027, "792": 967, "793": 322, "799": 726, "8": 997, "80": 141, "800": 119, "801": 669, "804": 797, "805": 533, "806": 378, "807": 799, "809": 803, "810": 48, "814": 698, "815": 799, "816": 1002, "817": 349, "819": 48, "823": 799, "824": 991, "826": 803, "827": 44, "829": 509, "831": 104, "836": 895, "837": 509, "838": 353, "84": 972, "844": 985, "845": 509, "847": 1002, "849": 677, "850": 326, "853": 518, "854": 369, "855": 1000, "856": 549, "857": 677, "86": 896, "860": 881, "861": 518, "862": 378, "864": 549, "865": 322, "868": 881, "87": 94, "870": 518, "873": 378, "875": 881, "878": 345, "879": 254, "882": 985, "883": 881, "884": 768, "887": 370, "888": 922, "889": 353, "89": 81, "891": 881, "893": 378, "894": 254, "895": 315, "896": 983, "897": 677, "899": 890, "9": 361, "902": 425, "905": 402, "906": 894, "91": 519, "910": 731, "913": 669, "914": 141, "915": 890, "919": 39, "92": 918, "921": 322, "923": 890, "925": 967, "927": 39, "929": 353, "932": 792, "935": 39, "937": 669, "939": 135, "943": 985, "945": 369, "947": 500, "95": 315, "950": 345, "952": 942, "954": 1002, "955": 235, "956": 776, "959": 1018, "960": 1009, "962": 993, "963": 235, "964": 776, "965": 894, "966": 942, "969": 746, "97": 541, "971": 776, "973": 409, "974": 345, "977": 895, "983": 1009, "987": 771, "988": 895, "990": 1018, "991": 1009, "996": 104, "999": 918, "1": 557, "100": 165, "1000": 1009, "1001": 928, "1004": 467, "1007": 1073, "1008": 864, "101": 607, "1011": 247, "1016": 840, "1017": 1073, "1019": 784, "102": 760, "1020": 138, "1022": 818, "1025": 1073, "1028": 784, "103": 71, "1031": 818, "1033": 1073, "1036": 784, "1037": 822, "1038": 329, "1039": 720, "1041": 1073, "1044": 864, "1047": 329, "1048": 370, "1049": 1073, "105": 398, "1052": 348, "1056": 1017, "106": 370, "1061": 1100, "1063": 1032, "1064": 1017, "1065": 343, "10671": 1097, "1070": 329, "10702": 726, "10703": 726, "10704": 726, "1071": 1032, "1072": 475, "1073": 528, "1078": 1032, "1079": 475, "108": 81, "1084": 822, "1087": 1040, "1088": 475, "1089": 695, "1092": 896, "1093": 987, "1095": 1040, "1097": 1129, "11": 1040, "1100": 323, "1103": 1040, "1105": 278, "1108": 776, "1112": 1040, "1113": 239, "1116": 798, "1119": 1040, "1123": 752, "1126": 557, "1129": 343, "1131": 798, "114": 398, "1143": 528, "1144": 528, "1145": 528, "115": 323, "116": 81, "117": 848, "118": 157, "12": 165, "122": 398, "124": 73, "125": 848, "126": 141, "127": 239, "128": 323, "129": 73, "131": 703, "133": 141, "134": 760, "135": 370, "136": 370, "138": 856, "14": 896, "140": 73, "145": 73, "146": 1117, "147": 1117, "149": 571, "15": 571, "151": 698, "153": 145, "155": 239, "157": 1097, "158": 832, "159": 698, "161": 154, "162": 1117, "164": 73, "165": 348, "169": 154, "170": 1008, "173": 290, "174": 824, "177": 154, "178": 1014, "181": 571, "184": 315, "186": 958, "188": 151, "189": 51, "19": 1080, "194": 290, "196": 151, "197": 165, "198": 784, "201": 329, "202": 701, "204": 151, "206": 379, "207": 386, "209": 701, "210": 331, "212": 507, "214": 323, "217": 701, "218": 138, "22": 315, "220": 507, "222": 379, "223": 157, "225": 701, "226": 290, "228": 507, "229": 901, "23": 278, "230": 379, "231": 323, "233": 402, "235": 370, "237": 917, "238": 1117, "239": 856, "242": 275, "246": 323, "247": 315, "254": 315, "255": 239, "257": 533, "261": 871, "262": 856, "263": 141, "27": 1014, "272": 141, "275": 477, "277": 871, "280": 987, "286": 141, "287": 809, "290": 1097, "292": 278, "295": 703, "3": 1040, "30": 157, "301": 768, "304325711": 997, "31": 315, "310": 275, "312782616": 417, "312782628": 669, "312782640": 312782628, "313": 343, "317": 760, "319": 703, "325": 138, "326": 752, "329": 322, "33": 385, "331": 467, "333": 275, "336": 848, "338": 141, "341": 824, "342": 835, "343": 8, "347": 141, "35": 323, "350": 1117, "351": 809, "354": 1065, "357": 848, "359": 351, "362": 444, "365": 896, "366": 444, "367": 351, "374": 348, "378": 453, "38": 157, "380": 514, "381": 323, "382": 375, "384": 911, "385": 669, "388": 514, "390": 157, "391": 382, "394": 669, "395": 370, "396": 514, "397": 863, "398": 1132, "399": 382, "4": 339, "402": 669, "404": 490, "406": 864, "407": 382, "409": 669, "413": 933, "415": 382, "417": 22, "423": 375, "425": 669, "429": 386, "43": 1040, "431": 423, "432": 332, "433": 394, "436": 737, "437": 386, "438": 423, "439": 63, "445": 386, "446": 423, "447": 63, "452": 141, "453": 315, "454": 423, "455": 63, "460": 339, "462": 987, "463": 375, "464": 63, "466": 1099, "467": 1097, "47": 71, "470": 290, "471": 463, "475": 1008, "477": 623, "479": 463, "481": 754, "482": 948, "483": 958, "485": 477, "486": 463, "489": 754, "491": 331, "493": 477, "495": 463, "499": 1123, "501": 533, "502": 822, "504": 463, "506": 948, "507": 698, "51": 856, "512": 8, "513": 359, "514": 932, "515": 467, "519": 512, "521": 359, "523": 141, "525": 331, "528": 512, "533": 669, "536": 278, "54": 824, "541": 315, "547": 70, "549": 1129, "552": 987, "553": 1123, "554": 359, "557": 331, "56": 493, "564": 904, "567": 8, "568": 370, "571": 856, "573": 409, "574": 987, "575": 51, "578": 367, "579": 956, "58": 323, "580": 339, "581": 826, "583": 703, "587": 795, "589": 698, "594": 367, "595": 1083, "596": 904, "599": 51, "6": 784, "603": 1099, "604": 1117, "607": 386, "611": 1083, "612": 1132, "613": 409, "614": 290, "615": 323, "616": 323, "617": 362, "618": 1099, "619": 698, "62": 832, "621": 987, "623": 567, "626": 362, "628": 1100, "63": 467, "631": 698, "632": 726, "633": 948, "634": 1100, "636": 362, "637": 864, "64": 239, "641": 948, "642": 386, "645": 528, "650": 1123, "651": 386, "653": 370, "658": 948, "66": 323, "661": 370, "665": 21, "669": 315, "67": 795, "672": 485, "673": 46, "674": 651, "678": 1011, "679": 1117, "681": 46, "685": 637, "688": 567, "689": 141, "691": 651, "693": 467, "695": 688, "697": 932, "7": 1132, "700": 88, "701": 370, "705": 229, "706": 1100, "708": 88, "709": 637, "71": 38, "710": 967, "711": 720, "713": 1099, "716": 88, "717": 967, "718": 709, "720": 386, "721": 385, "724": 88, "726": 1080, "73": 997, "730": 1083, "732": 491, "733": 709, "737": 1099, "74": 409, "741": 709, "744": 960, "749": 323, "75": 323, "753": 46, "754": 493, "757": 323, "763": 141, "771": 1065, "773": 370, "776": 983, "778": 385, "78": 752, "780": 703, "784": 983, "79": 71, "794": 229, "795": 323, "796": 797, "797": 290, "798": 967, "802": 1083, "803": 623, "808": 967, "81": 73, "811": 4, "812": 326, "813": 967, "818": 803, "82": 612, "820": 4, "821": 385, "822": 1089, "825": 349, "828": 4, "83": 370, "830": 141, "832": 967, "833": 349, "834": 302, "835": 803, "839": 370, "840": 967, "841": 948, "842": 302, "843": 822, "846": 519, "848": 967, "85": 812, "851": 302, "852": 370, "858": 932, "859": 370, "863": 1000, "866": 326, "867": 1132, "869": 425, "871": 967, "872": 165, "874": 339, "876": 848, "877": 1000, "88": 467, "880": 987, "881": 867, "885": 967, "886": 254, "890": 867, "892": 768, "898": 987, "90": 612, "900": 840, "901": 967, "903": 386, "904": 826, "907": 51, "908": 768, "909": 822, "911": 967, "912": 645, "916": 848, "917": 967, "918": 909, "920": 645, "922": 315, "924": 784, "926": 909, "928": 645, "93": 901, "930": 51, "931": 987, "933": 967, "934": 909, "936": 645, "938": 370, "94": 38, "940": 768, "941": 1000, "942": 703, "944": 645, "946": 467, "948": 933, "949": 967, "951": 645, "953": 958, "957": 645, "958": 856, "96": 607, "961": 698, "967": 1009, "968": 645, "970": 938, "972": 315, "975": 323, "976": 920, "978": 938, "979": 776, "98": 81, "980": 467, "981": 329, "982": 1080, "984": 920, "985": 500, "986": 776, "989": 519, "99": 612, "992": 928, "993": 500, "994": 784, "995": 370, "997": 0, "998": 493, "375": 1080, "403": 278, "752": 960, "315": 695, "322": 453, "1370229894": 329, "1344105173": 1370229894, "2615618683": 1370229894, "3116469840": 2615618683, "3379356047": 2615618683, "1315119484": 1370229894, "2436888515": 1370229894, "3577346235": 1370229894, "3902978127": 1370229894, "3651721123": 329, "1310126712": 3651721123, "1446874462": 3651721123, "3324056088": 1446874462, "2593521448": 1446874462, "3685934448": 3651721123, "3575805529": 3651721123, "1210837267": 3651721123, "1258169895": 3651721123, "2732283703": 329, "1994494334": 2732283703, "1447791371": 2732283703, "2590882612": 1447791371, "3761146439": 1447791371, "3139552203": 2732283703, "2692580507": 2732283703, "1677451927": 2732283703, "3379749055": 2732283703, "3896406483": 329, "2835342929": 3896406483, "1897248316": 3896406483, "3173729836": 1897248316, "3926962776": 1897248316, "2168807353": 3896406483, "3137025327": 3896406483, "2406188897": 3896406483, "3670777223": 3896406483, "2525641171": 329, "1516851569": 2525641171, "3913053667": 2525641171, "2196657368": 3913053667, "3986345576": 3913053667, "3495145594": 2525641171, "1644849336": 2525641171, "3289019263": 2525641171, "2194674250": 2525641171, "1673450198": 329, "3853526235": 1673450198, "3456985752": 1673450198, "1311366798": 3456985752, "1126601402": 3456985752, "3966633210": 1673450198, "2812530569": 1673450198, "1641347046": 1673450198, "3416776496": 1673450198, "1626685236": 329, "3565367498": 1626685236, "2657138906": 1626685236, "1881029055": 2657138906, "3080022137": 2657138906, "1547817274": 1626685236, "2369238059": 1626685236, "2478012832": 1626685236, "2739084189": 1626685236, "3347675430": 329, "2006047173": 3347675430, "2180527067": 3347675430, "1456682260": 2180527067, "3562601313": 2180527067, "1970686062": 3347675430, "3890169311": 3347675430, "2936441103": 3347675430, "3215542274": 3347675430, "1521759875": 329, "3486673188": 1521759875, "3783583602": 1521759875, "3970522306": 3783583602, "1054221329": 3783583602, "3895794866": 1521759875, "1496257237": 1521759875, "2152572352": 1521759875, "3048883337": 1521759875, "1013068637": 329, "1337935688": 1013068637, "1667660763": 1013068637, "1558550786": 1667660763, "2563782304": 1667660763, "3219108088": 1013068637, "1420546517": 1013068637, "1945434117": 1013068637, "2866280389": 1013068637, "2072239244": 329, "1082141991": 2072239244, "2157537321": 2072239244, "2525505631": 2157537321, "1714311201": 2157537321, "2930307508": 2072239244, "3188993656": 2072239244, "1843338795": 2072239244, "3291535006": 2072239244, "2937437636": 329, "3835740469": 2937437636, "1125438717": 2937437636, "2629778705": 1125438717, "3581771805": 1125438717, "3877358805": 2937437636, "1667278413": 2937437636, "2743616995": 2937437636, "1093211310": 2937437636, "3404738524": 329, "2151167540": 3404738524, "2460702429": 3404738524, "3167765177": 2460702429, "1639524986": 2460702429, "1549069626": 3404738524, "3085221154": 3404738524, "2659044087": 3404738524, "2700046659": 3404738524, "2062992388": 329, "1246610280": 2062992388, "3880590912": 2062992388, "1035739465": 3880590912, "1105483506": 3880590912, "1792980078": 2062992388, "3556494715": 2062992388, "1706307657": 2062992388, "1869881498": 2062992388, "1261138116": 329, "2933568634": 1261138116, "2013207018": 1261138116, "1805611561": 2013207018, "3719447735": 2013207018, "2371017187": 1261138116, "3985188708": 1261138116, "3796365620": 1261138116, "1714819828": 1261138116, "1171261412": 329, "3724099631": 1171261412, "2833279579": 1171261412, "2558258359": 2833279579, "3859877696": 2833279579, "2108774369": 1171261412, "3320050311": 1171261412, "3628159968": 1171261412, "3638507875": 1171261412, "3329043535": 329, "1743009264": 3329043535, "1884779226": 3329043535, "3623254419": 1884779226, "1926976537": 1884779226, "2047390011": 3329043535, "2798287336": 3329043535, "2987319910": 3329043535, "3872485424": 3329043535, "2036081150": 329, "1781030954": 2036081150, "2841658580": 2036081150, "3521164295": 2841658580, "1876807310": 2841658580, "1501393228": 2036081150, "1972094100": 2036081150, "3302405705": 2036081150, "1099096371": 2036081150, "3202423327": 329, "1117257996": 3202423327, "1453537399": 3202423327, "2567067139": 1453537399, "2427348802": 1453537399, "3859818063": 3202423327, "1588504257": 3202423327, "3571205574": 3202423327, "1096265790": 3202423327, "1412541198": 329, "1326886999": 1412541198, "1787178465": 1412541198, "2341450864": 1787178465, "2551618170": 1787178465, "1170723867": 1412541198, "1038004598": 1412541198, "1149652689": 1412541198, "1582478571": 1412541198, "1588741938": 329, "1315950883": 1588741938, "1947266943": 1588741938, "3990698322": 1947266943, "3301183793": 1947266943, "1464978040": 1588741938, "2387503636": 1588741938, "2023633893": 1588741938, "1913328693": 1588741938, "3920024588": 329, "1877482733": 3920024588, "2358987890": 3920024588, "3673895945": 2358987890, "1393608993": 2358987890, "2978179471": 3920024588, "3338653017": 3920024588, "2384899589": 3920024588, "2710463424": 3920024588, "3055000922": 329, "1406402073": 3055000922, "1373744894": 3055000922, "1156116970": 1373744894, "3453175542": 1373744894, "3652474151": 3055000922, "2236457933": 3055000922, "3277826222": 3055000922, "1005899076": 3055000922, "2438939909": 329, "1691306271": 2438939909, "3434166213": 2438939909, "1275601165": 3434166213, "3946289800": 3434166213, "2004775342": 2438939909, "1456398198": 2438939909, "3561503481": 2438939909, "1901850664": 2438939909, "1071521092": 329, "3807479791": 1071521092, "2803418480": 1071521092, "2980820846": 2803418480, "3188360247": 2803418480, "1477785742": 1071521092, "2964598138": 1071521092, "3093795446": 1071521092, "1507784331": 1071521092, "3054551821": 329, "3748961581": 3054551821, "3128223634": 3054551821, "2185403483": 3128223634, "1433026796": 3128223634, "1104248884": 3054551821, "3545403109": 3054551821, "1536696383": 3054551821, "3527105324": 3054551821, "2811301625": 329, "1897015494": 2811301625, "3331790659": 2811301625, "2795738785": 3331790659, "2768475141": 3331790659, "2658097375": 2811301625, "2157528000": 2811301625, "3309772165": 2811301625, "1928393658": 2811301625, "3840818183": 329, "3841505448": 3840818183, "3999683881": 3840818183, "3325173834": 3999683881, "1798728430": 3999683881, "3299719941": 3840818183, "2360313730": 3840818183, "3043750963": 3840818183, "2641148319": 3840818183, "1468793762": 329, "1427961626": 1468793762, "1643593739": 1468793762, "3092405473": 1643593739, "1181035221": 1643593739, "3118601025": 1468793762, "2374653061": 1468793762, "3026302666": 1468793762, "2197459620": 1468793762, "1965375801": 329, "1666373161": 1965375801, "3620340000": 1965375801, "2815501138": 3620340000, "2091848107": 3620340000, "2658756176": 1965375801, "2097438884": 1965375801, "2868822451": 1965375801, "3331415743": 1965375801, "3618095278": 329, "2613674898": 3618095278, "1951878763": 3618095278, "3413451609": 1951878763, "2225157452": 1951878763, "2842134861": 3618095278, "2064317417": 3618095278, "2123772309": 3618095278, "1510133109": 3618095278, "1624778115": 329, "1094902124": 1624778115, "3134535128": 1624778115, "3312222592": 3134535128, "1518704958": 3134535128, "1475527815": 1624778115, "1612593605": 1624778115, "2915675742": 1624778115, "2644357350": 1624778115, "1171320182": 329, "2810081477": 1171320182, "3513655281": 1171320182, "2790136061": 3513655281, "2667261098": 3513655281, "2302833148": 1171320182, "3278290071": 1171320182, "2688781451": 1171320182, "1848522986": 1171320182, "2358040414": 507, "2561915647": 159, "3389528505": 597, "1860102496": 605, "1953921139": 814, "1668688439": 961, "1466095084": 639, "1992072790": 655, "1375046773": 663, "1203939479": 788, "1209357605": 566, "1024543562": 698, "2952544119": 843, "2063775638": 1037, "1580329576": 1084, "1792026161": 502, "2449182232": 484682470, "3263488087": 1089, "2416897036": 703, "3672106733": 754, "2445320853": 403, "3034756217": 477, "2791423253": 351, "2165415682": 803, "3009745967": 362, "1043765183": 178, "2614168502": 549, "2218808594": 332, "2869757686": 38, "1463730273": 830, "1690235425": 88, "3449035628": 525, "2254557934": 515, "3467149620": 63, "2723065947": 693, "1171543751": 797, "1842735199": 1097, "1040222935": 128, "3654510924": 10, "2956165934": 795, "2183090366": 100, "3101970431": 313, "2127067043": 612, "3409505442": 867, "2557684018": 679, "1140764290": 771, "2316153360": 651, "1593308392": 445, "2114704803": 1069, "1557651847": 354, "3092369320": 512, "1166850207": 848, "3944974149": 832, "3537828992": 911, "2176156825": 229, "3283016083": 798, "2434751741": 812, "2692485271": 326, "3140724988": 1123, "3228324150": 956, "2718688460": 784, "1428498274": 863, "2923485783": 301, "1060511842": 349, "2500193001": 1009, "1744978404": 81, "3774104740": 145, "1811993763": 997}, "children_ids": {"0": [0, 997], "10": [494, 503, 511, 3654510924], "1002": [1005, 251, 735, 816, 847, 954], "1003": [], "1005": [], "1006": [], "1009": [484682512, 960, 983, 991, 1000, 967, 2500193001], "1010": [], "1012": [], "1014": [321, 563807439, 178, 27], "1015": [], "1018": [1023, 520, 598, 755, 959, 990], "1021": [], "1023": [], "1024": [1032, 1040, 624], "1026": [], "1027": [249, 456, 643, 696, 759, 791], "1029": [], "1030": [], "1032": [1055, 1063, 1071, 1078], "1034": [], "1035": [], "1037481934": [], "1037502706": [], "104": [1101, 328, 783, 831, 996], "1040": [18, 25, 34, 49, 57, 65, 1087, 1095, 11, 1103, 1112, 1119, 3, 43], "1042": [], "1043": [], "1045": [], "1046": [], "1050": [], "1051": [], "1053": [1037502706, 2949903222], "1054": [], "1055": [], "1057": [148, 180, 187, 36, 638, 662], "1058": [], "1059": [], "1060": [], "1062": [], "1066": [1645194511, 2292194787], "1067": [], "10672": [], "10673": [], "10674": [], "10675": [], "10676": [], "10677": [], "10678": [], "10679": [], "1068": [722], "10680": [], "10681": [], "10682": [], "10683": [], "10684": [], "10685": [], "10686": [], "10687": [], "10688": [], "10689": [], "1069": [185, 193, 2114704803], "10690": [], "10691": [], "10692": [], "10693": [], "10694": [], "10695": [], "10696": [], "10697": [], "10698": [], "10699": [], "107": [], "10700": [], "10701": [], "10705": [], "10706": [], "10707": [], "10708": [], "10709": [], "10710": [], "10711": [], "10712": [], "10713": [], "10714": [], "10715": [], "10716": [], "10717": [], "10718": [], "10719": [], "10720": [], "10721": [], "10722": [], "10723": [], "10724": [], "10725": [], "10726": [], "10727": [], "10728": [], "10729": [], "10730": [], "10731": [], "10732": [], "10733": [], "10734": [], "10735": [], "10736": [], "10737": [], "1074": [], "1075": [], "1076": [], "1077": [], "1080": [19, 726, 982, 375], "1081": [], "1082": [], "1083": [595, 611, 730, 802], "1085": [], "1086": [], "1087129144": [], "109": [], "1090": [], "1091": [10720, 10721, 10722], "1094": [], "1096": [], "1098": [], "1099": [474, 530, 745, 466, 603, 618, 713, 737], "110": [], "1101": [], "1102": [], "1104": [], "1106": [2189208794, 3964792502], "1107": [], "1109": [], "111": [120, 163, 314, 344, 355], "1110": [], "1111": [], "1114": [], "1117": [358, 146, 147, 162, 238, 350, 604, 679], "1118": [], "112": [], "1120": [], "1121": [], "1124": [], "1125": [], "1127": [2439179873, 2854337283], "1128": [], "113": [1454256797, 2951747260], "1132": [398, 612, 7, 867], "1133": [], "1139": [], "1140": [], "1141": [], "1142": [], "1160721590": [], "1165809076": [], "119": [675, 694, 699, 704, 800], "120": [], "121": [], "123": [], "12993": [], "12994": [1355885073, 3937412080], "12995": [], "12996": [], "12997": [], "12998": [], "130": [], "1307372013": [], "132": [], "1355885073": [], "137": [], "139": [], "141": [1109, 1124, 576073699, 72, 80, 914, 126, 133, 263, 272, 286, 338, 347, 452, 523, 689, 763, 830], "142": [], "143": [], "1430875964": [], "1431942459": [], "144": [], "1454256797": [], "1463157755": [], "148": [], "150": [], "152": [], "154": [161, 169, 177], "156": [], "1598869030": [], "16": [], "160": [], "1624848466": [], "163": [1672280517, 2414821463], "1645194511": [], "166": [], "167": [], "1672280517": [], "168": [], "1695203883": [], "17": [], "171": [], "1720700944": [], "175": [], "1758306548": [], "179": [], "18": [], "180": [3683796018, 3893800328], "182": [762, 770, 779, 787], "182305689": [182305693, 182305697, 182305701, 182305705, 182305709, 182305713], "182305693": [], "182305697": [2208057363, 3808433473], "182305701": [], "182305705": [], "182305709": [], "182305713": [], "183": [], "185": [], "187": [], "1890964946": [], "1896413216": [], "190": [], "191": [], "192": [], "193": [], "1942628671": [], "195": [], "199": [], "2": [], "20": [], "200": [], "2012716980": [], "2026216612": [], "203": [], "205": [], "2078623765": [], "208": [], "21": [459, 538, 665], "2102386393": [], "211": [2862362532, 3095364455], "213": [], "215": [], "2153924985": [], "216": [], "2167613582": [], "2186168811": [], "2189208794": [], "219": [2546495501, 2906756445], "2208057363": [], "221": [], "2218254883": [], "2224619882": [], "224": [], "2260827822": [], "227": [], "2292194787": [], "2300544548": [], "232": [], "2336071181": [], "234": [], "2341154899": [], "236": [], "2361776473": [], "240": [], "241": [2300544548, 3360392253], "2413172686": [], "2414821463": [], "243": [], "2430059008": [], "2439179873": [], "244": [], "245": [], "248": [], "249": [], "25": [], "250": [], "251": [1165809076, 2927119608], "2511156654": [], "252": [], "253": [], "2536061413": [], "2542216029": [], "2544082156": [], "2546495501": [], "256": [], "258": [], "259": [], "2598818153": [], "26": [], "260": [], "264": [], "2646114338": [], "266": [], "2668242174": [], "267": [], "268": [], "2683995601": [], "269": [1431942459, 2153924985], "2691358660": [], "270": [], "271": [], "274": [], "276": [], "278": [1105, 23, 292, 536, 403], "2782023316": [], "279": [], "2790124484": [], "28": [], "281": [], "283": [], "2835688982": [], "284": [], "2845253318": [], "285": [627], "2854337283": [], "2862362532": [], "288": [2413172686, 3653590473], "2887815719": [], "289": [], "2892558637": [], "2897348183": [], "29": [], "2906756445": [], "291": [], "2927119608": [], "293": [], "294": [10, 17, 26, 42], "2949903222": [], "2951747260": [], "296": [2897348183, 3582239403], "297": [], "298": [], "2985091592": [], "299": [], "300": [], "302": [834, 842, 851], "303": [], "304": [195, 2790124484], "3049552521": [], "305": [], "306": [], "307": [], "308": [], "3088876178": [], "309": [], "3095364455": [], "3099716140": [], "311": [], "3114287561": [], "312": [], "312782546": [312782550, 312782554, 312782558, 312782562, 312782566, 312782570], "312782550": [], "312782554": [1160721590, 1695203883], "312782558": [], "312782562": [], "312782566": [], "312782570": [], "312782574": [312782578, 312782582, 312782586, 312782590, 312782594, 312782598], "312782578": [], "312782582": [2985091592, 3808183566], "312782586": [], "312782590": [], "312782594": [], "312782598": [], "312782604": [], "312782608": [1430875964, 3714509274], "312782612": [], "312782620": [], "312782624": [], "312782632": [], "312782636": [2782023316, 3920533696], "312782644": [], "312782648": [], "312782652": [], "3132124329": [], "314": [], "316": [], "318": [], "3192952047": [], "320": [], "3206763505": [], "321": [], "323": [294, 549009207, 549009211, 606826663, 607344830, 1100, 115, 128, 214, 231, 246, 35, 381, 58, 615, 616, 66, 749, 75, 757, 795, 975], "324": [], "3250982806": [], "3269661528": [], "327": [], "328": [3088876178, 3250982806], "330": [], "3314370483": [], "332": [432, 2218808594], "334": [], "335": [], "3360392253": [], "337": [1030, 1094, 1128, 113, 478, 510], "3376791707": [], "339": [271, 302, 599626923, 4, 460, 580, 874], "34": [], "340": [], "3403314552": [], "3412423041": [], "344": [], "345": [1102, 2, 657, 878, 950, 974], "346": [2691358660, 3099716140], "348": [1052, 165, 374], "349": [817, 825, 833, 1060511842], "3516629919": [], "352": [], "353": [558, 654, 702, 838, 889, 929], "355": [], "356": [], "3562104832": [], "358": [], "3582239403": [], "3582777032": [], "3591549811": [], "36": [], "360": [], "361": [1006, 1086, 1111, 461, 670, 9], "363": [], "364": [], "3653590473": [], "368": [], "3683796018": [], "369": [1026, 450, 577, 625, 854, 945], "3693772975": [], "37": [], "370": [1069, 154, 203, 307, 372, 576, 640, 76, 765, 781, 887, 1048, 106, 135, 136, 235, 395, 568, 653, 661, 701, 773, 83, 839, 852, 859, 938, 995], "371": [], "3710667749": [], "3714509274": [], "3718675619": [], "372": [], "3724992631": [], "373": [], "376": [], "377": [], "3781663036": [], "379": [206, 222, 230], "3803368771": [], "3808183566": [], "3808433473": [], "383": [], "386": [589508451, 789, 207, 429, 437, 445, 607, 642, 651, 720, 903], "387": [], "3880005807": [], "389": [], "3893800328": [], "3894563657": [], "39": [1015, 211, 919, 927, 935], "392": [], "3920533696": [], "3927629261": [], "393": [], "3937412080": [], "3956191525": [], "3962734174": [], "3964792502": [], "400": [], "401": [], "405": [], "408": [], "41": [2544082156, 3710667749], "410": [], "411": [], "412": [1037481934, 3803368771], "414": [], "416": [], "418": [], "419": [], "42": [], "420": [], "421": [], "422": [], "424": [], "426": [472, 480, 487], "427": [2218254883, 3516629919], "428": [], "430": [3314370483, 606], "434": [2361776473, 3956191525], "435": [], "44": [1054, 1081, 556, 707, 827], "440": [], "441": [], "442": [], "443": [], "444": [1077, 59, 362, 366], "448": [], "449": [], "45": [], "450": [], "451": [], "456": [], "457": [], "458": [], "459": [], "46": [690, 673, 681, 753], "461": [], "465": [], "468": [], "469": [], "472": [], "473": [], "474": [], "476": [], "478": [], "48": [296, 588, 772, 810, 819], "480": [], "480149202": [480149206, 480149210, 480149214, 480149218, 480149222, 480149226], "480149206": [], "480149210": [1720700944, 3582777032], "480149214": [], "480149218": [], "480149222": [], "480149226": [], "480149230": [480149234, 480149238, 480149242, 480149246, 480149250, 480149254], "480149234": [], "480149238": [1087129144, 1896413216], "480149242": [], "480149246": [], "480149250": [], "480149254": [], "480149258": [480149262, 480149266, 480149270, 480149274, 480149278, 480149282], "480149262": [], "480149266": [2026216612, 3206763505], "480149270": [], "480149274": [], "480149278": [], "480149282": [], "480149286": [480149290, 480149294, 480149298, 480149302, 480149306, 480149310], "480149290": [], "480149294": [2341154899, 3403314552], "480149298": [], "480149302": [], "480149306": [], "480149310": [], "480149314": [480149318, 480149322, 480149326, 480149330, 480149334, 480149338], "480149318": [], "480149322": [2887815719, 3114287561], "480149326": [], "480149330": [], "480149334": [], "480149338": [], "484": [], "484682470": [484682475, 484682492, 2449182232], "484682475": [484682479, 484682483, 484682487], "484682479": [], "484682483": [], "484682487": [], "484682492": [484682496, 484682500, 484682504], "484682496": [], "484682500": [], "484682504": [], "484682508": [], "484682512": [], "484682516": [], "484682520": [], "484682524": [], "484682528": [], "487": [], "488": [], "49": [], "490": [410, 404], "492": [3269661528, 3880005807], "494": [], "496": [], "496345664": [], "496345668": [], "496345672": [], "497": [], "498": [], "50": [], "500": [107, 219, 299, 644, 947, 985, 993], "503": [], "505": [], "508": [], "509": [829, 837, 845], "510": [], "511": [], "516": [], "517": [], "518": [853, 861, 870], "52": [], "520": [], "522": [], "524": [], "526": [], "526157192": [], "526157196": [], "526322264": [], "527": [], "527696977": [], "529": [], "53": [], "530": [], "531": [], "532": [], "534": [], "535": [], "537": [], "538": [], "539": [], "540": [], "542": [], "543": [], "544": [], "545": [], "546": [], "548": [], "549009199": [], "549009203": [], "549009207": [], "549009211": [], "549009215": [], "549009219": [], "549009223": [], "549009227": [], "55": [], "550": [], "551": [], "555": [], "556": [2078623765, 747], "558": [], "559": [], "560": [], "560581551": [], "560581555": [], "560581559": [], "560581563": [], "561": [2430059008, 3724992631], "562": [], "563": [], "563807435": [], "563807439": [], "565": [], "566": [1140, 1141, 1142, 517, 1209357605], "569": [], "57": [], "570": [], "572": [], "576": [], "576073699": [], "576073704": [], "577": [], "582": [2012716980, 524], "584": [], "585": [], "586": [], "588": [], "589508447": [], "589508451": [], "589508455": [], "59": [], "590": [], "591": [], "592": [], "593": [], "597": [1034, 1042, 1050, 1059, 297, 3389528505], "598": [], "599626923": [], "599626927": [], "60": [], "600": [2186168811, 3781663036], "601": [], "602": [], "605": [1067, 1075, 1082, 306, 1860102496], "606": [], "606826647": [], "606826651": [], "606826655": [], "606826659": [], "606826663": [], "607344830": [], "607344834": [], "607344838": [], "607344842": [], "607344846": [], "607344850": [], "607344854": [], "607344858": [], "607344862": [], "608": [], "609": [], "61": [], "610": [], "614454277": [], "620": [], "622": [], "624": [], "625": [], "627": [], "629": [], "630": [], "635": [], "638": [], "639": [192, 200, 208, 1466095084], "640": [], "643": [1307372013, 1598869030], "644": [], "646": [], "647": [655, 663], "648": [], "649": [], "65": [], "652": [], "654": [], "655": [216, 224, 232, 376, 584, 1992072790], "656": [], "657": [2102386393, 3049552521], "659": [], "660": [], "662": [], "663": [240, 248, 256, 383, 592, 1375046773], "664": [], "666": [], "667": [2536061413, 2646114338], "668": [], "670": [2260827822, 3562104832], "671": [], "675": [], "676": [], "677": [1010, 1058, 1106, 849, 857, 897], "68": [], "680": [], "682": [], "683": [], "684": [], "686": [], "687": [], "69": [], "690": [], "692": [], "694": [2224619882, 3376791707], "696": [], "698": [566, 788, 814, 151, 159, 507, 589, 619, 631, 961, 1024543562], "699": [], "70": [563, 547], "702": [], "703": [16, 131, 295, 319, 583, 780, 942, 2416897036], "704": [], "707": [], "712": [], "714": [264, 352, 476, 492, 516, 723, 731, 738, 746], "715": [], "719": [], "72": [], "722": [], "723": [412, 440, 448, 488, 630], "725": [], "727": [], "728": [], "729": [], "731": [484, 527696977, 582, 620, 910], "734": [742, 751, 758], "735": [], "736": [], "738": [], "739": [], "740": [], "742": [], "743": [], "745": [420], "746": [1125, 288, 608, 680, 969], "747": [], "748": [], "750": [], "751": [], "755": [1942628671, 2167613582], "756": [], "758": [], "759": [], "76": [], "760": [109, 142, 309, 102, 134, 317], "761": [], "762": [], "764": [], "765": [], "766": [775, 782, 790], "767": [], "768": [1099, 37, 884, 301, 892, 908, 940], "769": [], "77": [], "770": [], "772": [], "774": [], "775": [], "777": [], "779": [], "781": [], "782": [], "783": [], "785": [], "786": [], "787": [], "788": [400, 408, 416, 424, 1203939479], "789": [], "790": [], "791": [], "792": [932], "793": [], "799": [807, 815, 823], "8": [343, 512, 567], "80": [], "800": [], "801": [], "804": [], "805": [], "806": [2336071181, 2542216029], "807": [], "809": [287, 351], "810": [], "814": [267, 360, 496, 535, 646, 1953921139], "815": [], "816": [], "817": [], "819": [], "823": [], "824": [1068, 1083, 150, 166, 182, 349, 405, 46, 70, 174, 341, 54], "826": [581, 904], "827": [], "829": [], "831": [], "836": [], "837": [], "838": [1624848466, 3591549811], "84": [], "844": [], "845": [], "847": [], "849": [], "850": [], "853": [], "854": [1890964946, 3693772975], "855": [205, 213], "856": [1014, 444, 138, 239, 262, 51, 571, 958], "857": [], "86": [], "860": [], "861": [], "862": [], "864": [609, 1008, 1044, 406, 637], "865": [], "868": [], "87": [], "870": [], "873": [], "875": [], "878": [], "879": [274, 330, 434, 442, 545, 610], "882": [], "883": [], "884": [], "887": [], "888": [2668242174, 3132124329], "889": [], "89": [], "891": [], "893": [], "894": [279, 480149258, 480149286, 480149314, 671, 774, 906, 965], "895": [1045, 427, 836, 977, 988], "896": [484682520, 484682524, 86, 1092, 14, 365], "897": [], "899": [], "9": [], "902": [], "905": [1463157755, 2845253318], "906": [], "91": [], "910": [], "913": [], "914": [], "915": [], "919": [], "92": [], "921": [], "923": [], "925": [], "927": [], "929": [], "932": [522, 570, 586, 514, 697, 858], "935": [], "937": [], "939": [], "943": [1758306548, 3718675619], "945": [], "947": [], "95": [104, 111, 119], "950": [], "952": [], "954": [], "955": [], "956": [579, 3228324150], "959": [], "960": [728, 744, 752], "962": [2511156654, 3412423041], "963": [], "964": [], "965": [2892558637, 3192952047], "966": [], "969": [], "97": [], "971": [], "973": [3927629261, 3962734174], "974": [], "977": [], "983": [896, 776, 784], "987": [283, 318, 534, 549009215, 549009219, 549009223, 549009227, 599626927, 1093, 280, 462, 552, 574, 621, 880, 898, 931], "988": [], "990": [], "991": [768, 824], "996": [], "999": [], "1": [], "100": [607344834, 607344838, 607344842, 607344846, 607344850, 607344854, 607344858, 607344862, 2183090366], "1000": [736, 760, 855, 863, 877, 941], "1001": [10717, 10718, 10719], "1004": [], "1007": [10672, 10673, 10674], "1008": [170, 475], "101": [], "1011": [156, 243, 252, 480149230, 527, 600, 678], "1016": [], "1017": [1056, 1064], "1019": [], "102": [], "1020": [], "1022": [], "1025": [10681, 10682, 10683], "1028": [], "103": [652, 660], "1031": [], "1033": [10684, 10685, 10686], "1036": [], "1037": [10696, 10697, 10698, 2063775638], "1038": [], "1039": [], "1041": [10687, 10688, 10689], "1044": [], "1047": [], "1048": [], "1049": [10690, 10691, 10692], "105": [], "1052": [], "1056": [10675, 10676, 10677], "106": [], "1061": [], "1063": [], "1064": [10678, 10679, 10680], "1065": [354, 771], "10671": [], "1070": [], "10702": [], "10703": [], "10704": [], "1071": [], "1072": [], "1073": [1007, 1017, 1025, 1033, 1041, 1049], "1078": [], "1079": [], "108": [], "1084": [10699, 10700, 10701, 1580329576], "1087": [], "1088": [], "1089": [1080, 822, 3263488087], "1092": [], "1093": [], "1095": [], "1097": [141, 10671, 157, 290, 467, 1842735199], "11": [], "1100": [215, 531, 549009203, 1061, 628, 634, 706], "1103": [], "1105": [], "1108": [], "1112": [], "1113": [], "1116": [], "1119": [], "1123": [490, 499, 553, 650, 3140724988], "1126": [], "1129": [1097, 549], "1131": [], "114": [], "1143": [], "1144": [], "1145": [], "115": [], "116": [], "117": [], "118": [], "12": [], "122": [], "124": [], "125": [], "126": [], "127": [1096, 1104], "128": [539, 548, 555, 1040222935], "129": [], "131": [], "133": [], "134": [], "135": [143, 939], "136": [], "138": [1029, 560581551, 560581555, 1020, 218, 325], "14": [], "140": [], "145": [153, 3774104740], "146": [], "147": [], "149": [], "15": [], "151": [188, 196, 204], "153": [], "155": [], "157": [332, 118, 223, 30, 38, 390], "158": [], "159": [160, 167, 168, 175, 183, 191, 199, 2561915647], "161": [], "162": [], "164": [], "165": [591, 100, 12, 197, 872], "169": [], "170": [496345664, 496345668, 496345672], "173": [], "174": [], "177": [], "178": [300, 316, 1043765183], "181": [], "184": [526157192, 526157196, 526322264, 667, 68], "186": [], "188": [], "189": [], "19": [], "194": [], "196": [], "197": [], "198": [], "201": [2598818153, 2835688982], "202": [], "204": [], "206": [], "207": [], "209": [], "210": [], "212": [], "214": [], "217": [], "218": [], "22": [241, 308, 312782546, 340, 532, 635, 683, 417], "220": [], "222": [], "223": [], "225": [], "226": [], "228": [], "229": [705, 794, 2176156825], "23": [], "230": [], "231": [], "233": [], "235": [955, 963], "237": [], "238": [], "239": [1120, 1113, 127, 155, 255, 64], "242": [250, 258, 266], "246": [], "247": [1002, 1018, 1027, 1011], "254": [879, 894, 886], "255": [], "257": [], "261": [], "262": [], "263": [], "27": [], "272": [], "275": [242, 310, 333], "277": [], "280": [], "286": [], "287": [], "290": [356, 364, 576073704, 173, 194, 226, 470, 614, 797], "292": [], "295": [303, 311, 451], "3": [], "30": [], "301": [484682528, 2923485783], "304325711": [], "31": [1053, 179, 227, 39, 48, 572, 739], "310": [], "312782616": [], "312782628": [312782632, 312782636, 312782644, 312782648, 312782652, 312782640], "312782640": [], "313": [323, 339, 348, 3101970431], "317": [], "319": [327, 334], "325": [], "326": [850, 812, 866, 2692485271], "329": [1062, 480149202, 1038, 1047, 1070, 201, 981, 1370229894, 3651721123, 2732283703, 3896406483, 2525641171, 1673450198, 1626685236, 3347675430, 1521759875, 1013068637, 2072239244, 2937437636, 3404738524, 2062992388, 1261138116, 1171261412, 3329043535, 2036081150, 3202423327, 1412541198, 1588741938, 3920024588, 3055000922, 2438939909, 1071521092, 3054551821, 2811301625, 3840818183, 1468793762, 1965375801, 3618095278, 1624778115, 1171320182], "33": [], "331": [210, 491, 525, 557], "333": [], "336": [], "338": [], "341": [], "342": [], "343": [1065, 1129, 313], "347": [], "35": [], "350": [], "351": [359, 367, 2791423253], "354": [370, 379, 386, 1557651847], "357": [], "359": [498, 505, 529, 537, 546, 562, 513, 521, 554], "362": [617, 626, 636, 3009745967], "365": [], "366": [], "367": [569, 585, 602, 578, 594], "374": [], "378": [1035, 1090, 806, 862, 873, 893], "38": [71, 94, 2869757686], "380": [], "381": [], "382": [391, 399, 407, 415], "384": [], "385": [305, 593, 33, 721, 778, 821], "388": [], "390": [], "391": [], "394": [1046, 1066, 281, 401, 441, 433], "395": [1098, 1107], "396": [], "397": [], "398": [105, 114, 122], "399": [], "4": [811, 820, 828], "402": [1074, 1114, 601, 649, 905, 233], "404": [], "406": [414, 422], "407": [], "409": [121, 421, 973, 573, 613, 74], "413": [], "415": [], "417": [312782604, 312782608, 312782612, 312782620, 312782624, 312782616], "423": [431, 438, 446, 454], "425": [269, 377, 393, 750, 902, 869], "429": [], "43": [], "431": [], "432": [], "433": [], "436": [], "437": [], "438": [], "439": [], "445": [45, 53, 61, 69, 77, 1593308392], "446": [], "447": [], "452": [], "453": [12993, 12994, 12995, 12996, 12997, 12998, 378, 322], "454": [], "455": [], "460": [], "462": [], "463": [471, 479, 486, 495, 504], "464": [], "466": [], "467": [1004, 331, 515, 63, 693, 88, 946, 980], "47": [], "470": [], "471": [], "475": [1072, 1079, 1088], "477": [278, 275, 485, 493, 3034756217], "479": [], "481": [], "482": [], "483": [], "485": [672], "486": [], "489": [], "491": [606826647, 606826651, 606826655, 606826659, 732], "493": [549009199, 56, 754, 998], "495": [], "499": [], "501": [], "502": [509, 518, 1792026161], "504": [], "506": [], "507": [236, 244, 212, 220, 228, 2358040414], "51": [560581563, 189, 575, 599, 907, 930], "512": [519, 528, 3092369320], "513": [], "514": [380, 388, 396], "515": [740, 748, 756, 2254557934], "519": [589508455, 91, 846, 989], "521": [], "523": [], "525": [1110, 1118, 3449035628], "528": [1073, 1143, 1144, 1145, 645], "533": [41, 469, 565, 805, 257, 501], "536": [544, 551, 559], "54": [], "541": [1127, 234, 289, 729, 786, 97], "547": [], "549": [856, 864, 2614168502], "552": [], "553": [], "554": [], "557": [1, 1126], "56": [], "564": [], "567": [623, 688], "568": [], "571": [560581559, 149, 15, 181], "573": [], "574": [], "575": [], "578": [], "579": [], "58": [], "580": [], "581": [], "583": [], "587": [], "589": [597, 605], "594": [], "595": [], "596": [], "599": [], "6": [], "603": [], "604": [], "607": [112, 560, 101, 96], "611": [], "612": [82, 90, 99, 2127067043], "613": [], "614": [], "615": [], "616": [], "617": [], "618": [443, 449], "619": [1139, 260, 268, 392], "62": [], "621": [], "623": [477, 803], "626": [], "628": [], "63": [439, 447, 455, 464, 3467149620], "631": [639, 647], "632": [], "633": [], "634": [], "636": [], "637": [563807435, 629, 685, 709], "64": [], "641": [], "642": [], "645": [912, 920, 928, 936, 944, 951, 957, 968], "650": [], "651": [659, 666, 682, 674, 691, 2316153360], "653": [], "658": [], "66": [], "661": [], "665": [], "669": [312782574, 457, 497, 561, 801, 913, 937, 312782628, 385, 394, 402, 409, 425, 533], "67": [], "672": [], "673": [], "674": [], "678": [], "679": [130, 137, 2557684018], "681": [], "685": [], "688": [703, 695], "689": [], "691": [], "693": [761, 769, 777, 785, 2723065947], "695": [698, 1089, 315], "697": [], "7": [], "700": [], "701": [202, 209, 217, 225], "705": [], "706": [], "708": [], "709": [725, 718, 733, 741], "71": [103, 47, 79], "710": [], "711": [], "713": [], "716": [], "717": [], "718": [], "720": [1039, 711], "721": [], "724": [], "726": [734, 766, 799, 10702, 10703, 10704, 632], "73": [124, 129, 140, 145, 164, 81], "730": [], "732": [], "733": [], "737": [428, 436], "74": [], "741": [], "744": [], "749": [], "75": [], "753": [], "754": [144, 458, 465, 473, 481, 489, 3672106733], "757": [], "763": [], "771": [1117, 1132, 987, 1140764290], "773": [], "776": [484682516, 956, 964, 971, 1108, 979, 986], "778": [], "78": [], "780": [], "784": [1003, 1012, 190, 1019, 1028, 1036, 198, 6, 924, 994, 2718688460], "79": [], "794": [], "795": [50, 614454277, 587, 67, 2956165934], "796": [], "797": [804, 796, 1171543751], "798": [1116, 1131, 3283016083], "802": [], "803": [809, 826, 818, 835, 2165415682], "808": [], "81": [89, 108, 116, 98, 1744978404], "811": [], "812": [85, 2434751741], "813": [], "818": [1022, 1031], "82": [], "820": [], "821": [2683995601, 3894563657], "822": [484682470, 484682508, 589508447, 1037, 1084, 502, 843, 909], "825": [], "828": [], "83": [], "830": [668, 676, 684, 1463730273], "832": [158, 62, 3944974149], "833": [], "834": [], "835": [298, 342], "839": [], "840": [21, 1016, 900], "841": [], "842": [], "843": [10693, 10694, 10695, 2952544119], "846": [], "848": [117, 125, 336, 357, 876, 916, 1166850207], "85": [], "851": [], "852": [], "858": [], "859": [], "863": [221, 397, 1428498274], "866": [], "867": [123, 881, 890, 3409505442], "869": [], "871": [245, 253, 270, 285, 29, 293, 389, 261, 277], "872": [], "874": [], "876": [], "877": [1043, 1051, 1060], "88": [700, 708, 716, 724, 1690235425], "880": [], "881": [860, 868, 875, 883, 891], "885": [], "886": [430, 542, 590, 622, 687], "890": [899, 915, 923], "892": [], "898": [], "90": [], "900": [], "901": [229, 93], "903": [], "904": [564, 596], "907": [], "908": [], "909": [918, 926, 934], "911": [384, 3537828992], "912": [10705, 10706, 10707], "916": [], "917": [237], "918": [1121, 139, 20, 28, 312, 387, 52, 60, 715, 764, 92, 999], "920": [976, 984], "922": [335, 368, 540, 692, 888], "924": [], "926": [468, 508, 526, 543, 550, 664, 712, 727, 743], "928": [1091, 1001, 992], "93": [], "930": [], "931": [], "933": [1076, 413, 948], "934": [1133, 259, 324, 371, 419], "936": [10723, 10724, 10725], "938": [970, 978], "94": [110, 55, 87], "940": [], "941": [], "942": [952, 966], "944": [10726, 10727, 10728], "946": [], "948": [482, 506, 633, 641, 658, 841], "949": [], "951": [10729, 10730, 10731], "953": [], "957": [10732, 10733, 10734], "958": [186, 483, 953], "96": [], "961": [152, 276, 284, 291, 1668688439], "967": [792, 925, 710, 717, 798, 808, 813, 832, 840, 848, 871, 885, 901, 911, 917, 933, 949], "968": [10735, 10736, 10737], "970": [], "972": [132, 171, 304, 363, 84], "975": [], "976": [10708, 10709, 10710], "978": [], "979": [], "98": [], "980": [], "981": [], "982": [], "984": [10711, 10712, 10713], "985": [320, 648, 844, 882, 943], "986": [], "989": [], "99": [], "992": [10714, 10715, 10716], "993": [1021, 1085, 656, 767, 962], "994": [], "995": [], "997": [1009, 1024, 8, 304325711, 73, 1811993763], "998": [], "375": [382, 423, 463], "403": [411, 418, 426, 435, 2445320853], "752": [373, 1123, 326, 78], "315": [1057, 44, 500, 677, 714, 895, 95, 184, 22, 247, 254, 31, 453, 541, 669, 922, 972], "322": [182305689, 337, 345, 346, 353, 361, 369, 686, 719, 793, 865, 921, 329], "1370229894": [1344105173, 2615618683, 1315119484, 2436888515, 3577346235, 3902978127], "1344105173": [], "2615618683": [3116469840, 3379356047], "3116469840": [], "3379356047": [], "1315119484": [], "2436888515": [], "3577346235": [], "3902978127": [], "3651721123": [1310126712, 1446874462, 3685934448, 3575805529, 1210837267, 1258169895], "1310126712": [], "1446874462": [3324056088, 2593521448], "3324056088": [], "2593521448": [], "3685934448": [], "3575805529": [], "1210837267": [], "1258169895": [], "2732283703": [1994494334, 1447791371, 3139552203, 2692580507, 1677451927, 3379749055], "1994494334": [], "1447791371": [2590882612, 3761146439], "2590882612": [], "3761146439": [], "3139552203": [], "2692580507": [], "1677451927": [], "3379749055": [], "3896406483": [2835342929, 1897248316, 2168807353, 3137025327, 2406188897, 3670777223], "2835342929": [], "1897248316": [3173729836, 3926962776], "3173729836": [], "3926962776": [], "2168807353": [], "3137025327": [], "2406188897": [], "3670777223": [], "2525641171": [1516851569, 3913053667, 3495145594, 1644849336, 3289019263, 2194674250], "1516851569": [], "3913053667": [2196657368, 3986345576], "2196657368": [], "3986345576": [], "3495145594": [], "1644849336": [], "3289019263": [], "2194674250": [], "1673450198": [3853526235, 3456985752, 3966633210, 2812530569, 1641347046, 3416776496], "3853526235": [], "3456985752": [1311366798, 1126601402], "1311366798": [], "1126601402": [], "3966633210": [], "2812530569": [], "1641347046": [], "3416776496": [], "1626685236": [3565367498, 2657138906, 1547817274, 2369238059, 2478012832, 2739084189], "3565367498": [], "2657138906": [1881029055, 3080022137], "1881029055": [], "3080022137": [], "1547817274": [], "2369238059": [], "2478012832": [], "2739084189": [], "3347675430": [2006047173, 2180527067, 1970686062, 3890169311, 2936441103, 3215542274], "2006047173": [], "2180527067": [1456682260, 3562601313], "1456682260": [], "3562601313": [], "1970686062": [], "3890169311": [], "2936441103": [], "3215542274": [], "1521759875": [3486673188, 3783583602, 3895794866, 1496257237, 2152572352, 3048883337], "3486673188": [], "3783583602": [3970522306, 1054221329], "3970522306": [], "1054221329": [], "3895794866": [], "1496257237": [], "2152572352": [], "3048883337": [], "1013068637": [1337935688, 1667660763, 3219108088, 1420546517, 1945434117, 2866280389], "1337935688": [], "1667660763": [1558550786, 2563782304], "1558550786": [], "2563782304": [], "3219108088": [], "1420546517": [], "1945434117": [], "2866280389": [], "2072239244": [1082141991, 2157537321, 2930307508, 3188993656, 1843338795, 3291535006], "1082141991": [], "2157537321": [2525505631, 1714311201], "2525505631": [], "1714311201": [], "2930307508": [], "3188993656": [], "1843338795": [], "3291535006": [], "2937437636": [3835740469, 1125438717, 3877358805, 1667278413, 2743616995, 1093211310], "3835740469": [], "1125438717": [2629778705, 3581771805], "2629778705": [], "3581771805": [], "3877358805": [], "1667278413": [], "2743616995": [], "1093211310": [], "3404738524": [2151167540, 2460702429, 1549069626, 3085221154, 2659044087, 2700046659], "2151167540": [], "2460702429": [3167765177, 1639524986], "3167765177": [], "1639524986": [], "1549069626": [], "3085221154": [], "2659044087": [], "2700046659": [], "2062992388": [1246610280, 3880590912, 1792980078, 3556494715, 1706307657, 1869881498], "1246610280": [], "3880590912": [1035739465, 1105483506], "1035739465": [], "1105483506": [], "1792980078": [], "3556494715": [], "1706307657": [], "1869881498": [], "1261138116": [2933568634, 2013207018, 2371017187, 3985188708, 3796365620, 1714819828], "2933568634": [], "2013207018": [1805611561, 3719447735], "1805611561": [], "3719447735": [], "2371017187": [], "3985188708": [], "3796365620": [], "1714819828": [], "1171261412": [3724099631, 2833279579, 2108774369, 3320050311, 3628159968, 3638507875], "3724099631": [], "2833279579": [2558258359, 3859877696], "2558258359": [], "3859877696": [], "2108774369": [], "3320050311": [], "3628159968": [], "3638507875": [], "3329043535": [1743009264, 1884779226, 2047390011, 2798287336, 2987319910, 3872485424], "1743009264": [], "1884779226": [3623254419, 1926976537], "3623254419": [], "1926976537": [], "2047390011": [], "2798287336": [], "2987319910": [], "3872485424": [], "2036081150": [1781030954, 2841658580, 1501393228, 1972094100, 3302405705, 1099096371], "1781030954": [], "2841658580": [3521164295, 1876807310], "3521164295": [], "1876807310": [], "1501393228": [], "1972094100": [], "3302405705": [], "1099096371": [], "3202423327": [1117257996, 1453537399, 3859818063, 1588504257, 3571205574, 1096265790], "1117257996": [], "1453537399": [2567067139, 2427348802], "2567067139": [], "2427348802": [], "3859818063": [], "1588504257": [], "3571205574": [], "1096265790": [], "1412541198": [1326886999, 1787178465, 1170723867, 1038004598, 1149652689, 1582478571], "1326886999": [], "1787178465": [2341450864, 2551618170], "2341450864": [], "2551618170": [], "1170723867": [], "1038004598": [], "1149652689": [], "1582478571": [], "1588741938": [1315950883, 1947266943, 1464978040, 2387503636, 2023633893, 1913328693], "1315950883": [], "1947266943": [3990698322, 3301183793], "3990698322": [], "3301183793": [], "1464978040": [], "2387503636": [], "2023633893": [], "1913328693": [], "3920024588": [1877482733, 2358987890, 2978179471, 3338653017, 2384899589, 2710463424], "1877482733": [], "2358987890": [3673895945, 1393608993], "3673895945": [], "1393608993": [], "2978179471": [], "3338653017": [], "2384899589": [], "2710463424": [], "3055000922": [1406402073, 1373744894, 3652474151, 2236457933, 3277826222, 1005899076], "1406402073": [], "1373744894": [1156116970, 3453175542], "1156116970": [], "3453175542": [], "3652474151": [], "2236457933": [], "3277826222": [], "1005899076": [], "2438939909": [1691306271, 3434166213, 2004775342, 1456398198, 3561503481, 1901850664], "1691306271": [], "3434166213": [1275601165, 3946289800], "1275601165": [], "3946289800": [], "2004775342": [], "1456398198": [], "3561503481": [], "1901850664": [], "1071521092": [3807479791, 2803418480, 1477785742, 2964598138, 3093795446, 1507784331], "3807479791": [], "2803418480": [2980820846, 3188360247], "2980820846": [], "3188360247": [], "1477785742": [], "2964598138": [], "3093795446": [], "1507784331": [], "3054551821": [3748961581, 3128223634, 1104248884, 3545403109, 1536696383, 3527105324], "3748961581": [], "3128223634": [2185403483, 1433026796], "2185403483": [], "1433026796": [], "1104248884": [], "3545403109": [], "1536696383": [], "3527105324": [], "2811301625": [1897015494, 3331790659, 2658097375, 2157528000, 3309772165, 1928393658], "1897015494": [], "3331790659": [2795738785, 2768475141], "2795738785": [], "2768475141": [], "2658097375": [], "2157528000": [], "3309772165": [], "1928393658": [], "3840818183": [3841505448, 3999683881, 3299719941, 2360313730, 3043750963, 2641148319], "3841505448": [], "3999683881": [3325173834, 1798728430], "3325173834": [], "1798728430": [], "3299719941": [], "2360313730": [], "3043750963": [], "2641148319": [], "1468793762": [1427961626, 1643593739, 3118601025, 2374653061, 3026302666, 2197459620], "1427961626": [], "1643593739": [3092405473, 1181035221], "3092405473": [], "1181035221": [], "3118601025": [], "2374653061": [], "3026302666": [], "2197459620": [], "1965375801": [1666373161, 3620340000, 2658756176, 2097438884, 2868822451, 3331415743], "1666373161": [], "3620340000": [2815501138, 2091848107], "2815501138": [], "2091848107": [], "2658756176": [], "2097438884": [], "2868822451": [], "3331415743": [], "3618095278": [2613674898, 1951878763, 2842134861, 2064317417, 2123772309, 1510133109], "2613674898": [], "1951878763": [3413451609, 2225157452], "3413451609": [], "2225157452": [], "2842134861": [], "2064317417": [], "2123772309": [], "1510133109": [], "1624778115": [1094902124, 3134535128, 1475527815, 1612593605, 2915675742, 2644357350], "1094902124": [], "3134535128": [3312222592, 1518704958], "3312222592": [], "1518704958": [], "1475527815": [], "1612593605": [], "2915675742": [], "2644357350": [], "1171320182": [2810081477, 3513655281, 2302833148, 3278290071, 2688781451, 1848522986], "2810081477": [], "3513655281": [2790136061, 2667261098], "2790136061": [], "2667261098": [], "2302833148": [], "3278290071": [], "2688781451": [], "1848522986": [], "2358040414": [], "2561915647": [], "3389528505": [], "1860102496": [], "1953921139": [], "1668688439": [], "1466095084": [], "1992072790": [], "1375046773": [], "1203939479": [], "1209357605": [], "1024543562": [], "2952544119": [], "2063775638": [], "1580329576": [], "1792026161": [], "2449182232": [], "3263488087": [], "2416897036": [], "3672106733": [], "2445320853": [], "3034756217": [], "2791423253": [], "2165415682": [], "3009745967": [], "1043765183": [], "2614168502": [], "2218808594": [], "2869757686": [], "1463730273": [], "1690235425": [], "3449035628": [], "2254557934": [], "3467149620": [], "2723065947": [], "1171543751": [], "1842735199": [], "1040222935": [], "3654510924": [], "2956165934": [], "2183090366": [], "3101970431": [], "2127067043": [], "3409505442": [], "2557684018": [], "1140764290": [], "2316153360": [], "1593308392": [], "2114704803": [], "1557651847": [], "3092369320": [], "1166850207": [], "3944974149": [], "3537828992": [], "2176156825": [], "3283016083": [], "2434751741": [], "2692485271": [], "3140724988": [], "3228324150": [], "2718688460": [], "1428498274": [], "2923485783": [], "1060511842": [], "2500193001": [], "1744978404": [], "3774104740": [], "1811993763": []}} diff --git a/tests/data/get_traces.json b/tests/data/get_traces.json new file mode 100644 index 0000000..1dc0011 --- /dev/null +++ b/tests/data/get_traces.json @@ -0,0 +1,201 @@ +{ + "hits": { + "hits": [ + { + "_id": "https://bbp.epfl.ch/data/demo/morpho-demo/1761e604-03fc-452b-9bf2-2214782bb751", + "_index": "nexus_search_d067e019-1398-4eb8-9e28-3af8a717dcd7_41b1545a-cb2e-4848-8c74-195ec79f7bd3_18", + "_score": 6.0860696, + "_source": { + "@id": "https://bbp.epfl.ch/data/demo/morpho-demo/1761e604-03fc-452b-9bf2-2214782bb751", + "@type": [ + "https://neuroshapes.org/Trace", + "https://bbp.epfl.ch/ontologies/core/bmo/ExperimentalTrace" + ], + "brainRegion": { + "@id": "http://api.brain-map.org/api/v2/data/Structure/382", + "idLabel": "http://api.brain-map.org/api/v2/data/Structure/382|Field CA1", + "identifier": "http://api.brain-map.org/api/v2/data/Structure/382", + "label": "Field CA1" + }, + "contributors": [ + { + "@id": "https://bbp.epfl.ch/neurosciencegraph/data/859a20a2-9eea-4ab5-9504-d080c3c79311", + "@type": [ + "http://www.w3.org/ns/prov#Agent", + "http://schema.org/Person" + ], + "idLabel": "https://bbp.epfl.ch/neurosciencegraph/data/859a20a2-9eea-4ab5-9504-d080c3c79311|Zsolt Kohus", + "label": "Zsolt Kohus" + } + ], + "createdAt": "2024-04-09T21:47:08.569Z", + "createdBy": "https://bbp.epfl.ch/nexus/v1/realms/bbp/users/budd", + "deprecated": false, + "derivation": [ + { + "@type": [ + "http://www.w3.org/ns/prov#Entity", + "https://neuroshapes.org/PatchedCell" + ], + "identifier": "https://bbp.epfl.ch/data/data/demo/morpho-demo/7173ea54-8e59-478d-85f3-ff86246ef22b", + "label": "s160106_0201" + } + ], + "distribution": [ + { + "contentSize": 4919928, + "contentUrl": "https://bbp.epfl.ch/nexus/v1/files/demo/morpho-demo/https%3A%2F%2Fbbp.epfl.ch%2Fdata%2Fdata%2Fdemo%2Fmorpho-demo%2F01dffb7b-1122-4e1a-9acf-837e683da4ba", + "encodingFormat": "application/nwb", + "label": "s160106_02.nwb" + } + ], + "generation": { + "@id": "https://bbp.epfl.ch/data/demo/morpho-demo/d857ff82-b558-4117-b643-74d44dbda5e6", + "endedAt": "2016-01-06T23:59:00.000Z", + "startedAt": "2016-01-06T00:00:00.000Z" + }, + "image": [ + { + "@id": "https://bbp.epfl.ch/neurosciencegraph/data/https%3A%2F%2Fbbp.epfl.ch%2Fdata%2Fdata%2Fdemo%2Fmorpho-demo%2Fc1ef69e4-0073-481d-9446-fcfd7b5b5f7a", + "about": "https://neuroshapes.org/StimulationTrace", + "identifier": "https://bbp.epfl.ch/neurosciencegraph/data/https%3A%2F%2Fbbp.epfl.ch%2Fdata%2Fdata%2Fdemo%2Fmorpho-demo%2Fc1ef69e4-0073-481d-9446-fcfd7b5b5f7a", + "repetition": 0, + "stimulusType": "square" + }, + { + "@id": "https://bbp.epfl.ch/neurosciencegraph/data/https%3A%2F%2Fbbp.epfl.ch%2Fdata%2Fdata%2Fdemo%2Fmorpho-demo%2F6fd03e92-b2ae-4ab8-8c9e-f0ff52d20a0d", + "about": "https://neuroshapes.org/ResponseTrace", + "identifier": "https://bbp.epfl.ch/neurosciencegraph/data/https%3A%2F%2Fbbp.epfl.ch%2Fdata%2Fdata%2Fdemo%2Fmorpho-demo%2F6fd03e92-b2ae-4ab8-8c9e-f0ff52d20a0d", + "repetition": 0, + "stimulusType": "square" + } + ], + "name": "s160106_02", + "project": { + "@id": "https://bbp.epfl.ch/nexus/v1/projects/demo/morpho-demo", + "identifier": "https://bbp.epfl.ch/nexus/v1/projects/demo/morpho-demo", + "label": "demo/morpho-demo" + }, + "subjectAge": { + "label": "59 days Post-natal", + "period": "Post-natal", + "unit": "days", + "value": 59 + }, + "subjectSpecies": { + "@id": "http://purl.obolibrary.org/obo/NCBITaxon_10090", + "identifier": "http://purl.obolibrary.org/obo/NCBITaxon_10090", + "label": "Mus musculus" + }, + "updatedAt": "2024-04-10T08:40:23.039Z", + "updatedBy": "https://bbp.epfl.ch/nexus/v1/realms/bbp/users/cgonzale", + "_self": "https://bbp.epfl.ch/nexus/v1/resources/demo/morpho-demo/_/https:%2F%2Fbbp.epfl.ch%2Fdata%2Fdemo%2Fmorpho-demo%2F1761e604-03fc-452b-9bf2-2214782bb751" + } + }, + { + "_id": "https://bbp.epfl.ch/data/demo/morpho-demo/5f710291-9aac-45d0-94ff-d3d318c6ba2f", + "_index": "nexus_search_d067e019-1398-4eb8-9e28-3af8a717dcd7_41b1545a-cb2e-4848-8c74-195ec79f7bd3_18", + "_score": 6.0860696, + "_source": { + "@id": "https://bbp.epfl.ch/data/demo/morpho-demo/5f710291-9aac-45d0-94ff-d3d318c6ba2f", + "@type": [ + "https://neuroshapes.org/Trace", + "https://bbp.epfl.ch/ontologies/core/bmo/ExperimentalTrace" + ], + "brainRegion": { + "@id": "http://api.brain-map.org/api/v2/data/Structure/382", + "idLabel": "http://api.brain-map.org/api/v2/data/Structure/382|Field CA1", + "identifier": "http://api.brain-map.org/api/v2/data/Structure/382", + "label": "Field CA1" + }, + "contributors": [ + { + "@id": "https://bbp.epfl.ch/neurosciencegraph/data/859a20a2-9eea-4ab5-9504-d080c3c79311", + "@type": [ + "http://www.w3.org/ns/prov#Agent", + "http://schema.org/Person" + ], + "idLabel": "https://bbp.epfl.ch/neurosciencegraph/data/859a20a2-9eea-4ab5-9504-d080c3c79311|Zsolt Kohus", + "label": "Zsolt Kohus" + } + ], + "createdAt": "2024-04-10T13:58:37.803Z", + "createdBy": "https://bbp.epfl.ch/nexus/v1/realms/bbp/users/budd", + "deprecated": false, + "derivation": [ + { + "@type": [ + "https://neuroshapes.org/PatchedCell", + "http://www.w3.org/ns/prov#Entity" + ], + "identifier": "https://bbp.epfl.ch/data/demo/morpho-demo/c5c68107-39e5-4124-8681-8a644ab0f8ce", + "label": "s160106_0201" + } + ], + "distribution": [ + { + "contentSize": 4919928, + "contentUrl": "https://bbp.epfl.ch/nexus/v1/files/demo/morpho-demo/https%3A%2F%2Fbbp.epfl.ch%2Fdata%2Fdemo%2Fmorpho-demo%2Ff759098b-3db5-4236-a577-eac23191e063", + "encodingFormat": "application/nwb", + "label": "s160106_02.nwb" + } + ], + "generation": { + "@id": "https://bbp.epfl.ch/data/demo/morpho-demo/d47968ad-7644-4153-9bf5-a68277f9e17e", + "endedAt": "2016-01-06T23:59:00.000Z", + "startedAt": "2016-01-06T00:00:00.000Z" + }, + "image": [ + { + "@id": "https://bbp.epfl.ch/neurosciencegraph/data/https%3A%2F%2Fbbp.epfl.ch%2Fdata%2Fdemo%2Fmorpho-demo%2F835b1f06-c688-4771-9e45-c9c091666a8d", + "about": "https://neuroshapes.org/ResponseTrace", + "identifier": "https://bbp.epfl.ch/neurosciencegraph/data/https%3A%2F%2Fbbp.epfl.ch%2Fdata%2Fdemo%2Fmorpho-demo%2F835b1f06-c688-4771-9e45-c9c091666a8d", + "repetition": 0, + "stimulusType": "square" + }, + { + "@id": "https://bbp.epfl.ch/neurosciencegraph/data/https%3A%2F%2Fbbp.epfl.ch%2Fdata%2Fdemo%2Fmorpho-demo%2Fde25efaf-6f6a-4245-a5a5-eeed4eddc83e", + "about": "https://neuroshapes.org/StimulationTrace", + "identifier": "https://bbp.epfl.ch/neurosciencegraph/data/https%3A%2F%2Fbbp.epfl.ch%2Fdata%2Fdemo%2Fmorpho-demo%2Fde25efaf-6f6a-4245-a5a5-eeed4eddc83e", + "repetition": 0, + "stimulusType": "square" + } + ], + "name": "s160106_02", + "project": { + "@id": "https://bbp.epfl.ch/nexus/v1/projects/demo/morpho-demo", + "identifier": "https://bbp.epfl.ch/nexus/v1/projects/demo/morpho-demo", + "label": "demo/morpho-demo" + }, + "subjectAge": { + "label": "59 days Post-natal", + "period": "Post-natal", + "unit": "days", + "value": 59 + }, + "subjectSpecies": { + "@id": "http://purl.obolibrary.org/obo/NCBITaxon_10090", + "identifier": "http://purl.obolibrary.org/obo/NCBITaxon_10090", + "label": "Mus musculus" + }, + "updatedAt": "2024-04-10T13:58:37.803Z", + "updatedBy": "https://bbp.epfl.ch/nexus/v1/realms/bbp/users/budd", + "_self": "https://bbp.epfl.ch/nexus/v1/resources/demo/morpho-demo/_/https:%2F%2Fbbp.epfl.ch%2Fdata%2Fdemo%2Fmorpho-demo%2F5f710291-9aac-45d0-94ff-d3d318c6ba2f" + } + } + ], + "max_score": 6.0860696, + "total": { + "relation": "eq", + "value": 1905 + } + }, + "timed_out": false, + "took": 65, + "_shards": { + "failed": 0, + "skipped": 664, + "successful": 742, + "total": 742 + } +} diff --git a/tests/data/kg_cell_types_hierarchy_test.json b/tests/data/kg_cell_types_hierarchy_test.json new file mode 100644 index 0000000..e7cb024 --- /dev/null +++ b/tests/data/kg_cell_types_hierarchy_test.json @@ -0,0 +1,13 @@ +{ + "@context": "https://neuroshapes.org", + "@id": "http://bbp.epfl.ch/neurosciencegraph/ontologies/core/celltypes", + "@type": "Ontology", + "preferredNamespacePrefix": "celltypes", + "versionInfo": "R1660", + "defines": [ + {"@id": "http://bbp.epfl.ch/neurosciencegraph/ontologies/etypes/cACint", "@type": "Class", "label": "cACint", "subClassOf": ["https://bbp.epfl.ch/ontologies/core/bmo/NeuronElectricalType", "https://neuroshapes.org/EType", "https://bbp.epfl.ch/ontologies/core/bmo/BrainCellType"], "definition": "Continuous accommodating interneuron electrical type", "color": "#108b8b", "prefLabel": "Continuous accommodating interneuron electrical type", "notation": "cACint", "atlasRelease": {"@id": "https://bbp.epfl.ch/neurosciencegraph/data/brainatlasrelease/c96c71a8-4c0d-4bc1-8a1a-141d9ed6693d", "@type": "BrainAtlasRelease", "_rev": 45}}, + {"@id": "http://bbp.epfl.ch/neurosciencegraph/ontologies/mtypes/GCL_GC", "@type": "Class", "label": "GCL_GC", "subClassOf": ["https://bbp.epfl.ch/ontologies/core/mtypes/HippocampusMType", "https://bbp.epfl.ch/ontologies/core/bmo/NeuronMorphologicalType", "https://neuroshapes.org/MType", "https://bbp.epfl.ch/ontologies/core/bmo/BrainCellType"], "atlasRelease": {"@id": "https://bbp.epfl.ch/neurosciencegraph/data/brainatlasrelease/c96c71a8-4c0d-4bc1-8a1a-141d9ed6693d", "@type": "BrainAtlasRelease", "_rev": 45}}, + {"@id": "http://bbp.epfl.ch/neurosciencegraph/ontologies/mtypes/L23_PTPC", "@type": "Class", "label": "L23_PTPC", "subClassOf": ["https://bbp.epfl.ch/ontologies/core/bmo/HumanNeocortexMType", "https://neuroshapes.org/PyramidalNeuron", "https://bbp.epfl.ch/ontologies/core/bmo/NeuronMorphologicalType", "https://neuroshapes.org/MType", "https://bbp.epfl.ch/ontologies/core/bmo/BrainCellType"], "notation": "L2_MC", "atlasRelease": {"@id": "https://bbp.epfl.ch/neurosciencegraph/data/brainatlasrelease/c96c71a8-4c0d-4bc1-8a1a-141d9ed6693d", "@type": "BrainAtlasRelease", "_rev": 45}} + ], + "label": "Cell Types Ontology" +} \ No newline at end of file diff --git a/tests/data/kg_morpho_features_response.json b/tests/data/kg_morpho_features_response.json new file mode 100644 index 0000000..70fd8de --- /dev/null +++ b/tests/data/kg_morpho_features_response.json @@ -0,0 +1,412 @@ +{ + "hits": { + "hits": [ + { + "_id": "https://bbp.epfl.ch/data/bbp/mmb-point-neuron-framework-model/9ca1c32b-e9fd-470b-a759-bdaf37d81ec9", + "_index": "nexus_search_711d6b8f-1285-42db-9259-b277dd687435_2a84a9ee-75b2-43c4-90a0-1eb5624e8ca0_15", + "_score": 17.77101, + "_source": { + "@id": "https://bbp.epfl.ch/data/bbp/mmb-point-neuron-framework-model/9ca1c32b-e9fd-470b-a759-bdaf37d81ec9", + "@type": [ + "https://neuroshapes.org/Annotation", + "https://bbp.epfl.ch/ontologies/core/bmo/NeuronMorphologyFeatureAnnotation" + ], + "brainRegion": { + "@id": "http://api.brain-map.org/api/v2/data/Structure/718", + "idLabel": [ + "http://api.brain-map.org/api/v2/data/Structure/718|Ventral posterolateral nucleus of the thalamus" + ], + "identifier": [ + "http://api.brain-map.org/api/v2/data/Structure/718" + ], + "label": "Ventral posterolateral nucleus of the thalamus" + }, + "compartment": "BasalDendrite", + "contributors": [ + { + "@id": [ + "https://bbp.epfl.ch/nexus/v1/realms/bbp/users/ricardi" + ], + "@type": [ + "http://www.w3.org/ns/prov#Agent", + "http://schema.org/Person" + ], + "idLabel": [ + "https://bbp.epfl.ch/nexus/v1/realms/bbp/users/ricardi|Niccolo Ricardi" + ], + "label": "Niccolo Ricardi" + } + ], + "createdAt": "2024-03-20T14:30:24.472Z", + "createdBy": [ + "https://bbp.epfl.ch/nexus/v1/realms/bbp/users/ricardi" + ], + "deprecated": false, + "featureSeries": [ + { + "compartment": "BasalDendrite", + "label": "Section Strahler Orders", + "statistic": "mean", + "unit": "dimensionless", + "value": 1.7364864864864864 + }, + { + "compartment": "BasalDendrite", + "label": "Section Areas", + "statistic": "median", + "unit": "μm²", + "value": 272.5912248893231 + }, + { + "compartment": "BasalDendrite", + "label": "Section Strahler Orders", + "statistic": "minimum", + "unit": "dimensionless", + "value": 1.0 + }, + { + "compartment": "BasalDendrite", + "label": "Section Volumes", + "statistic": "mean", + "unit": "μm³", + "value": 180.5787321010427 + }, + { + "compartment": "BasalDendrite", + "label": "Local Bifurcation Angles", + "statistic": "maximum", + "unit": "radian", + "value": 2.6143908349883835 + }, + { + "compartment": "BasalDendrite", + "label": "Number Of Leaves", + "statistic": "raw", + "unit": "dimensionless", + "value": 76.0 + }, + { + "compartment": "BasalDendrite", + "label": "Section Radial Distances", + "statistic": "minimum", + "unit": "μm", + "value": 5.518080711364746 + }, + { + "compartment": "BasalDendrite", + "label": "Local Bifurcation Angles", + "statistic": "mean", + "unit": "radian", + "value": 0.9031414351696362 + }, + { + "compartment": "BasalDendrite", + "label": "Section Tortuosity", + "statistic": "standard deviation", + "unit": "dimensionless", + "value": 0.08268018811941147 + }, + { + "compartment": "BasalDendrite", + "label": "Diameter Power Relations", + "statistic": "standard deviation", + "unit": "dimensionless", + "value": 0.0 + }, + { + "compartment": "BasalDendrite", + "label": "Section Strahler Orders", + "statistic": "standard deviation", + "unit": "dimensionless", + "value": 0.9106092887975676 + }, + { + "compartment": "BasalDendrite", + "label": "Section Bif Branch Orders", + "statistic": "standard deviation", + "unit": "dimensionless", + "value": 1.7145325641644276 + }, + { + "compartment": "BasalDendrite", + "label": "Section Bif Radial Distances", + "statistic": "standard deviation", + "unit": "μm", + "value": 35.042884826660156 + }, + { + "compartment": "BasalDendrite", + "label": "Terminal Path Lengths", + "statistic": "minimum", + "unit": "μm", + "value": 92.61423313617706 + }, + { + "compartment": "BasalDendrite", + "label": "Section Term Lengths", + "statistic": "median", + "unit": "μm", + "value": 92.60545349121094 + }, + { + "compartment": "BasalDendrite", + "label": "Section Path Distances", + "statistic": "standard deviation", + "unit": "μm", + "value": 71.30703565937296 + }, + { + "compartment": "BasalDendrite", + "label": "Section Bif Branch Orders", + "statistic": "mean", + "unit": "dimensionless", + "value": 3.3194444444444446 + }, + { + "compartment": "BasalDendrite", + "label": "Partition Asymmetry Length", + "statistic": "maximum", + "unit": "μm", + "value": 0.5928827095301132 + }, + { + "compartment": "BasalDendrite", + "label": "Sibling Ratios", + "statistic": "standard deviation", + "unit": "dimensionless", + "value": 0.0 + }, + { + "compartment": "BasalDendrite", + "label": "Section Path Distances", + "statistic": "maximum", + "unit": "μm", + "value": 343.71869564056396 + }, + { + "compartment": "BasalDendrite", + "label": "Section Lengths", + "statistic": "standard deviation", + "unit": "μm", + "value": 49.600032806396484 + }, + { + "compartment": "BasalDendrite", + "label": "Remote Bifurcation Angles", + "statistic": "maximum", + "unit": "radian", + "value": 2.2732763128975844 + }, + { + "compartment": "BasalDendrite", + "label": "Diameter Power Relations", + "statistic": "median", + "unit": "dimensionless", + "value": 2.0 + }, + { + "compartment": "BasalDendrite", + "label": "Section Term Branch Orders", + "statistic": "minimum", + "unit": "dimensionless", + "value": 1.0 + }, + { + "compartment": "BasalDendrite", + "label": "Section Strahler Orders", + "statistic": "median", + "unit": "dimensionless", + "value": 1.0 + }, + { + "compartment": "BasalDendrite", + "label": "Section Term Branch Orders", + "statistic": "standard deviation", + "unit": "dimensionless", + "value": 1.5169289394042875 + }, + { + "compartment": "BasalDendrite", + "label": "Section Tortuosity", + "statistic": "mean", + "unit": "dimensionless", + "value": 0.9999998807907104 + } + ], + "generation": { + "endedAt": "2024-03-20T14:24:16.000Z", + "startedAt": "2024-03-20T14:24:16.000Z" + }, + "name": "Neuron Morphology Feature Annotation", + "neuronMorphology": { + "@id": "https://bbp.epfl.ch/data/bbp/mmb-point-neuron-framework-model/75e5f49f-4edf-474a-a3fd-47073a38ea38", + "name": "AA0322" + }, + "project": { + "@id": "https://bbp.epfl.ch/nexus/v1/projects/bbp/mmb-point-neuron-framework-model", + "identifier": "https://bbp.epfl.ch/nexus/v1/projects/bbp/mmb-point-neuron-framework-model", + "label": "bbp/mmb-point-neuron-framework-model" + }, + "updatedAt": "2024-03-20T17:47:15.490Z", + "updatedBy": [ + "https://bbp.epfl.ch/nexus/v1/realms/bbp/users/ricardi" + ], + "_self": "https://bbp.epfl.ch/nexus/v1/resources/bbp/mmb-point-neuron-framework-model/_/https:%2F%2Fbbp.epfl.ch%2Fdata%2Fbbp%2Fmmb-point-neuron-framework-model%2F9ca1c32b-e9fd-470b-a759-bdaf37d81ec9" + } + }, + { + "_id": "https://bbp.epfl.ch/data/bbp/mmb-point-neuron-framework-model/09704e7e-e773-4f2f-a6ab-0c30a73216fc", + "_index": "nexus_search_711d6b8f-1285-42db-9259-b277dd687435_2a84a9ee-75b2-43c4-90a0-1eb5624e8ca0_15", + "_score": 17.77101, + "_source": { + "@id": "https://bbp.epfl.ch/data/bbp/mmb-point-neuron-framework-model/09704e7e-e773-4f2f-a6ab-0c30a73216fc", + "@type": [ + "https://neuroshapes.org/Annotation", + "https://bbp.epfl.ch/ontologies/core/bmo/NeuronMorphologyFeatureAnnotation" + ], + "brainRegion": { + "@id": "http://api.brain-map.org/api/v2/data/Structure/718", + "idLabel": [ + "http://api.brain-map.org/api/v2/data/Structure/718|Ventral posterolateral nucleus of the thalamus" + ], + "identifier": [ + "http://api.brain-map.org/api/v2/data/Structure/718" + ], + "label": "Ventral posterolateral nucleus of the thalamus" + }, + "compartment": "Axon", + "contributors": [ + { + "@id": [ + "https://bbp.epfl.ch/nexus/v1/realms/bbp/users/ricardi" + ], + "@type": [ + "http://www.w3.org/ns/prov#Agent", + "http://schema.org/Person" + ], + "idLabel": [ + "https://bbp.epfl.ch/nexus/v1/realms/bbp/users/ricardi|Niccolo Ricardi" + ], + "label": "Niccolo Ricardi" + } + ], + "createdAt": "2024-03-20T14:30:24.463Z", + "createdBy": [ + "https://bbp.epfl.ch/nexus/v1/realms/bbp/users/ricardi" + ], + "deprecated": false, + "featureSeries": [ + { + "compartment": "Axon", + "label": "Section Tortuosity", + "statistic": "standard deviation", + "unit": "dimensionless", + "value": 0.1429130584001541 + }, + { + "compartment": "Axon", + "label": "Section Tortuosity", + "statistic": "mean", + "unit": "dimensionless", + "value": 1.1262837648391724 + }, + { + "compartment": "Axon", + "label": "Terminal Path Lengths", + "statistic": "minimum", + "unit": "μm", + "value": 5599.079376220703 + }, + { + "compartment": "Axon", + "label": "Section Path Distances", + "statistic": "maximum", + "unit": "μm", + "value": 7780.849250793457 + }, + { + "compartment": "Axon", + "label": "Section Volumes", + "statistic": "mean", + "unit": "μm³", + "value": 136.01814291776782 + }, + { + "compartment": "Axon", + "label": "Diameter Power Relations", + "statistic": "maximum", + "unit": "dimensionless", + "value": 2.0 + }, + { + "compartment": "Axon", + "label": "Section Path Distances", + "statistic": "standard deviation", + "unit": "μm", + "value": 486.26185300130675 + }, + { + "compartment": "Axon", + "label": "Section Radial Distances", + "statistic": "median", + "unit": "μm", + "value": 3837.996337890625 + }, + { + "compartment": "Axon", + "label": "Section Strahler Orders", + "statistic": "median", + "unit": "dimensionless", + "value": 1.0 + }, + { + "compartment": "Axon", + "label": "Diameter Power Relations", + "statistic": "standard deviation", + "unit": "dimensionless", + "value": 0.0 + }, + { + "compartment": "Axon", + "label": "Section Lengths", + "statistic": "mean", + "unit": "μm", + "value": 233.14886474609375 + }, + { + "compartment": "Axon", + "label": "Section Path Distances", + "statistic": "minimum", + "unit": "μm", + "value": 4048.837158203125 + } + ], + "generation": { + "endedAt": "2024-03-20T14:24:16.000Z", + "startedAt": "2024-03-20T14:24:16.000Z" + }, + "name": "Neuron Morphology Feature Annotation", + "neuronMorphology": { + "@id": "https://bbp.epfl.ch/data/bbp/mmb-point-neuron-framework-model/75e5f49f-4edf-474a-a3fd-47073a38ea38", + "name": "AA0322" + }, + "project": { + "@id": "https://bbp.epfl.ch/nexus/v1/projects/bbp/mmb-point-neuron-framework-model", + "identifier": "https://bbp.epfl.ch/nexus/v1/projects/bbp/mmb-point-neuron-framework-model", + "label": "bbp/mmb-point-neuron-framework-model" + }, + "updatedAt": "2024-03-20T17:47:15.513Z", + "updatedBy": [ + "https://bbp.epfl.ch/nexus/v1/realms/bbp/users/ricardi" + ], + "_self": "https://bbp.epfl.ch/nexus/v1/resources/bbp/mmb-point-neuron-framework-model/_/https:%2F%2Fbbp.epfl.ch%2Fdata%2Fbbp%2Fmmb-point-neuron-framework-model%2F09704e7e-e773-4f2f-a6ab-0c30a73216fc" + } + } + ], + "max_score": 17.77101, + "total": {"relation": "eq", "value": 1230} + }, + "timed_out": false, + "took": 342, + "_shards": {"failed": 0, "skipped": 0, "successful": 13, "total": 13} +} diff --git a/tests/data/knowledge_graph.json b/tests/data/knowledge_graph.json new file mode 100644 index 0000000..11b0579 --- /dev/null +++ b/tests/data/knowledge_graph.json @@ -0,0 +1,182 @@ +{ + "hits": { + "hits": [ + { + "_id": "https://bbp.epfl.ch/data/bbp/mmb-point-neuron-framework-model/ca1f0e5f-ff08-4476-9b5f-95f3c9d004fd", + "_index": "nexus_search_711d6b8f-1285-42db-9259-b277dd687435_2a84a9ee-75b2-43c4-90a0-1eb5624e8ca0_15", + "_score": 10.407086, + "_source": { + "@id": "https://bbp.epfl.ch/data/bbp/mmb-point-neuron-framework-model/ca1f0e5f-ff08-4476-9b5f-95f3c9d004fd", + "@type": [ + "https://neuroshapes.org/ReconstructedNeuronMorphology", + "https://neuroshapes.org/NeuronMorphology" + ], + "brainRegion": { + "@id": "http://api.brain-map.org/api/v2/data/Structure/629", + "idLabel": "http://api.brain-map.org/api/v2/data/Structure/629|Ventral anterior-lateral complex of the thalamus", + "identifier": "http://api.brain-map.org/api/v2/data/Structure/629", + "label": "Ventral anterior-lateral complex of the thalamus" + }, + "contributors": [ + { + "@id": "https://bbp.epfl.ch/nexus/v1/realms/bbp/users/ajaquier", + "@type": [ + "http://www.w3.org/ns/prov#Agent", + "http://schema.org/Person" + ], + "idLabel": "https://bbp.epfl.ch/nexus/v1/realms/bbp/users/ajaquier|Aurélien Tristan Jaquier", + "label": "Aurélien Tristan Jaquier" + } + ], + "createdAt": "2023-10-30T10:27:09.334Z", + "createdBy": "https://bbp.epfl.ch/nexus/v1/realms/bbp/users/ajaquier", + "curated": true, + "deprecated": false, + "description": "This is a morphology reconstruction of a mouse thalamus cell that was obtained from the Janelia Mouselight project http://ml-neuronbrowser.janelia.org/ . This morphology is positioned in the Mouselight custom 'CCFv2.5' reference space, instead of the Allen Institute CCFv3 reference space.", + "distribution": [ + { + "contentSize": 105248, + "contentUrl": "https://bbp.epfl.ch/nexus/v1/files/bbp/mmb-point-neuron-framework-model/https%3A%2F%2Fbbp.epfl.ch%2Fdata%2Fbbp%2Fmmb-point-neuron-framework-model%2F6ca97d5c-c61b-43d0-8f89-6c041d3e5173", + "encodingFormat": "application/h5", + "label": "AA0519.h5" + }, + { + "contentSize": 480929, + "contentUrl": "https://bbp.epfl.ch/nexus/v1/files/bbp/mmb-point-neuron-framework-model/https%3A%2F%2Fbbp.epfl.ch%2Fdata%2Fbbp%2Fmmb-point-neuron-framework-model%2Fad8ed9fe-9aef-4716-9326-6b15c3219a1b", + "encodingFormat": "application/asc", + "label": "AA0519.asc" + }, + { + "contentSize": 405464, + "contentUrl": "https://bbp.epfl.ch/nexus/v1/files/bbp/mmb-point-neuron-framework-model/https%3A%2F%2Fbbp.epfl.ch%2Fdata%2Fbbp%2Fmmb-point-neuron-framework-model%2F358624e3-3449-4a10-8e31-aa19017d8583", + "encodingFormat": "application/swc", + "label": "AA0519.swc" + } + ], + "mType": { + "@id": "http://uri.interlex.org/base/ilx_0738236", + "idLabel": "http://uri.interlex.org/base/ilx_0738236|VPL_TC", + "identifier": "http://uri.interlex.org/base/ilx_0738236", + "label": "VPL_TC" + }, + "name": "AA0519", + "project": { + "@id": "https://bbp.epfl.ch/nexus/v1/projects/bbp/mmb-point-neuron-framework-model", + "identifier": "https://bbp.epfl.ch/nexus/v1/projects/bbp/mmb-point-neuron-framework-model", + "label": "bbp/mmb-point-neuron-framework-model" + }, + "subjectAge": { + "label": "60 days Post-natal", + "period": "Post-natal", + "unit": "days", + "value": 60 + }, + "subjectSpecies": { + "@id": "http://purl.obolibrary.org/obo/NCBITaxon_10090", + "identifier": "http://purl.obolibrary.org/obo/NCBITaxon_10090", + "label": "Mus musculus" + }, + "updatedAt": "2024-04-30T07:59:30.098Z", + "updatedBy": "https://bbp.epfl.ch/nexus/v1/realms/bbp/users/ajaquier", + "_self": "https://bbp.epfl.ch/nexus/v1/resources/bbp/mmb-point-neuron-framework-model/_/https:%2F%2Fbbp.epfl.ch%2Fdata%2Fbbp%2Fmmb-point-neuron-framework-model%2Fca1f0e5f-ff08-4476-9b5f-95f3c9d004fd" + } + }, + { + "_id": "https://bbp.epfl.ch/neurosciencegraph/data/neuronmorphologies/046fb11c-8de8-42e8-9303-9d5a65ac04b9", + "_index": "nexus_search_b5db4c20-8200-47f9-98d9-0ca8fa3be422_d2505573-bdde-4df9-92f9-0652523b3fb2_15", + "_score": 7.05979, + "_source": { + "@id": "https://bbp.epfl.ch/neurosciencegraph/data/neuronmorphologies/046fb11c-8de8-42e8-9303-9d5a65ac04b9", + "@type": [ + "https://neuroshapes.org/ReconstructedNeuronMorphology", + "https://neuroshapes.org/NeuronMorphology" + ], + "brainRegion": { + "@id": "http://api.brain-map.org/api/v2/data/Structure/262", + "idLabel": "http://api.brain-map.org/api/v2/data/Structure/262|Reticular nucleus of the thalamus", + "identifier": "http://api.brain-map.org/api/v2/data/Structure/262", + "label": "Reticular nucleus of the thalamus" + }, + "contributors": [ + { + "@id": "https://www.grid.ac/institutes/grid.443970.d", + "@type": [ + "http://schema.org/Organization", + "http://www.w3.org/ns/prov#Agent" + ], + "idLabel": "https://www.grid.ac/institutes/grid.443970.d|Janelia Research Campus", + "label": "Janelia Research Campus" + } + ], + "coordinatesInBrainAtlas": { + "valueX": "6413.2944", + "valueY": "4899.1997", + "valueZ": "4254.461" + }, + "createdAt": "2022-06-16T12:47:56.777Z", + "createdBy": "https://bbp.epfl.ch/nexus/v1/realms/bbp/users/akkaufma", + "curated": true, + "deprecated": false, + "description": "Annotation Space: CCFv3.0 Axes> X: Anterior-Posterior; Y: Inferior-Superior; Z:Left-Right. Despite Mouselight metadata for CCFv2.5 and CCFv3 versions of this morphology indicating that this cell belongs to either the 'Ventral anterior-lateral complex of the thalamus' ('VAL') or 'Ventral medial nucleus of the thalamus' ('VM') region, it is almost certainly a reticular cell belonging to the 'Reticular nucleus of the thalamus' or 'RT' region. Reticular cells only exist in the RT region, and while most of the axons of this morphology exist in non-RT thalamus, viewing the morphology alongside the area of RT in the Mouselight browser here http://ml-neuronbrowser.janelia.org/ clearly shows the soma and dendrites existing in or near the RT region. Based on the location of its soma and dendrites, their general structure, and the projection pattern of its axons, this is almost certainly a reticular inhibitory cell from the RT region. In https://doi.org/10.1016/j.celrep.2023.112200 , this morphology was used as reticular inhibitory morphology belonging to the 'Rt_RC' M-type.", + "distribution": [ + { + "contentSize": 137320, + "contentUrl": "https://bbp.epfl.ch/nexus/v1/files/bbp/mouselight/https%3A%2F%2Fbbp.epfl.ch%2Fneurosciencegraph%2Fdata%2F2ad0380d-c9b2-496e-9855-ab3d173930dd", + "encodingFormat": "application/h5", + "label": "AA0718.h5" + }, + { + "contentSize": 633407, + "contentUrl": "https://bbp.epfl.ch/nexus/v1/files/bbp/mouselight/https%3A%2F%2Fbbp.epfl.ch%2Fneurosciencegraph%2Fdata%2F5ef4f830-d6ac-4a26-8a39-2c918d2d9fb0", + "encodingFormat": "application/asc", + "label": "AA0718.asc" + }, + { + "contentSize": 413625, + "contentUrl": "https://bbp.epfl.ch/nexus/v1/files/bbp/mouselight/https%3A%2F%2Fbbp.epfl.ch%2Fneurosciencegraph%2Fdata%2Fad8fec6f-d59c-4998-beb4-274fa115add7", + "encodingFormat": "application/swc", + "label": "AA0718.swc" + } + ], + "license": { + "@id": "https://creativecommons.org/licenses/by-nc/4.0/", + "identifier": "https://creativecommons.org/licenses/by-nc/4.0/" + }, + "mType": { + "@id": "http://uri.interlex.org/base/ilx_0738229", + "idLabel": "http://uri.interlex.org/base/ilx_0738229|Rt_RC", + "identifier": "http://uri.interlex.org/base/ilx_0738229", + "label": "Rt_RC" + }, + "name": "AA0718", + "project": { + "@id": "https://bbp.epfl.ch/nexus/v1/projects/bbp/mouselight", + "identifier": "https://bbp.epfl.ch/nexus/v1/projects/bbp/mouselight", + "label": "bbp/mouselight" + }, + "subjectSpecies": { + "@id": "http://purl.obolibrary.org/obo/NCBITaxon_10090", + "identifier": "http://purl.obolibrary.org/obo/NCBITaxon_10090", + "label": "Mus musculus" + }, + "updatedAt": "2024-04-10T11:34:30.456Z", + "updatedBy": "https://bbp.epfl.ch/nexus/v1/realms/bbp/users/ricardi", + "_self": "https://bbp.epfl.ch/nexus/v1/resources/bbp/mouselight/_/https:%2F%2Fbbp.epfl.ch%2Fneurosciencegraph%2Fdata%2Fneuronmorphologies%2F046fb11c-8de8-42e8-9303-9d5a65ac04b9" + } + } + ], + "max_score": 10.407086, + "total": { + "relation": "eq", + "value": 160 + } + }, + "timed_out": false, + "took": 423, + "_shards": { + "failed": 0, + "skipped": 712, + "successful": 742, + "total": 742 + } +} diff --git a/tests/data/morphology_id_metadata_response.json b/tests/data/morphology_id_metadata_response.json new file mode 100644 index 0000000..904b6ad --- /dev/null +++ b/tests/data/morphology_id_metadata_response.json @@ -0,0 +1,102 @@ +{ + "hits": { + "hits": [ + { + "_id": "https://bbp.epfl.ch/neurosciencegraph/data/neuronmorphologies/046fb11c-8de8-42e8-9303-9d5a65ac04b9", + "_index": "nexus_search_b5db4c20-8200-47f9-98d9-0ca8fa3be422_d2505573-bdde-4df9-92f9-0652523b3fb2_15", + "_score": 12.887569, + "_source": { + "@id": "https://bbp.epfl.ch/neurosciencegraph/data/neuronmorphologies/046fb11c-8de8-42e8-9303-9d5a65ac04b9", + "@type": [ + "https://neuroshapes.org/ReconstructedNeuronMorphology", + "https://neuroshapes.org/NeuronMorphology" + ], + "brainRegion": { + "@id": "http://api.brain-map.org/api/v2/data/Structure/262", + "idLabel": "http://api.brain-map.org/api/v2/data/Structure/262|Reticular nucleus of the thalamus", + "identifier": "http://api.brain-map.org/api/v2/data/Structure/262", + "label": "Reticular nucleus of the thalamus" + }, + "contributors": [ + { + "@id": "https://www.grid.ac/institutes/grid.443970.d", + "@type": [ + "http://schema.org/Organization", + "http://www.w3.org/ns/prov#Agent" + ], + "idLabel": "https://www.grid.ac/institutes/grid.443970.d|Janelia Research Campus", + "label": "Janelia Research Campus" + } + ], + "coordinatesInBrainAtlas": { + "valueX": "6413.2944", + "valueY": "4899.1997", + "valueZ": "4254.461" + }, + "createdAt": "2022-06-16T12:47:56.777Z", + "createdBy": "https://bbp.epfl.ch/nexus/v1/realms/bbp/users/akkaufma", + "curated": true, + "deprecated": false, + "description": "Annotation Space: CCFv3.0 Axes> X: Anterior-Posterior; Y: Inferior-Superior; Z:Left-Right. Despite Mouselight metadata for CCFv2.5 and CCFv3 versions of this morphology indicating that this cell belongs to either the 'Ventral anterior-lateral complex of the thalamus' ('VAL') or 'Ventral medial nucleus of the thalamus' ('VM') region, it is almost certainly a reticular cell belonging to the 'Reticular nucleus of the thalamus' or 'RT' region. Reticular cells only exist in the RT region, and while most of the axons of this morphology exist in non-RT thalamus, viewing the morphology alongside the area of RT in the Mouselight browser here http://ml-neuronbrowser.janelia.org/ clearly shows the soma and dendrites existing in or near the RT region. Based on the location of its soma and dendrites, their general structure, and the projection pattern of its axons, this is almost certainly a reticular inhibitory cell from the RT region. In https://doi.org/10.1016/j.celrep.2023.112200 , this morphology was used as reticular inhibitory morphology belonging to the 'Rt_RC' M-type.", + "distribution": [ + { + "contentSize": 137320, + "contentUrl": "https://bbp.epfl.ch/nexus/v1/files/bbp/mouselight/https%3A%2F%2Fbbp.epfl.ch%2Fneurosciencegraph%2Fdata%2F2ad0380d-c9b2-496e-9855-ab3d173930dd", + "encodingFormat": "application/h5", + "label": "AA0718.h5" + }, + { + "contentSize": 633407, + "contentUrl": "https://bbp.epfl.ch/nexus/v1/files/bbp/mouselight/https%3A%2F%2Fbbp.epfl.ch%2Fneurosciencegraph%2Fdata%2F5ef4f830-d6ac-4a26-8a39-2c918d2d9fb0", + "encodingFormat": "application/asc", + "label": "AA0718.asc" + }, + { + "contentSize": 413625, + "contentUrl": "https://bbp.epfl.ch/nexus/v1/files/bbp/mouselight/https%3A%2F%2Fbbp.epfl.ch%2Fneurosciencegraph%2Fdata%2Fad8fec6f-d59c-4998-beb4-274fa115add7", + "encodingFormat": "application/swc", + "label": "AA0718.swc" + } + ], + "license": { + "@id": "https://creativecommons.org/licenses/by-nc/4.0/", + "identifier": "https://creativecommons.org/licenses/by-nc/4.0/" + }, + "mType": { + "@id": "http://uri.interlex.org/base/ilx_0738229", + "idLabel": "http://uri.interlex.org/base/ilx_0738229|Rt_RC", + "identifier": "http://uri.interlex.org/base/ilx_0738229", + "label": "Rt_RC" + }, + "name": "AA0718", + "project": { + "@id": "https://bbp.epfl.ch/nexus/v1/projects/bbp/mouselight", + "identifier": "https://bbp.epfl.ch/nexus/v1/projects/bbp/mouselight", + "label": "bbp/mouselight" + }, + "subjectSpecies": { + "@id": "http://purl.obolibrary.org/obo/NCBITaxon_10090", + "identifier": "http://purl.obolibrary.org/obo/NCBITaxon_10090", + "label": "Mus musculus" + }, + "updatedAt": "2024-04-10T11:34:30.456Z", + "updatedBy": "https://bbp.epfl.ch/nexus/v1/realms/bbp/users/ricardi", + "_self": "https://bbp.epfl.ch/nexus/v1/resources/bbp/mouselight/_/https:%2F%2Fbbp.epfl.ch%2Fneurosciencegraph%2Fdata%2Fneuronmorphologies%2F046fb11c-8de8-42e8-9303-9d5a65ac04b9" + } + } + ], + "max_score": 12.887569, + "total": { + "relation": "eq", + "value": 1 + } + }, + "timed_out": false, + "took": 53, + "_shards": { + "failed": 0, + "skipped": 664, + "successful": 742, + "total": 742 + } +} diff --git a/tests/data/resolve_query.json b/tests/data/resolve_query.json new file mode 100644 index 0000000..181dc08 --- /dev/null +++ b/tests/data/resolve_query.json @@ -0,0 +1 @@ +[{"head": {"vars": ["subject", "predicate", "object", "context"]}, "results": {"bindings": [{"object": {"type": "uri", "value": "http://www.w3.org/2002/07/owl#Class"}, "predicate": {"type": "uri", "value": "http://www.w3.org/1999/02/22-rdf-syntax-ns#type"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/549"}}, {"object": {"type": "literal", "value": "Thalamus"}, "predicate": {"type": "uri", "value": "http://www.w3.org/2000/01/rdf-schema#label"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/549"}}, {"object": {"type": "literal", "value": "Thalamus"}, "predicate": {"type": "uri", "value": "http://www.w3.org/2004/02/skos/core#prefLabel"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/549"}}, {"object": {"type": "literal", "value": "TH"}, "predicate": {"type": "uri", "value": "http://www.w3.org/2004/02/skos/core#altLabel"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/549"}}, {"object": {"type": "uri", "value": "https://neuroshapes.org/BrainRegion"}, "predicate": {"type": "uri", "value": "http://www.w3.org/2000/01/rdf-schema#subClassOf"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/549"}}, {"object": {"type": "uri", "value": "http://bbp.epfl.ch/neurosciencegraph/ontologies/core/brainregion"}, "predicate": {"type": "uri", "value": "http://www.w3.org/2000/01/rdf-schema#isDefinedBy"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/549"}}, {"object": {"type": "literal", "value": "TH"}, "predicate": {"type": "uri", "value": "http://www.w3.org/2004/02/skos/core#notation"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/549"}}, {"object": {"type": "uri", "value": "https://bbp.epfl.ch/neurosciencegraph/data/4906ab85-694f-469d-962f-c0174e901885"}, "predicate": {"type": "uri", "value": "https://neuroshapes.org/atlasRelease"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/549"}}, {"object": {"type": "literal", "value": "549"}, "predicate": {"type": "uri", "value": "http://schema.org/identifier"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/549"}}, {"object": {"datatype": "http://www.w3.org/2001/XMLSchema#boolean", "type": "literal", "value": "true"}, "predicate": {"type": "uri", "value": "https://bbp.epfl.ch/ontologies/core/bmo/representedInAnnotation"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/549"}}, {"object": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/262"}, "predicate": {"type": "uri", "value": "https://bbp.epfl.ch/ontologies/core/bmo/hasLeafRegionPart"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/549"}}, {"object": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/1129"}, "predicate": {"type": "uri", "value": "http://schema.org/isPartOf"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/549"}}, {"object": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/321"}, "predicate": {"type": "uri", "value": "https://bbp.epfl.ch/ontologies/core/bmo/hasLeafRegionPart"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/549"}}, {"object": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/483"}, "predicate": {"type": "uri", "value": "https://bbp.epfl.ch/ontologies/core/bmo/hasLeafRegionPart"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/549"}}]}}, {"head": {"vars": ["subject", "predicate", "object", "context"]}, "results": {"bindings": [{"object": {"type": "uri", "value": "http://www.w3.org/2002/07/owl#Class"}, "predicate": {"type": "uri", "value": "http://www.w3.org/1999/02/22-rdf-syntax-ns#type"}, "subject": {"type": "uri", "value": "https://neuroshapes.org/Interneuron"}}, {"object": {"type": "literal", "value": "Interneuron"}, "predicate": {"type": "uri", "value": "http://www.w3.org/2000/01/rdf-schema#label"}, "subject": {"type": "uri", "value": "https://neuroshapes.org/Interneuron"}}, {"object": {"type": "literal", "value": "Interneuron"}, "predicate": {"type": "uri", "value": "http://www.w3.org/2004/02/skos/core#prefLabel"}, "subject": {"type": "uri", "value": "https://neuroshapes.org/Interneuron"}}, {"object": {"type": "literal", "value": "An interneuron is a type of neuron that acts as a connector or messenger between other neurons within the brain and spinal cord, and is often connected using inhibitory synapses. Interneurons process messages between neurons, helping to coordinate and integrate information within the nervous system."}, "predicate": {"type": "uri", "value": "http://www.w3.org/2004/02/skos/core#definition"}, "subject": {"type": "uri", "value": "https://neuroshapes.org/Interneuron"}}, {"object": {"type": "uri", "value": "https://neuroshapes.org/Neuron"}, "predicate": {"type": "uri", "value": "http://www.w3.org/2000/01/rdf-schema#subClassOf"}, "subject": {"type": "uri", "value": "https://neuroshapes.org/Interneuron"}}, {"object": {"type": "literal", "value": "Int"}, "predicate": {"type": "uri", "value": "http://www.w3.org/2004/02/skos/core#notation"}, "subject": {"type": "uri", "value": "https://neuroshapes.org/Interneuron"}}, {"object": {"type": "uri", "value": "https://bbp.epfl.ch/neurosciencegraph/data/4906ab85-694f-469d-962f-c0174e901885"}, "predicate": {"type": "uri", "value": "https://neuroshapes.org/atlasRelease"}, "subject": {"type": "uri", "value": "https://neuroshapes.org/Interneuron"}}, {"object": {"type": "uri", "value": "https://neuroshapes.org/MType"}, "predicate": {"type": "uri", "value": "http://www.w3.org/2000/01/rdf-schema#subClassOf"}, "subject": {"type": "uri", "value": "https://neuroshapes.org/Interneuron"}}, {"object": {"type": "uri", "value": "https://bbp.epfl.ch/ontologies/core/bmo/BrainCellType"}, "predicate": {"type": "uri", "value": "http://www.w3.org/2000/01/rdf-schema#subClassOf"}, "subject": {"type": "uri", "value": "https://neuroshapes.org/Interneuron"}}, {"object": {"type": "uri", "value": "https://bbp.epfl.ch/ontologies/core/bmo/NeuronMorphologicalType"}, "predicate": {"type": "uri", "value": "http://www.w3.org/2000/01/rdf-schema#subClassOf"}, "subject": {"type": "uri", "value": "https://neuroshapes.org/Interneuron"}}]}}, {"head": {"vars": ["subject", "predicate", "object", "context"]}, "results": {"bindings": [{"object": {"type": "uri", "value": "http://www.w3.org/2002/07/owl#Class"}, "predicate": {"type": "uri", "value": "http://www.w3.org/1999/02/22-rdf-syntax-ns#type"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/382"}}, {"object": {"type": "literal", "value": "Field CA1"}, "predicate": {"type": "uri", "value": "http://www.w3.org/2000/01/rdf-schema#label"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/382"}}, {"object": {"type": "literal", "value": "Field CA1"}, "predicate": {"type": "uri", "value": "http://www.w3.org/2004/02/skos/core#prefLabel"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/382"}}, {"object": {"type": "literal", "value": "CA1"}, "predicate": {"type": "uri", "value": "http://www.w3.org/2004/02/skos/core#altLabel"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/382"}}, {"object": {"type": "uri", "value": "https://neuroshapes.org/BrainRegion"}, "predicate": {"type": "uri", "value": "http://www.w3.org/2000/01/rdf-schema#subClassOf"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/382"}}, {"object": {"type": "uri", "value": "http://bbp.epfl.ch/neurosciencegraph/ontologies/core/brainregion"}, "predicate": {"type": "uri", "value": "http://www.w3.org/2000/01/rdf-schema#isDefinedBy"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/382"}}, {"object": {"type": "literal", "value": "CA1"}, "predicate": {"type": "uri", "value": "http://www.w3.org/2004/02/skos/core#notation"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/382"}}, {"object": {"type": "uri", "value": "https://bbp.epfl.ch/neurosciencegraph/data/4906ab85-694f-469d-962f-c0174e901885"}, "predicate": {"type": "uri", "value": "https://neuroshapes.org/atlasRelease"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/382"}}, {"object": {"type": "literal", "value": "382"}, "predicate": {"type": "uri", "value": "http://schema.org/identifier"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/382"}}, {"object": {"datatype": "http://www.w3.org/2001/XMLSchema#boolean", "type": "literal", "value": "true"}, "predicate": {"type": "uri", "value": "https://bbp.epfl.ch/ontologies/core/bmo/representedInAnnotation"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/382"}}, {"object": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/391"}, "predicate": {"type": "uri", "value": "https://bbp.epfl.ch/ontologies/core/bmo/hasLeafRegionPart"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/382"}}, {"object": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/375"}, "predicate": {"type": "uri", "value": "http://schema.org/isPartOf"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/382"}}, {"object": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/399"}, "predicate": {"type": "uri", "value": "https://bbp.epfl.ch/ontologies/core/bmo/hasLeafRegionPart"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/382"}}, {"object": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/407"}, "predicate": {"type": "uri", "value": "https://bbp.epfl.ch/ontologies/core/bmo/hasLeafRegionPart"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/382"}}, {"object": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/415"}, "predicate": {"type": "uri", "value": "https://bbp.epfl.ch/ontologies/core/bmo/hasLeafRegionPart"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/382"}}, {"object": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/614454396"}, "predicate": {"type": "uri", "value": "https://bbp.epfl.ch/ontologies/core/bmo/hasLeafRegionPart"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/382"}}, {"object": {"type": "uri", "value": "http://www.w3.org/2002/07/owl#Class"}, "predicate": {"type": "uri", "value": "http://www.w3.org/1999/02/22-rdf-syntax-ns#type"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/423"}}, {"object": {"type": "literal", "value": "Field CA2"}, "predicate": {"type": "uri", "value": "http://www.w3.org/2000/01/rdf-schema#label"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/423"}}, {"object": {"type": "literal", "value": "Field CA2"}, "predicate": {"type": "uri", "value": "http://www.w3.org/2004/02/skos/core#prefLabel"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/423"}}, {"object": {"type": "literal", "value": "CA2"}, "predicate": {"type": "uri", "value": "http://www.w3.org/2004/02/skos/core#altLabel"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/423"}}, {"object": {"type": "uri", "value": "https://neuroshapes.org/BrainRegion"}, "predicate": {"type": "uri", "value": "http://www.w3.org/2000/01/rdf-schema#subClassOf"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/423"}}, {"object": {"type": "uri", "value": "http://bbp.epfl.ch/neurosciencegraph/ontologies/core/brainregion"}, "predicate": {"type": "uri", "value": "http://www.w3.org/2000/01/rdf-schema#isDefinedBy"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/423"}}, {"object": {"type": "literal", "value": "CA2"}, "predicate": {"type": "uri", "value": "http://www.w3.org/2004/02/skos/core#notation"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/423"}}, {"object": {"type": "uri", "value": "https://bbp.epfl.ch/neurosciencegraph/data/4906ab85-694f-469d-962f-c0174e901885"}, "predicate": {"type": "uri", "value": "https://neuroshapes.org/atlasRelease"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/423"}}, {"object": {"type": "literal", "value": "423"}, "predicate": {"type": "uri", "value": "http://schema.org/identifier"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/423"}}, {"object": {"datatype": "http://www.w3.org/2001/XMLSchema#boolean", "type": "literal", "value": "true"}, "predicate": {"type": "uri", "value": "https://bbp.epfl.ch/ontologies/core/bmo/representedInAnnotation"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/423"}}, {"object": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/431"}, "predicate": {"type": "uri", "value": "https://bbp.epfl.ch/ontologies/core/bmo/hasLeafRegionPart"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/423"}}, {"object": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/375"}, "predicate": {"type": "uri", "value": "http://schema.org/isPartOf"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/423"}}, {"object": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/454"}, "predicate": {"type": "uri", "value": "https://bbp.epfl.ch/ontologies/core/bmo/hasLeafRegionPart"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/423"}}, {"object": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/446"}, "predicate": {"type": "uri", "value": "https://bbp.epfl.ch/ontologies/core/bmo/hasLeafRegionPart"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/423"}}, {"object": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/438"}, "predicate": {"type": "uri", "value": "https://bbp.epfl.ch/ontologies/core/bmo/hasLeafRegionPart"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/423"}}, {"object": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/614454397"}, "predicate": {"type": "uri", "value": "https://bbp.epfl.ch/ontologies/core/bmo/hasLeafRegionPart"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/423"}}, {"object": {"type": "uri", "value": "http://www.w3.org/2002/07/owl#Class"}, "predicate": {"type": "uri", "value": "http://www.w3.org/1999/02/22-rdf-syntax-ns#type"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/463"}}, {"object": {"type": "literal", "value": "Field CA3"}, "predicate": {"type": "uri", "value": "http://www.w3.org/2000/01/rdf-schema#label"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/463"}}, {"object": {"type": "literal", "value": "Field CA3"}, "predicate": {"type": "uri", "value": "http://www.w3.org/2004/02/skos/core#prefLabel"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/463"}}, {"object": {"type": "literal", "value": "CA3"}, "predicate": {"type": "uri", "value": "http://www.w3.org/2004/02/skos/core#altLabel"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/463"}}, {"object": {"type": "uri", "value": "https://neuroshapes.org/BrainRegion"}, "predicate": {"type": "uri", "value": "http://www.w3.org/2000/01/rdf-schema#subClassOf"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/463"}}, {"object": {"type": "uri", "value": "http://bbp.epfl.ch/neurosciencegraph/ontologies/core/brainregion"}, "predicate": {"type": "uri", "value": "http://www.w3.org/2000/01/rdf-schema#isDefinedBy"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/463"}}, {"object": {"type": "literal", "value": "CA3"}, "predicate": {"type": "uri", "value": "http://www.w3.org/2004/02/skos/core#notation"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/463"}}, {"object": {"type": "uri", "value": "https://bbp.epfl.ch/neurosciencegraph/data/4906ab85-694f-469d-962f-c0174e901885"}, "predicate": {"type": "uri", "value": "https://neuroshapes.org/atlasRelease"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/463"}}, {"object": {"type": "literal", "value": "463"}, "predicate": {"type": "uri", "value": "http://schema.org/identifier"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/463"}}, {"object": {"datatype": "http://www.w3.org/2001/XMLSchema#boolean", "type": "literal", "value": "true"}, "predicate": {"type": "uri", "value": "https://bbp.epfl.ch/ontologies/core/bmo/representedInAnnotation"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/463"}}, {"object": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/479"}, "predicate": {"type": "uri", "value": "https://bbp.epfl.ch/ontologies/core/bmo/hasLeafRegionPart"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/463"}}, {"object": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/375"}, "predicate": {"type": "uri", "value": "http://schema.org/isPartOf"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/463"}}, {"object": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/486"}, "predicate": {"type": "uri", "value": "https://bbp.epfl.ch/ontologies/core/bmo/hasLeafRegionPart"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/463"}}, {"object": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/495"}, "predicate": {"type": "uri", "value": "https://bbp.epfl.ch/ontologies/core/bmo/hasLeafRegionPart"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/463"}}, {"object": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/471"}, "predicate": {"type": "uri", "value": "https://bbp.epfl.ch/ontologies/core/bmo/hasLeafRegionPart"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/463"}}, {"object": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/504"}, "predicate": {"type": "uri", "value": "https://bbp.epfl.ch/ontologies/core/bmo/hasLeafRegionPart"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/463"}}, {"object": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/614454398"}, "predicate": {"type": "uri", "value": "https://bbp.epfl.ch/ontologies/core/bmo/hasLeafRegionPart"}, "subject": {"type": "uri", "value": "http://api.brain-map.org/api/v2/data/Structure/463"}}]}}, {"head": {"vars": ["subject", "predicate", "object", "context"]}, "results": {"bindings": [{"object": {"type": "uri", "value": "http://www.w3.org/2002/07/owl#Class"}, "predicate": {"type": "uri", "value": "http://www.w3.org/1999/02/22-rdf-syntax-ns#type"}, "subject": {"type": "uri", "value": "https://neuroshapes.org/Interneuron"}}, {"object": {"type": "literal", "value": "Interneuron"}, "predicate": {"type": "uri", "value": "http://www.w3.org/2000/01/rdf-schema#label"}, "subject": {"type": "uri", "value": "https://neuroshapes.org/Interneuron"}}, {"object": {"type": "literal", "value": "Interneuron"}, "predicate": {"type": "uri", "value": "http://www.w3.org/2004/02/skos/core#prefLabel"}, "subject": {"type": "uri", "value": "https://neuroshapes.org/Interneuron"}}, {"object": {"type": "literal", "value": "An interneuron is a type of neuron that acts as a connector or messenger between other neurons within the brain and spinal cord, and is often connected using inhibitory synapses. Interneurons process messages between neurons, helping to coordinate and integrate information within the nervous system."}, "predicate": {"type": "uri", "value": "http://www.w3.org/2004/02/skos/core#definition"}, "subject": {"type": "uri", "value": "https://neuroshapes.org/Interneuron"}}, {"object": {"type": "uri", "value": "https://neuroshapes.org/Neuron"}, "predicate": {"type": "uri", "value": "http://www.w3.org/2000/01/rdf-schema#subClassOf"}, "subject": {"type": "uri", "value": "https://neuroshapes.org/Interneuron"}}, {"object": {"type": "literal", "value": "Int"}, "predicate": {"type": "uri", "value": "http://www.w3.org/2004/02/skos/core#notation"}, "subject": {"type": "uri", "value": "https://neuroshapes.org/Interneuron"}}, {"object": {"type": "uri", "value": "https://bbp.epfl.ch/neurosciencegraph/data/4906ab85-694f-469d-962f-c0174e901885"}, "predicate": {"type": "uri", "value": "https://neuroshapes.org/atlasRelease"}, "subject": {"type": "uri", "value": "https://neuroshapes.org/Interneuron"}}, {"object": {"type": "uri", "value": "https://neuroshapes.org/MType"}, "predicate": {"type": "uri", "value": "http://www.w3.org/2000/01/rdf-schema#subClassOf"}, "subject": {"type": "uri", "value": "https://neuroshapes.org/Interneuron"}}, {"object": {"type": "uri", "value": "https://bbp.epfl.ch/ontologies/core/bmo/BrainCellType"}, "predicate": {"type": "uri", "value": "http://www.w3.org/2000/01/rdf-schema#subClassOf"}, "subject": {"type": "uri", "value": "https://neuroshapes.org/Interneuron"}}, {"object": {"type": "uri", "value": "https://bbp.epfl.ch/ontologies/core/bmo/NeuronMorphologicalType"}, "predicate": {"type": "uri", "value": "http://www.w3.org/2000/01/rdf-schema#subClassOf"}, "subject": {"type": "uri", "value": "https://neuroshapes.org/Interneuron"}}, {"object": {"type": "uri", "value": "http://www.w3.org/2002/07/owl#Class"}, "predicate": {"type": "uri", "value": "http://www.w3.org/1999/02/22-rdf-syntax-ns#type"}, "subject": {"type": "uri", "value": "http://uri.interlex.org/base/ilx_0105044"}}, {"object": {"type": "literal", "value": "Hippocampus CA3 Oriens Interneuron"}, "predicate": {"type": "uri", "value": "http://www.w3.org/2000/01/rdf-schema#label"}, "subject": {"type": "uri", "value": "http://uri.interlex.org/base/ilx_0105044"}}, {"object": {"type": "literal", "value": "CA3 SO interneuron"}, "predicate": {"type": "uri", "value": "http://www.w3.org/2004/02/skos/core#altLabel"}, "subject": {"type": "uri", "value": "http://uri.interlex.org/base/ilx_0105044"}}, {"object": {"type": "literal", "value": "The CA3 stratum oriens interneuron is a fast spiking interneuron in hippocampal area CA3 with a main dendrite arborization extending in the stratum oriens and a widespread axonal arborization in all strata (Kawaguchi et al., 1987). The vast majority of dendritic processes were confined to the same layers as the cell bodies (Kantona et al., 1999)."}, "predicate": {"type": "uri", "value": "http://www.w3.org/2004/02/skos/core#definition"}, "subject": {"type": "uri", "value": "http://uri.interlex.org/base/ilx_0105044"}}, {"object": {"type": "uri", "value": "https://bbp.epfl.ch/ontologies/core/bmo/BrainCellType"}, "predicate": {"type": "uri", "value": "http://www.w3.org/2000/01/rdf-schema#subClassOf"}, "subject": {"type": "uri", "value": "http://uri.interlex.org/base/ilx_0105044"}}, {"object": {"type": "uri", "value": "https://bbp.epfl.ch/neurosciencegraph/data/4906ab85-694f-469d-962f-c0174e901885"}, "predicate": {"type": "uri", "value": "https://neuroshapes.org/atlasRelease"}, "subject": {"type": "uri", "value": "http://uri.interlex.org/base/ilx_0105044"}}, {"object": {"type": "uri", "value": "https://bbp.epfl.ch/ontologies/core/bmo/NewNeuronType"}, "predicate": {"type": "uri", "value": "http://www.w3.org/2000/01/rdf-schema#subClassOf"}, "subject": {"type": "uri", "value": "http://uri.interlex.org/base/ilx_0105044"}}, {"object": {"type": "uri", "value": "http://www.w3.org/2002/07/owl#Class"}, "predicate": {"type": "uri", "value": "http://www.w3.org/1999/02/22-rdf-syntax-ns#type"}, "subject": {"type": "uri", "value": "http://uri.interlex.org/base/ilx_0110929"}}, {"object": {"type": "literal", "value": "Spinal Cord Ventral Horn Interneuron IA"}, "predicate": {"type": "uri", "value": "http://www.w3.org/2000/01/rdf-schema#label"}, "subject": {"type": "uri", "value": "http://uri.interlex.org/base/ilx_0110929"}}, {"object": {"type": "literal", "value": "Spinal Ia interneuron"}, "predicate": {"type": "uri", "value": "http://www.w3.org/2004/02/skos/core#altLabel"}, "subject": {"type": "uri", "value": "http://uri.interlex.org/base/ilx_0110929"}}, {"object": {"type": "uri", "value": "https://bbp.epfl.ch/ontologies/core/bmo/BrainCellType"}, "predicate": {"type": "uri", "value": "http://www.w3.org/2000/01/rdf-schema#subClassOf"}, "subject": {"type": "uri", "value": "http://uri.interlex.org/base/ilx_0110929"}}, {"object": {"type": "uri", "value": "https://bbp.epfl.ch/neurosciencegraph/data/4906ab85-694f-469d-962f-c0174e901885"}, "predicate": {"type": "uri", "value": "https://neuroshapes.org/atlasRelease"}, "subject": {"type": "uri", "value": "http://uri.interlex.org/base/ilx_0110929"}}, {"object": {"type": "uri", "value": "https://bbp.epfl.ch/ontologies/core/bmo/NewNeuronType"}, "predicate": {"type": "uri", "value": "http://www.w3.org/2000/01/rdf-schema#subClassOf"}, "subject": {"type": "uri", "value": "http://uri.interlex.org/base/ilx_0110929"}}]}}, {"hits": {"hits": [{"_id": "http://api.brain-map.org/api/v2/data/Structure/688", "_index": "nexus_61cab3d6-56e5-4d32-b538-a08c90aca76c_3", "_score": 15.918847, "_source": {"@id": "http://api.brain-map.org/api/v2/data/Structure/688", "@type": "Class", "altLabel": "CTX", "atlasRelease": {"@id": "https://bbp.epfl.ch/neurosciencegraph/data/4906ab85-694f-469d-962f-c0174e901885", "@type": "BrainAtlasRelease", "_rev": 8}, "atlas_id": 85, "color_hex_triplet": "B0FFB8", "delineates": ["http://purl.obolibrary.org/obo/UBERON_0000956"], "graph_order": 3, "hasHierarchyView": ["https://neuroshapes.org/BrainRegion"], "hasLeafRegionPart": ["http://api.brain-map.org/api/v2/data/Structure/614454511", "http://api.brain-map.org/api/v2/data/Structure/614454297", "http://api.brain-map.org/api/v2/data/Structure/589508447", "http://api.brain-map.org/api/v2/data/Structure/480149218", "http://api.brain-map.org/api/v2/data/Structure/614454726", "http://api.brain-map.org/api/v2/data/Structure/614454762"], "hasPart": ["http://api.brain-map.org/api/v2/data/Structure/695", "http://api.brain-map.org/api/v2/data/Structure/703"], "hemisphere_id": 3, "identifier": "688", "isDefinedBy": "http://bbp.epfl.ch/neurosciencegraph/ontologies/core/brainregion", "isPartOf": ["http://api.brain-map.org/api/v2/data/Structure/567"], "label": "Cerebral cortex", "notation": "CTX", "prefLabel": "Cerebral cortex", "regionVolume": {"unitCode": "cubic micrometer", "value": 221549640625.0}, "regionVolumeRatioToWholeBrain": {"unitCode": "cubic micrometer", "value": 0.4377993777515536}, "representedInAnnotation": true, "st_level": 3, "subClassOf": ["https://neuroshapes.org/BrainRegion"], "_constrainedBy": "https://neuroshapes.org/dash/ontologyentity", "_createdAt": "2019-08-20T11:41:48.761Z", "_createdBy": "https://bbp.epfl.ch/nexus/v1/realms/bbp/users/sy", "_deprecated": false, "_incoming": "https://bbp.epfl.ch/nexus/v1/resources/neurosciencegraph/datamodels/_/http:%2F%2Fapi.brain-map.org%2Fapi%2Fv2%2Fdata%2FStructure%2F688/incoming", "_outgoing": "https://bbp.epfl.ch/nexus/v1/resources/neurosciencegraph/datamodels/_/http:%2F%2Fapi.brain-map.org%2Fapi%2Fv2%2Fdata%2FStructure%2F688/outgoing", "_project": "https://bbp.epfl.ch/nexus/v1/projects/neurosciencegraph/datamodels", "_rev": 82, "_schemaProject": "https://bbp.epfl.ch/nexus/v1/projects/neurosciencegraph/datamodels", "_self": "https://bbp.epfl.ch/nexus/v1/resources/neurosciencegraph/datamodels/_/http:%2F%2Fapi.brain-map.org%2Fapi%2Fv2%2Fdata%2FStructure%2F688", "_tags": ["v1.13.5"], "_updatedAt": "2024-05-28T16:40:32.374948Z", "_updatedBy": "https://bbp.epfl.ch/nexus/v1/realms/serviceaccounts/users/service-account-brain-modeling-ontology-ci-cd"}}, {"_id": "http://api.brain-map.org/api/v2/data/Structure/528", "_index": "nexus_61cab3d6-56e5-4d32-b538-a08c90aca76c_3", "_score": 15.918847, "_source": {"@id": "http://api.brain-map.org/api/v2/data/Structure/528", "@type": "Class", "altLabel": "CBX", "atlasRelease": {"@id": "https://bbp.epfl.ch/neurosciencegraph/data/4906ab85-694f-469d-962f-c0174e901885", "@type": "BrainAtlasRelease", "_rev": 8}, "atlas_id": 65, "color_hex_triplet": "F0F080", "delineates": ["http://purl.obolibrary.org/obo/UBERON_0002129"], "graph_order": 1015, "hasHierarchyView": ["https://neuroshapes.org/BrainRegion"], "hasLeafRegionPart": ["http://api.brain-map.org/api/v2/data/Structure/10730"], "hasPart": ["http://api.brain-map.org/api/v2/data/Structure/1145", "http://api.brain-map.org/api/v2/data/Structure/645"], "hemisphere_id": 3, "identifier": "528", "isDefinedBy": "http://bbp.epfl.ch/neurosciencegraph/ontologies/core/brainregion", "isPartOf": ["http://api.brain-map.org/api/v2/data/Structure/512"], "label": "Cerebellar cortex", "notation": "CBX", "prefLabel": "Cerebellar cortex", "regionVolume": {"unitCode": "cubic micrometer", "value": 51220765625.0}, "regionVolumeRatioToWholeBrain": {"unitCode": "cubic micrometer", "value": 0.10121622971413098}, "representedInAnnotation": true, "st_level": 5, "subClassOf": ["https://neuroshapes.org/BrainRegion"], "_constrainedBy": "https://neuroshapes.org/dash/ontologyentity", "_createdAt": "2019-08-20T11:40:24.927Z", "_createdBy": "https://bbp.epfl.ch/nexus/v1/realms/bbp/users/sy", "_deprecated": false, "_incoming": "https://bbp.epfl.ch/nexus/v1/resources/neurosciencegraph/datamodels/_/http:%2F%2Fapi.brain-map.org%2Fapi%2Fv2%2Fdata%2FStructure%2F528/incoming", "_outgoing": "https://bbp.epfl.ch/nexus/v1/resources/neurosciencegraph/datamodels/_/http:%2F%2Fapi.brain-map.org%2Fapi%2Fv2%2Fdata%2FStructure%2F528/outgoing", "_project": "https://bbp.epfl.ch/nexus/v1/projects/neurosciencegraph/datamodels", "_rev": 81, "_schemaProject": "https://bbp.epfl.ch/nexus/v1/projects/neurosciencegraph/datamodels", "_self": "https://bbp.epfl.ch/nexus/v1/resources/neurosciencegraph/datamodels/_/http:%2F%2Fapi.brain-map.org%2Fapi%2Fv2%2Fdata%2FStructure%2F528", "_tags": ["v1.12"], "_updatedAt": "2024-05-28T16:37:15.358248Z", "_updatedBy": "https://bbp.epfl.ch/nexus/v1/realms/serviceaccounts/users/service-account-brain-modeling-ontology-ci-cd"}}, {"_id": "http://api.brain-map.org/api/v2/data/Structure/184", "_index": "nexus_61cab3d6-56e5-4d32-b538-a08c90aca76c_3", "_score": 13.0758095, "_source": {"@id": "http://api.brain-map.org/api/v2/data/Structure/184", "@type": "Class", "altLabel": "FRP", "atlasRelease": {"@id": "https://bbp.epfl.ch/neurosciencegraph/data/4906ab85-694f-469d-962f-c0174e901885", "@type": "BrainAtlasRelease", "_rev": 8}, "atlas_id": 871, "color_hex_triplet": "268F45", "delineates": ["http://purl.obolibrary.org/obo/UBERON_0002795"], "graph_order": 6, "hasHierarchyView": ["https://neuroshapes.org/BrainRegion"], "hasLeafRegionPart": ["http://api.brain-map.org/api/v2/data/Structure/68", "http://api.brain-map.org/api/v2/data/Structure/667"], "hemisphere_id": 3, "identifier": "184", "isDefinedBy": "http://bbp.epfl.ch/neurosciencegraph/ontologies/core/brainregion", "isPartOf": ["http://api.brain-map.org/api/v2/data/Structure/315"], "label": "Frontal pole, cerebral cortex", "notation": "FRP", "prefLabel": "Frontal pole, cerebral cortex", "regionVolume": {"unitCode": "cubic micrometer", "value": 972296875.0}, "regionVolumeRatioToWholeBrain": {"unitCode": "cubic micrometer", "value": 0.0019213344948967013}, "representedInAnnotation": true, "st_level": 8, "subClassOf": ["https://neuroshapes.org/BrainRegion"], "_constrainedBy": "https://neuroshapes.org/dash/ontologyentity", "_createdAt": "2019-08-20T11:37:19.466Z", "_createdBy": "https://bbp.epfl.ch/nexus/v1/realms/bbp/users/sy", "_deprecated": false, "_incoming": "https://bbp.epfl.ch/nexus/v1/resources/neurosciencegraph/datamodels/_/http:%2F%2Fapi.brain-map.org%2Fapi%2Fv2%2Fdata%2FStructure%2F184/incoming", "_outgoing": "https://bbp.epfl.ch/nexus/v1/resources/neurosciencegraph/datamodels/_/http:%2F%2Fapi.brain-map.org%2Fapi%2Fv2%2Fdata%2FStructure%2F184/outgoing", "_project": "https://bbp.epfl.ch/nexus/v1/projects/neurosciencegraph/datamodels", "_rev": 80, "_schemaProject": "https://bbp.epfl.ch/nexus/v1/projects/neurosciencegraph/datamodels", "_self": "https://bbp.epfl.ch/nexus/v1/resources/neurosciencegraph/datamodels/_/http:%2F%2Fapi.brain-map.org%2Fapi%2Fv2%2Fdata%2FStructure%2F184", "_tags": ["v1.14.0"], "_updatedAt": "2024-05-28T16:30:18.357142Z", "_updatedBy": "https://bbp.epfl.ch/nexus/v1/realms/serviceaccounts/users/service-account-brain-modeling-ontology-ci-cd"}}], "max_score": 15.918847, "total": {"relation": "eq", "value": 48}}, "timed_out": false, "took": 8, "_shards": {"failed": 0, "skipped": 0, "successful": 1, "total": 1}}, {"hits": {"hits": [{"_id": "http://uri.interlex.org/base/ilx_0112352", "_index": "nexus_61cab3d6-56e5-4d32-b538-a08c90aca76c_3", "_score": 13.711583, "_source": {"@id": "http://uri.interlex.org/base/ilx_0112352", "@type": "Class", "altLabel": "Ventral tegmental area DA cell", "atlasRelease": {"@id": "https://bbp.epfl.ch/neurosciencegraph/data/4906ab85-694f-469d-962f-c0174e901885", "@type": "BrainAtlasRelease", "_rev": 8}, "definition": "Principal neuron of the ventral tegmental area", "label": "Ventral Tegmental Area Dopamine Neuron", "subClassOf": ["https://bbp.epfl.ch/ontologies/core/bmo/NewNeuronType", "https://bbp.epfl.ch/ontologies/core/bmo/BrainCellType"], "_constrainedBy": "https://neuroshapes.org/dash/ontologyentity", "_createdAt": "2022-11-01T10:43:54.694Z", "_createdBy": "https://bbp.epfl.ch/nexus/v1/realms/serviceaccounts/users/service-account-brain-modeling-ontology-ci-cd", "_deprecated": false, "_incoming": "https://bbp.epfl.ch/nexus/v1/resources/neurosciencegraph/datamodels/_/http:%2F%2Furi.interlex.org%2Fbase%2Filx_0112352/incoming", "_outgoing": "https://bbp.epfl.ch/nexus/v1/resources/neurosciencegraph/datamodels/_/http:%2F%2Furi.interlex.org%2Fbase%2Filx_0112352/outgoing", "_project": "https://bbp.epfl.ch/nexus/v1/projects/neurosciencegraph/datamodels", "_rev": 73, "_schemaProject": "https://bbp.epfl.ch/nexus/v1/projects/neurosciencegraph/datamodels", "_self": "https://bbp.epfl.ch/nexus/v1/resources/neurosciencegraph/datamodels/_/http:%2F%2Furi.interlex.org%2Fbase%2Filx_0112352", "_tags": ["v1.11.2"], "_updatedAt": "2024-05-28T16:48:32.797241Z", "_updatedBy": "https://bbp.epfl.ch/nexus/v1/realms/serviceaccounts/users/service-account-brain-modeling-ontology-ci-cd"}}, {"_id": "http://uri.interlex.org/base/ilx_0110943", "_ignored": ["definition.keyword"], "_index": "nexus_61cab3d6-56e5-4d32-b538-a08c90aca76c_3", "_score": 11.970262, "_source": {"@id": "http://uri.interlex.org/base/ilx_0110943", "@type": "Class", "altLabel": "fusimotor neuron", "atlasRelease": {"@id": "https://bbp.epfl.ch/neurosciencegraph/data/4906ab85-694f-469d-962f-c0174e901885", "@type": "BrainAtlasRelease", "_rev": 8}, "definition": "Motor neurons which activate the contractile regions of intrafusal muscle fibers, thus adjusting the sensitivity of the muscle spindles to stretch. Gamma motor neurons may be static or dynamic according to which aspect of responsiveness (or which fiber types) they regulate. The alpha and gamma motor neurons are often activated together (alpha gamma coactivation) which allows the spindles to contribute to the control of movement trajectories despite changes in muscle length (MSH).", "label": "Spinal Cord Ventral Horn Motor Neuron Gamma", "subClassOf": ["https://bbp.epfl.ch/ontologies/core/bmo/NewNeuronType", "https://bbp.epfl.ch/ontologies/core/bmo/BrainCellType"], "_constrainedBy": "https://neuroshapes.org/dash/ontologyentity", "_createdAt": "2022-11-01T10:43:51.808Z", "_createdBy": "https://bbp.epfl.ch/nexus/v1/realms/serviceaccounts/users/service-account-brain-modeling-ontology-ci-cd", "_deprecated": false, "_incoming": "https://bbp.epfl.ch/nexus/v1/resources/neurosciencegraph/datamodels/_/http:%2F%2Furi.interlex.org%2Fbase%2Filx_0110943/incoming", "_outgoing": "https://bbp.epfl.ch/nexus/v1/resources/neurosciencegraph/datamodels/_/http:%2F%2Furi.interlex.org%2Fbase%2Filx_0110943/outgoing", "_project": "https://bbp.epfl.ch/nexus/v1/projects/neurosciencegraph/datamodels", "_rev": 73, "_schemaProject": "https://bbp.epfl.ch/nexus/v1/projects/neurosciencegraph/datamodels", "_self": "https://bbp.epfl.ch/nexus/v1/resources/neurosciencegraph/datamodels/_/http:%2F%2Furi.interlex.org%2Fbase%2Filx_0110943", "_tags": ["v1.11.4"], "_updatedAt": "2024-05-28T16:48:26.214141Z", "_updatedBy": "https://bbp.epfl.ch/nexus/v1/realms/serviceaccounts/users/service-account-brain-modeling-ontology-ci-cd"}}, {"_id": "http://uri.interlex.org/base/ilx_0105169", "_index": "nexus_61cab3d6-56e5-4d32-b538-a08c90aca76c_3", "_score": 9.032584, "_source": {"@id": "http://uri.interlex.org/base/ilx_0105169", "@type": "Class", "altLabel": "hypoglossal motor neuron", "atlasRelease": {"@id": "https://bbp.epfl.ch/neurosciencegraph/data/4906ab85-694f-469d-962f-c0174e901885", "@type": "BrainAtlasRelease", "_rev": 8}, "definition": "Motor neuron whose soma lies in the hypoglossal nucleus", "label": "Hypoglossal Nucleus Motor Neuron", "subClassOf": ["https://bbp.epfl.ch/ontologies/core/bmo/NewNeuronType", "https://bbp.epfl.ch/ontologies/core/bmo/BrainCellType"], "_constrainedBy": "https://neuroshapes.org/dash/ontologyentity", "_createdAt": "2022-11-01T10:43:40.761Z", "_createdBy": "https://bbp.epfl.ch/nexus/v1/realms/serviceaccounts/users/service-account-brain-modeling-ontology-ci-cd", "_deprecated": false, "_incoming": "https://bbp.epfl.ch/nexus/v1/resources/neurosciencegraph/datamodels/_/http:%2F%2Furi.interlex.org%2Fbase%2Filx_0105169/incoming", "_outgoing": "https://bbp.epfl.ch/nexus/v1/resources/neurosciencegraph/datamodels/_/http:%2F%2Furi.interlex.org%2Fbase%2Filx_0105169/outgoing", "_project": "https://bbp.epfl.ch/nexus/v1/projects/neurosciencegraph/datamodels", "_rev": 73, "_schemaProject": "https://bbp.epfl.ch/nexus/v1/projects/neurosciencegraph/datamodels", "_self": "https://bbp.epfl.ch/nexus/v1/resources/neurosciencegraph/datamodels/_/http:%2F%2Furi.interlex.org%2Fbase%2Filx_0105169", "_tags": ["v1.12.2"], "_updatedAt": "2024-05-28T16:48:06.526340Z", "_updatedBy": "https://bbp.epfl.ch/nexus/v1/realms/serviceaccounts/users/service-account-brain-modeling-ontology-ci-cd"}}], "max_score": 13.711583, "total": {"relation": "eq", "value": 48}}, "timed_out": false, "took": 15, "_shards": {"failed": 0, "skipped": 0, "successful": 1, "total": 1}}] diff --git a/tests/data/simple.swc b/tests/data/simple.swc new file mode 100644 index 0000000..ebba216 --- /dev/null +++ b/tests/data/simple.swc @@ -0,0 +1,31 @@ +# SWC structure: +# index, type, x, y, z, radius, parent +# +# (0, 5) +# (-5, 5)----- ------ (6, 5) +# | +# | +# | +# | Type = 3 +# | +# o origin +# | +# | Type = 2 +# | +# | +#(-5, -4)----- ------ (6, -4) +# (0, -4) +# +# all radii are 1, except for end points, which are 0 +# section types: soma=1, axon=2, basal=3, apical=4 + + + 1 1 0 0 0 1. -1 + 2 3 0 0 0 1. 1 + 3 3 0 5 0 1. 2 + 4 3 -5 5 0 0. 3 + 5 3 6 5 0 0. 3 + 6 2 0 0 0 1. 1 + 7 2 0 -4 0 1. 6 + 8 2 6 -4 0 0. 7 + 9 2 -5 -4 0 0. 7 diff --git a/tests/data/trace_id_metadata.json b/tests/data/trace_id_metadata.json new file mode 100644 index 0000000..388651c --- /dev/null +++ b/tests/data/trace_id_metadata.json @@ -0,0 +1,110 @@ +{ + "hits": { + "hits": [ + { + "_id": "https://bbp.epfl.ch/data/demo/morpho-demo/1761e604-03fc-452b-9bf2-2214782bb751", + "_index": "nexus_search_d067e019-1398-4eb8-9e28-3af8a717dcd7_41b1545a-cb2e-4848-8c74-195ec79f7bd3_18", + "_score": 8.232272, + "_source": { + "@id": "https://bbp.epfl.ch/data/demo/morpho-demo/1761e604-03fc-452b-9bf2-2214782bb751", + "@type": [ + "https://neuroshapes.org/Trace", + "https://bbp.epfl.ch/ontologies/core/bmo/ExperimentalTrace" + ], + "brainRegion": { + "@id": "http://api.brain-map.org/api/v2/data/Structure/382", + "idLabel": "http://api.brain-map.org/api/v2/data/Structure/382|Field CA1", + "identifier": "http://api.brain-map.org/api/v2/data/Structure/382", + "label": "Field CA1" + }, + "contributors": [ + { + "@id": "https://bbp.epfl.ch/neurosciencegraph/data/859a20a2-9eea-4ab5-9504-d080c3c79311", + "@type": [ + "http://www.w3.org/ns/prov#Agent", + "http://schema.org/Person" + ], + "idLabel": "https://bbp.epfl.ch/neurosciencegraph/data/859a20a2-9eea-4ab5-9504-d080c3c79311|Zsolt Kohus", + "label": "Zsolt Kohus" + } + ], + "createdAt": "2024-04-09T21:47:08.569Z", + "createdBy": "https://bbp.epfl.ch/nexus/v1/realms/bbp/users/budd", + "deprecated": false, + "derivation": [ + { + "@type": [ + "http://www.w3.org/ns/prov#Entity", + "https://neuroshapes.org/PatchedCell" + ], + "identifier": "https://bbp.epfl.ch/data/data/demo/morpho-demo/7173ea54-8e59-478d-85f3-ff86246ef22b", + "label": "s160106_0201" + } + ], + "distribution": [ + { + "contentSize": 4919928, + "contentUrl": "https://bbp.epfl.ch/nexus/v1/files/demo/morpho-demo/https%3A%2F%2Fbbp.epfl.ch%2Fdata%2Fdata%2Fdemo%2Fmorpho-demo%2F01dffb7b-1122-4e1a-9acf-837e683da4ba", + "encodingFormat": "application/nwb", + "label": "s160106_02.nwb" + } + ], + "generation": { + "@id": "https://bbp.epfl.ch/data/demo/morpho-demo/d857ff82-b558-4117-b643-74d44dbda5e6", + "endedAt": "2016-01-06T23:59:00.000Z", + "startedAt": "2016-01-06T00:00:00.000Z" + }, + "image": [ + { + "@id": "https://bbp.epfl.ch/neurosciencegraph/data/https%3A%2F%2Fbbp.epfl.ch%2Fdata%2Fdata%2Fdemo%2Fmorpho-demo%2Fc1ef69e4-0073-481d-9446-fcfd7b5b5f7a", + "about": "https://neuroshapes.org/StimulationTrace", + "identifier": "https://bbp.epfl.ch/neurosciencegraph/data/https%3A%2F%2Fbbp.epfl.ch%2Fdata%2Fdata%2Fdemo%2Fmorpho-demo%2Fc1ef69e4-0073-481d-9446-fcfd7b5b5f7a", + "repetition": 0, + "stimulusType": "square" + }, + { + "@id": "https://bbp.epfl.ch/neurosciencegraph/data/https%3A%2F%2Fbbp.epfl.ch%2Fdata%2Fdata%2Fdemo%2Fmorpho-demo%2F6fd03e92-b2ae-4ab8-8c9e-f0ff52d20a0d", + "about": "https://neuroshapes.org/ResponseTrace", + "identifier": "https://bbp.epfl.ch/neurosciencegraph/data/https%3A%2F%2Fbbp.epfl.ch%2Fdata%2Fdata%2Fdemo%2Fmorpho-demo%2F6fd03e92-b2ae-4ab8-8c9e-f0ff52d20a0d", + "repetition": 0, + "stimulusType": "square" + } + ], + "name": "s160106_02", + "project": { + "@id": "https://bbp.epfl.ch/nexus/v1/projects/demo/morpho-demo", + "identifier": "https://bbp.epfl.ch/nexus/v1/projects/demo/morpho-demo", + "label": "demo/morpho-demo" + }, + "subjectAge": { + "label": "59 days Post-natal", + "period": "Post-natal", + "unit": "days", + "value": 59 + }, + "subjectSpecies": { + "@id": "http://purl.obolibrary.org/obo/NCBITaxon_10090", + "identifier": "http://purl.obolibrary.org/obo/NCBITaxon_10090", + "label": "Mus musculus" + }, + "updatedAt": "2024-04-10T08:40:23.039Z", + "updatedBy": "https://bbp.epfl.ch/nexus/v1/realms/bbp/users/cgonzale", + "_self": "https://bbp.epfl.ch/nexus/v1/resources/demo/morpho-demo/_/https:%2F%2Fbbp.epfl.ch%2Fdata%2Fdemo%2Fmorpho-demo%2F1761e604-03fc-452b-9bf2-2214782bb751" + } + } + ], + "max_score": 8.232272, + "total": { + "relation": "eq", + "value": 1 + } + }, + "timed_out": false, + "took": 284, + "_shards": { + "failed": 0, + "skipped": 664, + "successful": 742, + "total": 742 + } +} diff --git a/tests/test_cell_types.py b/tests/test_cell_types.py new file mode 100644 index 0000000..0596534 --- /dev/null +++ b/tests/test_cell_types.py @@ -0,0 +1,130 @@ +"""Test cell types meta functions.""" + +from pathlib import Path + +import pytest +from neuroagent.cell_types import CellTypesMeta, get_celltypes_descendants + +CELL_TYPES_FILE = Path(__file__).parent / "data" / "kg_cell_types_hierarchy_test.json" + + +@pytest.mark.parametrize( + "cell_type_id,expected_descendants", + [ + ( + "https://bbp.epfl.ch/ontologies/core/bmo/BrainCellType", + { + "http://bbp.epfl.ch/neurosciencegraph/ontologies/mtypes/L23_PTPC", + "http://bbp.epfl.ch/neurosciencegraph/ontologies/etypes/cACint", + "http://bbp.epfl.ch/neurosciencegraph/ontologies/mtypes/GCL_GC", + "https://bbp.epfl.ch/ontologies/core/bmo/BrainCellType", + }, + ), + ( + "https://bbp.epfl.ch/ontologies/core/bmo/NeuronElectricalType", + { + "https://bbp.epfl.ch/ontologies/core/bmo/NeuronElectricalType", + "http://bbp.epfl.ch/neurosciencegraph/ontologies/etypes/cACint", + }, + ), + ( + "http://bbp.epfl.ch/neurosciencegraph/ontologies/etypes/cACint", + { + "http://bbp.epfl.ch/neurosciencegraph/ontologies/etypes/cACint", + }, + ), + ], +) +def test_get_celltypes_descendants(cell_type_id, expected_descendants, tmp_path): + cell_types_meta = CellTypesMeta.from_json(CELL_TYPES_FILE) + save_file = tmp_path / "tmp_config_cell_types_meta.json" + cell_types_meta.save_config(save_file) + + descendants = get_celltypes_descendants(cell_type_id, json_path=save_file) + assert expected_descendants == descendants + + +class TestCellTypesMeta: + def test_from_json(self): + ct_meta = CellTypesMeta.from_json(CELL_TYPES_FILE) + assert isinstance(ct_meta.name_, dict) + assert isinstance(ct_meta.descendants_ids, dict) + + expected_names = { + "http://bbp.epfl.ch/neurosciencegraph/ontologies/etypes/cACint": "cACint", + "http://bbp.epfl.ch/neurosciencegraph/ontologies/mtypes/GCL_GC": "GCL_GC", + "http://bbp.epfl.ch/neurosciencegraph/ontologies/mtypes/L23_PTPC": ( + "L23_PTPC" + ), + } + + assert ct_meta.name_ == expected_names + assert ct_meta.descendants_ids[ + "https://bbp.epfl.ch/ontologies/core/mtypes/HippocampusMType" + ] == {"http://bbp.epfl.ch/neurosciencegraph/ontologies/mtypes/GCL_GC"} + assert ct_meta.descendants_ids[ + "https://bbp.epfl.ch/ontologies/core/bmo/BrainCellType" + ] == { + "http://bbp.epfl.ch/neurosciencegraph/ontologies/mtypes/L23_PTPC", + "http://bbp.epfl.ch/neurosciencegraph/ontologies/etypes/cACint", + "http://bbp.epfl.ch/neurosciencegraph/ontologies/mtypes/GCL_GC", + } + + @pytest.mark.parametrize( + "cell_type_id,expected_descendants", + [ + ( + "https://bbp.epfl.ch/ontologies/core/bmo/BrainCellType", + { + "http://bbp.epfl.ch/neurosciencegraph/ontologies/mtypes/L23_PTPC", + "http://bbp.epfl.ch/neurosciencegraph/ontologies/etypes/cACint", + "http://bbp.epfl.ch/neurosciencegraph/ontologies/mtypes/GCL_GC", + "https://bbp.epfl.ch/ontologies/core/bmo/BrainCellType", + }, + ), + ( + "https://bbp.epfl.ch/ontologies/core/bmo/NeuronElectricalType", + { + "https://bbp.epfl.ch/ontologies/core/bmo/NeuronElectricalType", + "http://bbp.epfl.ch/neurosciencegraph/ontologies/etypes/cACint", + }, + ), + ( + [ + "https://bbp.epfl.ch/ontologies/core/bmo/BrainCellType", + "https://bbp.epfl.ch/ontologies/core/bmo/NeuronElectricalType", + ], + { + "http://bbp.epfl.ch/neurosciencegraph/ontologies/mtypes/L23_PTPC", + "http://bbp.epfl.ch/neurosciencegraph/ontologies/etypes/cACint", + "http://bbp.epfl.ch/neurosciencegraph/ontologies/mtypes/GCL_GC", + "https://bbp.epfl.ch/ontologies/core/bmo/BrainCellType", + "https://bbp.epfl.ch/ontologies/core/bmo/NeuronElectricalType", + }, + ), + ( + "https://bbp.epfl.ch/ontologies/core/bmo/NeuronElectricalType", + { + "https://bbp.epfl.ch/ontologies/core/bmo/NeuronElectricalType", + "http://bbp.epfl.ch/neurosciencegraph/ontologies/etypes/cACint", + }, + ), + ( + "http://bbp.epfl.ch/neurosciencegraph/ontologies/etypes/cACint", + { + "http://bbp.epfl.ch/neurosciencegraph/ontologies/etypes/cACint", + }, + ), + ], + ) + def test_descendants(self, cell_type_id, expected_descendants): + ct_meta = CellTypesMeta.from_json(CELL_TYPES_FILE) + assert ct_meta.descendants(cell_type_id) == expected_descendants + + def test_load_and_save_config(self, tmp_path): + ct_meta = CellTypesMeta.from_json(CELL_TYPES_FILE) + file_path = tmp_path / "ct_meta_tmp.json" + ct_meta.save_config(file_path) + ct_meta2 = CellTypesMeta.load_config(file_path) + assert ct_meta.name_ == ct_meta2.name_ + assert ct_meta.descendants_ids == ct_meta2.descendants_ids diff --git a/tests/test_resolving.py b/tests/test_resolving.py new file mode 100644 index 0000000..eeafbad --- /dev/null +++ b/tests/test_resolving.py @@ -0,0 +1,318 @@ +import pytest +from httpx import AsyncClient +from neuroagent.resolving import ( + es_resolve, + escape_punctuation, + resolve_query, + sparql_exact_resolve, + sparql_fuzzy_resolve, +) + + +@pytest.mark.asyncio +async def test_sparql_exact_resolve(httpx_mock, get_resolve_query_output): + brain_region = "Thalamus" + url = "http://fakeurl.com" + mocked_response = get_resolve_query_output[0] + httpx_mock.add_response( + url=url, + json=mocked_response, + ) + response = await sparql_exact_resolve( + query=brain_region, + resource_type="nsg:BrainRegion", + sparql_view_url=url, + token="greattokenpleasedontexpire", + httpx_client=AsyncClient(), + ) + assert response == [ + { + "label": "Thalamus", + "id": "http://api.brain-map.org/api/v2/data/Structure/549", + } + ] + + httpx_mock.reset(assert_all_responses_were_requested=False) + + mtype = "Interneuron" + mocked_response = get_resolve_query_output[1] + httpx_mock.add_response( + url=url, + json=mocked_response, + ) + response = await sparql_exact_resolve( + query=mtype, + resource_type="bmo:BrainCellType", + sparql_view_url=url, + token="greattokenpleasedontexpire", + httpx_client=AsyncClient(), + ) + assert response == [ + {"label": "Interneuron", "id": "https://neuroshapes.org/Interneuron"} + ] + + +@pytest.mark.asyncio +async def test_sparql_fuzzy_resolve(httpx_mock, get_resolve_query_output): + brain_region = "Field" + url = "http://fakeurl.com" + mocked_response = get_resolve_query_output[2] + httpx_mock.add_response( + url=url, + json=mocked_response, + ) + response = await sparql_fuzzy_resolve( + query=brain_region, + resource_type="nsg:BrainRegion", + sparql_view_url=url, + token="greattokenpleasedontexpire", + httpx_client=AsyncClient(), + search_size=3, + ) + assert response == [ + { + "label": "Field CA1", + "id": "http://api.brain-map.org/api/v2/data/Structure/382", + }, + { + "label": "Field CA2", + "id": "http://api.brain-map.org/api/v2/data/Structure/423", + }, + { + "label": "Field CA3", + "id": "http://api.brain-map.org/api/v2/data/Structure/463", + }, + ] + httpx_mock.reset(assert_all_responses_were_requested=False) + + mtype = "Interneu" + mocked_response = get_resolve_query_output[3] + httpx_mock.add_response( + url=url, + json=mocked_response, + ) + response = await sparql_fuzzy_resolve( + query=mtype, + resource_type="bmo:BrainCellType", + sparql_view_url=url, + token="greattokenpleasedontexpire", + httpx_client=AsyncClient(), + search_size=3, + ) + assert response == [ + {"label": "Interneuron", "id": "https://neuroshapes.org/Interneuron"}, + { + "label": "Hippocampus CA3 Oriens Interneuron", + "id": "http://uri.interlex.org/base/ilx_0105044", + }, + { + "label": "Spinal Cord Ventral Horn Interneuron IA", + "id": "http://uri.interlex.org/base/ilx_0110929", + }, + ] + + +@pytest.mark.asyncio +async def test_es_resolve(httpx_mock, get_resolve_query_output): + brain_region = "Auditory Cortex" + mocked_response = get_resolve_query_output[4] + httpx_mock.add_response( + url="http://goodurl.com", + json=mocked_response, + ) + response = await es_resolve( + query=brain_region, + resource_type="nsg:BrainRegion", + token="greattokenpleasedontexpire", + httpx_client=AsyncClient(), + search_size=3, + es_view_url="http://goodurl.com", + ) + assert response == [ + { + "label": "Cerebral cortex", + "id": "http://api.brain-map.org/api/v2/data/Structure/688", + }, + { + "label": "Cerebellar cortex", + "id": "http://api.brain-map.org/api/v2/data/Structure/528", + }, + { + "label": "Frontal pole, cerebral cortex", + "id": "http://api.brain-map.org/api/v2/data/Structure/184", + }, + ] + httpx_mock.reset(assert_all_responses_were_requested=True) + + mtype = "Ventral neuron" + mocked_response = get_resolve_query_output[5] + httpx_mock.add_response( + url="http://goodurl.com", + json=mocked_response, + ) + response = await es_resolve( + query=mtype, + resource_type="bmo:BrainCellType", + token="greattokenpleasedontexpire", + httpx_client=AsyncClient(), + search_size=3, + es_view_url="http://goodurl.com", + ) + assert response == [ + { + "label": "Ventral Tegmental Area Dopamine Neuron", + "id": "http://uri.interlex.org/base/ilx_0112352", + }, + { + "label": "Spinal Cord Ventral Horn Motor Neuron Gamma", + "id": "http://uri.interlex.org/base/ilx_0110943", + }, + { + "label": "Hypoglossal Nucleus Motor Neuron", + "id": "http://uri.interlex.org/base/ilx_0105169", + }, + ] + + +@pytest.mark.asyncio +async def test_resolve_query(httpx_mock, get_resolve_query_output): + url = "http://terribleurl.com" + class_view_url = "http://somewhatokurl.com" + # Mock exact match to fail + httpx_mock.add_response( + url=url, + json={ + "head": {"vars": ["subject", "predicate", "object", "context"]}, + "results": {"bindings": []}, + }, + ) + + # Hit fuzzy match + httpx_mock.add_response( + url=url, + json=get_resolve_query_output[2], + ) + + # Hit ES match + httpx_mock.add_response( + url=class_view_url, + json=get_resolve_query_output[4], + ) + response = await resolve_query( + query="Field", + resource_type="nsg:BrainRegion", + sparql_view_url=url, + es_view_url=class_view_url, + token="greattokenpleasedontexpire", + httpx_client=AsyncClient(), + search_size=3, + ) + assert response == [ + { + "label": "Field CA1", + "id": "http://api.brain-map.org/api/v2/data/Structure/382", + }, + { + "label": "Field CA2", + "id": "http://api.brain-map.org/api/v2/data/Structure/423", + }, + { + "label": "Field CA3", + "id": "http://api.brain-map.org/api/v2/data/Structure/463", + }, + ] + httpx_mock.reset(assert_all_responses_were_requested=True) + + httpx_mock.add_response(url=url, json=get_resolve_query_output[0]) + + # Hit fuzzy match + httpx_mock.add_response( + url=url, + json={ + "head": {"vars": ["subject", "predicate", "object", "context"]}, + "results": {"bindings": []}, + }, + ) + + # Hit ES match + httpx_mock.add_response(url=class_view_url, json={"hits": {"hits": []}}) + + response = await resolve_query( + query="Thalamus", + resource_type="nsg:BrainRegion", + sparql_view_url=url, + es_view_url=class_view_url, + token="greattokenpleasedontexpire", + httpx_client=AsyncClient(), + search_size=3, + ) + assert response == [ + { + "label": "Thalamus", + "id": "http://api.brain-map.org/api/v2/data/Structure/549", + } + ] + httpx_mock.reset(assert_all_responses_were_requested=True) + httpx_mock.add_response( + url=url, + json={ + "head": {"vars": ["subject", "predicate", "object", "context"]}, + "results": {"bindings": []}, + }, + ) + + # Hit fuzzy match + httpx_mock.add_response( + url=url, + json={ + "head": {"vars": ["subject", "predicate", "object", "context"]}, + "results": {"bindings": []}, + }, + ) + + # Hit ES match + httpx_mock.add_response( + url=class_view_url, + json=get_resolve_query_output[4], + ) + response = await resolve_query( + query="Auditory Cortex", + resource_type="nsg:BrainRegion", + sparql_view_url=url, + es_view_url=class_view_url, + token="greattokenpleasedontexpire", + httpx_client=AsyncClient(), + search_size=3, + ) + assert response == [ + { + "label": "Cerebral cortex", + "id": "http://api.brain-map.org/api/v2/data/Structure/688", + }, + { + "label": "Cerebellar cortex", + "id": "http://api.brain-map.org/api/v2/data/Structure/528", + }, + { + "label": "Frontal pole, cerebral cortex", + "id": "http://api.brain-map.org/api/v2/data/Structure/184", + }, + ] + + +@pytest.mark.parametrize( + "before,after", + [ + ("this is a text", "this is a text"), + ("this is text with punctuation!", "this is text with punctuation\\\\!"), + ], +) +def test_escape_punctuation(before, after): + assert after == escape_punctuation(before) + + +def test_failing_escape_punctuation(): + text = 15 # this is not a string + with pytest.raises(TypeError) as e: + escape_punctuation(text) + assert e.value.args[0] == "Only accepting strings." diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..742388b --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,443 @@ +"""Test utility functions.""" + +import json +from pathlib import Path + +import pytest +from httpx import AsyncClient +from neuroagent.schemas import KGMetadata +from neuroagent.utils import ( + RegionMeta, + get_descendants_id, + get_file_from_KG, + get_kg_data, + is_lnmc, +) + + +@pytest.mark.parametrize( + "brain_region_id,expected_descendants", + [ + ("brain-region-id/68", {"brain-region-id/68"}), + ( + "another-brain-region-id/985", + { + "another-brain-region-id/320", + "another-brain-region-id/648", + "another-brain-region-id/844", + "another-brain-region-id/882", + "another-brain-region-id/943", + "another-brain-region-id/985", + "another-brain-region-id/3718675619", + "another-brain-region-id/1758306548", + }, + ), + ( + "another-brain-region-id/369", + { + "another-brain-region-id/450", + "another-brain-region-id/369", + "another-brain-region-id/1026", + "another-brain-region-id/854", + "another-brain-region-id/577", + "another-brain-region-id/625", + "another-brain-region-id/945", + "another-brain-region-id/1890964946", + "another-brain-region-id/3693772975", + }, + ), + ( + "another-brain-region-id/178", + { + "another-brain-region-id/316", + "another-brain-region-id/178", + "another-brain-region-id/300", + "another-brain-region-id/1043765183", + }, + ), + ("brain-region-id/not-a-int", {"brain-region-id/not-a-int"}), + ], +) +def test_get_descendants(brain_region_id, expected_descendants, brain_region_json_path): + descendants = get_descendants_id(brain_region_id, json_path=brain_region_json_path) + assert expected_descendants == descendants + + +def test_get_descendants_errors(brain_region_json_path): + brain_region_id = "does-not-exits/1111111111" + with pytest.raises(KeyError): + get_descendants_id(brain_region_id, json_path=brain_region_json_path) + + +def test_RegionMeta_from_KG_dict(): + with open( + Path(__file__).parent / "data" / "KG_brain_regions_hierarchy_test.json" + ) as fh: + KG_hierarchy = json.load(fh) + + RegionMeta_test = RegionMeta.from_KG_dict(KG_hierarchy) + + # check names. + assert RegionMeta_test.name_[1] == "Tuberomammillary nucleus, ventral part" + assert ( + RegionMeta_test.name_[2] + == "Superior colliculus, motor related, intermediate gray layer" + ) + assert RegionMeta_test.name_[3] == "Primary Motor Cortex" + + # check parents / childrens. + assert RegionMeta_test.parent_id[1] == 2 + assert RegionMeta_test.parent_id[2] == 0 + assert RegionMeta_test.parent_id[3] == 2 + assert RegionMeta_test.children_ids[1] == [] + assert RegionMeta_test.children_ids[2] == [1, 3] + assert RegionMeta_test.children_ids[3] == [] + + +def test_RegionMeta_save_load(tmp_path: Path): + # load fake file from KG + with open( + Path(__file__).parent / "data" / "KG_brain_regions_hierarchy_test.json" + ) as fh: + KG_hierarchy = json.load(fh) + + RegionMeta_test = RegionMeta.from_KG_dict(KG_hierarchy) + + # save / load file. + json_file = tmp_path / "test.json" + RegionMeta_test.save_config(json_file) + RegionMeta_test.load_config(json_file) + + # check names. + assert RegionMeta_test.name_[1] == "Tuberomammillary nucleus, ventral part" + assert ( + RegionMeta_test.name_[2] + == "Superior colliculus, motor related, intermediate gray layer" + ) + assert RegionMeta_test.name_[3] == "Primary Motor Cortex" + + # check parents / childrens. + assert RegionMeta_test.parent_id[1] == 2 + assert RegionMeta_test.parent_id[2] == 0 + assert RegionMeta_test.parent_id[3] == 2 + assert RegionMeta_test.children_ids[1] == [] + assert RegionMeta_test.children_ids[2] == [1, 3] + assert RegionMeta_test.children_ids[3] == [] + + +def test_RegionMeta_load_real_file(brain_region_json_path): + RegionMeta_test = RegionMeta.load_config(brain_region_json_path) + + # check root. + assert RegionMeta_test.root_id == 997 + assert RegionMeta_test.parent_id[997] == 0 + + # check some names / st_levels. + assert RegionMeta_test.name_[123] == "Koelliker-Fuse subnucleus" + assert RegionMeta_test.name_[78] == "middle cerebellar peduncle" + assert RegionMeta_test.st_level[55] == 10 + + # check some random parents / childrens. + assert RegionMeta_test.parent_id[12] == 165 + assert RegionMeta_test.parent_id[78] == 752 + assert RegionMeta_test.parent_id[700] == 88 + assert RegionMeta_test.parent_id[900] == 840 + assert RegionMeta_test.children_ids[12] == [] + assert RegionMeta_test.children_ids[23] == [] + assert RegionMeta_test.children_ids[670] == [2260827822, 3562104832] + assert RegionMeta_test.children_ids[31] == [1053, 179, 227, 39, 48, 572, 739] + + +@pytest.mark.asyncio +async def test_get_file_from_KG_errors(httpx_mock): + file_url = "http://fake_url.com" + file_name = "fake_file" + view_url = "http://fake_url_view.com" + token = "fake_token" + client = AsyncClient() + + # first response from KG is not a json + httpx_mock.add_response(url=view_url, text="not a json") + + with pytest.raises(ValueError) as not_json: + await get_file_from_KG( + file_url=file_url, + file_name=file_name, + view_url=view_url, + token=token, + httpx_client=client, + ) + assert not_json.value.args[0] == "url_response did not return a Json." + + # no file url found in the KG + httpx_mock.add_response( + url=view_url, json={"head": {"vars": ["file_url"]}, "results": {"bindings": []}} + ) + + with pytest.raises(IndexError) as not_found: + await get_file_from_KG( + file_url=file_url, + file_name=file_name, + view_url=view_url, + token=token, + httpx_client=client, + ) + assert not_found.value.args[0] == "No file url was found." + + httpx_mock.reset(assert_all_responses_were_requested=True) + # no file found corresponding to file_url + test_file_url = "http://test_url.com" + json_response = { + "head": {"vars": ["file_url"]}, + "results": { + "bindings": [{"file_url": {"type": "uri", "value": test_file_url}}] + }, + } + + httpx_mock.add_response(url=view_url, json=json_response) + httpx_mock.add_response(url=test_file_url, status_code=401) + + with pytest.raises(ValueError) as not_found: + await get_file_from_KG( + file_url=file_url, + file_name=file_name, + view_url=view_url, + token=token, + httpx_client=client, + ) + assert not_found.value.args[0] == "Could not find the file, status code : 401" + + # Problem finding the file url + httpx_mock.add_response(url=view_url, status_code=401) + + with pytest.raises(ValueError) as not_found: + await get_file_from_KG( + file_url=file_url, + file_name=file_name, + view_url=view_url, + token=token, + httpx_client=client, + ) + assert not_found.value.args[0] == "Could not find the file url, status code : 401" + + +@pytest.mark.asyncio +async def test_get_file_from_KG(httpx_mock): + file_url = "http://fake_url" + file_name = "fake_file" + view_url = "http://fake_url" + token = "fake_token" + test_file_url = "http://test_url" + client = AsyncClient() + + json_response_url = { + "head": {"vars": ["file_url"]}, + "results": { + "bindings": [{"file_url": {"type": "uri", "value": test_file_url}}] + }, + } + with open( + Path(__file__).parent / "data" / "KG_brain_regions_hierarchy_test.json" + ) as fh: + json_response_file = json.load(fh) + + httpx_mock.add_response(url=view_url, json=json_response_url) + httpx_mock.add_response(url=test_file_url, json=json_response_file) + + response = await get_file_from_KG( + file_url=file_url, + file_name=file_name, + view_url=view_url, + token=token, + httpx_client=client, + ) + + assert response == json_response_file + + +@pytest.mark.asyncio +async def test_get_kg_data_errors(httpx_mock): + url = "http://fake_url" + token = "fake_token" + client = AsyncClient() + + # First failure: invalid object_id + with pytest.raises(ValueError) as invalid_object_id: + await get_kg_data( + object_id="invalid_object_id", + httpx_client=client, + url=url, + token=token, + preferred_format="preferred_format", + ) + + assert ( + invalid_object_id.value.args[0] + == "The provided ID (invalid_object_id) is not valid." + ) + + # Second failure: Number of hits = 0 + httpx_mock.add_response(url=url, json={"hits": {"hits": []}}) + + with pytest.raises(ValueError) as no_hits: + await get_kg_data( + object_id="https://object-id", + httpx_client=client, + url=url, + token=token, + preferred_format="preferred_format", + ) + + assert ( + no_hits.value.args[0] + == "We did not find the object https://object-id you are asking" + ) + + # Third failure: Wrong object id + httpx_mock.add_response( + url=url, json={"hits": {"hits": [{"_source": {"@id": "wrong-object-id"}}]}} + ) + + with pytest.raises(ValueError) as wrong_object_id: + await get_kg_data( + object_id="https://object-id", + httpx_client=client, + url=url, + token=token, + preferred_format="preferred_format", + ) + + assert ( + wrong_object_id.value.args[0] + == "We did not find the object https://object-id you are asking" + ) + + +@pytest.mark.asyncio +async def test_get_kg_data(httpx_mock): + url = "http://fake_url" + token = "fake_token" + client = AsyncClient() + preferred_format = "txt" + object_id = "https://object-id" + + response_json = { + "hits": { + "hits": [ + { + "_source": { + "@id": object_id, + "distribution": [ + { + "encodingFormat": f"application/{preferred_format}", + "contentUrl": "http://content-url-txt", + } + ], + "contributors": [ + { + "@id": "https://www.grid.ac/institutes/grid.5333.6", + } + ], + "brainRegion": { + "@id": "http://api.brain-map.org/api/v2/data/Structure/252", + "idLabel": ( + "http://api.brain-map.org/api/v2/data/Structure/252|Dorsal" + " auditory area, layer 5" + ), + "identifier": ( + "http://api.brain-map.org/api/v2/data/Structure/252" + ), + "label": "Dorsal auditory area, layer 5", + }, + } + } + ] + } + } + httpx_mock.add_response( + url=url, + json=response_json, + ) + + httpx_mock.add_response( + url="http://content-url-txt", + content=b"this is the txt content", + ) + + # Response with preferred format + object_content, metadata = await get_kg_data( + object_id="https://object-id", + httpx_client=client, + url=url, + token=token, + preferred_format=preferred_format, + ) + + assert isinstance(object_content, bytes) + assert isinstance(metadata, KGMetadata) + assert metadata.file_extension == "txt" + assert metadata.is_lnmc is True + + # Response without preferred format + object_content, reader = await get_kg_data( + object_id="https://object-id", + httpx_client=client, + url=url, + token=token, + preferred_format="no_preferred_format_available", + ) + + assert isinstance(object_content, bytes) + assert isinstance(metadata, KGMetadata) + assert metadata.file_extension == "txt" + assert metadata.is_lnmc is True + + +@pytest.mark.parametrize( + "contributors,expected_bool", + [ + ( + [ + { + "@id": "https://www.grid.ac/institutes/grid.5333.6", + "@type": ["http://schema.org/Organization"], + "label": "École Polytechnique Fédérale de Lausanne", + } + ], + True, + ), + ( + [ + { + "@id": "https://bbp.epfl.ch/nexus/v1/realms/bbp/users/gevaert", + "@type": ["http://schema.org/Person"], + "affiliation": "École Polytechnique Fédérale de Lausanne", + } + ], + True, + ), + ( + [ + {}, + { + "@id": "https://bbp.epfl.ch/nexus/v1/realms/bbp/users/kanari", + "@type": ["http://schema.org/Person"], + "affiliation": "École Polytechnique Fédérale de Lausanne", + }, + ], + True, + ), + ( + [ + { + "@id": "wrong-id", + "@type": ["http://schema.org/Person"], + "affiliation": "Another school", + } + ], + False, + ), + ], +) +def test_is_lnmc(contributors, expected_bool): + assert is_lnmc(contributors) is expected_bool diff --git a/tests/tools/__init__.py b/tests/tools/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/tools/test_basic_tool.py b/tests/tools/test_basic_tool.py new file mode 100644 index 0000000..fb9ab17 --- /dev/null +++ b/tests/tools/test_basic_tool.py @@ -0,0 +1,105 @@ +from typing import Literal, Type + +from langchain_core.language_models.fake_chat_models import FakeMessagesListChatModel +from langchain_core.messages import AIMessage, HumanMessage +from langchain_core.tools import ToolException +from langgraph.prebuilt import create_react_agent +from neuroagent.tools.base_tool import BasicTool +from pydantic import BaseModel + + +class input_for_test(BaseModel): + test_str: str + test_int: int + test_litteral: Literal["Allowed_1", "Allowed_2"] | None = None + + +class basic_tool_for_test(BasicTool): + name: str = "basic_tool_for_test" + description: str = "Dummy tool to test validation and tool errors." + args_schema: Type[BaseModel] = input_for_test + + def _run(self, test_str, test_int): + raise ToolException("fake tool error message", self.name) + + +def test_basic_tool_error_handling(): + response_list = [ + # test tool error. + AIMessage( + content="", + tool_calls=[ + { + "id": "tool_call00", + "name": "basic_tool_for_test", + "args": { + "test_str": "Hello", + "test_int": 1, + }, + }, + ], + ), + # test all possible validation error. + AIMessage( + content="", + tool_calls=[ + { + "id": "tool_call123", + "name": "basic_tool_for_test", + "args": { + "test_str": "3", + "test_int": 1, + "test_litteral": "Forbidden_value", + }, + }, + { + "id": "tool_call567", + "name": "basic_tool_for_test", + "args": {}, + }, + { + "id": "tool_call891", + "name": "basic_tool_for_test", + "args": { + "test_str": {"dummy": "test_dict"}, + "test_int": "hello", + }, + }, + ], + ), + AIMessage(content="fake answer"), + ] + tool_list = [basic_tool_for_test()] + + class FakeFuntionChatModel(FakeMessagesListChatModel): + def bind_tools(self, functions: list): + return self + + fake_llm = FakeFuntionChatModel(responses=response_list) + + fake_agent = create_react_agent(fake_llm, tool_list) + + response = fake_agent.invoke({"messages": [HumanMessage(content="fake_message")]}) + + assert ( + response["messages"][2].content + == '{"basic_tool_for_test": "fake tool error message"}' + ) + assert ( + response["messages"][4].content + == '[{"Validation error": "Wrong value: provided Forbidden_value for input' + ' test_litteral. Try again and change this problematic input."}]' + ) + assert ( + response["messages"][5].content + == '[{"Validation error": "Missing input : test_str. Try again and add this' + ' input."}, {"Validation error": "Missing input : test_int. Try again and' + ' add this input."}]' + ) + assert ( + response["messages"][6].content + == '[{"Validation error": "test_str. Input should be a valid string"}, ' + '{"Validation error": "test_int. Input should be a valid integer, ' + 'unable to parse string as an integer"}]' + ) + assert response["messages"][7].content == "fake answer" diff --git a/tests/tools/test_electrophys_tool.py b/tests/tools/test_electrophys_tool.py new file mode 100644 index 0000000..e7729fc --- /dev/null +++ b/tests/tools/test_electrophys_tool.py @@ -0,0 +1,133 @@ +"""Tests Electrophys tool.""" + +import json +from pathlib import Path + +import httpx +import pytest +from langchain_core.tools import ToolException +from neuroagent.tools import ElectrophysFeatureTool +from neuroagent.tools.electrophys_tool import ( + CALCULATED_FEATURES, + AmplitudeInput, + FeaturesOutput, +) + + +class TestElectrophysTool: + @pytest.mark.asyncio + async def test_arun(self, httpx_mock): + url = "http://fake_url" + json_path = ( + Path(__file__).resolve().parent.parent / "data" / "trace_id_metadata.json" + ) + with open(json_path) as f: + electrophys_response = json.load(f) + + httpx_mock.add_response( + url=url, + json=electrophys_response, + ) + + trace_path = Path(__file__).resolve().parent.parent / "data" / "99111002.nwb" + with open(trace_path, "rb") as f: + trace_content = f.read() + + httpx_mock.add_response( + url="https://bbp.epfl.ch/nexus/v1/files/demo/morpho-demo/https%3A%2F%2Fbbp.epfl.ch%2Fdata%2Fdata%2Fdemo%2Fmorpho-demo%2F01dffb7b-1122-4e1a-9acf-837e683da4ba", + content=trace_content, + ) + + tool = ElectrophysFeatureTool( + metadata={ + "url": url, + "search_size": 2, + "httpx_client": httpx.AsyncClient(), + "token": "fake_token", + } + ) + + trace_id = "https://bbp.epfl.ch/data/demo/morpho-demo/1761e604-03fc-452b-9bf2-2214782bb751" + + response = await tool._arun( + trace_id=trace_id, + stimuli_types=[ + "step", + ], + calculated_feature=[ + "mean_frequency", + ], + ) + assert isinstance(response, FeaturesOutput) + assert isinstance(response.feature_dict, dict) + assert len(response.feature_dict.keys()) == 1 + assert ( + len(response.feature_dict["step_0"].keys()) + == 2 # mean_frequency + 1 for stimulus current added manually + ) + + # With specified amplitude + response = await tool._arun( + trace_id=trace_id, + stimuli_types=[ + "step", + ], + calculated_feature=[ + "mean_frequency", + ], + amplitude=AmplitudeInput(min_value=-0.5, max_value=1), + ) + assert isinstance(response, FeaturesOutput) + assert isinstance(response.feature_dict, dict) + assert len(response.feature_dict.keys()) == 1 + assert ( + len(response.feature_dict["step_0.25"].keys()) + == 2 # mean_frequency + 1 for stimulus current added manually + ) + + # Without stimuli types and calculated features + response = await tool._arun( + trace_id=trace_id, + stimuli_types=[ + "step", + ], + calculated_feature=[], + ) + assert isinstance(response, FeaturesOutput) + assert isinstance(response.feature_dict, dict) + assert len(response.feature_dict.keys()) == 1 + assert ( + len(response.feature_dict["step_0"].keys()) + == len(list(CALCULATED_FEATURES.__args__[0].__args__)) + + 1 # 1 for stimulus current added manually + ) + + @pytest.mark.asyncio + async def test_arun_errors(self): + # Do not receive trace content back + url = "http://fake_url" + + tool = ElectrophysFeatureTool( + metadata={ + "url": url, + "search_size": 2, + "httpx_client": httpx.AsyncClient(), + "token": "fake_token", + } + ) + + with pytest.raises(ToolException) as tool_exception: + _ = await tool._arun( + trace_id="wrong-trace-id", + stimuli_types=[ + "idrest", + ], + calculated_feature=[ + "mean_frequency", + ], + ) + + assert ( + tool_exception.value.args[0] + == "The provided ID (wrong-trace-id) is not valid." + ) diff --git a/tests/tools/test_get_morpho_tool.py b/tests/tools/test_get_morpho_tool.py new file mode 100644 index 0000000..d3ef4ab --- /dev/null +++ b/tests/tools/test_get_morpho_tool.py @@ -0,0 +1,167 @@ +"""Tests Get Morpho tool.""" + +import json +from pathlib import Path + +import httpx +import pytest +from langchain_core.tools import ToolException +from neuroagent.tools import GetMorphoTool +from neuroagent.tools.get_morpho_tool import KnowledgeGraphOutput + + +class TestGetMorphoTool: + @pytest.mark.asyncio + async def test_arun(self, httpx_mock, brain_region_json_path, tmp_path): + url = "http://fake_url" + json_path = ( + Path(__file__).resolve().parent.parent / "data" / "knowledge_graph.json" + ) + with open(json_path) as f: + knowledge_graph_response = json.load(f) + + httpx_mock.add_response( + url=url, + json=knowledge_graph_response, + ) + tool = GetMorphoTool( + metadata={ + "url": url, + "search_size": 2, + "httpx_client": httpx.AsyncClient(), + "token": "fake_token", + "brainregion_path": brain_region_json_path, + "celltypes_path": tmp_path, + } + ) + response = await tool._arun( + brain_region_id="brain_region_id_link/549", + mtype_id="brain_region_id_link/549", + ) + assert isinstance(response, list) + assert len(response) == 2 + assert isinstance(response[0], KnowledgeGraphOutput) + + @pytest.mark.asyncio + async def test_arun_errors(self, httpx_mock, brain_region_json_path, tmp_path): + url = "http://fake_url" + httpx_mock.add_response( + url=url, + json={}, + ) + + tool = GetMorphoTool( + metadata={ + "url": url, + "search_size": 2, + "httpx_client": httpx.AsyncClient(), + "token": "fake_token", + "brainregion_path": brain_region_json_path, + "celltypes_path": tmp_path, + } + ) + with pytest.raises(ToolException) as tool_exception: + _ = await tool._arun( + brain_region_id="brain_region_id_link/bad", + mtype_id="brain_region_id_link/superbad", + ) + + assert tool_exception.value.args[0] == "'hits'" + + +def test_create_query(): + url = "http://fake_url" + + tool = GetMorphoTool( + metadata={ + "url": url, + "search_size": 2, + "httpx_client": httpx.AsyncClient(), + "token": "fake_token", + } + ) + + # This should be a set, but passing a list here ensures that the test doesn;t rely on order. + brain_regions_ids = ["brain-region-id/68", "brain-region-id/131"] + mtype_id = "mtype-id/1234" + + entire_query = tool.create_query( + brain_regions_ids=brain_regions_ids, mtype_ids={mtype_id} + ) + expected_query = { + "size": 2, + "track_total_hits": True, + "query": { + "bool": { + "must": [ + { + "bool": { + "should": [ + { + "term": { + "brainRegion.@id.keyword": "brain-region-id/68" + } + }, + { + "term": { + "brainRegion.@id.keyword": "brain-region-id/131" + } + }, + ] + } + }, + {"bool": {"should": [{"term": {"mType.@id.keyword": mtype_id}}]}}, + { + "term": { + "@type.keyword": ( + "https://neuroshapes.org/ReconstructedNeuronMorphology" + ) + } + }, + {"term": {"deprecated": False}}, + {"term": {"curated": True}}, + ] + } + }, + } + assert isinstance(entire_query, dict) + assert entire_query == expected_query + + # Case 2 with no mtype + entire_query1 = tool.create_query(brain_regions_ids=brain_regions_ids) + expected_query1 = { + "size": 2, + "track_total_hits": True, + "query": { + "bool": { + "must": [ + { + "bool": { + "should": [ + { + "term": { + "brainRegion.@id.keyword": "brain-region-id/68" + } + }, + { + "term": { + "brainRegion.@id.keyword": "brain-region-id/131" + } + }, + ] + } + }, + { + "term": { + "@type.keyword": ( + "https://neuroshapes.org/ReconstructedNeuronMorphology" + ) + } + }, + {"term": {"deprecated": False}}, + {"term": {"curated": True}}, + ] + } + }, + } + assert entire_query1 == expected_query1 diff --git a/tests/tools/test_kg_morpho_features_tool.py b/tests/tools/test_kg_morpho_features_tool.py new file mode 100644 index 0000000..2f5473c --- /dev/null +++ b/tests/tools/test_kg_morpho_features_tool.py @@ -0,0 +1,396 @@ +"""Tests KG Morpho Features tool.""" + +import json +from pathlib import Path + +import httpx +import pytest +from langchain_core.tools import ToolException +from neuroagent.tools import KGMorphoFeatureTool +from neuroagent.tools.kg_morpho_features_tool import ( + FeatRangeInput, + FeatureInput, + KGMorphoFeatureOutput, +) + + +class TestKGMorphoFeaturesTool: + @pytest.mark.asyncio + async def test_arun(self, httpx_mock, brain_region_json_path): + url = "http://fake_url" + json_path = ( + Path(__file__).resolve().parent.parent + / "data" + / "kg_morpho_features_response.json" + ) + with open(json_path) as f: + kg_morpho_features_response = json.load(f) + + httpx_mock.add_response( + url=url, + json=kg_morpho_features_response, + ) + + tool = KGMorphoFeatureTool( + metadata={ + "url": url, + "search_size": 2, + "httpx_client": httpx.AsyncClient(), + "token": "fake_token", + "brainregion_path": brain_region_json_path, + } + ) + + feature_input = FeatureInput( + label="Section Tortuosity", + ) + + response = await tool._arun( + brain_region_id="brain_region_id_link/549", features=feature_input + ) + assert isinstance(response, list) + assert len(response) == 2 + assert isinstance(response[0], KGMorphoFeatureOutput) + + @pytest.mark.asyncio + async def test_arun_errors(self, httpx_mock, brain_region_json_path): + url = "http://fake_url" + + # Mock issue (resolve query without results) + httpx_mock.add_response( + url=url, + json={}, + ) + + tool = KGMorphoFeatureTool( + metadata={ + "url": url, + "search_size": 2, + "httpx_client": httpx.AsyncClient(), + "token": "fake_token", + "brainregion_path": brain_region_json_path, + } + ) + + feature_input = FeatureInput( + label="Section Tortuosity", + ) + with pytest.raises(ToolException) as tool_exception: + _ = await tool._arun( + brain_region_id="brain_region_id_link/549", features=feature_input + ) + assert tool_exception.value.args[0] == "'hits'" + + def test_create_query(self, brain_region_json_path): + url = "http://fake_url" + + tool = KGMorphoFeatureTool( + metadata={ + "url": url, + "search_size": 2, + "httpx_client": httpx.AsyncClient(), + "token": "fake_token", + "brainregion_path": brain_region_json_path, + } + ) + + feature_input = FeatureInput( + label="Soma Radius", + compartment="NeuronMorphology", + ) + + brain_regions_ids = {"brain-region-id/68"} + + entire_query = tool.create_query( + brain_regions_ids=brain_regions_ids, features=feature_input + ) + expected_query = { + "size": 2, + "track_total_hits": True, + "query": { + "bool": { + "must": [ + { + "bool": { + "should": [ + { + "term": { + "brainRegion.@id.keyword": ( + "brain-region-id/68" + ) + } + } + ] + } + }, + { + "nested": { + "path": "featureSeries", + "query": { + "bool": { + "must": [ + { + "term": { + "featureSeries.label.keyword": ( + "Soma Radius" + ) + } + }, + { + "term": { + "featureSeries.compartment.keyword": ( + "NeuronMorphology" + ) + } + }, + ] + } + }, + } + }, + { + "term": { + "@type.keyword": "https://bbp.epfl.ch/ontologies/core/bmo/NeuronMorphologyFeatureAnnotation" + } + }, + {"term": {"deprecated": False}}, + ] + } + }, + } + assert isinstance(entire_query, dict) + assert entire_query == expected_query + + # Case 2 with max value + feature_input1 = FeatureInput( + label="Soma Radius", + compartment="NeuronMorphology", + feat_range=FeatRangeInput(max_value=5), + ) + entire_query1 = tool.create_query( + brain_regions_ids=brain_regions_ids, features=feature_input1 + ) + expected_query1 = { + "size": 2, + "track_total_hits": True, + "query": { + "bool": { + "must": [ + { + "bool": { + "should": [ + { + "term": { + "brainRegion.@id.keyword": ( + "brain-region-id/68" + ) + } + } + ] + } + }, + { + "nested": { + "path": "featureSeries", + "query": { + "bool": { + "must": [ + { + "term": { + "featureSeries.label.keyword": ( + "Soma Radius" + ) + } + }, + { + "term": { + "featureSeries.compartment.keyword": ( + "NeuronMorphology" + ) + } + }, + { + "term": { + "featureSeries.statistic.keyword": ( + "raw" + ) + } + }, + { + "range": { + "featureSeries.value": {"lte": 5.0} + } + }, + ] + } + }, + } + }, + { + "term": { + "@type.keyword": "https://bbp.epfl.ch/ontologies/core/bmo/NeuronMorphologyFeatureAnnotation" + } + }, + {"term": {"deprecated": False}}, + ] + } + }, + } + assert entire_query1 == expected_query1 + + # Case 3 with min value + feature_input2 = FeatureInput( + label="Soma Radius", + compartment="NeuronMorphology", + feat_range=FeatRangeInput(min_value=2), + ) + entire_query2 = tool.create_query( + brain_regions_ids=brain_regions_ids, features=feature_input2 + ) + expected_query2 = { + "size": 2, + "track_total_hits": True, + "query": { + "bool": { + "must": [ + { + "bool": { + "should": [ + { + "term": { + "brainRegion.@id.keyword": ( + "brain-region-id/68" + ) + } + } + ] + } + }, + { + "nested": { + "path": "featureSeries", + "query": { + "bool": { + "must": [ + { + "term": { + "featureSeries.label.keyword": ( + "Soma Radius" + ) + } + }, + { + "term": { + "featureSeries.compartment.keyword": ( + "NeuronMorphology" + ) + } + }, + { + "term": { + "featureSeries.statistic.keyword": ( + "raw" + ) + } + }, + { + "range": { + "featureSeries.value": {"gte": 2.0} + } + }, + ] + } + }, + } + }, + { + "term": { + "@type.keyword": "https://bbp.epfl.ch/ontologies/core/bmo/NeuronMorphologyFeatureAnnotation" + } + }, + {"term": {"deprecated": False}}, + ] + } + }, + } + assert entire_query2 == expected_query2 + + # Case 4 with min and max value + feature_input3 = FeatureInput( + label="Soma Radius", + compartment="NeuronMorphology", + feat_range=FeatRangeInput(min_value=2, max_value=5), + ) + entire_query3 = tool.create_query( + brain_regions_ids=brain_regions_ids, features=feature_input3 + ) + expected_query3 = { + "size": 2, + "track_total_hits": True, + "query": { + "bool": { + "must": [ + { + "bool": { + "should": [ + { + "term": { + "brainRegion.@id.keyword": ( + "brain-region-id/68" + ) + } + } + ] + } + }, + { + "nested": { + "path": "featureSeries", + "query": { + "bool": { + "must": [ + { + "term": { + "featureSeries.label.keyword": ( + "Soma Radius" + ) + } + }, + { + "term": { + "featureSeries.compartment.keyword": ( + "NeuronMorphology" + ) + } + }, + { + "term": { + "featureSeries.statistic.keyword": ( + "raw" + ) + } + }, + { + "range": { + "featureSeries.value": { + "gte": 2.0, + "lte": 5.0, + } + } + }, + ] + } + }, + } + }, + { + "term": { + "@type.keyword": "https://bbp.epfl.ch/ontologies/core/bmo/NeuronMorphologyFeatureAnnotation" + } + }, + {"term": {"deprecated": False}}, + ] + } + }, + } + assert entire_query3 == expected_query3 diff --git a/tests/tools/test_literature_search_tool.py b/tests/tools/test_literature_search_tool.py new file mode 100644 index 0000000..0f58728 --- /dev/null +++ b/tests/tools/test_literature_search_tool.py @@ -0,0 +1,84 @@ +"""Tests Literature Search tool.""" + +from unittest.mock import AsyncMock, Mock + +import httpx +import pytest +from neuroagent.tools import LiteratureSearchTool +from neuroagent.tools.literature_search_tool import ParagraphMetadata + + +class TestLiteratureSearchTool: + @pytest.mark.asyncio + async def test_arun(self): + url = "http://fake_url" + reranker_k = 5 + + client = httpx.AsyncClient() + client.get = AsyncMock() + response = Mock() + response.status_code = 200 + client.get.return_value = response + response.json.return_value = [ + { + "article_title": "Article title", + "article_authors": ["Author1", "Author2"], + "paragraph": "This is the paragraph", + "section": "fake_section", + "article_doi": "fake_doi", + "journal_issn": "fake_journal_issn", + } + for _ in range(reranker_k) + ] + + tool = LiteratureSearchTool( + metadata={ + "url": url, + "httpx_client": client, + "token": "fake_token", + "retriever_k": 100, + "use_reranker": True, + "reranker_k": 5, + } + ) + + response = await tool._arun(query="covid 19") + assert isinstance(response, list) + assert len(response) == reranker_k + assert isinstance(response[0], ParagraphMetadata) + + def test_run(self): + url = "http://fake_url" + reranker_k = 5 + + client = httpx.Client() + client.get = Mock() + response = Mock() + client.get.return_value = response + response.json.return_value = [ + { + "article_title": "Article title", + "article_authors": ["Author1", "Author2"], + "paragraph": "This is the paragraph", + "section": "fake_section", + "article_doi": "fake_doi", + "journal_issn": "fake_journal_issn", + } + for _ in range(reranker_k) + ] + + tool = LiteratureSearchTool( + metadata={ + "url": url, + "httpx_client": client, + "token": "fake_token", + "retriever_k": 100, + "use_reranker": True, + "reranker_k": 5, + } + ) + + response = tool._run(query="covid 19") + assert isinstance(response, list) + assert len(response) == reranker_k + assert isinstance(response[0], ParagraphMetadata) diff --git a/tests/tools/test_morphology_features_tool.py b/tests/tools/test_morphology_features_tool.py new file mode 100644 index 0000000..22943e6 --- /dev/null +++ b/tests/tools/test_morphology_features_tool.py @@ -0,0 +1,94 @@ +"""Tests Morphology features tool.""" + +import json +from pathlib import Path + +import httpx +import pytest +from langchain_core.tools import ToolException +from neuroagent.tools import MorphologyFeatureTool +from neuroagent.tools.morphology_features_tool import MorphologyFeatureOutput + + +class TestMorphologyFeatureTool: + @pytest.mark.asyncio + async def test_arun(self, httpx_mock): + url = "http://fake_url" + morphology_id = "https://bbp.epfl.ch/neurosciencegraph/data/neuronmorphologies/046fb11c-8de8-42e8-9303-9d5a65ac04b9" + json_path = ( + Path(__file__).resolve().parent.parent + / "data" + / "morphology_id_metadata_response.json" + ) + with open(json_path) as f: + morphology_metadata_response = json.load(f) + + # Mock get morphology ids + httpx_mock.add_response( + url=url, + json=morphology_metadata_response, + ) + + morphology_path = Path(__file__).resolve().parent.parent / "data" / "simple.swc" + with open(morphology_path) as f: + morphology_content = f.read() + + # Mock get object id request + httpx_mock.add_response( + url="https://bbp.epfl.ch/nexus/v1/files/bbp/mouselight/https%3A%2F%2Fbbp.epfl.ch%2Fneurosciencegraph%2Fdata%2Fad8fec6f-d59c-4998-beb4-274fa115add7", + content=morphology_content, + ) + + tool = MorphologyFeatureTool( + metadata={ + "url": url, + "httpx_client": httpx.AsyncClient(), + "token": "fake_token", + } + ) + + response = await tool._arun(morphology_id=morphology_id) + assert isinstance(response[0], MorphologyFeatureOutput) + assert len(response[0].feature_dict) == 23 + + @pytest.mark.asyncio + async def test_arun_errors(self, httpx_mock): + url = "http://fake_url" + morphology_id = "https://bbp.epfl.ch/neurosciencegraph/data/neuronmorphologies/046fb11c-8de8-42e8-9303-9d5a65ac04b9" + tool = MorphologyFeatureTool( + metadata={ + "url": url, + "httpx_client": httpx.AsyncClient(), + "token": "fake_token", + } + ) + + # test different failures + # Failure 1 + httpx_mock.add_response( + url=url, + status_code=404, + ) + with pytest.raises(ToolException) as tool_exception: + _ = await tool._arun(morphology_id=morphology_id) + + assert ( + tool_exception.value.args[0] == "We did not find the object" + " https://bbp.epfl.ch/neurosciencegraph/data/neuronmorphologies/046fb11c-8de8-42e8-9303-9d5a65ac04b9" + " you are asking" + ) + + # Failure 2 + fake_json = {"hits": {"hits": [{"_source": {"@id": "wrong_id"}}]}} + httpx_mock.add_response( + url=url, + json=fake_json, + ) + with pytest.raises(ToolException) as tool_exception: + _ = await tool._arun(morphology_id=morphology_id) + + assert ( + tool_exception.value.args[0] == "We did not find the object" + " https://bbp.epfl.ch/neurosciencegraph/data/neuronmorphologies/046fb11c-8de8-42e8-9303-9d5a65ac04b9" + " you are asking" + ) diff --git a/tests/tools/test_resolve_br_tool.py b/tests/tools/test_resolve_br_tool.py new file mode 100644 index 0000000..697a6ef --- /dev/null +++ b/tests/tools/test_resolve_br_tool.py @@ -0,0 +1,88 @@ +"""Test the revole_brain_region_tool.""" + +import pytest +from httpx import AsyncClient +from neuroagent.tools import ResolveBrainRegionTool +from neuroagent.tools.resolve_brain_region_tool import ( + BRResolveOutput, + MTypeResolveOutput, +) + + +@pytest.mark.asyncio +async def test_arun(httpx_mock, get_resolve_query_output): + tool = ResolveBrainRegionTool( + metadata={ + "search_size": 3, + "token": "greattokenpleasedontexpire", + "httpx_client": AsyncClient(timeout=None), + "kg_sparql_url": "http://fake_sparql_url.com/78", + "kg_class_view_url": "http://fake_class_url.com/78", + } + ) + + # Mock exact match to fail + httpx_mock.add_response( + url="http://fake_sparql_url.com/78", + json={ + "head": {"vars": ["subject", "predicate", "object", "context"]}, + "results": {"bindings": []}, + }, + ) + + # Hit fuzzy match + httpx_mock.add_response( + url="http://fake_sparql_url.com/78", + json=get_resolve_query_output[2], + ) + + # Hit ES match + httpx_mock.add_response( + url="http://fake_class_url.com/78", + json=get_resolve_query_output[4], + ) + + # Mock exact match to fail (mtype) + httpx_mock.add_response( + url="http://fake_sparql_url.com/78", + json={ + "head": {"vars": ["subject", "predicate", "object", "context"]}, + "results": {"bindings": []}, + }, + ) + + # Hit fuzzy match (mtype) + httpx_mock.add_response( + url="http://fake_sparql_url.com/78", + json=get_resolve_query_output[3], + ) + # Hit ES match (mtype). + httpx_mock.add_response( + url="http://fake_class_url.com/78", json=get_resolve_query_output[5] + ) + response = await tool._arun(brain_region="Field", mtype="Interneu") + assert response == [ + BRResolveOutput( + brain_region_name="Field CA1", + brain_region_id="http://api.brain-map.org/api/v2/data/Structure/382", + ), + BRResolveOutput( + brain_region_name="Field CA2", + brain_region_id="http://api.brain-map.org/api/v2/data/Structure/423", + ), + BRResolveOutput( + brain_region_name="Field CA3", + brain_region_id="http://api.brain-map.org/api/v2/data/Structure/463", + ), + MTypeResolveOutput( + mtype_name="Interneuron", mtype_id="https://neuroshapes.org/Interneuron" + ), + MTypeResolveOutput( + mtype_name="Hippocampus CA3 Oriens Interneuron", + mtype_id="http://uri.interlex.org/base/ilx_0105044", + ), + MTypeResolveOutput( + mtype_name="Spinal Cord Ventral Horn Interneuron IA", + mtype_id="http://uri.interlex.org/base/ilx_0110929", + ), + ] diff --git a/tests/tools/test_traces_tool.py b/tests/tools/test_traces_tool.py new file mode 100644 index 0000000..0bae056 --- /dev/null +++ b/tests/tools/test_traces_tool.py @@ -0,0 +1,117 @@ +"""Tests Traces tool.""" + +import json +from pathlib import Path + +import httpx +import pytest +from langchain_core.tools import ToolException +from neuroagent.tools import GetTracesTool +from neuroagent.tools.traces_tool import TracesOutput + + +class TestTracesTool: + @pytest.mark.asyncio + async def test_arun(self, httpx_mock, brain_region_json_path): + url = "http://fake_url" + json_path = Path(__file__).resolve().parent.parent / "data" / "get_traces.json" + with open(json_path) as f: + get_traces_response = json.load(f) + + httpx_mock.add_response( + url=url, + json=get_traces_response, + ) + + tool = GetTracesTool( + metadata={ + "url": url, + "search_size": 2, + "httpx_client": httpx.AsyncClient(), + "token": "fake_token", + "brainregion_path": brain_region_json_path, + } + ) + + response = await tool._arun(brain_region_id="brain_region_id_link/549") + assert isinstance(response, list) + assert len(response) == 2 + assert isinstance(response[0], TracesOutput) + + # With specific etype + response = await tool._arun( + brain_region_id="brain_region_id_link/549", etype="bAC" + ) + assert isinstance(response, list) + assert len(response) == 2 + assert isinstance(response[0], TracesOutput) + + def test_create_query(self): + brain_region_ids = {"brain_region_id1"} + etype = "bAC" + + tool = GetTracesTool(metadata={"search_size": 2}) + entire_query = tool.create_query(brain_region_ids=brain_region_ids, etype=etype) + expected_query = { + "size": 2, + "track_total_hits": True, + "query": { + "bool": { + "must": [ + { + "bool": { + "should": [ + { + "term": { + "brainRegion.@id.keyword": ( + "brain_region_id1" + ) + } + }, + ] + } + }, + { + "term": { + "eType.@id.keyword": ( + "http://uri.interlex.org/base/ilx_0738199" + ) + } + }, + { + "term": { + "@type.keyword": "https://bbp.epfl.ch/ontologies/core/bmo/ExperimentalTrace" + } + }, + {"term": {"curated": True}}, + {"term": {"deprecated": False}}, + ] + } + }, + } + assert entire_query == expected_query + + @pytest.mark.asyncio + async def test_arun_errors(self, httpx_mock, brain_region_json_path): + url = "http://fake_url" + + # Mocking an issue + httpx_mock.add_response( + url=url, + json={}, + ) + + tool = GetTracesTool( + metadata={ + "url": url, + "search_size": 2, + "httpx_client": httpx.AsyncClient(), + "token": "fake_token", + "brainregion_path": brain_region_json_path, + } + ) + + with pytest.raises(ToolException) as tool_exception: + _ = await tool._arun(brain_region_id="brain_region_id_link/549") + + assert tool_exception.value.args[0] == "'hits'"