From 679b14445f2893f7113e5c4a619a3d659cb5ac5f Mon Sep 17 00:00:00 2001 From: Nicolas Frank <58003267+WonderPG@users.noreply.github.com> Date: Fri, 20 Dec 2024 16:10:42 +0100 Subject: [PATCH] Move swarm_copy to neuroagent and delete old code. (#60) * Move swarm_copy to neuroagent and delete old code. * Restore some of main.py's features * Remove warning ignore from pyproject * Add aiohttp * Merge main * add routeur tests * add file * adress changes --------- Co-authored-by: Boris-Bergsma --- .github/workflows/ci.yaml | 10 +- .pre-commit-config.yaml | 2 +- CHANGELOG.md | 1 + alembic/env.py | 14 +- .../169bed537507_add_entity_to_message.py | 19 +- .../b57e558cf11f_initial_migration.py | 50 +- pyproject.toml | 11 +- {swarm_copy => src/neuroagent}/README.md | 0 .../neuroagent}/agent_routine.py | 6 +- src/neuroagent/agents/__init__.py | 13 - src/neuroagent/agents/base_agent.py | 105 ---- src/neuroagent/agents/simple_agent.py | 123 ---- src/neuroagent/agents/simple_chat_agent.py | 124 ---- src/neuroagent/app/__init__.py | 1 - src/neuroagent/app/app_utils.py | 38 +- src/neuroagent/app/config.py | 6 +- .../neuroagent}/app/database/__init__.py | 0 .../neuroagent}/app/database/db_utils.py | 4 +- .../neuroagent}/app/database/schemas.py | 2 +- .../neuroagent}/app/database/sql_schemas.py | 0 src/neuroagent/app/dependencies.py | 456 ++++---------- src/neuroagent/app/main.py | 24 +- .../app/routers/database/__init__.py | 1 - .../app/routers/database/schemas.py | 62 -- src/neuroagent/app/routers/database/sql.py | 44 -- .../app/routers/database/threads.py | 241 -------- src/neuroagent/app/routers/database/tools.py | 200 ------ src/neuroagent/app/routers/qa.py | 122 ++-- .../neuroagent}/app/routers/threads.py | 12 +- .../neuroagent}/app/routers/tools.py | 8 +- src/neuroagent/app/schemas.py | 9 - .../neuroagent}/bluenaas_models.py | 0 src/neuroagent/multi_agents/__init__.py | 9 - .../multi_agents/base_multi_agent.py | 37 -- .../multi_agents/supervisor_multi_agent.py | 225 ------- {swarm_copy => src/neuroagent}/new_types.py | 2 +- src/neuroagent/scripts/__init__.py | 2 +- {swarm_copy => src/neuroagent}/stream.py | 18 +- src/neuroagent/tools/__init__.py | 16 +- src/neuroagent/tools/base_tool.py | 105 ++-- .../tools/bluenaas_memodel_getall.py | 4 +- .../tools/bluenaas_memodel_getone.py | 4 +- .../neuroagent}/tools/bluenaas_scs_getall.py | 4 +- .../neuroagent}/tools/bluenaas_scs_getone.py | 4 +- .../neuroagent}/tools/bluenaas_scs_post.py | 2 +- src/neuroagent/tools/bluenaas_tool.py | 269 -------- src/neuroagent/tools/electrophys_tool.py | 308 +++++----- src/neuroagent/tools/get_me_model_tool.py | 239 -------- src/neuroagent/tools/get_morpho_tool.py | 117 ++-- .../tools/kg_morpho_features_tool.py | 120 ++-- .../tools/literature_search_tool.py | 108 ++-- .../tools/morphology_features_tool.py | 100 ++- src/neuroagent/tools/resolve_entities_tool.py | 166 +++-- src/neuroagent/tools/traces_tool.py | 118 ++-- src/neuroagent/utils.py | 20 + swarm_copy/__init__.py | 1 - swarm_copy/app/app_utils.py | 61 -- swarm_copy/app/config.py | 256 -------- swarm_copy/app/dependencies.py | 334 ---------- swarm_copy/app/main.py | 150 ----- swarm_copy/app/routers/__init__.py | 1 - swarm_copy/app/routers/qa.py | 96 --- swarm_copy/cell_types.py | 192 ------ swarm_copy/requirements.txt | 7 - swarm_copy/resolving.py | 448 -------------- swarm_copy/schemas.py | 11 - swarm_copy/tools/__init__.py | 48 -- swarm_copy/tools/base_tool.py | 90 --- swarm_copy/tools/electrophys_tool.py | 332 ---------- swarm_copy/tools/get_morpho_tool.py | 218 ------- swarm_copy/tools/kg_morpho_features_tool.py | 354 ----------- swarm_copy/tools/literature_search_tool.py | 107 ---- swarm_copy/tools/morphology_features_tool.py | 149 ----- swarm_copy/tools/resolve_entities_tool.py | 148 ----- swarm_copy/tools/traces_tool.py | 196 ------ swarm_copy/utils.py | 488 --------------- swarm_copy_tests/__init__.py | 1 - swarm_copy_tests/app/database/__init__.py | 1 - swarm_copy_tests/app/test_app_utils.py | 75 --- swarm_copy_tests/app/test_config.py | 71 --- swarm_copy_tests/app/test_dependencies.py | 387 ------------ swarm_copy_tests/app/test_main.py | 75 --- swarm_copy_tests/conftest.py | 190 ------ swarm_copy_tests/data/99111002.nwb | Bin 530771 -> 0 bytes .../data/KG_brain_regions_hierarchy_test.json | 165 ----- .../data/brainregion_hierarchy.json | 1 - swarm_copy_tests/data/get_traces.json | 201 ------ .../data/kg_cell_types_hierarchy_test.json | 13 - swarm_copy_tests/data/kg_me_model_output.json | 152 ----- .../data/kg_morpho_features_response.json | 412 ------------- swarm_copy_tests/data/knowledge_graph.json | 182 ------ .../data/morphology_id_metadata_response.json | 102 ---- swarm_copy_tests/data/resolve_query.json | 1 - swarm_copy_tests/data/simple.swc | 31 - swarm_copy_tests/data/tool_calls.json | 102 ---- swarm_copy_tests/data/trace_id_metadata.json | 110 ---- swarm_copy_tests/test_cell_types.py | 190 ------ swarm_copy_tests/test_resolving.py | 319 ---------- swarm_copy_tests/test_utils.py | 510 ---------------- swarm_copy_tests/tools/__init__.py | 0 swarm_copy_tests/tools/test_base_tool.py | 0 .../tools/test_electrophys_tool.py | 206 ------- .../tools/test_get_morpho_tool.py | 175 ------ .../tools/test_kg_morpho_features_tool.py | 453 -------------- .../tools/test_literature_search_tool.py | 50 -- .../tools/test_morphology_features_tool.py | 114 ---- .../tools/test_resolve_entities_tool.py | 100 --- swarm_copy_tests/tools/test_traces_tool.py | 150 ----- tests/__init__.py | 1 + tests/agents/__init__.py | 0 tests/agents/test_simple_agent.py | 57 -- tests/agents/test_simple_chat_agent.py | 120 ---- tests/app/__init__.py | 0 tests/app/database/__init__.py | 1 + .../app/database/test_db_utils.py | 77 ++- tests/app/database/test_threads.py | 209 ------- tests/app/database/test_tools.py | 172 ------ .../app/routers/__init__.py | 0 tests/app/routers/test_qa.py | 168 +++++ .../app/routers/test_threads.py | 11 +- .../app/routers/test_tools.py | 12 +- tests/app/test_app_utils.py | 2 +- tests/app/test_dependencies.py | 573 ++++++------------ tests/app/test_main.py | 25 +- tests/app/test_middleware.py | 44 -- tests/app/test_qa.py | 134 ---- tests/conftest.py | 167 ++--- tests/data/kg_cell_types_hierarchy_test.json | 2 +- tests/data/tool_calls.json | 189 +++--- {swarm_copy_tests => tests}/mock_client.py | 0 tests/multi_agents/__init__.py | 0 .../test_supervisor_multi_agent.py | 82 --- .../test_avalidate_tool_calls.py} | 0 .../test_agent_routine.py | 8 +- tests/test_cell_types.py | 38 ++ tests/test_dummy.py | 5 - tests/test_utils.py | 68 +++ tests/tools/test_base_tool.py | 106 ---- tests/tools/test_bluenaas_tool.py | 285 --------- tests/tools/test_electrophys_tool.py | 191 ++++-- tests/tools/test_get_me_model_tool.py | 161 ----- tests/tools/test_get_morpho_tool.py | 77 +-- tests/tools/test_kg_morpho_features_tool.py | 186 ++++-- tests/tools/test_literature_search_tool.py | 77 +-- tests/tools/test_morphology_features_tool.py | 59 +- tests/tools/test_resolve_entities_tool.py | 41 +- tests/tools/test_traces_tool.py | 119 ++-- 147 files changed, 2027 insertions(+), 13371 deletions(-) rename {swarm_copy => src/neuroagent}/README.md (100%) rename {swarm_copy => src/neuroagent}/agent_routine.py (98%) delete mode 100644 src/neuroagent/agents/__init__.py delete mode 100644 src/neuroagent/agents/base_agent.py delete mode 100644 src/neuroagent/agents/simple_agent.py delete mode 100644 src/neuroagent/agents/simple_chat_agent.py delete mode 100644 src/neuroagent/app/__init__.py rename {swarm_copy => src/neuroagent}/app/database/__init__.py (100%) rename {swarm_copy => src/neuroagent}/app/database/db_utils.py (94%) rename {swarm_copy => src/neuroagent}/app/database/schemas.py (94%) rename {swarm_copy => src/neuroagent}/app/database/sql_schemas.py (100%) delete mode 100644 src/neuroagent/app/routers/database/__init__.py delete mode 100644 src/neuroagent/app/routers/database/schemas.py delete mode 100644 src/neuroagent/app/routers/database/sql.py delete mode 100644 src/neuroagent/app/routers/database/threads.py delete mode 100644 src/neuroagent/app/routers/database/tools.py rename {swarm_copy => src/neuroagent}/app/routers/threads.py (91%) rename {swarm_copy => src/neuroagent}/app/routers/tools.py (93%) delete mode 100644 src/neuroagent/app/schemas.py rename {swarm_copy => src/neuroagent}/bluenaas_models.py (100%) delete mode 100644 src/neuroagent/multi_agents/__init__.py delete mode 100644 src/neuroagent/multi_agents/base_multi_agent.py delete mode 100644 src/neuroagent/multi_agents/supervisor_multi_agent.py rename {swarm_copy => src/neuroagent}/new_types.py (95%) rename {swarm_copy => src/neuroagent}/stream.py (72%) rename {swarm_copy => src/neuroagent}/tools/bluenaas_memodel_getall.py (95%) rename {swarm_copy => src/neuroagent}/tools/bluenaas_memodel_getone.py (93%) rename {swarm_copy => src/neuroagent}/tools/bluenaas_scs_getall.py (94%) rename {swarm_copy => src/neuroagent}/tools/bluenaas_scs_getone.py (92%) rename {swarm_copy => src/neuroagent}/tools/bluenaas_scs_post.py (99%) delete mode 100644 src/neuroagent/tools/bluenaas_tool.py delete mode 100644 src/neuroagent/tools/get_me_model_tool.py delete mode 100644 swarm_copy/__init__.py delete mode 100644 swarm_copy/app/app_utils.py delete mode 100644 swarm_copy/app/config.py delete mode 100644 swarm_copy/app/dependencies.py delete mode 100644 swarm_copy/app/main.py delete mode 100644 swarm_copy/app/routers/__init__.py delete mode 100644 swarm_copy/app/routers/qa.py delete mode 100644 swarm_copy/cell_types.py delete mode 100644 swarm_copy/requirements.txt delete mode 100644 swarm_copy/resolving.py delete mode 100644 swarm_copy/schemas.py delete mode 100644 swarm_copy/tools/__init__.py delete mode 100644 swarm_copy/tools/base_tool.py delete mode 100644 swarm_copy/tools/electrophys_tool.py delete mode 100644 swarm_copy/tools/get_morpho_tool.py delete mode 100644 swarm_copy/tools/kg_morpho_features_tool.py delete mode 100644 swarm_copy/tools/literature_search_tool.py delete mode 100644 swarm_copy/tools/morphology_features_tool.py delete mode 100644 swarm_copy/tools/resolve_entities_tool.py delete mode 100644 swarm_copy/tools/traces_tool.py delete mode 100644 swarm_copy/utils.py delete mode 100644 swarm_copy_tests/__init__.py delete mode 100644 swarm_copy_tests/app/database/__init__.py delete mode 100644 swarm_copy_tests/app/test_app_utils.py delete mode 100644 swarm_copy_tests/app/test_config.py delete mode 100644 swarm_copy_tests/app/test_dependencies.py delete mode 100644 swarm_copy_tests/app/test_main.py delete mode 100644 swarm_copy_tests/conftest.py delete mode 100644 swarm_copy_tests/data/99111002.nwb delete mode 100644 swarm_copy_tests/data/KG_brain_regions_hierarchy_test.json delete mode 100644 swarm_copy_tests/data/brainregion_hierarchy.json delete mode 100644 swarm_copy_tests/data/get_traces.json delete mode 100644 swarm_copy_tests/data/kg_cell_types_hierarchy_test.json delete mode 100644 swarm_copy_tests/data/kg_me_model_output.json delete mode 100644 swarm_copy_tests/data/kg_morpho_features_response.json delete mode 100644 swarm_copy_tests/data/knowledge_graph.json delete mode 100644 swarm_copy_tests/data/morphology_id_metadata_response.json delete mode 100644 swarm_copy_tests/data/resolve_query.json delete mode 100644 swarm_copy_tests/data/simple.swc delete mode 100644 swarm_copy_tests/data/tool_calls.json delete mode 100644 swarm_copy_tests/data/trace_id_metadata.json delete mode 100644 swarm_copy_tests/test_cell_types.py delete mode 100644 swarm_copy_tests/test_resolving.py delete mode 100644 swarm_copy_tests/test_utils.py delete mode 100644 swarm_copy_tests/tools/__init__.py delete mode 100644 swarm_copy_tests/tools/test_base_tool.py delete mode 100644 swarm_copy_tests/tools/test_electrophys_tool.py delete mode 100644 swarm_copy_tests/tools/test_get_morpho_tool.py delete mode 100644 swarm_copy_tests/tools/test_kg_morpho_features_tool.py delete mode 100644 swarm_copy_tests/tools/test_literature_search_tool.py delete mode 100644 swarm_copy_tests/tools/test_morphology_features_tool.py delete mode 100644 swarm_copy_tests/tools/test_resolve_entities_tool.py delete mode 100644 swarm_copy_tests/tools/test_traces_tool.py delete mode 100644 tests/agents/__init__.py delete mode 100644 tests/agents/test_simple_agent.py delete mode 100644 tests/agents/test_simple_chat_agent.py delete mode 100644 tests/app/__init__.py rename {swarm_copy_tests => tests}/app/database/test_db_utils.py (86%) delete mode 100644 tests/app/database/test_threads.py delete mode 100644 tests/app/database/test_tools.py rename {swarm_copy_tests => tests}/app/routers/__init__.py (100%) create mode 100644 tests/app/routers/test_qa.py rename {swarm_copy_tests => tests}/app/routers/test_threads.py (95%) rename {swarm_copy_tests => tests}/app/routers/test_tools.py (94%) delete mode 100644 tests/app/test_middleware.py delete mode 100644 tests/app/test_qa.py rename {swarm_copy_tests => tests}/mock_client.py (100%) delete mode 100644 tests/multi_agents/__init__.py delete mode 100644 tests/multi_agents/test_supervisor_multi_agent.py rename tests/{tools/test_validate_tool_call.py => scripts/test_avalidate_tool_calls.py} (100%) rename {swarm_copy_tests => tests}/test_agent_routine.py (98%) delete mode 100644 tests/test_dummy.py delete mode 100644 tests/tools/test_bluenaas_tool.py delete mode 100644 tests/tools/test_get_me_model_tool.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 0347c9b..9c7bcdf 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -37,9 +37,9 @@ jobs: pip install bandit[toml]==1.7.4 ruff==0.6.7 - name: Linting check run: | - bandit -qr -c pyproject.toml src/ swarm_copy/ - ruff check src/ tests/ swarm_copy/ - ruff format --check src/ tests/ swarm_copy/ + bandit -qr -c pyproject.toml src/ + ruff check src/ tests/ + ruff format --check src/ tests/ unit-tests: runs-on: ${{ matrix.os }} services: @@ -87,7 +87,7 @@ jobs: pip install ".[dev]" - name: Running mypy and tests run: | - mypy src/ swarm_copy/ + mypy src/ # Include src/ directory in Python path to prioritize local files in pytest export PYTHONPATH=$(pwd)/src:$PYTHONPATH - pytest --color=yes tests/ swarm_copy_tests/ + pytest --color=yes tests/ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index da5d7d0..739354d 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -23,7 +23,7 @@ repos: rev: v1.8.0 hooks: - id: mypy - files: ^src/ ^swarm_copy/ + files: ^src/ additional_dependencies: ['pydantic', 'types-requests'] - repo: https://github.com/PyCQA/bandit diff --git a/CHANGELOG.md b/CHANGELOG.md index 548ebd4..9143f6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - Return model dumps of DB schema objects. +- Moved swarm_copy to neuroagent and delete old code. ### Added - LLM evaluation logic diff --git a/alembic/env.py b/alembic/env.py index 5b5e6c7..6168781 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -1,16 +1,16 @@ +import os from logging.config import fileConfig -from sqlalchemy import engine_from_config -from sqlalchemy import pool -import os -from alembic import context from dotenv import load_dotenv +from sqlalchemy import engine_from_config, pool + +from alembic import context # Load environment variables from .env file load_dotenv() # Import your Base class -from swarm_copy.app.database.sql_schemas import Base +from neuroagent.app.database.sql_schemas import Base # this is the Alembic Config object, which provides # access to the values within the .ini file in use. @@ -92,9 +92,7 @@ def run_migrations_online() -> None: ) with connectable.connect() as connection: - context.configure( - connection=connection, target_metadata=target_metadata - ) + context.configure(connection=connection, target_metadata=target_metadata) with context.begin_transaction(): context.run_migrations() diff --git a/alembic/versions/169bed537507_add_entity_to_message.py b/alembic/versions/169bed537507_add_entity_to_message.py index 951e106..2f9c49c 100644 --- a/alembic/versions/169bed537507_add_entity_to_message.py +++ b/alembic/versions/169bed537507_add_entity_to_message.py @@ -5,32 +5,33 @@ Create Date: 2024-11-13 10:27:11.640265 """ + from typing import Sequence, Union -from alembic import op import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. -revision: str = '169bed537507' -down_revision: Union[str, None] = 'b57e558cf11f' +revision: str = "169bed537507" +down_revision: Union[str, None] = "b57e558cf11f" branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None def upgrade() -> None: # Create the enum type first - entity_enum = sa.Enum('USER', 'AI_TOOL', 'TOOL', 'AI_MESSAGE', name='entity') + entity_enum = sa.Enum("USER", "AI_TOOL", "TOOL", "AI_MESSAGE", name="entity") entity_enum.create(op.get_bind()) - + # Then add the column using the enum - op.add_column('messages', sa.Column('entity', entity_enum, nullable=False)) + op.add_column("messages", sa.Column("entity", entity_enum, nullable=False)) def downgrade() -> None: # Drop the column first - op.drop_column('messages', 'entity') - + op.drop_column("messages", "entity") + # Then drop the enum type - entity_enum = sa.Enum(name='entity') + entity_enum = sa.Enum(name="entity") entity_enum.drop(op.get_bind()) diff --git a/alembic/versions/b57e558cf11f_initial_migration.py b/alembic/versions/b57e558cf11f_initial_migration.py index 167a4a9..75dd78e 100644 --- a/alembic/versions/b57e558cf11f_initial_migration.py +++ b/alembic/versions/b57e558cf11f_initial_migration.py @@ -1,18 +1,19 @@ """Initial migration Revision ID: b57e558cf11f -Revises: +Revises: Create Date: 2024-11-11 14:42:05.624237 """ + from typing import Sequence, Union -from alembic import op import sqlalchemy as sa +from alembic import op # revision identifiers, used by Alembic. -revision: str = 'b57e558cf11f' +revision: str = "b57e558cf11f" down_revision: Union[str, None] = None branch_labels: Union[str, Sequence[str], None] = None depends_on: Union[str, Sequence[str], None] = None @@ -20,30 +21,35 @@ def upgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### - op.create_table('threads', - sa.Column('thread_id', sa.String(), nullable=False), - sa.Column('vlab_id', sa.String(), nullable=False), - sa.Column('project_id', sa.String(), nullable=False), - sa.Column('title', sa.String(), nullable=False), - sa.Column('creation_date', sa.DateTime(), nullable=False), - sa.Column('update_date', sa.DateTime(), nullable=False), - sa.Column('user_id', sa.String(), nullable=False), - sa.PrimaryKeyConstraint('thread_id') + op.create_table( + "threads", + sa.Column("thread_id", sa.String(), nullable=False), + sa.Column("vlab_id", sa.String(), nullable=False), + sa.Column("project_id", sa.String(), nullable=False), + sa.Column("title", sa.String(), nullable=False), + sa.Column("creation_date", sa.DateTime(), nullable=False), + sa.Column("update_date", sa.DateTime(), nullable=False), + sa.Column("user_id", sa.String(), nullable=False), + sa.PrimaryKeyConstraint("thread_id"), ) - op.create_table('messages', - sa.Column('message_id', sa.String(), nullable=False), - sa.Column('order', sa.Integer(), nullable=False), - sa.Column('creation_date', sa.DateTime(), nullable=False), - sa.Column('content', sa.String(), nullable=False), - sa.Column('thread_id', sa.String(), nullable=False), - sa.ForeignKeyConstraint(['thread_id'], ['threads.thread_id'], ), - sa.PrimaryKeyConstraint('message_id') + op.create_table( + "messages", + sa.Column("message_id", sa.String(), nullable=False), + sa.Column("order", sa.Integer(), nullable=False), + sa.Column("creation_date", sa.DateTime(), nullable=False), + sa.Column("content", sa.String(), nullable=False), + sa.Column("thread_id", sa.String(), nullable=False), + sa.ForeignKeyConstraint( + ["thread_id"], + ["threads.thread_id"], + ), + sa.PrimaryKeyConstraint("message_id"), ) # ### end Alembic commands ### def downgrade() -> None: # ### commands auto generated by Alembic - please adjust! ### - op.drop_table('messages') - op.drop_table('threads') + op.drop_table("messages") + op.drop_table("threads") # ### end Alembic commands ### diff --git a/pyproject.toml b/pyproject.toml index c27af47..116ddd4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,19 +11,15 @@ readme = "README.md" requires-python = ">=3.10" dynamic = ["version"] dependencies = [ + "aiohttp", "aiosqlite", "asgi-correlation-id", "asyncpg", "bluepyefe", "efel", "fastapi", - "langchain", - "langchain-cohere", - "langchain-openai", - "langgraph", - "langgraph-checkpoint-postgres", - "langgraph-checkpoint-sqlite", "neurom", + "openai", "psycopg-binary", "pydantic-settings", "python-dotenv", @@ -75,7 +71,7 @@ convention = "numpy" [tool.ruff.lint.per-file-ignores] "tests/*" = ["D"] -"swarm_copy_tests/*" = ["D"] +"alembic/*" = ["D", "E"] [tool.mypy] mypy_path = "src" @@ -105,7 +101,6 @@ filterwarnings = [ "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", "ignore:The configuration option 'asyncio_default_fixture_loop_scope' is unset." ] diff --git a/swarm_copy/README.md b/src/neuroagent/README.md similarity index 100% rename from swarm_copy/README.md rename to src/neuroagent/README.md diff --git a/swarm_copy/agent_routine.py b/src/neuroagent/agent_routine.py similarity index 98% rename from swarm_copy/agent_routine.py rename to src/neuroagent/agent_routine.py index 4797545..835a4bf 100644 --- a/swarm_copy/agent_routine.py +++ b/src/neuroagent/agent_routine.py @@ -14,13 +14,13 @@ ) from pydantic import ValidationError -from swarm_copy.new_types import ( +from neuroagent.new_types import ( Agent, Response, Result, ) -from swarm_copy.tools.base_tool import BaseTool -from swarm_copy.utils import merge_chunk +from neuroagent.tools.base_tool import BaseTool +from neuroagent.utils import merge_chunk class AgentsRoutine: diff --git a/src/neuroagent/agents/__init__.py b/src/neuroagent/agents/__init__.py deleted file mode 100644 index 382377e..0000000 --- a/src/neuroagent/agents/__init__.py +++ /dev/null @@ -1,13 +0,0 @@ -"""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 deleted file mode 100644 index 35f95d7..0000000 --- a/src/neuroagent/agents/base_agent.py +++ /dev/null @@ -1,105 +0,0 @@ -"""Base agent.""" - -from abc import ABC, abstractmethod -from contextlib import asynccontextmanager -from typing import Any, AsyncIterator - -from langchain.chat_models.base import BaseChatModel -from langchain_core.tools import BaseTool -from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver -from langgraph.checkpoint.serde.base import SerializerProtocol -from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver -from pydantic import BaseModel, ConfigDict - - -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] - - -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 - 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.""" - - -class AsyncSqliteSaverWithPrefix(AsyncSqliteSaver): - """Wrapper around the AsyncSqliteSaver that accepts a connection string with prefix.""" - - @classmethod - @asynccontextmanager - async def from_conn_string( - cls, conn_string: str - ) -> AsyncIterator[AsyncSqliteSaver]: - """Create a new AsyncSqliteSaver instance from a connection string. - - Args: - conn_string (str): The async SQLite connection string. It can have the 'sqlite+aiosqlite:///' prefix. - - Yields - ------ - AsyncSqliteSaverWithPrefix: A new connected AsyncSqliteSaverWithPrefix instance. - """ - conn_string = conn_string.split("///")[-1] - async with super().from_conn_string(conn_string) as memory: - yield AsyncSqliteSaverWithPrefix(memory.conn) - - -class AsyncPostgresSaverWithPrefix(AsyncPostgresSaver): - """Wrapper around the AsyncSqliteSaver that accepts a connection string with prefix.""" - - @classmethod - @asynccontextmanager - async def from_conn_string( - cls, - conn_string: str, - *, - pipeline: bool = False, - serde: SerializerProtocol | None = None, - ) -> AsyncIterator[AsyncPostgresSaver]: - """Create a new AsyncPostgresSaver instance from a connection string. - - Args: - conn_string (str): The async Postgres connection string. It can have the 'postgresql+asyncpg://' prefix. - - Yields - ------ - AsyncPostgresSaverWithPrefix: A new connected AsyncPostgresSaverWithPrefix instance. - """ - prefix, body = conn_string.split("://", maxsplit=1) - currated_prefix = prefix.split("+", maxsplit=1)[0] # Still works if + not there - conn_string = currated_prefix + "://" + body - async with super().from_conn_string( - conn_string, pipeline=pipeline, serde=serde - ) as memory: - yield AsyncPostgresSaverWithPrefix(memory.conn, memory.pipe, memory.serde) diff --git a/src/neuroagent/agents/simple_agent.py b/src/neuroagent/agents/simple_agent.py deleted file mode 100644 index 3ea1bd2..0000000 --- a/src/neuroagent/agents/simple_agent.py +++ /dev/null @@ -1,123 +0,0 @@ -"""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]: - """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 deleted file mode 100644 index 882b8d7..0000000 --- a/src/neuroagent/agents/simple_chat_agent.py +++ /dev/null @@ -1,124 +0,0 @@ -"""Simple agent.""" - -import logging -from contextlib import AsyncExitStack -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[Any] - - @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, connection_string: str | None = None - ) -> AsyncIterator[str]: - """Run the agent against a query in streaming way. - - Parameters - ---------- - thread_id - ID of the thread of the chat. - query - Query of the user. - connection_string - connection string for the checkpoint database. - - Yields - ------ - Iterator streaming the processed output of the LLM - """ - async with ( - self.agent.checkpointer.__class__.from_conn_string(connection_string) - if connection_string - else AsyncExitStack() as memory - ): - if isinstance(memory, BaseCheckpointSaver): - self.agent.checkpointer = memory - config = {"configurable": {"thread_id": thread_id}} - streamed_response = self.agent.astream_events( - {"messages": query}, version="v2", config=config - ) - is_streaming = False - async for event in streamed_response: - kind = event["event"] - # 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: - if not is_streaming: - yield "\n\n" - is_streaming = True - 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 deleted file mode 100644 index 612fa7d..0000000 --- a/src/neuroagent/app/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Main package for neuroagent deployment.""" diff --git a/src/neuroagent/app/app_utils.py b/src/neuroagent/app/app_utils.py index 54e43bf..d5218d0 100644 --- a/src/neuroagent/app/app_utils.py +++ b/src/neuroagent/app/app_utils.py @@ -14,25 +14,6 @@ logger = logging.getLogger(__name__) -async def validate_project( - httpx_client: AsyncClient, - vlab_id: str, - project_id: str, - token: str, - vlab_project_url: str, -) -> None: - """Check user appartenance to vlab and project before running agent.""" - response = await httpx_client.get( - f"{vlab_project_url}/{vlab_id}/projects/{project_id}", - headers={"Authorization": f"Bearer {token}"}, - ) - if response.status_code != 200: - raise HTTPException( - status_code=HTTP_401_UNAUTHORIZED, - detail="User does not belong to the project.", - ) - - def setup_engine( settings: Settings, connection_string: str | None = None ) -> AsyncEngine | None: @@ -59,3 +40,22 @@ def setup_engine( f" {connection_string if not settings.db.password else connection_string.replace(settings.db.password.get_secret_value(), '*****')}." ) return None + + +async def validate_project( + httpx_client: AsyncClient, + vlab_id: str, + project_id: str, + token: str, + vlab_project_url: str, +) -> None: + """Check user appartenance to vlab and project before running agent.""" + response = await httpx_client.get( + f"{vlab_project_url}/{vlab_id}/projects/{project_id}", + headers={"Authorization": f"Bearer {token}"}, + ) + if response.status_code != 200: + raise HTTPException( + status_code=HTTP_401_UNAUTHORIZED, + detail="User does not belong to the project.", + ) diff --git a/src/neuroagent/app/config.py b/src/neuroagent/app/config.py index 1721ad4..f0334e9 100644 --- a/src/neuroagent/app/config.py +++ b/src/neuroagent/app/config.py @@ -73,8 +73,8 @@ class SettingsLiterature(BaseModel): """Literature search API settings.""" url: str - retriever_k: int = 500 - use_reranker: bool = True + retriever_k: int = 8 + use_reranker: bool = False reranker_k: int = 8 model_config = ConfigDict(frozen=True) @@ -115,7 +115,7 @@ class SettingsGetMEModel(BaseModel): class SettingsBlueNaaS(BaseModel): """BlueNaaS settings.""" - url: str = "https://openbluebrain.com/api/bluenaas/simulation/single-neuron/run" + url: str = "https://openbluebrain.com/api/bluenaas" model_config = ConfigDict(frozen=True) diff --git a/swarm_copy/app/database/__init__.py b/src/neuroagent/app/database/__init__.py similarity index 100% rename from swarm_copy/app/database/__init__.py rename to src/neuroagent/app/database/__init__.py diff --git a/swarm_copy/app/database/db_utils.py b/src/neuroagent/app/database/db_utils.py similarity index 94% rename from swarm_copy/app/database/db_utils.py rename to src/neuroagent/app/database/db_utils.py index 7c41492..f9880b6 100644 --- a/swarm_copy/app/database/db_utils.py +++ b/src/neuroagent/app/database/db_utils.py @@ -7,8 +7,8 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from swarm_copy.app.database.sql_schemas import Entity, Messages, Threads, utc_now -from swarm_copy.app.dependencies import get_session, get_user_id +from neuroagent.app.database.sql_schemas import Entity, Messages, Threads, utc_now +from neuroagent.app.dependencies import get_session, get_user_id async def get_thread( diff --git a/swarm_copy/app/database/schemas.py b/src/neuroagent/app/database/schemas.py similarity index 94% rename from swarm_copy/app/database/schemas.py rename to src/neuroagent/app/database/schemas.py index 6a45ff1..5bba1d0 100644 --- a/swarm_copy/app/database/schemas.py +++ b/src/neuroagent/app/database/schemas.py @@ -5,7 +5,7 @@ from pydantic import BaseModel -from swarm_copy.app.database.db_utils import Entity +from neuroagent.app.database.db_utils import Entity class ThreadsRead(BaseModel): diff --git a/swarm_copy/app/database/sql_schemas.py b/src/neuroagent/app/database/sql_schemas.py similarity index 100% rename from swarm_copy/app/database/sql_schemas.py rename to src/neuroagent/app/database/sql_schemas.py diff --git a/src/neuroagent/app/dependencies.py b/src/neuroagent/app/dependencies.py index 74561d1..33a1e71 100644 --- a/src/neuroagent/app/dependencies.py +++ b/src/neuroagent/app/dependencies.py @@ -1,4 +1,4 @@ -"""Dependencies.""" +"""App dependencies.""" import logging from functools import cache @@ -8,35 +8,30 @@ from fastapi.security import HTTPBearer from httpx import AsyncClient, HTTPStatusError from keycloak import KeycloakOpenID -from langchain_openai import ChatOpenAI -from langgraph.checkpoint.base import BaseCheckpointSaver +from openai import AsyncOpenAI +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession from starlette.status import HTTP_401_UNAUTHORIZED -from neuroagent.agents import ( - BaseAgent, - SimpleAgent, - SimpleChatAgent, -) -from neuroagent.agents.base_agent import ( - AsyncPostgresSaverWithPrefix, - AsyncSqliteSaverWithPrefix, -) +from neuroagent.agent_routine import AgentsRoutine from neuroagent.app.app_utils import validate_project from neuroagent.app.config import Settings -from neuroagent.app.routers.database.schemas import Threads +from neuroagent.app.database.sql_schemas import Threads from neuroagent.cell_types import CellTypesMeta -from neuroagent.multi_agents import BaseMultiAgent, SupervisorMultiAgent +from neuroagent.new_types import Agent from neuroagent.tools import ( - BlueNaaSTool, ElectrophysFeatureTool, - GetMEModelTool, GetMorphoTool, GetTracesTool, KGMorphoFeatureTool, LiteratureSearchTool, + MEModelGetAllTool, + MEModelGetOneTool, MorphologyFeatureTool, ResolveEntitiesTool, + SCSGetAllTool, + SCSGetOneTool, + SCSPostTool, ) from neuroagent.utils import RegionMeta, get_file_from_KG @@ -57,10 +52,7 @@ async def __call__(self, request: Request) -> str | None: # type: ignore @cache def get_settings() -> Settings: - """Load all parameters. - - Note that this function is cached and environment will be read just once. - """ + """Get the global settings.""" logger.info("Reading the environment and instantiating settings") return Settings() @@ -72,7 +64,24 @@ async def get_httpx_client(request: Request) -> AsyncIterator[AsyncClient]: verify=False, headers={"x-request-id": request.headers["x-request-id"]}, ) - yield client + try: + yield client + finally: + await client.aclose() + + +async def get_openai_client( + settings: Annotated[Settings, Depends(get_settings)], +) -> AsyncIterator[AsyncOpenAI | None]: + """Get the OpenAi Async client.""" + if not settings.openai.token: + yield None + else: + try: + client = AsyncOpenAI(api_key=settings.openai.token.get_secret_value()) + yield client + finally: + await client.close() def get_connection_string( @@ -126,19 +135,22 @@ async def get_user_id( 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." - ) + if settings.keycloak.validate_token: + if 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: + raise HTTPException(status_code=404, detail="user info url not provided.") else: return "dev" @@ -162,214 +174,6 @@ def get_kg_token( )["access_token"] -def get_bluenaas_tool( - settings: Annotated[Settings, Depends(get_settings)], - token: Annotated[str, Depends(get_kg_token)], - httpx_client: Annotated[AsyncClient, Depends(get_httpx_client)], -) -> BlueNaaSTool: - """Load BlueNaaS tool.""" - tool = BlueNaaSTool( - metadata={ - "url": settings.tools.bluenaas.url, - "token": token, - "httpx_client": httpx_client, - } - ) - return tool - - -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_entities_resolver_tool( - token: Annotated[str, Depends(get_kg_token)], - httpx_client: Annotated[AsyncClient, Depends(get_httpx_client)], - settings: Annotated[Settings, Depends(get_settings)], -) -> ResolveEntitiesTool: - """Load resolve brain region tool.""" - tool = ResolveEntitiesTool( - 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_me_model_tool( - settings: Annotated[Settings, Depends(get_settings)], - token: Annotated[str, Depends(get_kg_token)], - httpx_client: Annotated[AsyncClient, Depends(get_httpx_client)], -) -> GetMEModelTool: - """Load get ME model tool.""" - tool = GetMEModelTool( - metadata={ - "url": settings.knowledge_graph.url, - "token": token, - "httpx_client": httpx_client, - "search_size": settings.tools.me_model.search_size, - "brainregion_path": settings.knowledge_graph.br_saving_path, - "celltypes_path": settings.knowledge_graph.ct_saving_path, - } - ) - 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.openai.model}.") - return ChatOpenAI( - model_name=settings.openai.model, - temperature=settings.openai.temperature, - openai_api_key=settings.openai.token.get_secret_value(), # type: ignore - max_tokens=settings.openai.max_tokens, - seed=78, - streaming=True, - ) - - -async def get_agent_memory( - connection_string: Annotated[str | None, Depends(get_connection_string)], -) -> AsyncIterator[BaseCheckpointSaver[Any] | None]: - """Get the agent checkpointer.""" - if connection_string: - if connection_string.startswith("sqlite"): - async with AsyncSqliteSaverWithPrefix.from_conn_string( - connection_string - ) as memory: - await memory.setup() - yield memory - await memory.conn.close() - - elif connection_string.startswith("postgresql"): - async with AsyncPostgresSaverWithPrefix.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 - - async def get_vlab_and_project( user_id: Annotated[str, Depends(get_user_id)], session: Annotated[AsyncSession, Depends(get_session)], @@ -386,12 +190,22 @@ async def get_vlab_and_project( } elif not settings.keycloak.validate_token: vlab_and_project = { - "vlab_id": "430108e9-a81d-4b13-b7b6-afca00195908", - "project_id": "eff09ea1-be16-47f0-91b6-52a3ea3ee575", + "vlab_id": "32c83739-f39c-49d1-833f-58c981ebd2a2", + "project_id": "123251a1-be18-4146-87b5-5ca2f8bfaf48", } else: thread_id = request.path_params.get("thread_id") - thread = await session.get(Threads, (thread_id, user_id)) + thread_result = await session.execute( + select(Threads).where( + Threads.user_id == user_id, Threads.thread_id == thread_id + ) + ) + thread = thread_result.scalars().one_or_none() + if not thread: + raise HTTPException( + status_code=404, + detail="Thread not found.", + ) if thread and thread.vlab_id and thread.project_id: vlab_and_project = { "vlab_id": thread.vlab_id, @@ -413,97 +227,71 @@ async def get_vlab_and_project( return vlab_and_project -def get_agent( +def get_starting_agent( _: Annotated[None, Depends(get_vlab_and_project)], - llm: Annotated[ChatOpenAI, Depends(get_language_model)], - literature_tool: Annotated[LiteratureSearchTool, Depends(get_literature_tool)], - entities_resolver_tool: Annotated[ - ResolveEntitiesTool, Depends(get_entities_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)], - me_model_tool: Annotated[GetMEModelTool, Depends(get_me_model_tool)], - bluenaas_tool: Annotated[BlueNaaSTool, Depends(get_bluenaas_tool)], settings: Annotated[Settings, Depends(get_settings)], -) -> BaseAgent | BaseMultiAgent: - """Get the generative question answering service.""" - if settings.agent.model == "multi": - logger.info("Load multi-agent chat") - tools_list = [ - ("literature", [literature_tool]), - ( - "morphologies", - [ - entities_resolver_tool, - morpho_tool, - morphology_feature_tool, - kg_morpho_feature_tool, - ], - ), - ("traces", [entities_resolver_tool, electrophys_feature_tool, traces_tool]), - ] - return SupervisorMultiAgent(llm=llm, agents=tools_list) # type: ignore - else: - tools = [ - literature_tool, - entities_resolver_tool, - morpho_tool, - morphology_feature_tool, - kg_morpho_feature_tool, - electrophys_feature_tool, - traces_tool, - me_model_tool, - bluenaas_tool, - ] - logger.info("Load simple agent") - return SimpleAgent(llm=llm, tools=tools) # type: ignore - - -def get_chat_agent( - _: Annotated[None, Depends(get_vlab_and_project)], - llm: Annotated[ChatOpenAI, Depends(get_language_model)], - memory: Annotated[BaseCheckpointSaver[Any], Depends(get_agent_memory)], - bluenaas_tool: Annotated[BlueNaaSTool, Depends(get_bluenaas_tool)], - literature_tool: Annotated[LiteratureSearchTool, Depends(get_literature_tool)], - entities_resolver_tool: Annotated[ - ResolveEntitiesTool, Depends(get_entities_resolver_tool) - ], - morpho_tool: Annotated[GetMorphoTool, Depends(get_morpho_tool)], - morphology_feature_tool: Annotated[ - MorphologyFeatureTool, Depends(get_morphology_feature_tool) - ], - me_model_tool: Annotated[GetMEModelTool, Depends(get_me_model_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: - """Get the generative question answering service.""" - logger.info("Load simple chat") - tools = [ - bluenaas_tool, - literature_tool, - entities_resolver_tool, - me_model_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 +) -> Agent: + """Get the starting agent.""" + logger.info(f"Loading model {settings.openai.model}.") + agent = Agent( + name="Agent", + instructions="""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.""", + tools=[ + SCSGetAllTool, + SCSGetOneTool, + SCSPostTool, + MEModelGetAllTool, + MEModelGetOneTool, + LiteratureSearchTool, + ElectrophysFeatureTool, + GetMorphoTool, + KGMorphoFeatureTool, + MorphologyFeatureTool, + ResolveEntitiesTool, + GetTracesTool, + ], + model=settings.openai.model, + ) + return agent + + +def get_context_variables( + settings: Annotated[Settings, Depends(get_settings)], + starting_agent: Annotated[Agent, Depends(get_starting_agent)], + token: Annotated[str, Depends(get_kg_token)], + httpx_client: Annotated[AsyncClient, Depends(get_httpx_client)], +) -> dict[str, Any]: + """Get the global context variables to feed the tool's metadata.""" + return { + "starting_agent": starting_agent, + "token": token, + "vlab_id": "32c83739-f39c-49d1-833f-58c981ebd2a2", # New god account vlab. Replaced by actual id in endpoint for now. Meant for usage without history + "project_id": "123251a1-be18-4146-87b5-5ca2f8bfaf48", # New god account proj. Replaced by actual id in endpoint for now. Meant for usage without history + "retriever_k": settings.tools.literature.retriever_k, + "reranker_k": settings.tools.literature.reranker_k, + "use_reranker": settings.tools.literature.use_reranker, + "literature_search_url": settings.tools.literature.url, + "knowledge_graph_url": settings.knowledge_graph.url, + "me_model_search_size": settings.tools.me_model.search_size, + "brainregion_path": settings.knowledge_graph.br_saving_path, + "celltypes_path": settings.knowledge_graph.ct_saving_path, + "morpho_search_size": settings.tools.morpho.search_size, + "kg_morpho_feature_search_size": settings.tools.kg_morpho_features.search_size, + "trace_search_size": settings.tools.trace.search_size, + "kg_sparql_url": settings.knowledge_graph.sparql_url, + "kg_class_view_url": settings.knowledge_graph.class_view_url, + "bluenaas_url": settings.tools.bluenaas.url, + "httpx_client": httpx_client, + } + + +def get_agents_routine( + openai: Annotated[AsyncOpenAI | None, Depends(get_openai_client)], +) -> AgentsRoutine: + """Get the AgentRoutine client.""" + return AgentsRoutine(openai) async def get_update_kg_hierarchy( diff --git a/src/neuroagent/app/main.py b/src/neuroagent/app/main.py index 951989a..ee870e0 100644 --- a/src/neuroagent/app/main.py +++ b/src/neuroagent/app/main.py @@ -1,4 +1,4 @@ -"""FastAPI for the Agent.""" +"""Main.""" import logging from contextlib import asynccontextmanager @@ -15,8 +15,8 @@ from neuroagent import __version__ from neuroagent.app.app_utils import setup_engine from neuroagent.app.config import Settings +from neuroagent.app.database.sql_schemas import Base from neuroagent.app.dependencies import ( - get_agent_memory, get_cell_types_kg_hierarchy, get_connection_string, get_kg_token, @@ -24,9 +24,7 @@ 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 +from neuroagent.app.routers import qa, threads, tools LOGGING = { "version": 1, @@ -69,22 +67,13 @@ @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)() - # Get the sqlalchemy engine - conn_string = get_connection_string(app_settings) - engine = setup_engine(app_settings, conn_string) - - # Store it in the state + # Get the sqlalchemy engine and store it in app state. + engine = setup_engine(app_settings, get_connection_string(app_settings)) fastapi_app.state.engine = engine - # Create the checkpoints and writes tables. - await anext( - fastapi_app.dependency_overrides.get(get_agent_memory, get_agent_memory)( - get_connection_string(app_settings) - ) - ) + # Create the tables for the agent memory. if engine: async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) @@ -92,6 +81,7 @@ async def lifespan(fastapi_app: FastAPI) -> AsyncContextManager[None]: # type: 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( diff --git a/src/neuroagent/app/routers/database/__init__.py b/src/neuroagent/app/routers/database/__init__.py deleted file mode 100644 index a81230d..0000000 --- a/src/neuroagent/app/routers/database/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Database utilities.""" diff --git a/src/neuroagent/app/routers/database/schemas.py b/src/neuroagent/app/routers/database/schemas.py deleted file mode 100644 index be379c8..0000000 --- a/src/neuroagent/app/routers/database/schemas.py +++ /dev/null @@ -1,62 +0,0 @@ -"""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, nullable=False, primary_key=True) - vlab_id = Column(String, nullable=False) - project_id = Column(String, nullable=False) - 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 - vlab_id: str - project_id: 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 deleted file mode 100644 index 57f5d12..0000000 --- a/src/neuroagent/app/routers/database/sql.py +++ /dev/null @@ -1,44 +0,0 @@ -"""SQL related functions.""" - -from typing import Annotated - -from fastapi import Depends, HTTPException -from fastapi.security import HTTPBasic -from sqlalchemy.ext.asyncio import AsyncSession - -from neuroagent.app.dependencies import get_session, get_user_id -from neuroagent.app.routers.database.schemas import Threads - -security = HTTPBasic() - - -async def get_object( - session: Annotated[AsyncSession, 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 = await 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 deleted file mode 100644 index b836972..0000000 --- a/src/neuroagent/app/routers/database/threads.py +++ /dev/null @@ -1,241 +0,0 @@ -"""Conversation related CRUD operations.""" - -import logging -from typing import Annotated - -from fastapi import APIRouter, Depends, HTTPException -from httpx import AsyncClient -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.ext.asyncio import AsyncEngine, AsyncSession - -from neuroagent.app.app_utils import validate_project -from neuroagent.app.config import Settings -from neuroagent.app.dependencies import ( - get_agent_memory, - get_engine, - get_httpx_client, - get_kg_token, - get_session, - get_settings, - 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("/") -async def create_thread( - httpx_client: Annotated[AsyncClient, Depends(get_httpx_client)], - settings: Annotated[Settings, Depends(get_settings)], - token: Annotated[str, Depends(get_kg_token)], - session: Annotated[AsyncSession, Depends(get_session)], - user_id: Annotated[str, Depends(get_user_id)], - virtual_lab_id: str, - project_id: str, - 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 - # We first need to check if the combination thread/vlab/project is valid - await validate_project( - httpx_client=httpx_client, - vlab_id=virtual_lab_id, - project_id=project_id, - token=token, - vlab_project_url=settings.virtual_lab.get_project_url, - ) - - new_thread = Threads( - user_sub=user_id, vlab_id=virtual_lab_id, title=title, project_id=project_id - ) - session.add(new_thread) - await session.commit() - await session.refresh(new_thread) - return ThreadsRead(**new_thread.__dict__) - - -@router.get("/") -async def get_threads( - session: Annotated[AsyncSession, 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) - results = await session.execute(query) - threads = results.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}") -async def update_thread_title( - thread: Annotated[Threads, Depends(get_object)], - session: Annotated[AsyncSession, 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) - await session.commit() - await session.refresh(thread) - thread_return = ThreadsRead(**thread.__dict__) # For mypy. - return thread_return - - -@router.delete("/{thread_id}") -async def delete_thread( - _: Annotated[Threads, Depends(get_object)], - session: Annotated[AsyncSession, Depends(get_session)], - engine: Annotated[AsyncEngine, 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() - async with engine.begin() as conn: - await conn.run_sync(metadata.reflect) - 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) - await session.execute(query) - - await session.commit() - return {"Acknowledged": "true"} diff --git a/src/neuroagent/app/routers/database/tools.py b/src/neuroagent/app/routers/database/tools.py deleted file mode 100644 index 9248c66..0000000 --- a/src/neuroagent/app/routers/database/tools.py +++ /dev/null @@ -1,200 +0,0 @@ -"""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 index b655c5a..86d1684 100644 --- a/src/neuroagent/app/routers/qa.py +++ b/src/neuroagent/app/routers/qa.py @@ -1,72 +1,96 @@ """Endpoints for agent's question answering pipeline.""" import logging -from typing import Annotated +from typing import Annotated, Any from fastapi import APIRouter, Depends from fastapi.responses import StreamingResponse +from sqlalchemy.ext.asyncio import AsyncSession -from neuroagent.agents import ( - AgentOutput, - BaseAgent, - SimpleChatAgent, -) +from neuroagent.agent_routine import AgentsRoutine +from neuroagent.app.database.db_utils import get_history, get_thread, save_history +from neuroagent.app.database.sql_schemas import Threads from neuroagent.app.dependencies import ( - get_agent, - get_chat_agent, - get_connection_string, + get_agents_routine, + get_context_variables, + get_session, + get_starting_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 +from neuroagent.new_types import Agent, AgentRequest, AgentResponse +from neuroagent.stream import stream_agent_response -router = APIRouter( - prefix="/qa", tags=["Run the agent"], dependencies=[Depends(get_user_id)] -) +router = APIRouter(prefix="/qa", tags=["Run the agent"]) 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.query}") - return await agent.arun(request.query) +@router.post("/run/", response_model=AgentResponse) +async def run_simple_agent( + user_request: AgentRequest, + agent_routine: Annotated[AgentsRoutine, Depends(get_agents_routine)], + agent: Annotated[Agent, Depends(get_starting_agent)], + context_variables: Annotated[dict[str, Any], Depends(get_context_variables)], +) -> AgentResponse: + """Run a single agent query.""" + response = await agent_routine.arun( + agent, [{"role": "user", "content": user_request.query}], context_variables + ) + return AgentResponse(message=response.messages[-1]["content"]) -@router.post("/chat/{thread_id}", response_model=AgentOutput) +@router.post("/chat/{thread_id}", response_model=AgentResponse) 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.query}") - return await agent.arun(query=request.query, thread_id=thread_id) + user_request: AgentRequest, + agent_routine: Annotated[AgentsRoutine, Depends(get_agents_routine)], + agent: Annotated[Agent, Depends(get_starting_agent)], + context_variables: Annotated[dict[str, Any], Depends(get_context_variables)], + session: Annotated[AsyncSession, Depends(get_session)], + user_id: Annotated[str, Depends(get_user_id)], + thread: Annotated[Threads, Depends(get_thread)], + messages: Annotated[list[dict[str, Any]], Depends(get_history)], +) -> AgentResponse: + """Run a single agent query.""" + # Temporary solution + context_variables["vlab_id"] = thread.vlab_id + context_variables["project_id"] = thread.project_id + + messages.append({"role": "user", "content": user_request.query}) + response = await agent_routine.arun(agent, messages, context_variables) + await save_history( + user_id=user_id, + history=response.messages, + offset=len(messages) - 1, + thread_id=thread.thread_id, + session=session, + ) + return AgentResponse(message=response.messages[-1]["content"]) @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)], - connection_string: Annotated[str | None, Depends(get_connection_string)], - thread_id: str, +async def stream_chat_agent( + user_request: AgentRequest, + agents_routine: Annotated[AgentsRoutine, Depends(get_agents_routine)], + agent: Annotated[Agent, Depends(get_starting_agent)], + context_variables: Annotated[dict[str, Any], Depends(get_context_variables)], + session: Annotated[AsyncSession, Depends(get_session)], + user_id: Annotated[str, Depends(get_user_id)], + thread: Annotated[Threads, Depends(get_thread)], + messages: Annotated[list[dict[str, Any]], Depends(get_history)], ) -> StreamingResponse: - """Run agent in streaming mode.""" - logger.info("Running agent query.") - logger.info(f"User's query: {request.query}") - return StreamingResponse( - agent.astream( - query=request.query, - thread_id=thread_id, - connection_string=connection_string, - ) + """Run a single agent query in a streamed fashion.""" + # Temporary solution + context_variables["vlab_id"] = thread.vlab_id + context_variables["project_id"] = thread.project_id + + messages.append({"role": "user", "content": user_request.query}) + stream_generator = stream_agent_response( + agents_routine, + agent, + messages, + context_variables, + user_id, + thread.thread_id, + session, ) + return StreamingResponse(stream_generator, media_type="text/event-stream") diff --git a/swarm_copy/app/routers/threads.py b/src/neuroagent/app/routers/threads.py similarity index 91% rename from swarm_copy/app/routers/threads.py rename to src/neuroagent/app/routers/threads.py index 58e4b2d..2b92432 100644 --- a/swarm_copy/app/routers/threads.py +++ b/src/neuroagent/app/routers/threads.py @@ -9,12 +9,12 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from swarm_copy.app.app_utils import validate_project -from swarm_copy.app.config import Settings -from swarm_copy.app.database.db_utils import get_thread -from swarm_copy.app.database.schemas import MessagesRead, ThreadsRead, ThreadUpdate -from swarm_copy.app.database.sql_schemas import Entity, Messages, Threads -from swarm_copy.app.dependencies import ( +from neuroagent.app.app_utils import validate_project +from neuroagent.app.config import Settings +from neuroagent.app.database.db_utils import get_thread +from neuroagent.app.database.schemas import MessagesRead, ThreadsRead, ThreadUpdate +from neuroagent.app.database.sql_schemas import Entity, Messages, Threads +from neuroagent.app.dependencies import ( get_httpx_client, get_kg_token, get_session, diff --git a/swarm_copy/app/routers/tools.py b/src/neuroagent/app/routers/tools.py similarity index 93% rename from swarm_copy/app/routers/tools.py rename to src/neuroagent/app/routers/tools.py index 98389f7..81c5b21 100644 --- a/swarm_copy/app/routers/tools.py +++ b/src/neuroagent/app/routers/tools.py @@ -8,10 +8,10 @@ from sqlalchemy import desc, select from sqlalchemy.ext.asyncio import AsyncSession -from swarm_copy.app.database.db_utils import get_thread -from swarm_copy.app.database.schemas import ToolCallSchema -from swarm_copy.app.database.sql_schemas import Entity, Messages, Threads -from swarm_copy.app.dependencies import get_session +from neuroagent.app.database.db_utils import get_thread +from neuroagent.app.database.schemas import ToolCallSchema +from neuroagent.app.database.sql_schemas import Entity, Messages, Threads +from neuroagent.app.dependencies import get_session logger = logging.getLogger(__name__) diff --git a/src/neuroagent/app/schemas.py b/src/neuroagent/app/schemas.py deleted file mode 100644 index 962ae84..0000000 --- a/src/neuroagent/app/schemas.py +++ /dev/null @@ -1,9 +0,0 @@ -"""Schemas.""" - -from pydantic import BaseModel - - -class AgentRequest(BaseModel): - """Class for agent request.""" - - query: str diff --git a/swarm_copy/bluenaas_models.py b/src/neuroagent/bluenaas_models.py similarity index 100% rename from swarm_copy/bluenaas_models.py rename to src/neuroagent/bluenaas_models.py diff --git a/src/neuroagent/multi_agents/__init__.py b/src/neuroagent/multi_agents/__init__.py deleted file mode 100644 index ace9a8c..0000000 --- a/src/neuroagent/multi_agents/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -"""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 deleted file mode 100644 index 8a3f62a..0000000 --- a/src/neuroagent/multi_agents/base_multi_agent.py +++ /dev/null @@ -1,37 +0,0 @@ -"""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 deleted file mode 100644 index 15df2e3..0000000 --- a/src/neuroagent/multi_agents/supervisor_multi_agent.py +++ /dev/null @@ -1,225 +0,0 @@ -"""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/swarm_copy/new_types.py b/src/neuroagent/new_types.py similarity index 95% rename from swarm_copy/new_types.py rename to src/neuroagent/new_types.py index 25fedc9..6ccac05 100644 --- a/swarm_copy/new_types.py +++ b/src/neuroagent/new_types.py @@ -5,7 +5,7 @@ # Third-party imports from pydantic import BaseModel -from swarm_copy.tools.base_tool import BaseTool +from neuroagent.tools.base_tool import BaseTool class Agent(BaseModel): diff --git a/src/neuroagent/scripts/__init__.py b/src/neuroagent/scripts/__init__.py index cc662d0..a9a18d7 100644 --- a/src/neuroagent/scripts/__init__.py +++ b/src/neuroagent/scripts/__init__.py @@ -1 +1 @@ -"""Neuroagent scripts.""" +"""Scripts.""" diff --git a/swarm_copy/stream.py b/src/neuroagent/stream.py similarity index 72% rename from swarm_copy/stream.py rename to src/neuroagent/stream.py index 62fd638..0a34e00 100644 --- a/swarm_copy/stream.py +++ b/src/neuroagent/stream.py @@ -2,12 +2,13 @@ from typing import Any, AsyncIterator +from httpx import AsyncClient from openai import AsyncOpenAI from sqlalchemy.ext.asyncio import AsyncSession -from swarm_copy.agent_routine import AgentsRoutine -from swarm_copy.app.database.db_utils import save_history -from swarm_copy.new_types import Agent, Response +from neuroagent.agent_routine import AgentsRoutine +from neuroagent.app.database.db_utils import save_history +from neuroagent.new_types import Agent, Response async def stream_agent_response( @@ -20,6 +21,7 @@ async def stream_agent_response( session: AsyncSession, ) -> AsyncIterator[str]: """Redefine fastAPI connections to enable streaming.""" + # Restore the OpenAI client if isinstance(agents_routine.client, AsyncOpenAI): connected_agents_routine = AgentsRoutine( client=AsyncOpenAI(api_key=agents_routine.client.api_key) @@ -27,6 +29,16 @@ async def stream_agent_response( else: connected_agents_routine = AgentsRoutine(client=None) + # Restore the httpx client + httpx_client = AsyncClient( + timeout=None, + verify=False, + headers={ + "x-request-id": context_variables["httpx_client"].headers["x-request-id"] + }, + ) + context_variables["httpx_client"] = httpx_client + iterator = connected_agents_routine.astream(agent, messages, context_variables) async for chunk in iterator: # To stream to the user diff --git a/src/neuroagent/tools/__init__.py b/src/neuroagent/tools/__init__.py index 86abf14..429a826 100644 --- a/src/neuroagent/tools/__init__.py +++ b/src/neuroagent/tools/__init__.py @@ -1,8 +1,11 @@ -"""Tools folder.""" +"""Tools package.""" -from neuroagent.tools.bluenaas_tool import BlueNaaSTool +from neuroagent.tools.bluenaas_memodel_getall import MEModelGetAllTool +from neuroagent.tools.bluenaas_memodel_getone import MEModelGetOneTool +from neuroagent.tools.bluenaas_scs_getall import SCSGetAllTool +from neuroagent.tools.bluenaas_scs_getone import SCSGetOneTool +from neuroagent.tools.bluenaas_scs_post import SCSPostTool from neuroagent.tools.electrophys_tool import ElectrophysFeatureTool, FeatureOutput -from neuroagent.tools.get_me_model_tool import GetMEModelTool from neuroagent.tools.get_morpho_tool import GetMorphoTool, KnowledgeGraphOutput from neuroagent.tools.kg_morpho_features_tool import ( KGMorphoFeatureOutput, @@ -23,7 +26,9 @@ from neuroagent.tools.traces_tool import GetTracesTool, TracesOutput __all__ = [ - "BlueNaaSTool", + "SCSGetAllTool", + "SCSGetOneTool", + "SCSPostTool", "BRResolveOutput", "ElectrophysFeatureTool", "FeatureOutput", @@ -33,10 +38,11 @@ "KGMorphoFeatureTool", "KnowledgeGraphOutput", "LiteratureSearchTool", + "MEModelGetAllTool", + "MEModelGetOneTool", "MorphologyFeatureOutput", "MorphologyFeatureTool", "ParagraphMetadata", "ResolveEntitiesTool", "TracesOutput", - "GetMEModelTool", ] diff --git a/src/neuroagent/tools/base_tool.py b/src/neuroagent/tools/base_tool.py index c224b63..e366ab6 100644 --- a/src/neuroagent/tools/base_tool.py +++ b/src/neuroagent/tools/base_tool.py @@ -1,14 +1,15 @@ -"""Base tool (to handle errors).""" +"""Base tool.""" -import json import logging -from typing import Any, Literal +from abc import ABC, abstractmethod +from typing import Any, ClassVar, Literal -from langchain_core.tools import BaseTool, ToolException -from pydantic import BaseModel, ValidationError, model_validator +from httpx import AsyncClient +from pydantic import BaseModel, ConfigDict logger = logging.getLogger(__name__) + EtypesLiteral = Literal[ "bSTUT", "dSTUT", @@ -53,75 +54,37 @@ } -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) +class BaseMetadata(BaseModel): + """Base class for metadata.""" + httpx_client: AsyncClient + model_config = ConfigDict(extra="ignore", arbitrary_types_allowed=True) -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 BaseTool(BaseModel, ABC): + """Base class for the tools.""" -class BasicTool(BaseTool): - """Basic class for tools.""" + name: ClassVar[str] + description: ClassVar[str] + metadata: BaseMetadata + input_schema: BaseModel - 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() + def pydantic_to_openai_schema(cls) -> dict[str, Any]: + """Convert pydantic schema to OpenAI json.""" + new_retval: dict[str, Any] = { + "type": "function", + "function": { + "name": cls.name, + "description": cls.description, + "strict": False, + "parameters": cls.__annotations__["input_schema"].model_json_schema(), + }, + } + new_retval["function"]["parameters"]["additionalProperties"] = False + + return new_retval + + @abstractmethod + async def arun(self) -> Any: + """Run the tool.""" diff --git a/swarm_copy/tools/bluenaas_memodel_getall.py b/src/neuroagent/tools/bluenaas_memodel_getall.py similarity index 95% rename from swarm_copy/tools/bluenaas_memodel_getall.py rename to src/neuroagent/tools/bluenaas_memodel_getall.py index 6c95c0e..8f50d6c 100644 --- a/swarm_copy/tools/bluenaas_memodel_getall.py +++ b/src/neuroagent/tools/bluenaas_memodel_getall.py @@ -5,10 +5,10 @@ from pydantic import BaseModel, Field -from swarm_copy.bluenaas_models import ( +from neuroagent.bluenaas_models import ( PaginatedResponseUnionMEModelResponseSynaptomeModelResponse, ) -from swarm_copy.tools.base_tool import BaseMetadata, BaseTool +from neuroagent.tools.base_tool import BaseMetadata, BaseTool logger = logging.getLogger(__name__) diff --git a/swarm_copy/tools/bluenaas_memodel_getone.py b/src/neuroagent/tools/bluenaas_memodel_getone.py similarity index 93% rename from swarm_copy/tools/bluenaas_memodel_getone.py rename to src/neuroagent/tools/bluenaas_memodel_getone.py index 2f36f64..c0a46c0 100644 --- a/swarm_copy/tools/bluenaas_memodel_getone.py +++ b/src/neuroagent/tools/bluenaas_memodel_getone.py @@ -6,8 +6,8 @@ from pydantic import BaseModel, Field -from swarm_copy.bluenaas_models import MEModelResponse -from swarm_copy.tools.base_tool import BaseMetadata, BaseTool +from neuroagent.bluenaas_models import MEModelResponse +from neuroagent.tools.base_tool import BaseMetadata, BaseTool logger = logging.getLogger(__name__) diff --git a/swarm_copy/tools/bluenaas_scs_getall.py b/src/neuroagent/tools/bluenaas_scs_getall.py similarity index 94% rename from swarm_copy/tools/bluenaas_scs_getall.py rename to src/neuroagent/tools/bluenaas_scs_getall.py index 533ed7f..78e5ad1 100644 --- a/swarm_copy/tools/bluenaas_scs_getall.py +++ b/src/neuroagent/tools/bluenaas_scs_getall.py @@ -5,8 +5,8 @@ from pydantic import BaseModel, Field -from swarm_copy.bluenaas_models import PaginatedResponseSimulationDetailsResponse -from swarm_copy.tools.base_tool import BaseMetadata, BaseTool +from neuroagent.bluenaas_models import PaginatedResponseSimulationDetailsResponse +from neuroagent.tools.base_tool import BaseMetadata, BaseTool logger = logging.getLogger(__name__) diff --git a/swarm_copy/tools/bluenaas_scs_getone.py b/src/neuroagent/tools/bluenaas_scs_getone.py similarity index 92% rename from swarm_copy/tools/bluenaas_scs_getone.py rename to src/neuroagent/tools/bluenaas_scs_getone.py index 2575682..88fec5b 100644 --- a/swarm_copy/tools/bluenaas_scs_getone.py +++ b/src/neuroagent/tools/bluenaas_scs_getone.py @@ -5,8 +5,8 @@ from pydantic import BaseModel, Field -from swarm_copy.bluenaas_models import SimulationDetailsResponse -from swarm_copy.tools.base_tool import BaseMetadata, BaseTool +from neuroagent.bluenaas_models import SimulationDetailsResponse +from neuroagent.tools.base_tool import BaseMetadata, BaseTool logger = logging.getLogger(__name__) diff --git a/swarm_copy/tools/bluenaas_scs_post.py b/src/neuroagent/tools/bluenaas_scs_post.py similarity index 99% rename from swarm_copy/tools/bluenaas_scs_post.py rename to src/neuroagent/tools/bluenaas_scs_post.py index 7e3144c..5df3225 100644 --- a/swarm_copy/tools/bluenaas_scs_post.py +++ b/src/neuroagent/tools/bluenaas_scs_post.py @@ -5,7 +5,7 @@ from pydantic import BaseModel, Field -from swarm_copy.tools.base_tool import BaseMetadata, BaseTool +from neuroagent.tools.base_tool import BaseMetadata, BaseTool logger = logging.getLogger(__name__) diff --git a/src/neuroagent/tools/bluenaas_tool.py b/src/neuroagent/tools/bluenaas_tool.py deleted file mode 100644 index e615f85..0000000 --- a/src/neuroagent/tools/bluenaas_tool.py +++ /dev/null @@ -1,269 +0,0 @@ -"""BlueNaaS single cell stimulation, simulation and synapse placement tool.""" - -import json -import logging -from typing import Annotated, Any, Literal - -from langchain_core.messages import AIMessage, BaseMessage, HumanMessage, ToolMessage -from langchain_core.tools import ToolException -from langgraph.prebuilt import InjectedState -from pydantic import BaseModel, Field - -from neuroagent.tools.base_tool import BaseToolOutput, BasicTool - -logger = logging.getLogger(__name__) - - -class RecordingLocation(BaseModel): - """Configuration for the recording location in the simulation.""" - - section: str = Field(default="soma[0]", description="Section to record from") - offset: float = Field( - default=0.5, ge=0, le=1, description="Offset in the section to record from" - ) - - -class InputBlueNaaS(BaseModel): - """Inputs for the BlueNaaS single-neuron simulation.""" - - me_model_id: str = Field( - description=( - "ID of the neuron model to be used in the simulation. The model ID can be" - " fetched using the 'get-me-model-tool'." - ) - ) - current_injection__inject_to: str = Field( - default="soma[0]", description="Section to inject the current to." - ) - current_injection__stimulus__stimulus_type: Literal[ - "current_clamp", "voltage_clamp", "conductance" - ] = Field(default="current_clamp", description="Type of stimulus to be used.") - current_injection__stimulus__stimulus_protocol: Literal[ - "ap_waveform", "idrest", "iv", "fire_pattern" - ] = Field(default="ap_waveform", description="Stimulus protocol to be used.") - - current_injection__stimulus__amplitudes: list[float] = Field( - default=[0.1], - min_length=1, - description="List of amplitudes for the stimulus", - ) - record_from: list[RecordingLocation] = Field( - default=[RecordingLocation()], - description=( - "List of sections to record from during the simulation. Each record" - " configuration includes the section name and offset." - ), - ) - conditions__celsius: int = Field( - default=34, ge=0, le=50, description="Temperature in celsius" - ) - conditions__vinit: int = Field(default=-73, description="Initial voltage in mV") - conditions__hypamp: int = Field(default=0, description="Holding current in nA") - conditions__max_time: int = Field( - default=100, le=3000, description="Maximum simulation time in ms" - ) - conditions__time_step: float = Field( - default=0.05, ge=0.001, le=10, description="Time step in ms" - ) - conditions__seed: int = Field(default=100, description="Random seed") - messages: Annotated[list[BaseMessage], InjectedState("messages")] - - -class BlueNaaSValidatedOutput(BaseToolOutput): - """Should return a successful POST request.""" - - status: Literal["success", "pending", "error"] - - -class BlueNaaSInvalidatedOutput(BaseModel): - """Response to the user if the simulation has not been validated yet.""" - - inputs: dict[str, Any] - - def __str__(self) -> str: - """Format the response passed to the LLM.""" - return f"A simulation will be ran with the following inputs {self.inputs}. \n Please confirm that you are satisfied by the simulation parameters, or correct them accordingly." - - -class BlueNaaSTool(BasicTool): - """Class defining the BlueNaaS tool.""" - - name: str = "bluenaas-tool" - description: str = """Runs a single-neuron simulation using the BlueNaaS service. - Requires a "me_model_id" which must be fetched by get-me-model-tool. - Optionally, the user can specify simulation parameters. - The tool will always ask for config validation from the user before running. - If the user mentions an existing configuration, it must always be passed in the tool first to get user's approval. - Specify ALL of the parameters everytime you enter this tool. - """ - metadata: dict[str, Any] - args_schema: type[BaseModel] = InputBlueNaaS - response_format: Literal["content", "content_and_artifact"] = "content_and_artifact" - - def _run(self) -> None: - pass - - async def _arun( - self, - me_model_id: str, - messages: Annotated[list[BaseMessage], InjectedState("messages")], - current_injection__inject_to: str = "soma[0]", - current_injection__stimulus__stimulus_type: Literal[ - "current_clamp", "voltage_clamp", "conductance" - ] = "current_clamp", - current_injection__stimulus__stimulus_protocol: Literal[ - "ap_waveform", "idrest", "iv", "fire_pattern" - ] = "ap_waveform", - current_injection__stimulus__amplitudes: list[float] | None = None, - record_from: list[RecordingLocation] | None = None, - conditions__celsius: int = 34, - conditions__vinit: int = -73, - conditions__hypamp: int = 0, - conditions__max_time: int = 100, - conditions__time_step: float = 0.05, - conditions__seed: int = 100, - ) -> tuple[BaseToolOutput | BaseModel, dict[str, bool]]: - """Run the BlueNaaS tool.""" - logger.info("Running BlueNaaS tool") - - json_api = self.create_json_api( - current_injection__inject_to=current_injection__inject_to, - current_injection__stimulus__stimulus_type=current_injection__stimulus__stimulus_type, - current_injection__stimulus__stimulus_protocol=current_injection__stimulus__stimulus_protocol, - current_injection__stimulus__amplitudes=current_injection__stimulus__amplitudes, - record_from=record_from, - conditions__celsius=conditions__celsius, - conditions__vinit=conditions__vinit, - conditions__hypamp=conditions__hypamp, - conditions__max_time=conditions__max_time, - conditions__time_step=conditions__time_step, - conditions__seed=conditions__seed, - ) - - if self.is_validated(messages, json_api): - try: - await self.metadata["httpx_client"].post( - url=self.metadata["url"], - params={"model_id": me_model_id}, - headers={"Authorization": f'Bearer {self.metadata["token"]}'}, - json=json_api, - timeout=5.0, - ) - - return BlueNaaSValidatedOutput(status="success"), { - "is_validated": False - } - - except Exception as e: - raise ToolException(str(e), self.name) - else: - return BlueNaaSInvalidatedOutput(inputs=json_api), {"is_validated": True} - - @staticmethod - def create_json_api( - current_injection__inject_to: str = "soma[0]", - current_injection__stimulus__stimulus_type: Literal[ - "current_clamp", "voltage_clamp", "conductance" - ] = "current_clamp", - current_injection__stimulus__stimulus_protocol: Literal[ - "ap_waveform", "idrest", "iv", "fire_pattern" - ] = "ap_waveform", - current_injection__stimulus__amplitudes: list[float] | None = None, - record_from: list[RecordingLocation] | None = None, - conditions__celsius: int = 34, - conditions__vinit: int = -73, - conditions__hypamp: int = 0, - conditions__max_time: int = 100, - conditions__time_step: float = 0.05, - conditions__seed: int = 100, - ) -> dict[str, Any]: - """Based on the simulation config, create a valid JSON for the API.""" - if not current_injection__stimulus__amplitudes: - current_injection__stimulus__amplitudes = [0.1] - if not record_from: - record_from = [RecordingLocation()] - json_api = { - "currentInjection": { - "injectTo": current_injection__inject_to, - "stimulus": { - "stimulusType": current_injection__stimulus__stimulus_type, - "stimulusProtocol": current_injection__stimulus__stimulus_protocol, - "amplitudes": current_injection__stimulus__amplitudes, - }, - }, - "recordFrom": [recording.model_dump() for recording in record_from], - "conditions": { - "celsius": conditions__celsius, - "vinit": conditions__vinit, - "hypamp": conditions__hypamp, - "max_time": conditions__max_time, - "time_step": conditions__time_step, - "seed": conditions__seed, - }, - "type": "single-neuron-simulation", - "simulationDuration": conditions__max_time, - } - return json_api - - @staticmethod - def is_validated(messages: list[BaseMessage], json_api: dict[str, Any]) -> bool: - """Decide whether the current configuration has been validated by the user. - - Parameters - ---------- - messages - List of Langgraph messages extracted from the graph state. - json_api - Simulation configuration that the tool will run if it has been validated. - - Returns - ------- - is_validated - Boolean stating wether or not the configuration has been validated by the user. - """ - # If it is the first time the tool is called in the conversation, need validation - try: - # Get the last bluenaas call - last_bluenaas_call = next( - ( - message - for message in reversed(messages) - if isinstance(message, ToolMessage) - and message.name == "bluenaas-tool" - ) - ) - except StopIteration: - return False - - # Verify if the tool has been recently called to ask for validation - # There has to be at least 3 messages in the state otherwise cannot be validated - if len(messages) > 3: - last_messages = messages[-4:-1] - recently_validated = ( - isinstance(last_messages[-1], HumanMessage) # Approval from the human - and isinstance( - last_messages[-2], AIMessage - ) # AI answering the human and asking for validation - and isinstance( - last_messages[-3], ToolMessage - ) # First tool call not validated - and last_messages[-3].name == "bluenaas-tool" - ) - # If it hasn't been recently validated, we need more validation - if not recently_validated: - return False - # If there is not enough messages in the state to have a potential validation - else: - return False - - # If the previous simulation was started, ask for validation on the new one - if not last_bluenaas_call.artifact.get("is_validated"): - return False - - # Verify if the config has changed since previous call. Validate again if so - old_config = json.loads( - last_bluenaas_call.content.split("")[-1] # type: ignore - .split("")[0] - .replace("'", '"') - ) - return old_config == json_api diff --git a/src/neuroagent/tools/electrophys_tool.py b/src/neuroagent/tools/electrophys_tool.py index 33b1f38..90484d6 100644 --- a/src/neuroagent/tools/electrophys_tool.py +++ b/src/neuroagent/tools/electrophys_tool.py @@ -3,14 +3,13 @@ import logging import tempfile from statistics import mean -from typing import Any, Literal, Optional, Type +from typing import Any, ClassVar, Literal 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.tools.base_tool import BaseMetadata, BaseTool from neuroagent.utils import get_kg_data logger = logging.getLogger(__name__) @@ -118,7 +117,7 @@ class AmplitudeInput(BaseModel): max_value: float -class InputElectrophys(BaseModel): +class ElectrophysInput(BaseModel): """Inputs of the NeuroM API.""" trace_id: str = Field( @@ -127,7 +126,8 @@ class InputElectrophys(BaseModel): " link such as 'https://bbp.epfl.ch/neurosciencegraph/data/traces...'." ) ) - stimuli_types: Optional[STIMULI_TYPES] = Field( + stimuli_types: STIMULI_TYPES | None = Field( + default=None, description=( "Type of stimuli requested by the user. Should be one of 'spontaneous'," " 'idrest', 'idthres', 'apwaveform', 'iv', 'step', 'spontaps'," @@ -135,9 +135,10 @@ class InputElectrophys(BaseModel): " 'delta', 'sahp', 'idhyperpol', 'irdepol', 'irhyperpol','iddepol', 'ramp'," " 'ap_thresh', 'hyperdepol', 'negcheops', 'poscheops'," " 'spikerec', 'sinespec'." - ) + ), ) - calculated_feature: Optional[CALCULATED_FEATURES] = Field( + calculated_feature: CALCULATED_FEATURES | None = Field( + default=None, description=( "Feature requested by the user. Should be one of 'spike_count'," "'time_to_first_spike', 'time_to_last_spike'," @@ -150,9 +151,10 @@ class InputElectrophys(BaseModel): "'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( + amplitude: AmplitudeInput | None = Field( + default=None, description=( "Amplitude of the protocol (should be specified in nA)." "Can be a range of amplitudes with min and max values" @@ -162,173 +164,169 @@ class InputElectrophys(BaseModel): ) -class FeatureOutput(BaseToolOutput): +class ElectrophysMetadata(BaseMetadata): + """Metadata class for the electrophys tool.""" + + knowledge_graph_url: str + token: str + + +class FeatureOutput(BaseModel): """Output schema for the neurom tool.""" brain_region: str feature_dict: dict[str, Any] -class ElectrophysFeatureTool(BasicTool): +class ElectrophysFeatureTool(BaseTool): """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. + name: ClassVar[str] = "electrophys-features-tool" + description: ClassVar[ + 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, - ) -> FeatureOutput | 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 - """ + input_schema: ElectrophysInput + metadata: ElectrophysMetadata + + async def arun(self) -> dict[str, Any]: + """Give features about trace.""" logger.info( - f"Entering electrophys tool. Inputs: {trace_id=}, {calculated_feature=}," - f" {amplitude=}, {stimuli_types=}" + f"Entering electrophys tool. Inputs: {self.input_schema.trace_id=}, {self.input_schema.calculated_feature=}," + f" {self.input_schema.amplitude=}, {self.input_schema.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" + # Deal with cases where user did not specify stimulus type or/and feature + if not self.input_schema.stimuli_types: + # Default to IDRest if protocol not specified + logger.warning("No stimulus type specified. Defaulting to IDRest.") + stimuli_types = ["idrest"] + else: + stimuli_types = self.input_schema.stimuli_types # type: ignore + + if not self.input_schema.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 + else: + calculated_feature = self.input_schema.calculated_feature + + # Download the .nwb file associated to the trace from the KG + trace_content, metadata = await get_kg_data( + object_id=self.input_schema.trace_id, + httpx_client=self.metadata.httpx_client, + url=self.metadata.knowledge_graph_url, + token=self.metadata.token, + preferred_format="nwb", + ) + + # Turn amplitude requirement of user into a bluepyefe compatible representation + if isinstance(self.input_schema.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( + [ + self.input_schema.amplitude.min_value, + self.input_schema.amplitude.max_value, + ] ) - 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, + # If the range is just one number, use 10% of it as tolerance + if ( + self.input_schema.amplitude.min_value + == self.input_schema.amplitude.max_value ): - 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 - } + desired_tolerance = self.input_schema.amplitude.max_value * 0.1 + else: + desired_tolerance = ( + self.input_schema.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, } - 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 - } + 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 } - - # 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"] + } + 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 } - - # Add the stimulus current of the protocol to the output - output_features[protocol_name]["stimulus_current"] = ( - f"{protocol_def['step']['amp']} nA" - ) - return FeatureOutput( - brain_region=metadata.brain_region, feature_dict=output_features + } + + # 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, ) - except Exception as e: - raise ToolException(str(e), self.name) + 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 FeatureOutput( + brain_region=metadata.brain_region, feature_dict=output_features + ).model_dump() diff --git a/src/neuroagent/tools/get_me_model_tool.py b/src/neuroagent/tools/get_me_model_tool.py deleted file mode 100644 index ea9944a..0000000 --- a/src/neuroagent/tools/get_me_model_tool.py +++ /dev/null @@ -1,239 +0,0 @@ -"""Module defining the Get ME Model tool.""" - -import logging -from typing import Any, 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 InputGetMEModel(BaseModel): - """Inputs of the knowledge graph API.""" - - brain_region_id: str = Field( - description="ID of the brain region of interest. To get this ID, please use the `resolve-entities-tool` first." - ) - mtype_id: str | None = Field( - default=None, - description="ID of the M-type of interest. To get this ID, please use the `resolve-entities-tool` first.", - ) - etype_id: str | None = Field( - default=None, - description="ID of the electrical type of the cell. Can be obtained through the 'resolve-entities-tool'.", - ) - - -class MEModelOutput(BaseToolOutput): - """Output schema for the knowledge graph API.""" - - me_model_id: str - me_model_name: str | None - me_model_description: str | None - mtype: str | None - etype: str | None - - brain_region_id: str - brain_region_label: str | None - - subject_species_label: str | None - subject_age: str | None - - -class GetMEModelTool(BasicTool): - """Class defining the Get ME Model logic.""" - - name: str = "get-me-model-tool" - description: str = """Searches a neuroscience based knowledge graph to retrieve neuron morpho-electric model (ME models) 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. - Optionally accepts an mtype_id and/or an etype_id. - The output is a list of ME models, containing: - - The brain region ID. - - The brain region name. - - The subject species name. - - The subject age. - - The model ID. - - The model name. - - The model description. - The model ID is in the form of an HTTP(S) link such as 'https://bbp.epfl.ch/data/bbp/mmb-point-neuron-framework-model/...'.""" - metadata: dict[str, Any] - args_schema: Type[BaseModel] = InputGetMEModel - - def _run(self) -> None: - pass - - async def _arun( - self, - brain_region_id: str, - mtype_id: str | None = None, - etype_id: str | None = None, - ) -> list[MEModelOutput]: - """From a brain region ID, extract ME models. - - 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 model. - etype_id - ID of the etype of the model. - - Returns - ------- - list of MEModelOutput to describe the model and its metadata, or an error dict. - """ - logger.info( - f"Entering Get ME Model tool. Inputs: {brain_region_id=}, {mtype_id=}, {etype_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." - ) - - if mtype_id: - mtype_ids = get_celltypes_descendants( - mtype_id, self.metadata["celltypes_path"] - ) - logger.info( - f"Found {len(list(mtype_ids))} children of the cell types ontology for mtype." - ) - else: - mtype_ids = None - - # Create the ES query to query the KG. - entire_query = self.create_query( - brain_regions_ids=hierarchy_ids, - mtype_ids=mtype_ids, - etype_id=etype_id, - ) - - # Send the query to get ME models. - 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, - etype_id: str | None = None, - ) -> dict[str, Any]: - """Create ES query out of the BR, mtype, and etype 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_id - ID of the mtype of the model - etype_id - ID of the etype of the model - - 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 - ] - } - }, - {"term": {"@type.keyword": "https://neuroshapes.org/MEModel"}}, - {"term": {"deprecated": False}}, - ] - - 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 - ] - } - } - ) - - if etype_id: - # The correct etype should match. - conditions.append({"term": {"eType.@id.keyword": etype_id}}) - - # Assemble the query to return ME models. - entire_query = { - "size": self.metadata["search_size"], - "track_total_hits": True, - "query": {"bool": {"must": conditions}}, - } - return entire_query - - @staticmethod - def _process_output(output: Any) -> list[MEModelOutput]: - """Process output to fit the MEModelOutput pydantic class defined above. - - Parameters - ---------- - output - Raw output of the _arun method, which comes from the KG - - Returns - ------- - list of MEModelOutput to describe the model and its metadata. - """ - formatted_output = [ - MEModelOutput( - me_model_id=res["_source"]["_self"], - me_model_name=res["_source"].get("name"), - me_model_description=res["_source"].get("description"), - mtype=( - res["_source"]["mType"].get("label") - if "mType" in res["_source"] - else None - ), - etype=( - res["_source"]["eType"].get("label") - if "eType" 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/get_morpho_tool.py b/src/neuroagent/tools/get_morpho_tool.py index 9c23be4..9f7024b 100644 --- a/src/neuroagent/tools/get_morpho_tool.py +++ b/src/neuroagent/tools/get_morpho_tool.py @@ -1,37 +1,47 @@ """Get Morpho tool.""" import logging -from typing import Any, Optional, Type +from pathlib import Path +from typing import Any, ClassVar -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.tools.base_tool import BaseMetadata, BaseTool from neuroagent.utils import get_descendants_id logger = logging.getLogger(__name__) -class InputGetMorpho(BaseModel): +class GetMorphoInput(BaseModel): """Inputs of the knowledge graph API.""" brain_region_id: str = Field( description="ID of the brain region of interest. To get this ID, please use the `resolve-entities-tool` first." ) - mtype_id: Optional[str] = Field( + mtype_id: str | None = Field( default=None, description="ID of the M-type of interest. To get this ID, please use the `resolve-entities-tool` first.", ) -class KnowledgeGraphOutput(BaseToolOutput): +class GetMorphoMetadata(BaseMetadata): + """Metadata class for GetMorphoTool.""" + + knowledge_graph_url: str + token: str + morpho_search_size: int + brainregion_path: str | Path + celltypes_path: str | Path + + +class KnowledgeGraphOutput(BaseModel): """Output schema for the knowledge graph API.""" morphology_id: str morphology_name: str | None morphology_description: str | None - mtype: str | None + mtype: list[str] | None brain_region_id: str brain_region_label: str | None @@ -40,11 +50,13 @@ class KnowledgeGraphOutput(BaseToolOutput): subject_age: str | None -class GetMorphoTool(BasicTool): +class GetMorphoTool(BaseTool): """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. + name: ClassVar[str] = "get-morpho-tool" + description: ClassVar[ + 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. Optionally accepts an mtype_id. The output is a list of morphologies, containing: @@ -56,62 +68,47 @@ class GetMorphoTool(BasicTool): - 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 + input_schema: GetMorphoInput + metadata: GetMorphoMetadata - def _run(self) -> None: - pass - - async def _arun( - self, brain_region_id: str, mtype_id: str | None = None - ) -> list[KnowledgeGraphOutput]: + async def arun(self) -> list[dict[str, Any]]: """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=}" + f"Entering Get Morpho tool. Inputs: {self.input_schema.brain_region_id=}, {self.input_schema.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 - ) + # From the brain region ID, get the descendants. + hierarchy_ids = get_descendants_id( + self.input_schema.brain_region_id, + json_path=self.metadata.brainregion_path, + ) + logger.info(f"Found {len(list(hierarchy_ids))} children of the brain ontology.") - # 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, + # Create the ES query to query the KG. + mtype_ids = ( + get_celltypes_descendants( + self.input_schema.mtype_id, self.metadata.celltypes_path ) + if self.input_schema.mtype_id + else None + ) + entire_query = self.create_query( + brain_regions_ids=hierarchy_ids, mtype_ids=mtype_ids + ) - # Process the output and return. - return self._process_output(response.json()) + # Send the query to get morphologies. + response = await self.metadata.httpx_client.post( + url=self.metadata.knowledge_graph_url, + headers={"Authorization": f"Bearer {self.metadata.token}"}, + json=entire_query, + ) - except Exception as e: - raise ToolException(str(e), self.name) + # Process the output and return. + return self._process_output(response.json()) def create_query( self, brain_regions_ids: set[str], mtype_ids: set[str] | None = None @@ -159,7 +156,7 @@ def create_query( # Assemble the query to return morphologies. entire_query = { - "size": self.metadata["search_size"], + "size": self.metadata.morpho_search_size, "track_total_hits": True, "query": { "bool": { @@ -179,13 +176,13 @@ def create_query( return entire_query @staticmethod - def _process_output(output: Any) -> list[KnowledgeGraphOutput]: + def _process_output(output: Any) -> list[dict[str, Any]]: """Process output to fit the KnowledgeGraphOutput pydantic class defined above. Parameters ---------- output - Raw output of the _arun method, which comes from the KG + Raw output of the arun method, which comes from the KG Returns ------- @@ -197,8 +194,10 @@ def _process_output(output: Any) -> list[KnowledgeGraphOutput]: morphology_name=res["_source"].get("name"), morphology_description=res["_source"].get("description"), mtype=( - res["_source"]["mType"].get("label") - if "mType" in res["_source"] + [res["_source"]["mType"].get("label")] + if isinstance(res["_source"].get("mType"), dict) + else [item.get("label") for item in res["_source"]["mType"]] + if isinstance(res["_source"].get("mType"), list) else None ), brain_region_id=res["_source"]["brainRegion"]["@id"], @@ -213,7 +212,7 @@ def _process_output(output: Any) -> list[KnowledgeGraphOutput]: if "subjectAge" in res["_source"] else None ), - ) + ).model_dump() 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 index ba67c0f..446dc5c 100644 --- a/src/neuroagent/tools/kg_morpho_features_tool.py +++ b/src/neuroagent/tools/kg_morpho_features_tool.py @@ -1,14 +1,17 @@ """KG Morpho Feature tool.""" import logging -from typing import Any, Literal, Type +from pathlib import Path +from typing import Any, ClassVar, Literal -from langchain_core.tools import ToolException from pydantic import BaseModel, Field, model_validator -from neuroagent.tools.base_tool import BaseToolOutput, BasicTool +from neuroagent.tools.base_tool import BaseMetadata, BaseTool from neuroagent.utils import get_descendants_id +logger = logging.getLogger(__name__) + + LABEL = Literal[ "Neurite Max Radial Distance", "Number Of Sections", @@ -95,17 +98,15 @@ "Soma Number Of Points": ["N"], } -logger = logging.getLogger(__name__) - -class FeatRangeInput(BaseModel): +class KGFeatRangeInput(BaseModel): """Features Range input class.""" min_value: float | int | None = None max_value: float | int | None = None -class FeatureInput(BaseModel): +class KGFeatureInput(BaseModel): """Class defining the scheme of inputs the agent should use for the features.""" label: LABEL @@ -119,7 +120,7 @@ class FeatureInput(BaseModel): " user" ), ) - feat_range: FeatRangeInput | None = None + feat_range: KGFeatRangeInput | None = None @model_validator(mode="before") @classmethod @@ -132,11 +133,11 @@ def check_if_list(cls, data: Any) -> dict[str, str | list[float | int] | None]: return data_dict -class InputKGMorphoFeatures(BaseModel): - """Inputs of the knowledge graph API when retrieving features of morphologies.""" +class KGMorphoFeatureInput(BaseModel): + """Input definition for MorphoFeatures.""" brain_region_id: str = Field(description="ID of the brain region of interest.") - features: FeatureInput = Field( + features: KGFeatureInput = 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. @@ -145,7 +146,16 @@ class InputKGMorphoFeatures(BaseModel): ) -class KGMorphoFeatureOutput(BaseToolOutput): +class KGMorphoFeatureMetadata(BaseMetadata): + """Metadata class for the morpho features tool.""" + + knowledge_graph_url: str + token: str + kg_morpho_feature_search_size: int + brainregion_path: str | Path + + +class KGMorphoFeatureOutput(BaseModel): """Output schema for the knowledge graph API.""" brain_region_id: str @@ -157,11 +167,13 @@ class KGMorphoFeatureOutput(BaseToolOutput): features: dict[str, str] -class KGMorphoFeatureTool(BasicTool): +class KGMorphoFeatureTool(BaseTool): """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. + name: ClassVar[str] = "kg-morpho-feature-tool" + description: ClassVar[ + 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...'. @@ -172,63 +184,43 @@ class KGMorphoFeatureTool(BasicTool): - 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. + input_schema: KGMorphoFeatureInput + metadata: KGMorphoFeatureMetadata - 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 + async def arun(self) -> list[dict[str, Any]]: + """Run the tool async. 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 - ) + logger.info( + f"Entering KG morpho feature tool. Inputs: {self.input_schema.brain_region_id=}," + f" {self.input_schema.features=}" + ) + # Get the descendants of the brain region specified as input + hierarchy_ids = get_descendants_id( + self.input_schema.brain_region_id, + json_path=self.metadata.brainregion_path, + ) + logger.info(f"Found {len(list(hierarchy_ids))} children of the brain ontology.") - # 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, - ) + # Get the associated ES query + entire_query = self.create_query( + brain_regions_ids=hierarchy_ids, features=self.input_schema.features + ) - return self._process_output(response.json()) + # Send the ES query to the KG + response = await self.metadata.httpx_client.post( + url=self.metadata.knowledge_graph_url, + headers={"Authorization": f"Bearer {self.metadata.token}"}, + json=entire_query, + ) - except Exception as e: - raise ToolException(str(e), self.name) + return self._process_output(response.json()) def create_query( - self, brain_regions_ids: set[str], features: FeatureInput + self, brain_regions_ids: set[str], features: KGFeatureInput ) -> dict[str, Any]: """Create ES query to query the KG with. @@ -308,7 +300,7 @@ def create_query( # Unwrap all of the conditions in the global query entire_query = { - "size": self.metadata["search_size"], + "size": self.metadata.kg_morpho_feature_search_size, "track_total_hits": True, "query": { "bool": { @@ -328,7 +320,7 @@ def create_query( return entire_query @staticmethod - def _process_output(output: Any) -> list[KGMorphoFeatureOutput]: + def _process_output(output: Any) -> list[dict[str, Any]]: """Process output. Parameters @@ -356,7 +348,7 @@ def _process_output(output: Any) -> list[KGMorphoFeatureOutput]: morphology_id=morpho_source["neuronMorphology"]["@id"], morphology_name=morpho_source["neuronMorphology"].get("name"), features=feature_output, - ) + ).model_dump() ) return formatted_output diff --git a/src/neuroagent/tools/literature_search_tool.py b/src/neuroagent/tools/literature_search_tool.py index b25d9bb..c2e05db 100644 --- a/src/neuroagent/tools/literature_search_tool.py +++ b/src/neuroagent/tools/literature_search_tool.py @@ -1,17 +1,16 @@ """Literature Search tool.""" import logging -from typing import Any, Type +from typing import Any, ClassVar -from langchain_core.tools import ToolException -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field -from neuroagent.tools.base_tool import BaseToolOutput, BasicTool +from neuroagent.tools.base_tool import BaseMetadata, BaseTool logger = logging.getLogger(__name__) -class InputLiteratureSearch(BaseModel): +class LiteratureSearchInput(BaseModel): """Inputs of the literature search API.""" query: str = Field( @@ -23,7 +22,17 @@ class InputLiteratureSearch(BaseModel): ) -class ParagraphMetadata(BaseToolOutput, extra="ignore"): +class LiteratureSearchMetadata(BaseMetadata): + """Metadata class for LiteratureSearchTool.""" + + literature_search_url: str + token: str + retriever_k: int + reranker_k: int + use_reranker: bool + + +class ParagraphMetadata(BaseModel): """Metadata for an article.""" article_title: str @@ -32,13 +41,16 @@ class ParagraphMetadata(BaseToolOutput, extra="ignore"): section: str | None = None article_doi: str | None = None journal_issn: str | None = None + model_config = ConfigDict(extra="ignore") -class LiteratureSearchTool(BasicTool): +class LiteratureSearchTool(BaseTool): """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. + name: ClassVar[str] = "literature-search-tool" + description: ClassVar[ + 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 @@ -46,78 +58,40 @@ class LiteratureSearchTool(BasicTool): - 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. + input_schema: LiteratureSearchInput + metadata: LiteratureSearchMetadata - Parameters - ---------- - query - Query to send to the literature search backend + async def arun(self) -> list[dict[str, Any]]: + """Async search the scientific literature and returns citations. Returns ------- List of paragraphs and their metadata """ + logger.info( + f"Entering literature search tool. Inputs: {self.input_schema.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"], + "query": self.input_schema.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() + response = await self.metadata.httpx_client.get( + self.metadata.literature_search_url, + headers={"Authorization": f"Bearer {self.metadata.token}"}, + params=req_body, # type: ignore + timeout=None, ) - 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) + return self._process_output(response.json()) @staticmethod - def _process_output(output: list[dict[str, Any]]) -> list[ParagraphMetadata]: + def _process_output(output: list[dict[str, Any]]) -> list[dict[str, Any]]: """Process output.""" paragraphs_metadata = [ ParagraphMetadata( @@ -127,7 +101,7 @@ def _process_output(output: list[dict[str, Any]]) -> list[ParagraphMetadata]: section=paragraph["section"], article_doi=paragraph["article_doi"], journal_issn=paragraph["journal_issn"], - ) + ).model_dump() 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 index aee5f72..c96c538 100644 --- a/src/neuroagent/tools/morphology_features_tool.py +++ b/src/neuroagent/tools/morphology_features_tool.py @@ -1,22 +1,28 @@ """Morphology features tool.""" import logging -from typing import Any, Type +from typing import Any, ClassVar import neurom import numpy as np -from langchain_core.tools import ToolException -from neurom.io.utils import load_morphology +from neurom import load_morphology from pydantic import BaseModel, Field -from neuroagent.tools.base_tool import BaseToolOutput, BasicTool +from neuroagent.tools.base_tool import BaseMetadata, BaseTool from neuroagent.utils import get_kg_data logger = logging.getLogger(__name__) -class InputNeuroM(BaseModel): - """Inputs of the NeuroM API.""" +class MorphologyFeatureOutput(BaseModel): + """Output schema for the neurom tool.""" + + brain_region: str + feature_dict: dict[str, Any] + + +class MorphologyFeatureInput(BaseModel): + """Inputs for MorphologyFeatureTool.""" morphology_id: str = Field( description=( @@ -27,70 +33,50 @@ class InputNeuroM(BaseModel): ) -class MorphologyFeatureOutput(BaseToolOutput): - """Output schema for the neurom tool.""" +class MorphologyFeatureMetadata(BaseMetadata): + """Metadata for MorphologyFeatureTool.""" - brain_region: str - feature_dict: dict[str, Any] + knowledge_graph_url: str + token: str -class MorphologyFeatureTool(BasicTool): +class MorphologyFeatureTool(BaseTool): """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'. + name: ClassVar[str] = "morpho-features-tool" + description: ClassVar[ + 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 + input_schema: MorphologyFeatureInput + metadata: MorphologyFeatureMetadata - 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 + async def arun(self) -> list[dict[str, Any]]: + """Give features about morphology.""" + logger.info( + f"Entering morphology feature tool. Inputs: {self.input_schema.morphology_id=}" + ) + # Download the .swc file describing the morphology from the KG + morphology_content, metadata = await get_kg_data( + object_id=self.input_schema.morphology_id, + httpx_client=self.metadata.httpx_client, + url=self.metadata.knowledge_graph_url, + token=self.metadata.token, + preferred_format="swc", + ) - 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) + # Extract the features from it + features = self.get_features(morphology_content, metadata.file_extension) + return [ + MorphologyFeatureOutput( + brain_region=metadata.brain_region, feature_dict=features + ).model_dump() + ] 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. diff --git a/src/neuroagent/tools/resolve_entities_tool.py b/src/neuroagent/tools/resolve_entities_tool.py index 72dce21..2f8d60c 100644 --- a/src/neuroagent/tools/resolve_entities_tool.py +++ b/src/neuroagent/tools/resolve_entities_tool.py @@ -1,31 +1,26 @@ """Tool to resolve the brain region from natural english to a KG ID.""" import logging -from typing import Any, Type +from typing import Any, ClassVar -from langchain_core.tools import ToolException from pydantic import BaseModel, Field from neuroagent.resolving import resolve_query from neuroagent.tools.base_tool import ( ETYPE_IDS, - BaseToolOutput, - BasicTool, + BaseMetadata, + BaseTool, EtypesLiteral, ) logger = logging.getLogger(__name__) -class InputResolveBR(BaseModel): - """Inputs of the Resolve Brain Region tool..""" +class ResolveBRInput(BaseModel): + """Inputs of the Resolve Brain Region tool.""" brain_region: str = Field( - description=( - "Allen Institute's Mouse Brain Hierarchy Brain Region Names." - "User's given brain region of interest should be mapped to the closest Allen Brain region." - "If a given brain region is not found, try literature search tool for its synonyms" - ) + description="Brain region of interest specified by the user in natural english." ) mtype: str | None = Field( default=None, @@ -46,113 +41,108 @@ class InputResolveBR(BaseModel): ) -class BRResolveOutput(BaseToolOutput): +class BRResolveOutput(BaseModel): """Output schema for the Brain region resolver.""" brain_region_name: str brain_region_id: str -class MTypeResolveOutput(BaseToolOutput): +class MTypeResolveOutput(BaseModel): """Output schema for the Mtype resolver.""" mtype_name: str mtype_id: str -class EtypeResolveOutput(BaseToolOutput): +class EtypeResolveOutput(BaseModel): """Output schema for the Mtype resolver.""" etype_name: str etype_id: str -class ResolveEntitiesTool(BasicTool): +class ResolveBRMetadata(BaseMetadata): + """Metadata for ResolveEntitiesTool.""" + + token: str + kg_sparql_url: str + kg_class_view_url: str + + +class ResolveEntitiesTool(BaseTool): """Class defining the Brain Region Resolving logic.""" - name: str = "resolve-entities-tool" - description: str = """From a brain region name written in natural english, search a knowledge graph to retrieve its corresponding ID. + name: ClassVar[str] = "resolve-entities-tool" + description: ClassVar[ + 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, etype: str | None = None - ) -> list[BRResolveOutput | MTypeResolveOutput | EtypeResolveOutput]: - """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. - """ + input_schema: ResolveBRInput + metadata: ResolveBRMetadata + + async def arun( + self, + ) -> list[dict[str, Any]]: + """Given a brain region in natural language, resolve its ID.""" logger.info( - f"Entering Brain Region resolver tool. Inputs: {brain_region=}, {mtype=}, {etype=}" + f"Entering Brain Region resolver tool. Inputs: {self.input_schema.brain_region=}, " + f"{self.input_schema.mtype=}, {self.input_schema.etype=}" ) - try: - # Prepare the output list. - output: list[BRResolveOutput | MTypeResolveOutput | EtypeResolveOutput] = [] - - # 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", + # Prepare the output list. + output: list[dict[str, Any]] = [] + + # First resolve the brain regions. + brain_regions = await resolve_query( + sparql_view_url=self.metadata.kg_sparql_url, + token=self.metadata.token, + query=self.input_schema.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"] + ).model_dump() + for br in brain_regions + ] + ) + + # Optionally resolve the mtypes. + if self.input_schema.mtype is not None: + mtypes = await resolve_query( + sparql_view_url=self.metadata.kg_sparql_url, + token=self.metadata.token, + query=self.input_schema.mtype, + resource_type="bmo:BrainCellType", search_size=10, - httpx_client=self.metadata["httpx_client"], - es_view_url=self.metadata["kg_class_view_url"], + httpx_client=self.metadata.httpx_client, + es_view_url=self.metadata.kg_class_view_url, ) - # Extend the resolved BRs. + # Extend the resolved mtypes. output.extend( [ - BRResolveOutput( - brain_region_name=br["label"], brain_region_id=br["id"] - ) - for br in brain_regions + MTypeResolveOutput( + mtype_name=mtype["label"], mtype_id=mtype["id"] + ).model_dump() + for mtype in mtypes ] ) - # 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 - ] - ) - - # Optionally resolve the etype - if etype is not None: - output.append( - EtypeResolveOutput(etype_name=etype, etype_id=ETYPE_IDS[etype]) - ) - - return output - except Exception as e: - raise ToolException(str(e), self.name) + # Optionally resolve the etype + if self.input_schema.etype is not None: + output.append( + EtypeResolveOutput( + etype_name=self.input_schema.etype, + etype_id=ETYPE_IDS[self.input_schema.etype], + ).model_dump() + ) + + return output diff --git a/src/neuroagent/tools/traces_tool.py b/src/neuroagent/tools/traces_tool.py index 66dd723..cf13a79 100644 --- a/src/neuroagent/tools/traces_tool.py +++ b/src/neuroagent/tools/traces_tool.py @@ -1,21 +1,18 @@ """Traces tool.""" import logging -from typing import Any, Type +from pathlib import Path +from typing import Any, ClassVar -from langchain_core.tools import ToolException from pydantic import BaseModel, Field -from neuroagent.tools.base_tool import ( - BaseToolOutput, - BasicTool, -) +from neuroagent.tools.base_tool import BaseMetadata, BaseTool from neuroagent.utils import get_descendants_id logger = logging.getLogger(__name__) -class InputGetTraces(BaseModel): +class GetTracesInput(BaseModel): """Inputs of the knowledge graph API.""" brain_region_id: str = Field( @@ -29,7 +26,7 @@ class InputGetTraces(BaseModel): ) -class TracesOutput(BaseToolOutput): +class TracesOutput(BaseModel): """Output schema for the traces.""" trace_id: str @@ -44,16 +41,24 @@ class TracesOutput(BaseToolOutput): subject_age: str | None -class GetTracesTool(BasicTool): +class GetTracesMetadata(BaseMetadata): + """Metadata for GetTracesTool.""" + + knowledge_graph_url: str + token: str + trace_search_size: int + brainregion_path: str | Path + + +class GetTracesTool(BaseTool): """Class defining the logic to obtain traces ids.""" - name: str = "get-traces-tool" - description: str = """Searches a neuroscience based knowledge graph to - retrieve experimental traces names, IDs and descriptions. - Dont run this tool unless user requests already published traces. - Requires a 'brain_region_id' which is the ID of the brain region of interest - as registered in the knowledge graph. - Optionally accepts an e-type id. + name: ClassVar[str] = "get-traces-tool" + description: ClassVar[ + 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. + Optionally accepts an e-type id. The output is a list of traces, containing: - The trace id. - The brain region ID. @@ -63,54 +68,33 @@ class GetTracesTool(BasicTool): - 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_id: str | 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_id=}") - 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_id=etype_id - ) - - # 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) + input_schema: GetTracesInput + metadata: GetTracesMetadata + + async def arun(self) -> list[dict[str, Any]]: + """From a brain region ID, extract traces.""" + logger.info( + f"Entering get trace tool. Inputs: {self.input_schema.brain_region_id=}, {self.input_schema.etype_id=}" + ) + # Get descendants of the brain region specified as input + hierarchy_ids = get_descendants_id( + self.input_schema.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_id=self.input_schema.etype_id + ) + + # Send the query to the KG + response = await self.metadata.httpx_client.post( + url=self.metadata.knowledge_graph_url, + headers={"Authorization": f"Bearer {self.metadata.token}"}, + json=entire_query, + ) + return self._process_output(response.json()) def create_query( self, @@ -149,7 +133,7 @@ def create_query( # Unwrap everything into the main query entire_query = { - "size": self.metadata["search_size"], + "size": self.metadata.trace_search_size, "track_total_hits": True, "query": { "bool": { @@ -169,7 +153,7 @@ def create_query( return entire_query @staticmethod - def _process_output(output: Any) -> list[TracesOutput]: + def _process_output(output: Any) -> list[dict[str, Any]]: """Process output to fit the TracesOutput pydantic class defined above. Parameters @@ -206,7 +190,7 @@ def _process_output(output: Any) -> list[TracesOutput]: if "subjectAge" in res["_source"] else None ), - ) + ).model_dump() for res in output["hits"]["hits"] ] return results diff --git a/src/neuroagent/utils.py b/src/neuroagent/utils.py index 61c4488..27d6933 100644 --- a/src/neuroagent/utils.py +++ b/src/neuroagent/utils.py @@ -14,6 +14,26 @@ logger = logging.getLogger(__name__) +def merge_fields(target: dict[str, Any], source: dict[str, Any]) -> None: + """Recursively merge each field in the target dictionary.""" + for key, value in source.items(): + if isinstance(value, str): + target[key] += value + elif value is not None and isinstance(value, dict): + merge_fields(target[key], value) + + +def merge_chunk(final_response: dict[str, Any], delta: dict[str, Any]) -> None: + """Merge a chunk into the final message.""" + delta.pop("role", None) + merge_fields(final_response, delta) + + tool_calls = delta.get("tool_calls") + if tool_calls and len(tool_calls) > 0: + index = tool_calls[0].pop("index") + merge_fields(final_response["tool_calls"][index], tool_calls[0]) + + class RegionMeta: """Class holding the hierarchical region metadata. diff --git a/swarm_copy/__init__.py b/swarm_copy/__init__.py deleted file mode 100644 index 37600f4..0000000 --- a/swarm_copy/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Swarm temp copy.""" diff --git a/swarm_copy/app/app_utils.py b/swarm_copy/app/app_utils.py deleted file mode 100644 index fc158d1..0000000 --- a/swarm_copy/app/app_utils.py +++ /dev/null @@ -1,61 +0,0 @@ -"""App utilities functions.""" - -import logging -from typing import Any - -from fastapi import HTTPException -from httpx import AsyncClient -from sqlalchemy.exc import SQLAlchemyError -from sqlalchemy.ext.asyncio import AsyncEngine, create_async_engine -from starlette.status import HTTP_401_UNAUTHORIZED - -from swarm_copy.app.config import Settings - -logger = logging.getLogger(__name__) - - -def setup_engine( - settings: Settings, connection_string: str | None = None -) -> AsyncEngine | 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_async_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 - - -async def validate_project( - httpx_client: AsyncClient, - vlab_id: str, - project_id: str, - token: str, - vlab_project_url: str, -) -> None: - """Check user appartenance to vlab and project before running agent.""" - response = await httpx_client.get( - f"{vlab_project_url}/{vlab_id}/projects/{project_id}", - headers={"Authorization": f"Bearer {token}"}, - ) - if response.status_code != 200: - raise HTTPException( - status_code=HTTP_401_UNAUTHORIZED, - detail="User does not belong to the project.", - ) diff --git a/swarm_copy/app/config.py b/swarm_copy/app/config.py deleted file mode 100644 index f0334e9..0000000 --- a/swarm_copy/app/config.py +++ /dev/null @@ -1,256 +0,0 @@ -"""Configuration.""" - -import os -import pathlib -from typing import Literal, Optional - -from dotenv import dotenv_values -from pydantic import BaseModel, ConfigDict, SecretStr, model_validator -from pydantic_settings import BaseSettings, SettingsConfigDict - - -class SettingsAgent(BaseModel): - """Agent setting.""" - - model: Literal["simple", "multi"] = "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 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 = 8 - use_reranker: bool = False - reranker_k: int = 8 - - 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 SettingsGetMEModel(BaseModel): - """Get ME Model settings.""" - - search_size: int = 10 - - model_config = ConfigDict(frozen=True) - - -class SettingsBlueNaaS(BaseModel): - """BlueNaaS settings.""" - - url: str = "https://openbluebrain.com/api/bluenaas" - model_config = ConfigDict(frozen=True) - - -class SettingsKnowledgeGraph(BaseModel): - """Knowledge graph API settings.""" - - base_url: str - 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 SettingsVlab(BaseModel): - """Virtual lab endpoint settings.""" - - get_project_url: str = ( - "https://openbluebrain.com/api/virtual-lab-manager/virtual-labs" - ) - model_config = ConfigDict(frozen=True) - - -class SettingsTools(BaseModel): - """Database settings.""" - - literature: SettingsLiterature - bluenaas: SettingsBlueNaaS = SettingsBlueNaaS() - morpho: SettingsGetMorpho = SettingsGetMorpho() - trace: SettingsTrace = SettingsTrace() - kg_morpho_features: SettingsKGMorpho = SettingsKGMorpho() - me_model: SettingsGetMEModel = SettingsGetMEModel() - - 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 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 - openai: SettingsOpenAI = SettingsOpenAI() # has no required - logging: SettingsLogging = SettingsLogging() # has no required - keycloak: SettingsKeycloak = SettingsKeycloak() # has no required - virtual_lab: SettingsVlab = SettingsVlab() # 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. - - """ - # If you don't enforce keycloak auth, you need a way to communicate with the APIs the tools leverage - if not self.keycloak.password and not self.keycloak.validate_token: - raise ValueError( - "Need an auth method for subsequent APIs called by the tools." - ) - - 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/swarm_copy/app/dependencies.py b/swarm_copy/app/dependencies.py deleted file mode 100644 index 087f9a1..0000000 --- a/swarm_copy/app/dependencies.py +++ /dev/null @@ -1,334 +0,0 @@ -"""App dependencies.""" - -import logging -from functools import cache -from typing import Annotated, Any, AsyncIterator - -from fastapi import Depends, HTTPException, Request -from fastapi.security import HTTPBearer -from httpx import AsyncClient, HTTPStatusError -from keycloak import KeycloakOpenID -from openai import AsyncOpenAI -from sqlalchemy import select -from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession -from starlette.status import HTTP_401_UNAUTHORIZED - -from swarm_copy.agent_routine import AgentsRoutine -from swarm_copy.app.app_utils import validate_project -from swarm_copy.app.config import Settings -from swarm_copy.app.database.sql_schemas import Threads -from swarm_copy.cell_types import CellTypesMeta -from swarm_copy.new_types import Agent -from swarm_copy.tools import ( - ElectrophysFeatureTool, - GetMorphoTool, - GetTracesTool, - KGMorphoFeatureTool, - LiteratureSearchTool, - MEModelGetAllTool, - MEModelGetOneTool, - MorphologyFeatureTool, - ResolveEntitiesTool, - SCSGetAllTool, - SCSGetOneTool, - SCSPostTool, -) -from swarm_copy.utils import RegionMeta, get_file_from_KG - -logger = logging.getLogger(__name__) - - -class HTTPBearerDirect(HTTPBearer): - """HTTPBearer class that returns directly the token in the call.""" - - async def __call__(self, request: Request) -> str | None: # type: ignore - """Intercept the bearer token in the headers.""" - auth_credentials = await super().__call__(request) - return auth_credentials.credentials if auth_credentials else None - - -auth = HTTPBearerDirect(auto_error=False) - - -@cache -def get_settings() -> Settings: - """Get the global settings.""" - 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"]}, - ) - try: - yield client - finally: - await client.aclose() - - -async def get_openai_client( - settings: Annotated[Settings, Depends(get_settings)], -) -> AsyncIterator[AsyncOpenAI | None]: - """Get the OpenAi Async client.""" - if not settings.openai.token: - yield None - else: - try: - client = AsyncOpenAI(api_key=settings.openai.token.get_secret_value()) - yield client - finally: - await client.close() - - -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 - - -def get_engine(request: Request) -> AsyncEngine | None: - """Get the SQL engine.""" - return request.app.state.engine - - -async def get_session( - engine: Annotated[AsyncEngine | None, Depends(get_engine)], -) -> AsyncIterator[AsyncSession]: - """Yield a session per request.""" - if not engine: - raise HTTPException( - status_code=500, - detail={ - "detail": "Couldn't connect to the SQL DB.", - }, - ) - async with AsyncSession(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: - if 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: - raise HTTPException(status_code=404, detail="user info url not provided.") - 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: - 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"] - - -async def get_vlab_and_project( - user_id: Annotated[str, Depends(get_user_id)], - session: Annotated[AsyncSession, Depends(get_session)], - request: Request, - settings: Annotated[Settings, Depends(get_settings)], - httpx_client: Annotated[AsyncClient, Depends(get_httpx_client)], - token: Annotated[str, Depends(get_kg_token)], -) -> dict[str, str]: - """Get the current vlab and project ID.""" - if "x-project-id" in request.headers and "x-virtual-lab-id" in request.headers: - vlab_and_project = { - "vlab_id": request.headers["x-virtual-lab-id"], - "project_id": request.headers["x-project-id"], - } - elif not settings.keycloak.validate_token: - vlab_and_project = { - "vlab_id": "32c83739-f39c-49d1-833f-58c981ebd2a2", - "project_id": "123251a1-be18-4146-87b5-5ca2f8bfaf48", - } - else: - thread_id = request.path_params.get("thread_id") - thread_result = await session.execute( - select(Threads).where( - Threads.user_id == user_id, Threads.thread_id == thread_id - ) - ) - thread = thread_result.scalars().one_or_none() - if not thread: - raise HTTPException( - status_code=404, - detail="Thread not found.", - ) - if thread and thread.vlab_id and thread.project_id: - vlab_and_project = { - "vlab_id": thread.vlab_id, - "project_id": thread.project_id, - } - else: - raise HTTPException( - status_code=404, - detail="thread not found when trying to validate project ID.", - ) - - await validate_project( - httpx_client=httpx_client, - vlab_id=vlab_and_project["vlab_id"], - project_id=vlab_and_project["project_id"], - token=token, - vlab_project_url=settings.virtual_lab.get_project_url, - ) - return vlab_and_project - - -def get_starting_agent( - _: Annotated[None, Depends(get_vlab_and_project)], - settings: Annotated[Settings, Depends(get_settings)], -) -> Agent: - """Get the starting agent.""" - logger.info(f"Loading model {settings.openai.model}.") - agent = Agent( - name="Agent", - instructions="""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.""", - tools=[ - SCSGetAllTool, - SCSGetOneTool, - SCSPostTool, - MEModelGetAllTool, - MEModelGetOneTool, - LiteratureSearchTool, - ElectrophysFeatureTool, - GetMorphoTool, - KGMorphoFeatureTool, - MorphologyFeatureTool, - ResolveEntitiesTool, - GetTracesTool, - ], - model=settings.openai.model, - ) - return agent - - -def get_context_variables( - settings: Annotated[Settings, Depends(get_settings)], - starting_agent: Annotated[Agent, Depends(get_starting_agent)], - token: Annotated[str, Depends(get_kg_token)], - httpx_client: Annotated[AsyncClient, Depends(get_httpx_client)], -) -> dict[str, Any]: - """Get the global context variables to feed the tool's metadata.""" - return { - "starting_agent": starting_agent, - "token": token, - "vlab_id": "32c83739-f39c-49d1-833f-58c981ebd2a2", # New god account vlab. Replaced by actual id in endpoint for now. Meant for usage without history - "project_id": "123251a1-be18-4146-87b5-5ca2f8bfaf48", # New god account proj. Replaced by actual id in endpoint for now. Meant for usage without history - "retriever_k": settings.tools.literature.retriever_k, - "reranker_k": settings.tools.literature.reranker_k, - "use_reranker": settings.tools.literature.use_reranker, - "literature_search_url": settings.tools.literature.url, - "knowledge_graph_url": settings.knowledge_graph.url, - "me_model_search_size": settings.tools.me_model.search_size, - "brainregion_path": settings.knowledge_graph.br_saving_path, - "celltypes_path": settings.knowledge_graph.ct_saving_path, - "morpho_search_size": settings.tools.morpho.search_size, - "kg_morpho_feature_search_size": settings.tools.kg_morpho_features.search_size, - "trace_search_size": settings.tools.trace.search_size, - "kg_sparql_url": settings.knowledge_graph.sparql_url, - "kg_class_view_url": settings.knowledge_graph.class_view_url, - "bluenaas_url": settings.tools.bluenaas.url, - "httpx_client": httpx_client, - } - - -def get_agents_routine( - openai: Annotated[AsyncOpenAI | None, Depends(get_openai_client)], -) -> AgentsRoutine: - """Get the AgentRoutine client.""" - return AgentsRoutine(openai) - - -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/swarm_copy/app/main.py b/swarm_copy/app/main.py deleted file mode 100644 index 187628e..0000000 --- a/swarm_copy/app/main.py +++ /dev/null @@ -1,150 +0,0 @@ -"""Main.""" - -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 swarm_copy.app.app_utils import setup_engine -from swarm_copy.app.config import Settings -from swarm_copy.app.database.sql_schemas import Base -from swarm_copy.app.dependencies import ( - get_cell_types_kg_hierarchy, - get_connection_string, - get_kg_token, - get_settings, - get_update_kg_hierarchy, -) -from swarm_copy.app.routers import qa, threads, tools - -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).""" - app_settings = fastapi_app.dependency_overrides.get(get_settings, get_settings)() - - # Get the sqlalchemy engine and store it in app state. - engine = setup_engine(app_settings, get_connection_string(app_settings)) - fastapi_app.state.engine = engine - - # Create the tables for the agent memory. - if engine: - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - - logging.getLogger().setLevel(app_settings.logging.external_packages.upper()) - logging.getLogger("swarm_copy").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 - if engine: - await engine.dispose() - - -app = FastAPI( - title="Agents", - summary=( - "Use an AI agent to answer queries based on the knowledge graph, literature" - " search and neuroM." - ), - version="0.0.0", - 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.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/swarm_copy/app/routers/__init__.py b/swarm_copy/app/routers/__init__.py deleted file mode 100644 index 2802fc7..0000000 --- a/swarm_copy/app/routers/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Application routers.""" diff --git a/swarm_copy/app/routers/qa.py b/swarm_copy/app/routers/qa.py deleted file mode 100644 index c04962c..0000000 --- a/swarm_copy/app/routers/qa.py +++ /dev/null @@ -1,96 +0,0 @@ -"""Endpoints for agent's question answering pipeline.""" - -import logging -from typing import Annotated, Any - -from fastapi import APIRouter, Depends -from fastapi.responses import StreamingResponse -from sqlalchemy.ext.asyncio import AsyncSession - -from swarm_copy.agent_routine import AgentsRoutine -from swarm_copy.app.database.db_utils import get_history, get_thread, save_history -from swarm_copy.app.database.sql_schemas import Threads -from swarm_copy.app.dependencies import ( - get_agents_routine, - get_context_variables, - get_session, - get_starting_agent, - get_user_id, -) -from swarm_copy.new_types import Agent, AgentRequest, AgentResponse -from swarm_copy.stream import stream_agent_response - -router = APIRouter(prefix="/qa", tags=["Run the agent"]) - -logger = logging.getLogger(__name__) - - -@router.post("/run/", response_model=AgentResponse) -async def run_simple_agent( - user_request: AgentRequest, - agent_routine: Annotated[AgentsRoutine, Depends(get_agents_routine)], - agent: Annotated[Agent, Depends(get_starting_agent)], - context_variables: Annotated[dict[str, Any], Depends(get_context_variables)], -) -> AgentResponse: - """Run a single agent query.""" - response = await agent_routine.arun( - agent, [{"role": "user", "content": user_request.query}], context_variables - ) - return AgentResponse(message=response.messages[-1]["content"]) - - -@router.post("/chat/{thread_id}", response_model=AgentResponse) -async def run_chat_agent( - user_request: AgentRequest, - agent_routine: Annotated[AgentsRoutine, Depends(get_agents_routine)], - agent: Annotated[Agent, Depends(get_starting_agent)], - context_variables: Annotated[dict[str, Any], Depends(get_context_variables)], - session: Annotated[AsyncSession, Depends(get_session)], - user_id: Annotated[str, Depends(get_user_id)], - thread: Annotated[Threads, Depends(get_thread)], - messages: Annotated[list[dict[str, Any]], Depends(get_history)], -) -> AgentResponse: - """Run a single agent query.""" - # Temporary solution - context_variables["vlab_id"] = thread.vlab_id - context_variables["project_id"] = thread.project_id - - messages.append({"role": "user", "content": user_request.query}) - response = await agent_routine.arun(agent, messages, context_variables) - await save_history( - user_id=user_id, - history=response.messages, - offset=len(messages) - 1, - thread_id=thread.thread_id, - session=session, - ) - return AgentResponse(message=response.messages[-1]["content"]) - - -@router.post("/chat_streamed/{thread_id}") -async def stream_chat_agent( - user_request: AgentRequest, - agents_routine: Annotated[AgentsRoutine, Depends(get_agents_routine)], - agent: Annotated[Agent, Depends(get_starting_agent)], - context_variables: Annotated[dict[str, Any], Depends(get_context_variables)], - session: Annotated[AsyncSession, Depends(get_session)], - user_id: Annotated[str, Depends(get_user_id)], - thread: Annotated[Threads, Depends(get_thread)], - messages: Annotated[list[dict[str, Any]], Depends(get_history)], -) -> StreamingResponse: - """Run a single agent query in a streamed fashion.""" - # Temporary solution - context_variables["vlab_id"] = thread.vlab_id - context_variables["project_id"] = thread.project_id - - messages.append({"role": "user", "content": user_request.query}) - stream_generator = stream_agent_response( - agents_routine, - agent, - messages, - context_variables, - user_id, - thread.thread_id, - session, - ) - return StreamingResponse(stream_generator, media_type="text/event-stream") diff --git a/swarm_copy/cell_types.py b/swarm_copy/cell_types.py deleted file mode 100644 index 9c91e32..0000000 --- a/swarm_copy/cell_types.py +++ /dev/null @@ -1,192 +0,0 @@ -"""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[Any, Any | None] = {} - 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/swarm_copy/requirements.txt b/swarm_copy/requirements.txt deleted file mode 100644 index c443e3b..0000000 --- a/swarm_copy/requirements.txt +++ /dev/null @@ -1,7 +0,0 @@ -instructor -numpy -openai>=1.33.0 -pre-commit -pytest -requests -tqdm diff --git a/swarm_copy/resolving.py b/swarm_copy/resolving.py deleted file mode 100644 index 27a8c74..0000000 --- a/swarm_copy/resolving.py +++ /dev/null @@ -1,448 +0,0 @@ -"""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/swarm_copy/schemas.py b/swarm_copy/schemas.py deleted file mode 100644 index b621775..0000000 --- a/swarm_copy/schemas.py +++ /dev/null @@ -1,11 +0,0 @@ -"""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/swarm_copy/tools/__init__.py b/swarm_copy/tools/__init__.py deleted file mode 100644 index 8a3365d..0000000 --- a/swarm_copy/tools/__init__.py +++ /dev/null @@ -1,48 +0,0 @@ -"""Tools package.""" - -from swarm_copy.tools.bluenaas_memodel_getall import MEModelGetAllTool -from swarm_copy.tools.bluenaas_memodel_getone import MEModelGetOneTool -from swarm_copy.tools.bluenaas_scs_getall import SCSGetAllTool -from swarm_copy.tools.bluenaas_scs_getone import SCSGetOneTool -from swarm_copy.tools.bluenaas_scs_post import SCSPostTool -from swarm_copy.tools.electrophys_tool import ElectrophysFeatureTool, FeatureOutput -from swarm_copy.tools.get_morpho_tool import GetMorphoTool, KnowledgeGraphOutput -from swarm_copy.tools.kg_morpho_features_tool import ( - KGMorphoFeatureOutput, - KGMorphoFeatureTool, -) -from swarm_copy.tools.literature_search_tool import ( - LiteratureSearchTool, - ParagraphMetadata, -) -from swarm_copy.tools.morphology_features_tool import ( - MorphologyFeatureOutput, - MorphologyFeatureTool, -) -from swarm_copy.tools.resolve_entities_tool import ( - BRResolveOutput, - ResolveEntitiesTool, -) -from swarm_copy.tools.traces_tool import GetTracesTool, TracesOutput - -__all__ = [ - "SCSGetAllTool", - "SCSGetOneTool", - "SCSPostTool", - "BRResolveOutput", - "ElectrophysFeatureTool", - "FeatureOutput", - "GetMorphoTool", - "GetTracesTool", - "KGMorphoFeatureOutput", - "KGMorphoFeatureTool", - "KnowledgeGraphOutput", - "LiteratureSearchTool", - "MEModelGetAllTool", - "MEModelGetOneTool", - "MorphologyFeatureOutput", - "MorphologyFeatureTool", - "ParagraphMetadata", - "ResolveEntitiesTool", - "TracesOutput", -] diff --git a/swarm_copy/tools/base_tool.py b/swarm_copy/tools/base_tool.py deleted file mode 100644 index e366ab6..0000000 --- a/swarm_copy/tools/base_tool.py +++ /dev/null @@ -1,90 +0,0 @@ -"""Base tool.""" - -import logging -from abc import ABC, abstractmethod -from typing import Any, ClassVar, Literal - -from httpx import AsyncClient -from pydantic import BaseModel, ConfigDict - -logger = logging.getLogger(__name__) - - -EtypesLiteral = Literal[ - "bSTUT", - "dSTUT", - "bNAC", - "cSTUT", - "dNAC", - "bAC", - "cIR", - "cAC", - "cACint", - "bIR", - "cNAC", - "cAD", - "cADpyr", - "cAD_ltb", - "cNAD_ltb", - "cAD_noscltb", - "cNAD_noscltb", - "dAD_ltb", - "dNAD_ltb", -] -ETYPE_IDS = { - "bSTUT": "http://uri.interlex.org/base/ilx_0738200", - "dSTUT": "http://uri.interlex.org/base/ilx_0738202", - "bNAC": "http://uri.interlex.org/base/ilx_0738203", - "cSTUT": "http://uri.interlex.org/base/ilx_0738198", - "dNAC": "http://uri.interlex.org/base/ilx_0738205", - "bAC": "http://uri.interlex.org/base/ilx_0738199", - "cIR": "http://uri.interlex.org/base/ilx_0738204", - "cAC": "http://uri.interlex.org/base/ilx_0738197", - "cACint": "http://bbp.epfl.ch/neurosciencegraph/ontologies/etypes/cACint", - "bIR": "http://uri.interlex.org/base/ilx_0738206", - "cNAC": "http://uri.interlex.org/base/ilx_0738201", - "cAD": "http://uri.interlex.org/base/ilx_0738207", # Both are the same id, what's the purpose ? - "cADpyr": "http://uri.interlex.org/base/ilx_0738207", # Both are the same id, what's the purpose ? - "cAD_ltb": "http://uri.interlex.org/base/ilx_0738255", - "cNAD_ltb": "http://uri.interlex.org/base/ilx_0738254", - "cAD_noscltb": "http://uri.interlex.org/base/ilx_0738250", - "cNAD_noscltb": "http://uri.interlex.org/base/ilx_0738249", - "dAD_ltb": "http://uri.interlex.org/base/ilx_0738258", - "dNAD_ltb": "http://uri.interlex.org/base/ilx_0738256", -} - - -class BaseMetadata(BaseModel): - """Base class for metadata.""" - - httpx_client: AsyncClient - model_config = ConfigDict(extra="ignore", arbitrary_types_allowed=True) - - -class BaseTool(BaseModel, ABC): - """Base class for the tools.""" - - name: ClassVar[str] - description: ClassVar[str] - metadata: BaseMetadata - input_schema: BaseModel - - @classmethod - def pydantic_to_openai_schema(cls) -> dict[str, Any]: - """Convert pydantic schema to OpenAI json.""" - new_retval: dict[str, Any] = { - "type": "function", - "function": { - "name": cls.name, - "description": cls.description, - "strict": False, - "parameters": cls.__annotations__["input_schema"].model_json_schema(), - }, - } - new_retval["function"]["parameters"]["additionalProperties"] = False - - return new_retval - - @abstractmethod - async def arun(self) -> Any: - """Run the tool.""" diff --git a/swarm_copy/tools/electrophys_tool.py b/swarm_copy/tools/electrophys_tool.py deleted file mode 100644 index 2984836..0000000 --- a/swarm_copy/tools/electrophys_tool.py +++ /dev/null @@ -1,332 +0,0 @@ -"""Electrophys tool.""" - -import logging -import tempfile -from statistics import mean -from typing import Any, ClassVar, Literal - -from bluepyefe.extract import extract_efeatures -from efel.units import get_unit -from pydantic import BaseModel, Field - -from swarm_copy.tools.base_tool import BaseMetadata, BaseTool -from swarm_copy.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 ElectrophysInput(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: STIMULI_TYPES | None = Field( - default=None, - 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: CALCULATED_FEATURES | None = Field( - default=None, - 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: AmplitudeInput | None = Field( - default=None, - 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 ElectrophysMetadata(BaseMetadata): - """Metadata class for the electrophys tool.""" - - knowledge_graph_url: str - token: str - - -class FeatureOutput(BaseModel): - """Output schema for the neurom tool.""" - - brain_region: str - feature_dict: dict[str, Any] - - -class ElectrophysFeatureTool(BaseTool): - """Class defining the Electrophys Featyres Tool.""" - - name: ClassVar[str] = "electrophys-features-tool" - description: ClassVar[ - 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. - """ - input_schema: ElectrophysInput - metadata: ElectrophysMetadata - - async def arun(self) -> dict[str, Any]: - """Give features about trace.""" - logger.info( - f"Entering electrophys tool. Inputs: {self.input_schema.trace_id=}, {self.input_schema.calculated_feature=}," - f" {self.input_schema.amplitude=}, {self.input_schema.stimuli_types=}" - ) - - # Deal with cases where user did not specify stimulus type or/and feature - if not self.input_schema.stimuli_types: - # Default to IDRest if protocol not specified - logger.warning("No stimulus type specified. Defaulting to IDRest.") - stimuli_types = ["idrest"] - else: - stimuli_types = self.input_schema.stimuli_types # type: ignore - - if not self.input_schema.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 - else: - calculated_feature = self.input_schema.calculated_feature - - # Download the .nwb file associated to the trace from the KG - trace_content, metadata = await get_kg_data( - object_id=self.input_schema.trace_id, - httpx_client=self.metadata.httpx_client, - url=self.metadata.knowledge_graph_url, - token=self.metadata.token, - preferred_format="nwb", - ) - - # Turn amplitude requirement of user into a bluepyefe compatible representation - if isinstance(self.input_schema.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( - [ - self.input_schema.amplitude.min_value, - self.input_schema.amplitude.max_value, - ] - ) - - # If the range is just one number, use 10% of it as tolerance - if ( - self.input_schema.amplitude.min_value - == self.input_schema.amplitude.max_value - ): - desired_tolerance = self.input_schema.amplitude.max_value * 0.1 - else: - desired_tolerance = ( - self.input_schema.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 FeatureOutput( - brain_region=metadata.brain_region, feature_dict=output_features - ).model_dump() diff --git a/swarm_copy/tools/get_morpho_tool.py b/swarm_copy/tools/get_morpho_tool.py deleted file mode 100644 index f7e2aa6..0000000 --- a/swarm_copy/tools/get_morpho_tool.py +++ /dev/null @@ -1,218 +0,0 @@ -"""Get Morpho tool.""" - -import logging -from pathlib import Path -from typing import Any, ClassVar - -from pydantic import BaseModel, Field - -from swarm_copy.cell_types import get_celltypes_descendants -from swarm_copy.tools.base_tool import BaseMetadata, BaseTool -from swarm_copy.utils import get_descendants_id - -logger = logging.getLogger(__name__) - - -class GetMorphoInput(BaseModel): - """Inputs of the knowledge graph API.""" - - brain_region_id: str = Field( - description="ID of the brain region of interest. To get this ID, please use the `resolve-entities-tool` first." - ) - mtype_id: str | None = Field( - default=None, - description="ID of the M-type of interest. To get this ID, please use the `resolve-entities-tool` first.", - ) - - -class GetMorphoMetadata(BaseMetadata): - """Metadata class for GetMorphoTool.""" - - knowledge_graph_url: str - token: str - morpho_search_size: int - brainregion_path: str | Path - celltypes_path: str | Path - - -class KnowledgeGraphOutput(BaseModel): - """Output schema for the knowledge graph API.""" - - morphology_id: str - morphology_name: str | None - morphology_description: str | None - mtype: list[str] | None - - brain_region_id: str - brain_region_label: str | None - - subject_species_label: str | None - subject_age: str | None - - -class GetMorphoTool(BaseTool): - """Class defining the Get Morpho logic.""" - - name: ClassVar[str] = "get-morpho-tool" - description: ClassVar[ - 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. - Optionally accepts an mtype_id. - 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...'.""" - input_schema: GetMorphoInput - metadata: GetMorphoMetadata - - async def arun(self) -> list[dict[str, Any]]: - """From a brain region ID, extract morphologies. - - Returns - ------- - list of KnowledgeGraphOutput to describe the morphology and its metadata, or an error dict. - """ - logger.info( - f"Entering Get Morpho tool. Inputs: {self.input_schema.brain_region_id=}, {self.input_schema.mtype_id=}" - ) - # From the brain region ID, get the descendants. - hierarchy_ids = get_descendants_id( - self.input_schema.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( - self.input_schema.mtype_id, self.metadata.celltypes_path - ) - if self.input_schema.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.knowledge_graph_url, - headers={"Authorization": f"Bearer {self.metadata.token}"}, - json=entire_query, - ) - - # Process the output and return. - return self._process_output(response.json()) - - 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.morpho_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[dict[str, Any]]: - """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 isinstance(res["_source"].get("mType"), dict) - else [item.get("label") for item in res["_source"]["mType"]] - if isinstance(res["_source"].get("mType"), list) - 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 - ), - ).model_dump() - for res in output["hits"]["hits"] - ] - return formatted_output diff --git a/swarm_copy/tools/kg_morpho_features_tool.py b/swarm_copy/tools/kg_morpho_features_tool.py deleted file mode 100644 index 31023b8..0000000 --- a/swarm_copy/tools/kg_morpho_features_tool.py +++ /dev/null @@ -1,354 +0,0 @@ -"""KG Morpho Feature tool.""" - -import logging -from pathlib import Path -from typing import Any, ClassVar, Literal - -from pydantic import BaseModel, Field, model_validator - -from swarm_copy.tools.base_tool import BaseMetadata, BaseTool -from swarm_copy.utils import get_descendants_id - -logger = logging.getLogger(__name__) - - -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"], -} - - -class KGFeatRangeInput(BaseModel): - """Features Range input class.""" - - min_value: float | int | None = None - max_value: float | int | None = None - - -class KGFeatureInput(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: KGFeatRangeInput | 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 KGMorphoFeatureInput(BaseModel): - """Input definition for MorphoFeatures.""" - - brain_region_id: str = Field(description="ID of the brain region of interest.") - features: KGFeatureInput = 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 KGMorphoFeatureMetadata(BaseMetadata): - """Metadata class for the morpho features tool.""" - - knowledge_graph_url: str - token: str - kg_morpho_feature_search_size: int - brainregion_path: str | Path - - -class KGMorphoFeatureOutput(BaseModel): - """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(BaseTool): - """Class defining the Knowledge Graph logic.""" - - name: ClassVar[str] = "kg-morpho-feature-tool" - description: ClassVar[ - 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.""" - input_schema: KGMorphoFeatureInput - metadata: KGMorphoFeatureMetadata - - async def arun(self) -> list[dict[str, Any]]: - """Run the tool async. - - Returns - ------- - list of KGMorphoFeatureOutput to describe the morphology and its features, or an error dict. - """ - logger.info( - f"Entering KG morpho feature tool. Inputs: {self.input_schema.brain_region_id=}," - f" {self.input_schema.features=}" - ) - # Get the descendants of the brain region specified as input - hierarchy_ids = get_descendants_id( - self.input_schema.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=self.input_schema.features - ) - - # Send the ES query to the KG - response = await self.metadata.httpx_client.post( - url=self.metadata.knowledge_graph_url, - headers={"Authorization": f"Bearer {self.metadata.token}"}, - json=entire_query, - ) - - return self._process_output(response.json()) - - def create_query( - self, brain_regions_ids: set[str], features: KGFeatureInput - ) -> 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.kg_morpho_feature_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[dict[str, Any]]: - """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, - ).model_dump() - ) - - return formatted_output diff --git a/swarm_copy/tools/literature_search_tool.py b/swarm_copy/tools/literature_search_tool.py deleted file mode 100644 index 92ebf7d..0000000 --- a/swarm_copy/tools/literature_search_tool.py +++ /dev/null @@ -1,107 +0,0 @@ -"""Literature Search tool.""" - -import logging -from typing import Any, ClassVar - -from pydantic import BaseModel, ConfigDict, Field - -from swarm_copy.tools.base_tool import BaseMetadata, BaseTool - -logger = logging.getLogger(__name__) - - -class LiteratureSearchInput(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 LiteratureSearchMetadata(BaseMetadata): - """Metadata class for LiteratureSearchTool.""" - - literature_search_url: str - token: str - retriever_k: int - reranker_k: int - use_reranker: bool - - -class ParagraphMetadata(BaseModel): - """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 - model_config = ConfigDict(extra="ignore") - - -class LiteratureSearchTool(BaseTool): - """Class defining the Literature Search logic.""" - - name: ClassVar[str] = "literature-search-tool" - description: ClassVar[ - 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""" - input_schema: LiteratureSearchInput - metadata: LiteratureSearchMetadata - - async def arun(self) -> list[dict[str, Any]]: - """Async search the scientific literature and returns citations. - - Returns - ------- - List of paragraphs and their metadata - """ - logger.info( - f"Entering literature search tool. Inputs: {self.input_schema.query=}" - ) - - # Prepare the request's body - req_body = { - "query": self.input_schema.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.literature_search_url, - headers={"Authorization": f"Bearer {self.metadata.token}"}, - params=req_body, # type: ignore - timeout=None, - ) - - return self._process_output(response.json()) - - @staticmethod - def _process_output(output: list[dict[str, Any]]) -> list[dict[str, Any]]: - """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"], - ).model_dump() - for paragraph in output - ] - return paragraphs_metadata diff --git a/swarm_copy/tools/morphology_features_tool.py b/swarm_copy/tools/morphology_features_tool.py deleted file mode 100644 index 31b2548..0000000 --- a/swarm_copy/tools/morphology_features_tool.py +++ /dev/null @@ -1,149 +0,0 @@ -"""Morphology features tool.""" - -import logging -from typing import Any, ClassVar - -import neurom -import numpy as np -from neurom import load_morphology -from pydantic import BaseModel, Field - -from swarm_copy.tools.base_tool import BaseMetadata, BaseTool -from swarm_copy.utils import get_kg_data - -logger = logging.getLogger(__name__) - - -class MorphologyFeatureOutput(BaseModel): - """Output schema for the neurom tool.""" - - brain_region: str - feature_dict: dict[str, Any] - - -class MorphologyFeatureInput(BaseModel): - """Inputs for MorphologyFeatureTool.""" - - 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 MorphologyFeatureMetadata(BaseMetadata): - """Metadata for MorphologyFeatureTool.""" - - knowledge_graph_url: str - token: str - - -class MorphologyFeatureTool(BaseTool): - """Class defining the morphology feature retrieval logic.""" - - name: ClassVar[str] = "morpho-features-tool" - description: ClassVar[ - 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.""" - input_schema: MorphologyFeatureInput - metadata: MorphologyFeatureMetadata - - async def arun(self) -> list[dict[str, Any]]: - """Give features about morphology.""" - logger.info( - f"Entering morphology feature tool. Inputs: {self.input_schema.morphology_id=}" - ) - # Download the .swc file describing the morphology from the KG - morphology_content, metadata = await get_kg_data( - object_id=self.input_schema.morphology_id, - httpx_client=self.metadata.httpx_client, - url=self.metadata.knowledge_graph_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 - ).model_dump() - ] - - def get_features(self, morphology_content: bytes, reader: str) -> dict[str, Any]: - """Get features from a morphology. - - 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/swarm_copy/tools/resolve_entities_tool.py b/swarm_copy/tools/resolve_entities_tool.py deleted file mode 100644 index e6ab88d..0000000 --- a/swarm_copy/tools/resolve_entities_tool.py +++ /dev/null @@ -1,148 +0,0 @@ -"""Tool to resolve the brain region from natural english to a KG ID.""" - -import logging -from typing import Any, ClassVar - -from pydantic import BaseModel, Field - -from swarm_copy.resolving import resolve_query -from swarm_copy.tools.base_tool import ( - ETYPE_IDS, - BaseMetadata, - BaseTool, - EtypesLiteral, -) - -logger = logging.getLogger(__name__) - - -class ResolveBRInput(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: str | None = Field( - default=None, - description="M-type of interest specified by the user in natural english.", - ) - etype: EtypesLiteral | None = Field( - default=None, - description=( - "E-type of interest specified by the user in natural english. Possible values:" - f" {', '.join(list(ETYPE_IDS.keys()))}. The first letter meaning c: continuous," - "b: bursting or d: delayed, The other letters in capital meaning AC: accomodating," - "NAC: non-accomodating, AD: adapting, NAD: non-adapting, STUT: stuttering," - "IR: irregular spiking. Optional suffixes in lowercase can exist:" - "pyr: pyramidal, int: interneuron, _ltb: low threshold bursting," - "_noscltb: non-oscillatory low-threshold bursting. Examples: " - "cADpyr: continuous adapting pyramidal. dAD_ltb: delayed adapting low-threshold bursting" - ), - ) - - -class BRResolveOutput(BaseModel): - """Output schema for the Brain region resolver.""" - - brain_region_name: str - brain_region_id: str - - -class MTypeResolveOutput(BaseModel): - """Output schema for the Mtype resolver.""" - - mtype_name: str - mtype_id: str - - -class EtypeResolveOutput(BaseModel): - """Output schema for the Mtype resolver.""" - - etype_name: str - etype_id: str - - -class ResolveBRMetadata(BaseMetadata): - """Metadata for ResolveEntitiesTool.""" - - token: str - kg_sparql_url: str - kg_class_view_url: str - - -class ResolveEntitiesTool(BaseTool): - """Class defining the Brain Region Resolving logic.""" - - name: ClassVar[str] = "resolve-entities-tool" - description: ClassVar[ - 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`.""" - input_schema: ResolveBRInput - metadata: ResolveBRMetadata - - async def arun( - self, - ) -> list[dict[str, Any]]: - """Given a brain region in natural language, resolve its ID.""" - logger.info( - f"Entering Brain Region resolver tool. Inputs: {self.input_schema.brain_region=}, " - f"{self.input_schema.mtype=}, {self.input_schema.etype=}" - ) - # Prepare the output list. - output: list[dict[str, Any]] = [] - - # First resolve the brain regions. - brain_regions = await resolve_query( - sparql_view_url=self.metadata.kg_sparql_url, - token=self.metadata.token, - query=self.input_schema.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"] - ).model_dump() - for br in brain_regions - ] - ) - - # Optionally resolve the mtypes. - if self.input_schema.mtype is not None: - mtypes = await resolve_query( - sparql_view_url=self.metadata.kg_sparql_url, - token=self.metadata.token, - query=self.input_schema.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"] - ).model_dump() - for mtype in mtypes - ] - ) - - # Optionally resolve the etype - if self.input_schema.etype is not None: - output.append( - EtypeResolveOutput( - etype_name=self.input_schema.etype, - etype_id=ETYPE_IDS[self.input_schema.etype], - ).model_dump() - ) - - return output diff --git a/swarm_copy/tools/traces_tool.py b/swarm_copy/tools/traces_tool.py deleted file mode 100644 index 0703259..0000000 --- a/swarm_copy/tools/traces_tool.py +++ /dev/null @@ -1,196 +0,0 @@ -"""Traces tool.""" - -import logging -from pathlib import Path -from typing import Any, ClassVar - -from pydantic import BaseModel, Field - -from swarm_copy.tools.base_tool import BaseMetadata, BaseTool -from swarm_copy.utils import get_descendants_id - -logger = logging.getLogger(__name__) - - -class GetTracesInput(BaseModel): - """Inputs of the knowledge graph API.""" - - brain_region_id: str = Field( - description="ID of the brain region of interest. Can be obtained from 'resolve-entities-tool'." - ) - etype_id: str | None = Field( - default=None, - description=( - "ID of the electrical type of the cell. Can be obtained through the 'resolve-entities-tool'." - ), - ) - - -class TracesOutput(BaseModel): - """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 GetTracesMetadata(BaseMetadata): - """Metadata for GetTracesTool.""" - - knowledge_graph_url: str - token: str - trace_search_size: int - brainregion_path: str | Path - - -class GetTracesTool(BaseTool): - """Class defining the logic to obtain traces ids.""" - - name: ClassVar[str] = "get-traces-tool" - description: ClassVar[ - 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. - Optionally accepts an e-type id. - 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...'.""" - input_schema: GetTracesInput - metadata: GetTracesMetadata - - async def arun(self) -> list[dict[str, Any]]: - """From a brain region ID, extract traces.""" - logger.info( - f"Entering get trace tool. Inputs: {self.input_schema.brain_region_id=}, {self.input_schema.etype_id=}" - ) - # Get descendants of the brain region specified as input - hierarchy_ids = get_descendants_id( - self.input_schema.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_id=self.input_schema.etype_id - ) - - # Send the query to the KG - response = await self.metadata.httpx_client.post( - url=self.metadata.knowledge_graph_url, - headers={"Authorization": f"Bearer {self.metadata.token}"}, - json=entire_query, - ) - return self._process_output(response.json()) - - def create_query( - self, - brain_region_ids: set[str], - etype_id: str | 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_id is not None: - 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.trace_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[dict[str, Any]]: - """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 - ), - ).model_dump() - for res in output["hits"]["hits"] - ] - return results diff --git a/swarm_copy/utils.py b/swarm_copy/utils.py deleted file mode 100644 index bd486d2..0000000 --- a/swarm_copy/utils.py +++ /dev/null @@ -1,488 +0,0 @@ -"""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 swarm_copy.schemas import KGMetadata - -logger = logging.getLogger(__name__) - - -def merge_fields(target: dict[str, Any], source: dict[str, Any]) -> None: - """Recursively merge each field in the target dictionary.""" - for key, value in source.items(): - if isinstance(value, str): - target[key] += value - elif value is not None and isinstance(value, dict): - merge_fields(target[key], value) - - -def merge_chunk(final_response: dict[str, Any], delta: dict[str, Any]) -> None: - """Merge a chunk into the final message.""" - delta.pop("role", None) - merge_fields(final_response, delta) - - tool_calls = delta.get("tool_calls") - if tool_calls and len(tool_calls) > 0: - index = tool_calls[0].pop("index") - merge_fields(final_response["tool_calls"][index], tool_calls[0]) - - -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/swarm_copy_tests/__init__.py b/swarm_copy_tests/__init__.py deleted file mode 100644 index 480d94b..0000000 --- a/swarm_copy_tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Sarm copy tests.""" diff --git a/swarm_copy_tests/app/database/__init__.py b/swarm_copy_tests/app/database/__init__.py deleted file mode 100644 index 8ce3e8d..0000000 --- a/swarm_copy_tests/app/database/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Unit tests for database.""" diff --git a/swarm_copy_tests/app/test_app_utils.py b/swarm_copy_tests/app/test_app_utils.py deleted file mode 100644 index 70018f2..0000000 --- a/swarm_copy_tests/app/test_app_utils.py +++ /dev/null @@ -1,75 +0,0 @@ -"""Test app utils.""" - -from unittest.mock import AsyncMock, patch - -import pytest -from fastapi.exceptions import HTTPException -from httpx import AsyncClient - -from swarm_copy.app.app_utils import setup_engine, validate_project -from swarm_copy.app.config import Settings - - -@pytest.mark.asyncio -async def test_validate_project(patch_required_env, httpx_mock, monkeypatch): - monkeypatch.setenv("NEUROAGENT_KEYCLOAK__VALIDATE_TOKEN", "true") - httpx_client = AsyncClient() - token = "fake_token" - test_vp = {"vlab_id": "test_vlab_DB", "project_id": "project_id_DB"} - vlab_url = "https://openbluebrain.com/api/virtual-lab-manager/virtual-labs" - - # test with bad config - httpx_mock.add_response( - url=f'{vlab_url}/{test_vp["vlab_id"]}/projects/{test_vp["project_id"]}', - status_code=404, - ) - with pytest.raises(HTTPException) as error: - await validate_project( - httpx_client=httpx_client, - vlab_id=test_vp["vlab_id"], - project_id=test_vp["project_id"], - token=token, - vlab_project_url=vlab_url, - ) - assert error.value.status_code == 401 - - # test with good config - httpx_mock.add_response( - url=f'{vlab_url}/{test_vp["vlab_id"]}/projects/{test_vp["project_id"]}', - json="test_project_ID", - ) - await validate_project( - httpx_client=httpx_client, - vlab_id=test_vp["vlab_id"], - project_id=test_vp["project_id"], - token=token, - vlab_project_url=vlab_url, - ) - # we jsut want to assert that the httpx_mock was called. - - -@patch("neuroagent.app.app_utils.create_async_engine") -def test_setup_engine(create_engine_mock, monkeypatch, patch_required_env): - create_engine_mock.return_value = AsyncMock() - - monkeypatch.setenv("NEUROAGENT_DB__PREFIX", "prefix") - - settings = Settings() - - connection_string = "postgresql+asyncpg://user:password@localhost/dbname" - retval = setup_engine(settings=settings, connection_string=connection_string) - assert retval is not None - - -@patch("neuroagent.app.app_utils.create_async_engine") -def test_setup_engine_no_connection_string( - create_engine_mock, monkeypatch, patch_required_env -): - create_engine_mock.return_value = AsyncMock() - - monkeypatch.setenv("NEUROAGENT_DB__PREFIX", "prefix") - - settings = Settings() - - retval = setup_engine(settings=settings, connection_string=None) - assert retval is None diff --git a/swarm_copy_tests/app/test_config.py b/swarm_copy_tests/app/test_config.py deleted file mode 100644 index 5274b9c..0000000 --- a/swarm_copy_tests/app/test_config.py +++ /dev/null @@ -1,71 +0,0 @@ -"""Test config""" - -import pytest -from pydantic import ValidationError - -from swarm_copy.app.config import Settings - - -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.openai.token.get_secret_value() == "dummy" - - # 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" - - -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_KEYCLOAK__VALIDATE_TOKEN", "true") - - with pytest.raises(ValueError): - Settings() - - monkeypatch.setenv("NEUROAGENT_KEYCLOAK__VALIDATE_TOKEN", "false") - - with pytest.raises(ValueError): - Settings() - - monkeypatch.setenv("NEUROAGENT_KNOWLEDGE_GRAPH__BASE_URL", "http://fake_nexus.com") - monkeypatch.setenv("NEUROAGENT_KEYCLOAK__VALIDATE_TOKEN", "true") - monkeypatch.setenv("NEUROAGENT_KEYCLOAK__PASSWORD", "Hello") - - Settings() diff --git a/swarm_copy_tests/app/test_dependencies.py b/swarm_copy_tests/app/test_dependencies.py deleted file mode 100644 index ad8b8f2..0000000 --- a/swarm_copy_tests/app/test_dependencies.py +++ /dev/null @@ -1,387 +0,0 @@ -"""Test dependencies.""" - -import json -import os -from pathlib import Path -from typing import AsyncIterator -from unittest.mock import Mock, patch - -import pytest -from httpx import AsyncClient -from fastapi import Request, HTTPException - -from swarm_copy.app.app_utils import setup_engine -from swarm_copy.app.database.sql_schemas import Base, Threads -from swarm_copy.app.dependencies import ( - Settings, - get_cell_types_kg_hierarchy, - get_connection_string, - get_httpx_client, - get_settings, - get_update_kg_hierarchy, - get_user_id, get_session, get_vlab_and_project, get_starting_agent, get_kg_token, -) -from swarm_copy.new_types import Agent - - -def test_get_settings(patch_required_env): - settings = get_settings() - assert settings.tools.literature.url == "https://fake_url" - assert settings.knowledge_graph.url == "https://fake_url/api/nexus/v1/search/query/" - - -@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"] - - -@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) - - -def test_get_connection_string_full(monkeypatch, patch_required_env): - monkeypatch.setenv("NEUROAGENT_DB__PREFIX", "http://") - monkeypatch.setenv("NEUROAGENT_DB__USER", "John") - monkeypatch.setenv("NEUROAGENT_DB__PASSWORD", "Doe") - monkeypatch.setenv("NEUROAGENT_DB__HOST", "localhost") - monkeypatch.setenv("NEUROAGENT_DB__PORT", "5000") - monkeypatch.setenv("NEUROAGENT_DB__NAME", "test") - - settings = Settings() - result = get_connection_string(settings) - assert ( - result == "http://John:Doe@localhost:5000/test" - ), "must return fully formed connection string" - - - -@pytest.mark.asyncio -@pytest.mark.httpx_mock(can_send_already_matched_responses=True) -async def test_get_vlab_and_project( - patch_required_env, httpx_mock, db_connection, monkeypatch -): - # Setup DB with one thread to do the tests - monkeypatch.setenv("NEUROAGENT_KEYCLOAK__VALIDATE_TOKEN", "true") - test_settings = Settings( - db={"prefix": db_connection}, - ) - engine = setup_engine(test_settings, db_connection) - session = await anext(get_session(engine)) - user_id = "Super_user" - token = "fake_token" - httpx_client = AsyncClient() - httpx_mock.add_response( - url=f"{test_settings.virtual_lab.get_project_url}/test_vlab/projects/test_project", - json="test_project_ID", - ) - - # create test thread table - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - - new_thread = Threads( - user_id=user_id, - vlab_id="test_vlab_DB", - project_id="project_id_DB", - title="test_title", - ) - session.add(new_thread) - await session.commit() - - try: - # Test with info in headers. - good_request_headers = Request( - scope={ - "type": "http", - "method": "Get", - "url": "http://fake_url/thread_id", - "headers": [ - (b"x-virtual-lab-id", b"test_vlab"), - (b"x-project-id", b"test_project"), - ], - }, - ) - ids = await get_vlab_and_project( - user_id=user_id, - session=session, - request=good_request_headers, - settings=test_settings, - token=token, - httpx_client=httpx_client, - ) - assert ids == {"vlab_id": "test_vlab", "project_id": "test_project"} - finally: - # don't forget to close the session, otherwise the tests hangs. - await session.close() - await engine.dispose() - - -@pytest.mark.asyncio -async def test_get_vlab_and_project_no_info_in_headers( - patch_required_env, db_connection, monkeypatch -): - # Setup DB with one thread to do the tests - monkeypatch.setenv("NEUROAGENT_KEYCLOAK__VALIDATE_TOKEN", "true") - test_settings = Settings( - db={"prefix": db_connection}, - ) - engine = setup_engine(test_settings, db_connection) - session = await anext(get_session(engine)) - user_id = "Super_user" - token = "fake_token" - httpx_client = AsyncClient() - - # create test thread table - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - - new_thread = Threads( - user_id=user_id, - vlab_id="test_vlab_DB", - project_id="project_id_DB", - title="test_title", - ) - session.add(new_thread) - await session.commit() - - try: - # Test with no infos in headers. - bad_request = Request( - scope={ - "type": "http", - "method": "GET", - "scheme": "http", - "server": ("example.com", 80), - "path_params": {"dummy_patram": "fake_thread_id"}, - "headers": [ - (b"wong_header", b"wrong value"), - ], - } - ) - with pytest.raises(HTTPException) as error: - await get_vlab_and_project( - user_id=user_id, - session=session, - request=bad_request, - settings=test_settings, - token=token, - httpx_client=httpx_client, - ) - assert ( - error.value.detail == "Thread not found." - ) - finally: - # don't forget to close the session, otherwise the tests hangs. - await session.close() - await engine.dispose() - - -@pytest.mark.asyncio -@pytest.mark.httpx_mock(can_send_already_matched_responses=True) -async def test_get_vlab_and_project_valid_thread_id( - patch_required_env, httpx_mock, db_connection, monkeypatch -): - # Setup DB with one thread to do the tests - monkeypatch.setenv("NEUROAGENT_KEYCLOAK__VALIDATE_TOKEN", "true") - test_settings = Settings( - db={"prefix": db_connection}, - ) - engine = setup_engine(test_settings, db_connection) - session = await anext(get_session(engine)) - user_id = "Super_user" - token = "fake_token" - httpx_client = AsyncClient() - httpx_mock.add_response( - url=f"{test_settings.virtual_lab.get_project_url}/test_vlab_DB/projects/project_id_DB", - json="test_project_ID", - ) - - - # create test thread table - async with engine.begin() as conn: - await conn.run_sync(Base.metadata.create_all) - - new_thread = Threads( - user_id=user_id, - vlab_id="test_vlab_DB", - project_id="project_id_DB", - title="test_title", - ) - session.add(new_thread) - await session.commit() - await session.refresh(new_thread) - - try: - # Test with no infos in headers, but valid thread_ID. - good_request_DB = Request( - scope={ - "type": "http", - "method": "GET", - "scheme": "http", - "server": ("example.com", 80), - "path_params": {"thread_id": new_thread.thread_id}, - "headers": [ - (b"wong_header", b"wrong value"), - ], - } - ) - ids_from_DB = await get_vlab_and_project( - user_id=user_id, - session=session, - request=good_request_DB, - settings=test_settings, - token=token, - httpx_client=httpx_client, - ) - assert ids_from_DB == {"vlab_id": "test_vlab_DB", "project_id": "project_id_DB"} - - finally: - # don't forget to close the session, otherwise the tests hangs. - await session.close() - await engine.dispose() - - -def test_get_starting_agent(patch_required_env): - settings = Settings() - agent = get_starting_agent(None, settings) - - assert isinstance(agent, Agent) - - -@pytest.mark.parametrize( - "input_token, expected_token", - [ - ("existing_token", "existing_token"), - (None, "new_token"), - ], -) -def test_get_kg_token(patch_required_env, input_token, expected_token): - settings = Settings() - mock = Mock() - mock.token.return_value = {"access_token": expected_token} - with ( - patch("swarm_copy.app.dependencies.KeycloakOpenID", return_value=mock), - ): - result = get_kg_token(settings, input_token) - assert result == expected_token diff --git a/swarm_copy_tests/app/test_main.py b/swarm_copy_tests/app/test_main.py deleted file mode 100644 index 23f4299..0000000 --- a/swarm_copy_tests/app/test_main.py +++ /dev/null @@ -1,75 +0,0 @@ -import logging -from unittest.mock import patch - -from fastapi.testclient import TestClient - -from swarm_copy.app.dependencies import get_settings -from swarm_copy.app.main import app - - -def test_settings_endpoint(app_client, dont_look_at_env_file, settings): - response = app_client.get("/settings") - - replace_secretstr = settings.model_dump() - replace_secretstr["keycloak"]["password"] = "**********" - replace_secretstr["openai"]["token"] = "**********" - assert response.json() == replace_secretstr - - -def test_readyz(app_client): - response = app_client.get( - "/", - ) - - body = response.json() - assert isinstance(body, dict) - assert body["status"] == "ok" - - -def test_lifespan(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("swarm_copy.app.main.get_update_kg_hierarchy", new=save_dummy), - patch("swarm_copy.app.main.get_cell_types_kg_hierarchy", new=save_dummy), - patch("swarm_copy.app.main.get_kg_token", new=lambda *args, **kwargs: "dev"), - ): - # 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] == ( - "swarm_copy.app.dependencies", - "Reading the environment and instantiating settings", - ) - - assert ( - logging.getLevelName(logging.getLogger("swarm_copy").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" - ) diff --git a/swarm_copy_tests/conftest.py b/swarm_copy_tests/conftest.py deleted file mode 100644 index 48c5a59..0000000 --- a/swarm_copy_tests/conftest.py +++ /dev/null @@ -1,190 +0,0 @@ -"""Test configuration.""" - -import json -from pathlib import Path -from typing import ClassVar - -import pytest -import pytest_asyncio -from fastapi.testclient import TestClient -from pydantic import BaseModel, ConfigDict -from sqlalchemy import MetaData -from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine - -from swarm_copy.app.config import Settings -from swarm_copy.app.dependencies import Agent, get_kg_token, get_settings -from swarm_copy.app.main import app -from swarm_copy.tools.base_tool import BaseTool -from swarm_copy_tests.mock_client import MockOpenAIClient, create_mock_response - - -@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", - }, - 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 -def mock_openai_client(): - """Fake openai client.""" - m = MockOpenAIClient() - m.set_response( - create_mock_response( - {"role": "assistant", "content": "sample response content"} - ) - ) - return m - - -@pytest.fixture(name="get_weather_tool") -def fake_tool(): - """Fake get weather tool.""" - - class FakeToolInput(BaseModel): - location: str - - class FakeToolMetadata( - BaseModel - ): # Should be a BaseMetadata but we don't want httpx client here - model_config = ConfigDict(extra="ignore", arbitrary_types_allowed=True) - planet: str | None = None - - class FakeTool(BaseTool): - name: ClassVar[str] = "get_weather" - description: ClassVar[str] = "Great description" - metadata: FakeToolMetadata - input_schema: FakeToolInput - - async def arun(self): - if self.metadata.planet: - return f"It's sunny today in {self.input_schema.location} from planet {self.metadata.planet}." - return "It's sunny today." - - return FakeTool - - -@pytest.fixture -def agent_handoff_tool(): - """Fake agent handoff tool.""" - - class HandoffToolInput(BaseModel): - pass - - class HandoffToolMetadata( - BaseModel - ): # Should be a BaseMetadata but we don't want httpx client here - to_agent: Agent - model_config = ConfigDict(extra="ignore", arbitrary_types_allowed=True) - - class HandoffTool(BaseTool): - name: ClassVar[str] = "agent_handoff_tool" - description: ClassVar[str] = "Handoff to another agent." - metadata: HandoffToolMetadata - input_schema: HandoffToolInput - - async def arun(self): - return self.metadata.to_agent - - return HandoffTool - -@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_OPENAI__TOKEN", "dummy") - monkeypatch.setenv("NEUROAGENT_KEYCLOAK__VALIDATE_TOKEN", "False") - monkeypatch.setenv("NEUROAGENT_KEYCLOAK__PASSWORD", "password") - - -@pytest_asyncio.fixture(params=["sqlite", "postgresql"], name="db_connection") -async 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+aiosqlite:///{tmp_path / 'test_db.db'}" - if db_type == "sqlite" - else "postgresql+asyncpg://test:password@localhost:5432" - ) - if db_type == "postgresql": - try: - async with create_async_engine(path).connect() as conn: - pass - except Exception: - pytest.skip("Postgres database not connected") - yield path - if db_type == "postgresql": - metadata = MetaData() - engine = create_async_engine(path) - session = AsyncSession(bind=engine) - async with engine.begin() as conn: - await conn.run_sync(metadata.reflect) - await conn.run_sync(metadata.drop_all) - - await session.commit() - await engine.dispose() - await session.aclose() - - -@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(name="settings") -def settings(): - return Settings( - tools={ - "literature": { - "url": "fake_literature_url", - }, - }, - knowledge_graph={ - "base_url": "https://fake_url/api/nexus/v1", - }, - openai={ - "token": "fake_token", - }, - keycloak={ - "username": "fake_username", - "password": "fake_password", - }, - ) diff --git a/swarm_copy_tests/data/99111002.nwb b/swarm_copy_tests/data/99111002.nwb deleted file mode 100644 index ce6791daea58c5af64c6eed3960928ff1171b22a..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 530771 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 z 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

}TkUa#uBRQdH;}YMO&Gp0!0W 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 diff --git a/swarm_copy_tests/data/KG_brain_regions_hierarchy_test.json b/swarm_copy_tests/data/KG_brain_regions_hierarchy_test.json deleted file mode 100644 index c5a336a..0000000 --- a/swarm_copy_tests/data/KG_brain_regions_hierarchy_test.json +++ /dev/null @@ -1,165 +0,0 @@ -{ - "@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/swarm_copy_tests/data/brainregion_hierarchy.json b/swarm_copy_tests/data/brainregion_hierarchy.json deleted file mode 100644 index e623917..0000000 --- a/swarm_copy_tests/data/brainregion_hierarchy.json +++ /dev/null @@ -1 +0,0 @@ -{"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/swarm_copy_tests/data/get_traces.json b/swarm_copy_tests/data/get_traces.json deleted file mode 100644 index 1dc0011..0000000 --- a/swarm_copy_tests/data/get_traces.json +++ /dev/null @@ -1,201 +0,0 @@ -{ - "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/swarm_copy_tests/data/kg_cell_types_hierarchy_test.json b/swarm_copy_tests/data/kg_cell_types_hierarchy_test.json deleted file mode 100644 index 294e509..0000000 --- a/swarm_copy_tests/data/kg_cell_types_hierarchy_test.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "@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" -} diff --git a/swarm_copy_tests/data/kg_me_model_output.json b/swarm_copy_tests/data/kg_me_model_output.json deleted file mode 100644 index 6db0acd..0000000 --- a/swarm_copy_tests/data/kg_me_model_output.json +++ /dev/null @@ -1,152 +0,0 @@ -{ - "hits": { - "hits": [ - { - "sort": [ - 1716989796908 - ], - "_id": "https://bbp.epfl.ch/data/bbp/mmb-point-neuron-framework-model/eeeeac3c-6bf1-47ed-ab97-460668eba2d2", - "_index": "nexus_search_711d6b8f-1285-42db-9259-b277dd687435_711d6b8f-1285-42db-9259-b277dd687435_21", - "_source": { - "@id": "https://bbp.epfl.ch/data/bbp/mmb-point-neuron-framework-model/eeeeac3c-6bf1-47ed-ab97-460668eba2d2", - "@type": "https://neuroshapes.org/MEModel", - "analysisSuitable": false, - "brainRegion": { - "@id": "http://api.brain-map.org/api/v2/data/Structure/322", - "idLabel": "http://api.brain-map.org/api/v2/data/Structure/322|Primary somatosensory area", - "identifier": "http://api.brain-map.org/api/v2/data/Structure/322", - "label": "Primary somatosensory area" - }, - "createdAt": "2024-05-29T13:36:36.908Z", - "createdBy": "https://openbluebrain.com/api/nexus/v1/realms/bbp/users/antonel", - "deprecated": false, - "description": "My me-model", - "eType": { - "@id": "_:b0", - "identifier": "_:b0", - "label": "cNAC" - }, - "generation": {}, - "image": [ - { - "@id": "https://bbp.epfl.ch/data/bbp/mmb-point-neuron-framework-model/2869d500-3c6e-4d15-bcae-b4ece55c5586", - "about": "https://bbp.epfl.ch/data/bbp/mmb-point-neuron-framework-model/currentscape", - "identifier": "https://bbp.epfl.ch/data/bbp/mmb-point-neuron-framework-model/2869d500-3c6e-4d15-bcae-b4ece55c5586" - }, - { - "@id": "https://bbp.epfl.ch/data/bbp/mmb-point-neuron-framework-model/aea6c71b-41a9-4268-96e7-d0f9265693e1", - "about": "https://bbp.epfl.ch/data/bbp/mmb-point-neuron-framework-model/parameters_distribution", - "identifier": "https://bbp.epfl.ch/data/bbp/mmb-point-neuron-framework-model/aea6c71b-41a9-4268-96e7-d0f9265693e1" - }, - { - "@id": "https://bbp.epfl.ch/data/bbp/mmb-point-neuron-framework-model/ea0b79b5-66bb-48bf-8933-9b43b56959d5", - "about": "https://bbp.epfl.ch/data/bbp/mmb-point-neuron-framework-model/traces", - "identifier": "https://bbp.epfl.ch/data/bbp/mmb-point-neuron-framework-model/ea0b79b5-66bb-48bf-8933-9b43b56959d5" - }, - { - "@id": "https://bbp.epfl.ch/data/bbp/mmb-point-neuron-framework-model/ba3c8d1a-2839-4fdd-b13f-73950c84954a", - "about": "https://bbp.epfl.ch/data/bbp/mmb-point-neuron-framework-model/currentscape", - "identifier": "https://bbp.epfl.ch/data/bbp/mmb-point-neuron-framework-model/ba3c8d1a-2839-4fdd-b13f-73950c84954a" - }, - { - "@id": "https://bbp.epfl.ch/data/bbp/mmb-point-neuron-framework-model/af301c80-0c51-4726-95b7-dc65e5e180fd", - "about": "https://bbp.epfl.ch/data/bbp/mmb-point-neuron-framework-model/scores", - "identifier": "https://bbp.epfl.ch/data/bbp/mmb-point-neuron-framework-model/af301c80-0c51-4726-95b7-dc65e5e180fd" - }, - { - "@id": "https://bbp.epfl.ch/data/bbp/mmb-point-neuron-framework-model/798bab6a-4caa-460b-bac7-a6f08672f11b", - "about": "https://bbp.epfl.ch/data/bbp/mmb-point-neuron-framework-model/currentscape", - "identifier": "https://bbp.epfl.ch/data/bbp/mmb-point-neuron-framework-model/798bab6a-4caa-460b-bac7-a6f08672f11b" - }, - { - "@id": "https://bbp.epfl.ch/data/bbp/mmb-point-neuron-framework-model/489673cc-27c1-46bf-ad6a-510ea124a4cc", - "about": "https://bbp.epfl.ch/data/bbp/mmb-point-neuron-framework-model/currentscape", - "identifier": "https://bbp.epfl.ch/data/bbp/mmb-point-neuron-framework-model/489673cc-27c1-46bf-ad6a-510ea124a4cc" - }, - { - "@id": "https://bbp.epfl.ch/data/bbp/mmb-point-neuron-framework-model/3cb119a2-6a3a-4e75-99c5-53f2405d3e91", - "about": "https://bbp.epfl.ch/data/bbp/mmb-point-neuron-framework-model/currentscape", - "identifier": "https://bbp.epfl.ch/data/bbp/mmb-point-neuron-framework-model/3cb119a2-6a3a-4e75-99c5-53f2405d3e91" - }, - { - "@id": "https://bbp.epfl.ch/data/bbp/mmb-point-neuron-framework-model/3819e25e-ace3-4809-9a96-964d5d9fb932", - "about": "https://bbp.epfl.ch/data/bbp/mmb-point-neuron-framework-model/currentscape", - "identifier": "https://bbp.epfl.ch/data/bbp/mmb-point-neuron-framework-model/3819e25e-ace3-4809-9a96-964d5d9fb932" - }, - { - "@id": "https://bbp.epfl.ch/data/bbp/mmb-point-neuron-framework-model/33bcf9d3-b6db-4d2c-b3c6-3ea8a5932202", - "about": "https://bbp.epfl.ch/data/bbp/mmb-point-neuron-framework-model/currentscape", - "identifier": "https://bbp.epfl.ch/data/bbp/mmb-point-neuron-framework-model/33bcf9d3-b6db-4d2c-b3c6-3ea8a5932202" - }, - { - "@id": "https://bbp.epfl.ch/data/bbp/mmb-point-neuron-framework-model/29dc72cb-bb9d-4966-bc3b-70e6ff65c6e3", - "about": "https://bbp.epfl.ch/data/bbp/mmb-point-neuron-framework-model/currentscape", - "identifier": "https://bbp.epfl.ch/data/bbp/mmb-point-neuron-framework-model/29dc72cb-bb9d-4966-bc3b-70e6ff65c6e3" - }, - { - "@id": "https://bbp.epfl.ch/data/bbp/mmb-point-neuron-framework-model/e5e20e7a-87b5-47a2-abda-2d7212ab00e1", - "about": "https://bbp.epfl.ch/data/bbp/mmb-point-neuron-framework-model/thumbnail", - "identifier": "https://bbp.epfl.ch/data/bbp/mmb-point-neuron-framework-model/e5e20e7a-87b5-47a2-abda-2d7212ab00e1" - } - ], - "layer": [ - { - "@id": "http://purl.obolibrary.org/obo/UBERON_0005394", - "idLabel": "http://purl.obolibrary.org/obo/UBERON_0005394|layer 5", - "identifier": "http://purl.obolibrary.org/obo/UBERON_0005394", - "label": "layer 5" - } - ], - "mType": { - "@id": "_:b2", - "identifier": "_:b2", - "label": "L5_TPC:B" - }, - "memodel": { - "emodelResource": { - "@id": "https://bbp.epfl.ch/data/bbp/mmb-point-neuron-framework-model/67acf101-12f6-4b7b-8a89-5237aadf94db", - "name": "EM__hipp_rat__CA1_int_cNAC_010710HP2_20190328163258__2" - }, - "neuronMorphology": { - "@id": "https://bbp.epfl.ch/data/bbp/mmb-point-neuron-framework-model/255e007e-a6d1-4fc9-b984-1e0221e39ea3", - "name": "ch150801A1" - }, - "validated": true - }, - "name": "My me-model", - "objectOfStudy": { - "@id": "http://bbp.epfl.ch/neurosciencegraph/taxonomies/objectsofstudy/singlecells", - "identifier": "http://bbp.epfl.ch/neurosciencegraph/taxonomies/objectsofstudy/singlecells", - "label": "Single Cell" - }, - "project": { - "@id": "https://openbluebrain.com/api/nexus/v1/projects/bbp/mmb-point-neuron-framework-model", - "identifier": "https://openbluebrain.com/api/nexus/v1/projects/bbp/mmb-point-neuron-framework-model", - "label": "bbp/mmb-point-neuron-framework-model" - }, - "seed": 2, - "subjectSpecies": { - "@id": "http://purl.obolibrary.org/obo/NCBITaxon_10116", - "identifier": "http://purl.obolibrary.org/obo/NCBITaxon_10116", - "label": "Rattus norvegicus" - }, - "updatedAt": "2024-07-09T10:53:01.208Z", - "updatedBy": "https://openbluebrain.com/api/nexus/v1/realms/bbp/users/ajaquier", - "_self": "https://openbluebrain.com/api/nexus/v1/resources/bbp/mmb-point-neuron-framework-model/_/https:%2F%2Fbbp.epfl.ch%2Fdata%2Fbbp%2Fmmb-point-neuron-framework-model%2Feeeeac3c-6bf1-47ed-ab97-460668eba2d2" - } - } - ], - "total": { - "relation": "eq", - "value": 1 - } - }, - "timed_out": false, - "took": 14, - "_shards": { - "failed": 0, - "skipped": 4, - "successful": 15, - "total": 15 - } -} \ No newline at end of file diff --git a/swarm_copy_tests/data/kg_morpho_features_response.json b/swarm_copy_tests/data/kg_morpho_features_response.json deleted file mode 100644 index 70fd8de..0000000 --- a/swarm_copy_tests/data/kg_morpho_features_response.json +++ /dev/null @@ -1,412 +0,0 @@ -{ - "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/swarm_copy_tests/data/knowledge_graph.json b/swarm_copy_tests/data/knowledge_graph.json deleted file mode 100644 index 11b0579..0000000 --- a/swarm_copy_tests/data/knowledge_graph.json +++ /dev/null @@ -1,182 +0,0 @@ -{ - "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/swarm_copy_tests/data/morphology_id_metadata_response.json b/swarm_copy_tests/data/morphology_id_metadata_response.json deleted file mode 100644 index 904b6ad..0000000 --- a/swarm_copy_tests/data/morphology_id_metadata_response.json +++ /dev/null @@ -1,102 +0,0 @@ -{ - "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/swarm_copy_tests/data/resolve_query.json b/swarm_copy_tests/data/resolve_query.json deleted file mode 100644 index 181dc08..0000000 --- a/swarm_copy_tests/data/resolve_query.json +++ /dev/null @@ -1 +0,0 @@ -[{"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/swarm_copy_tests/data/simple.swc b/swarm_copy_tests/data/simple.swc deleted file mode 100644 index ebba216..0000000 --- a/swarm_copy_tests/data/simple.swc +++ /dev/null @@ -1,31 +0,0 @@ -# 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/swarm_copy_tests/data/tool_calls.json b/swarm_copy_tests/data/tool_calls.json deleted file mode 100644 index c8d9066..0000000 --- a/swarm_copy_tests/data/tool_calls.json +++ /dev/null @@ -1,102 +0,0 @@ -[ - { - "prompt": "What are the morphological features of neurons in the thalamus?", - "expected_tool_calls": [ - { - "tool_name": "resolve-entities-tool", - "arguments": {"brain_region": "thalamus"} - }, - { - "tool_name": "get-morpho-tool", - "arguments": {"brain_region_id":"http://api.brain-map.org/api/v2/data/Structure/549"} - } - ] - }, - { - "prompt": "Find me articles about the role of the hippocampus in memory formation.", - "expected_tool_calls": [ - { - "tool_name": "literature-search-tool", - "arguments": { - "query": "hippocampus memory formation" - } - } - ] - }, - { - "prompt": "Retrieve electrophysiological features of cortical neurons.", - "expected_tool_calls": [ - { - "tool_name": "resolve-entities-tool", - "arguments": { - "brain_region": "cortex" - } - }, - { - "tool_name": "get-traces-tool", - "arguments": { - "brain_region_id": "http://api.brain-map.org/api/v2/data/Structure/134" - } - }, - { - "tool_name": "electrophys-feature-tool", - "arguments": { - "brain_region": "cortex" - } - } - ] - }, - { - "prompt": "Get traces for neurons in the hippocampus.", - "expected_tool_calls": [ - { - "tool_name": "resolve-entities-tool", - "arguments": { - "brain_region": "hippocampus"} - }, - { - "tool_name": "get-traces-tool", - "arguments": { - "brain_region_id": "http://api.brain-map.org/api/v2/data/Structure/134" - } - }, - { - "tool_name": "electrophys-features-tool" - } - ] - }, - { - "prompt": "Search for literature on synaptic plasticity.", - "expected_tool_calls": [ - { - "tool_name": "literature-search-tool", - "arguments": { - "query": "synaptic plasticity" - } - } - ] - }, - { - "prompt": "Run 1000 ms of simulation of a me model from somatosensory cortex with 34 degree temperature, current clamp stimulation mode with step current for fire pattern detection. use 1 number of step and 0.05 nA current stimulation. Record from soma.", - "expected_tool_calls": [ - { - "tool_name": "resolve-entities-tool", - "arguments": { - "brain_region": "somatosensory area" - } - }, - { - "tool_name": "literature-search-tool" - }, - { - "tool_name": "get-me-model-tool", - "arguments": { - "brain_region_id" : "http://api.brain-map.org/api/v2/data/Structure/322" - } - }, - { - "tool_name": "bluenaas-tool" - } - ] -} -] \ No newline at end of file diff --git a/swarm_copy_tests/data/trace_id_metadata.json b/swarm_copy_tests/data/trace_id_metadata.json deleted file mode 100644 index 388651c..0000000 --- a/swarm_copy_tests/data/trace_id_metadata.json +++ /dev/null @@ -1,110 +0,0 @@ -{ - "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/swarm_copy_tests/test_cell_types.py b/swarm_copy_tests/test_cell_types.py deleted file mode 100644 index aabd7e5..0000000 --- a/swarm_copy_tests/test_cell_types.py +++ /dev/null @@ -1,190 +0,0 @@ -"""Test cell types meta functions.""" -import logging -from pathlib import Path - -import pytest - -from swarm_copy.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", - } - - def test_from_dict(self): - test_dict = { - "defines": [ - { - "@id": "id1", - "label": "cell1", - "subClassOf": [] - }, - { - "@id": "id2", - "label": "cell2", - "subClassOf": ["id1"] - }, - { - "@id": "id3", - "subClassOf": ["id2"] - } - ] - } - cell_meta = CellTypesMeta.from_dict(test_dict) - assert isinstance(cell_meta, CellTypesMeta) - assert cell_meta.name_ == {"id1": "cell1", "id2": "cell2", "id3": None} - assert cell_meta.descendants_ids == {"id1": {"id2", "id3"}, "id2": {"id3"}} - - def test_from_dict_missing_label(self): - test_dict = { - "defines": [ - { - "@id": "id1", - "subClassOf": [] - }, - { - "@id": "id2", - "subClassOf": ["id1"] - } - ] - } - cell_meta = CellTypesMeta.from_dict(test_dict) - assert cell_meta.name_ == {"id1": None, "id2": None} - assert cell_meta.descendants_ids == {"id1": {"id2"}} - - def test_from_dict_missing_subClassOf(self): - test_dict = { - "defines": [ - { - "@id": "id1", - "label": "cell1", - }, - { - "@id": "id2", - "label": "cell2", - "subClassOf": ["id1"] - } - ] - } - cell_meta = CellTypesMeta.from_dict(test_dict) - assert cell_meta.name_ == {"id1": "cell1", "id2": "cell2"} - assert cell_meta.descendants_ids == {"id1": {"id2"}} - - @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/swarm_copy_tests/test_resolving.py b/swarm_copy_tests/test_resolving.py deleted file mode 100644 index 1378416..0000000 --- a/swarm_copy_tests/test_resolving.py +++ /dev/null @@ -1,319 +0,0 @@ -import pytest -from httpx import AsyncClient - -from swarm_copy.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() - - 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() - - 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() - - 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() - - 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() - 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/swarm_copy_tests/test_utils.py b/swarm_copy_tests/test_utils.py deleted file mode 100644 index 43579d8..0000000 --- a/swarm_copy_tests/test_utils.py +++ /dev/null @@ -1,510 +0,0 @@ -"""Test utility functions.""" - -import json -from pathlib import Path - -import pytest -from httpx import AsyncClient - -from swarm_copy.schemas import KGMetadata -from swarm_copy.utils import ( - RegionMeta, - get_descendants_id, - merge_chunk, - merge_fields, get_file_from_KG, is_lnmc, get_kg_data, -) - - -def test_merge_fields_str(): - target = {"key_1": "abc", "key_2": ""} - source = {"key_1": "def"} - merge_fields(target, source) - assert target == {"key_1": "abcdef", "key_2": ""} - - source = {"key_1": "", "key_2": ""} - target = {"key_1": "value_1"} - with pytest.raises(KeyError): - merge_fields(target, source) - - -def test_merge_fields_dict(): - target = {"key_1": "abc", "key_2": {"sub_key_1": "", "sub_key_2": "abc"}} - source = {"key_1": "def", "key_2": {"sub_key_1": "hello", "sub_key_2": "cba"}} - merge_fields(target, source) - assert target == { - "key_1": "abcdef", - "key_2": {"sub_key_1": "hello", "sub_key_2": "abccba"}, - } - - -def test_merge_chunk(): - message = { - "content": "", - "sender": "test agent", - "role": "assistant", - "function_call": None, - "tool_calls": [ - { - "function": {"arguments": "", "name": ""}, - "id": "", - "type": "", - } - ], - } - delta = { - "content": "Great content", - "function_call": None, - "refusal": None, - "role": "assistant", - "tool_calls": [ - { - "index": 0, - "id": "call_NDiPAjDW4oLef44xIptVSAZC", - "function": {"arguments": "Thalamus", "name": "resolve-entities-tool"}, - "type": "function", - } - ], - } - merge_chunk(message, delta) - assert message == { - "content": "Great content", - "sender": "test agent", - "role": "assistant", - "function_call": None, - "tool_calls": [ - { - "function": {"arguments": "Thalamus", "name": "resolve-entities-tool"}, - "id": "call_NDiPAjDW4oLef44xIptVSAZC", - "type": "function", - } - ], - } - - -@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() - # 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.httpx_mock(can_send_already_matched_responses=True) -@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/swarm_copy_tests/tools/__init__.py b/swarm_copy_tests/tools/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/swarm_copy_tests/tools/test_base_tool.py b/swarm_copy_tests/tools/test_base_tool.py deleted file mode 100644 index e69de29..0000000 diff --git a/swarm_copy_tests/tools/test_electrophys_tool.py b/swarm_copy_tests/tools/test_electrophys_tool.py deleted file mode 100644 index 5706ec9..0000000 --- a/swarm_copy_tests/tools/test_electrophys_tool.py +++ /dev/null @@ -1,206 +0,0 @@ -"""Tests Electrophys tool.""" - -import json -from pathlib import Path - -import httpx -import pytest - -from swarm_copy.tools import ElectrophysFeatureTool -from swarm_copy.tools.electrophys_tool import ( - CALCULATED_FEATURES, - AmplitudeInput, - ElectrophysInput, - ElectrophysMetadata, -) - - -class TestElectrophysTool: - @pytest.mark.httpx_mock(can_send_already_matched_responses=True) - @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, - ) - - trace_id = "https://bbp.epfl.ch/data/demo/morpho-demo/1761e604-03fc-452b-9bf2-2214782bb751" - - tool = ElectrophysFeatureTool( - input_schema=ElectrophysInput( - trace_id=trace_id, - stimuli_types=[ - "step", - ], - calculated_feature=[ - "mean_frequency", - ], - amplitude=None, - ), - metadata=ElectrophysMetadata( - knowledge_graph_url=url, - search_size=2, - httpx_client=httpx.AsyncClient(), - token="fake_token", - ), - ) - response = await tool.arun() - assert isinstance(response, 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 - ) - - @pytest.mark.httpx_mock(can_send_already_matched_responses=True) - @pytest.mark.asyncio - async def test_arun_with_amplitude(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, - ) - - trace_id = "https://bbp.epfl.ch/data/demo/morpho-demo/1761e604-03fc-452b-9bf2-2214782bb751" - - tool = ElectrophysFeatureTool( - input_schema=ElectrophysInput( - trace_id=trace_id, - stimuli_types=[ - "step", - ], - calculated_feature=[ - "mean_frequency", - ], - amplitude=AmplitudeInput(min_value=-0.5, max_value=1), - ), - metadata=ElectrophysMetadata( - knowledge_graph_url=url, - search_size=2, - httpx_client=httpx.AsyncClient(), - token="fake_token", - ), - ) - response = await tool.arun() - assert isinstance(response, 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 - ) - - @pytest.mark.httpx_mock(can_send_already_matched_responses=True) - @pytest.mark.asyncio - async def test_arun_without_stimuli_types(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, - ) - - trace_id = "https://bbp.epfl.ch/data/demo/morpho-demo/1761e604-03fc-452b-9bf2-2214782bb751" - - tool = ElectrophysFeatureTool( - input_schema=ElectrophysInput( - trace_id=trace_id, - stimuli_types=[ - "step", - ], - calculated_feature=[], - amplitude=None, - ), - metadata=ElectrophysMetadata( - knowledge_graph_url=url, - search_size=2, - httpx_client=httpx.AsyncClient(), - token="fake_token", - ), - ) - - # Without stimuli types and calculated features - response = await tool.arun() - assert isinstance(response, 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( - input_schema=ElectrophysInput( - trace_id="wrong-trace-id", - stimuli_types=[ - "idrest", - ], - calculated_feature=[ - "mean_frequency", - ], - amplitude=None, - ), - metadata=ElectrophysMetadata( - knowledge_graph_url=url, - search_size=2, - httpx_client=httpx.AsyncClient(), - token="fake_token", - ), - ) - - with pytest.raises(ValueError) as tool_exception: - await tool.arun() - - assert ( - tool_exception.value.args[0] - == "The provided ID (wrong-trace-id) is not valid." - ) diff --git a/swarm_copy_tests/tools/test_get_morpho_tool.py b/swarm_copy_tests/tools/test_get_morpho_tool.py deleted file mode 100644 index d316d49..0000000 --- a/swarm_copy_tests/tools/test_get_morpho_tool.py +++ /dev/null @@ -1,175 +0,0 @@ -"""Tests Get Morpho tool.""" - -import json -from pathlib import Path - -import httpx -import pytest - -from swarm_copy.tools import GetMorphoTool -from swarm_copy.tools.get_morpho_tool import GetMorphoInput, GetMorphoMetadata - - -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( - input_schema=GetMorphoInput( - brain_region_id="brain_region_id_link/549", - mtype_id="brain_region_id_link/549", - ), - metadata=GetMorphoMetadata( - knowledge_graph_url=url, - morpho_search_size=2, - httpx_client=httpx.AsyncClient(), - token="fake_token", - brainregion_path=brain_region_json_path, - celltypes_path=tmp_path, - ), - ) - response = await tool.arun() - assert isinstance(response, list) - assert len(response) == 2 - assert isinstance(response[0], dict) - - @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( - input_schema=GetMorphoInput( - brain_region_id="brain_region_id_link/bad", - mtype_id="brain_region_id_link/superbad", - ), - metadata=GetMorphoMetadata( - knowledge_graph_url=url, - morpho_search_size=2, - httpx_client=httpx.AsyncClient(), - token="fake_token", - brainregion_path=brain_region_json_path, - celltypes_path=tmp_path, - ), - ) - with pytest.raises(KeyError) as tool_exception: - await tool.arun() - - assert tool_exception.value.args[0] == "hits" - - -def test_create_query(brain_region_json_path, tmp_path): - url = "http://fake_url" - - tool = GetMorphoTool( - input_schema=GetMorphoInput( - brain_region_id="not_needed", - mtype_id="not_needed", - ), - metadata=GetMorphoMetadata( - knowledge_graph_url=url, - morpho_search_size=2, - httpx_client=httpx.AsyncClient(), - token="fake_token", - brainregion_path=brain_region_json_path, - celltypes_path=tmp_path, - ), - ) - - # 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/swarm_copy_tests/tools/test_kg_morpho_features_tool.py b/swarm_copy_tests/tools/test_kg_morpho_features_tool.py deleted file mode 100644 index 2436acf..0000000 --- a/swarm_copy_tests/tools/test_kg_morpho_features_tool.py +++ /dev/null @@ -1,453 +0,0 @@ -"""Tests KG Morpho Features tool.""" - -import json -from pathlib import Path - -import httpx -import pytest - -from swarm_copy.tools import KGMorphoFeatureTool -from swarm_copy.tools.kg_morpho_features_tool import ( - KGFeatRangeInput, - KGFeatureInput, - KGMorphoFeatureInput, - KGMorphoFeatureMetadata, -) - - -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, - ) - - feature_input = KGFeatureInput( - label="Section Tortuosity", - ) - - tool = KGMorphoFeatureTool( - input_schema=KGMorphoFeatureInput( - brain_region_id="brain_region_id_link/549", features=feature_input - ), - metadata=KGMorphoFeatureMetadata( - knowledge_graph_url=url, - kg_morpho_feature_search_size=2, - token="fake_token", - brainregion_path=brain_region_json_path, - httpx_client=httpx.AsyncClient(), - ), - ) - response = await tool.arun() - assert isinstance(response, list) - assert len(response) == 2 - assert isinstance(response[0], dict) - - @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={}, - ) - - feature_input = KGFeatureInput( - label="Section Tortuosity", - ) - tool = KGMorphoFeatureTool( - input_schema=KGMorphoFeatureInput( - brain_region_id="brain_region_id_link/549", features=feature_input - ), - metadata=KGMorphoFeatureMetadata( - knowledge_graph_url=url, - kg_morpho_feature_search_size=2, - token="fake_token", - brainregion_path=brain_region_json_path, - httpx_client=httpx.AsyncClient(), - ), - ) - - with pytest.raises(KeyError) as tool_exception: - await tool.arun() - assert tool_exception.value.args[0] == "hits" - - def test_create_query(self, brain_region_json_path): - url = "http://fake_url" - - feature_input = KGFeatureInput( - label="Soma Radius", - compartment="NeuronMorphology", - ) - - brain_regions_ids = {"brain-region-id/68"} - - tool = KGMorphoFeatureTool( - input_schema=KGMorphoFeatureInput( - brain_region_id="", features=feature_input - ), - metadata=KGMorphoFeatureMetadata( - knowledge_graph_url=url, - kg_morpho_feature_search_size=2, - token="fake_token", - brainregion_path=brain_region_json_path, - httpx_client=httpx.AsyncClient(), - ), - ) - - 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 - - def test_create_query_with_max_value(self, brain_region_json_path): - url = "http://fake_url" - - feature_input = KGFeatureInput( - label="Soma Radius", - compartment="NeuronMorphology", - feat_range=KGFeatRangeInput(max_value=5), - ) - - brain_regions_ids = {"brain-region-id/68"} - - tool = KGMorphoFeatureTool( - input_schema=KGMorphoFeatureInput( - brain_region_id="", features=feature_input - ), - metadata=KGMorphoFeatureMetadata( - knowledge_graph_url=url, - kg_morpho_feature_search_size=2, - token="fake_token", - brainregion_path=brain_region_json_path, - httpx_client=httpx.AsyncClient(), - ), - ) - - 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": { - "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_query == expected_query - - def test_create_query_with_min_value(self, brain_region_json_path): - url = "http://fake_url" - - feature_input = KGFeatureInput( - label="Soma Radius", - compartment="NeuronMorphology", - feat_range=KGFeatRangeInput(min_value=2), - ) - - tool = KGMorphoFeatureTool( - input_schema=KGMorphoFeatureInput( - brain_region_id="", features=feature_input - ), - metadata=KGMorphoFeatureMetadata( - knowledge_graph_url=url, - kg_morpho_feature_search_size=2, - token="fake_token", - brainregion_path=brain_region_json_path, - httpx_client=httpx.AsyncClient(), - ), - ) - - 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": { - "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_query == expected_query - - def test_create_query_with_min_max_value(self, brain_region_json_path): - url = "http://fake_url" - - feature_input = KGFeatureInput( - label="Soma Radius", - compartment="NeuronMorphology", - feat_range=KGFeatRangeInput(min_value=2, max_value=5), - ) - - tool = KGMorphoFeatureTool( - input_schema=KGMorphoFeatureInput( - brain_region_id="", features=feature_input - ), - metadata=KGMorphoFeatureMetadata( - knowledge_graph_url=url, - kg_morpho_feature_search_size=2, - token="fake_token", - brainregion_path=brain_region_json_path, - httpx_client=httpx.AsyncClient(), - ), - ) - - 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": { - "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_query == expected_query diff --git a/swarm_copy_tests/tools/test_literature_search_tool.py b/swarm_copy_tests/tools/test_literature_search_tool.py deleted file mode 100644 index c57d8a1..0000000 --- a/swarm_copy_tests/tools/test_literature_search_tool.py +++ /dev/null @@ -1,50 +0,0 @@ -"""Tests Literature Search tool.""" - -import httpx -import pytest - -from swarm_copy.tools import LiteratureSearchTool -from swarm_copy.tools.literature_search_tool import ( - LiteratureSearchInput, - LiteratureSearchMetadata, -) - - -class TestLiteratureSearchTool: - @pytest.mark.asyncio - async def test_arun(self, httpx_mock): - url = "http://fake_url?query=covid+19&retriever_k=100&use_reranker=true&reranker_k=5" - reranker_k = 5 - - fake_response = [ - { - "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) - ] - - httpx_mock.add_response( - url=url, - json=fake_response, - ) - - tool = LiteratureSearchTool( - input_schema=LiteratureSearchInput(query="covid 19"), - metadata=LiteratureSearchMetadata( - literature_search_url=url, - httpx_client=httpx.AsyncClient(), - token="fake_token", - retriever_k=100, - use_reranker=True, - reranker_k=reranker_k, - ), - ) - response = await tool.arun() - assert isinstance(response, list) - assert len(response) == reranker_k - assert isinstance(response[0], dict) diff --git a/swarm_copy_tests/tools/test_morphology_features_tool.py b/swarm_copy_tests/tools/test_morphology_features_tool.py deleted file mode 100644 index 197b634..0000000 --- a/swarm_copy_tests/tools/test_morphology_features_tool.py +++ /dev/null @@ -1,114 +0,0 @@ -"""Tests Morphology features tool.""" - -import json -from pathlib import Path - -import httpx -import pytest - -from swarm_copy.tools import MorphologyFeatureTool -from swarm_copy.tools.morphology_features_tool import ( - MorphologyFeatureInput, - MorphologyFeatureMetadata, -) - - -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=MorphologyFeatureMetadata( - knowledge_graph_url=url, - httpx_client=httpx.AsyncClient(), - token="fake_token", - ), - input_schema=MorphologyFeatureInput(morphology_id=morphology_id), - ) - - response = await tool.arun() - assert isinstance(response[0], dict) - assert len(response[0]["feature_dict"]) == 23 - - @pytest.mark.asyncio - async def test_arun_errors_404(self, httpx_mock): - url = "http://fake_url" - morphology_id = "https://bbp.epfl.ch/neurosciencegraph/data/neuronmorphologies/046fb11c-8de8-42e8-9303-9d5a65ac04b9" - - tool = MorphologyFeatureTool( - metadata=MorphologyFeatureMetadata( - knowledge_graph_url=url, - httpx_client=httpx.AsyncClient(), - token="fake_token", - ), - input_schema=MorphologyFeatureInput(morphology_id=morphology_id), - ) - - # test different failures - # Failure 1 - httpx_mock.add_response( - url=url, - status_code=404, - ) - with pytest.raises(ValueError) as tool_exception: - await tool.arun() - - 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" - ) - - @pytest.mark.asyncio - async def test_arun_wrong_id(self, httpx_mock): - url = "http://fake_url" - morphology_id = "https://bbp.epfl.ch/neurosciencegraph/data/neuronmorphologies/046fb11c-8de8-42e8-9303-9d5a65ac04b9" - - tool = MorphologyFeatureTool( - metadata=MorphologyFeatureMetadata( - knowledge_graph_url=url, - httpx_client=httpx.AsyncClient(), - token="fake_token", - ), - input_schema=MorphologyFeatureInput(morphology_id=morphology_id), - ) - - # Failure 2 - fake_json = {"hits": {"hits": [{"_source": {"@id": "wrong_id"}}]}} - httpx_mock.add_response( - url=url, - json=fake_json, - ) - with pytest.raises(ValueError) as tool_exception: - await tool.arun() - - 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/swarm_copy_tests/tools/test_resolve_entities_tool.py b/swarm_copy_tests/tools/test_resolve_entities_tool.py deleted file mode 100644 index 01014ae..0000000 --- a/swarm_copy_tests/tools/test_resolve_entities_tool.py +++ /dev/null @@ -1,100 +0,0 @@ -"""Test the revole_brain_region_tool.""" - -import pytest -from httpx import AsyncClient - -from swarm_copy.tools import ResolveEntitiesTool -from swarm_copy.tools.resolve_entities_tool import ( - BRResolveOutput, - EtypeResolveOutput, - MTypeResolveOutput, - ResolveBRMetadata, - ResolveBRInput -) - - -@pytest.mark.asyncio -async def test_arun(httpx_mock, get_resolve_query_output): - # 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] - ) - - tool = ResolveEntitiesTool( - metadata=ResolveBRMetadata( - 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", - ), - input_schema=ResolveBRInput( - brain_region="Field", - mtype="Interneu", - etype="bAC" - ) - ) - - response = await tool.arun() - assert response == [ - BRResolveOutput( - brain_region_name="Field CA1", - brain_region_id="http://api.brain-map.org/api/v2/data/Structure/382", - ).model_dump(), - BRResolveOutput( - brain_region_name="Field CA2", - brain_region_id="http://api.brain-map.org/api/v2/data/Structure/423", - ).model_dump(), - BRResolveOutput( - brain_region_name="Field CA3", - brain_region_id="http://api.brain-map.org/api/v2/data/Structure/463", - ).model_dump(), - MTypeResolveOutput( - mtype_name="Interneuron", mtype_id="https://neuroshapes.org/Interneuron" - ).model_dump(), - MTypeResolveOutput( - mtype_name="Hippocampus CA3 Oriens Interneuron", - mtype_id="http://uri.interlex.org/base/ilx_0105044", - ).model_dump(), - MTypeResolveOutput( - mtype_name="Spinal Cord Ventral Horn Interneuron IA", - mtype_id="http://uri.interlex.org/base/ilx_0110929", - ).model_dump(), - EtypeResolveOutput( - etype_name="bAC", etype_id="http://uri.interlex.org/base/ilx_0738199" - ).model_dump(), - ] diff --git a/swarm_copy_tests/tools/test_traces_tool.py b/swarm_copy_tests/tools/test_traces_tool.py deleted file mode 100644 index 03bba53..0000000 --- a/swarm_copy_tests/tools/test_traces_tool.py +++ /dev/null @@ -1,150 +0,0 @@ -"""Tests Traces tool.""" - -import json -from pathlib import Path - -import httpx -import pytest - -from swarm_copy.tools import GetTracesTool -from swarm_copy.tools.traces_tool import GetTracesInput, GetTracesMetadata - - -class TestTracesTool: - @pytest.mark.httpx_mock(can_send_already_matched_responses=True) - @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=GetTracesMetadata( - knowledge_graph_url=url, - trace_search_size=2, - httpx_client=httpx.AsyncClient(), - token="fake_token", - brainregion_path=brain_region_json_path, - ), - input_schema=GetTracesInput(brain_region_id="brain_region_id_link/549"), - ) - - response = await tool.arun() - assert isinstance(response, list) - assert len(response) == 2 - assert isinstance(response[0], dict) - assert isinstance(response[0], dict) - - @pytest.mark.httpx_mock(can_send_already_matched_responses=True) - @pytest.mark.asyncio - async def test_arun_with_etype(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=GetTracesMetadata( - knowledge_graph_url=url, - trace_search_size=2, - httpx_client=httpx.AsyncClient(), - token="fake_token", - brainregion_path=brain_region_json_path, - ), - input_schema=GetTracesInput( - brain_region_id="brain_region_id_link/549", etype_id="bAC_id/123" - ), - ) - response = await tool.arun() - assert isinstance(response, list) - assert len(response) == 2 - assert isinstance(response[0], dict) - - @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=GetTracesMetadata( - knowledge_graph_url=url, - trace_search_size=2, - httpx_client=httpx.AsyncClient(), - token="fake_token", - brainregion_path=brain_region_json_path, - ), - input_schema=GetTracesInput(brain_region_id="brain_region_id_link/549"), - ) - with pytest.raises(KeyError) as tool_exception: - await tool.arun() - - assert tool_exception.value.args[0] == "hits" - - def test_create_query(self, brain_region_json_path): - brain_region_ids = {"brain_region_id1"} - etype_id = "bAC_id/123" - url = "http://fake_url" - - tool = GetTracesTool( - metadata=GetTracesMetadata( - knowledge_graph_url=url, - trace_search_size=2, - httpx_client=httpx.AsyncClient(), - token="fake_token", - brainregion_path=brain_region_json_path, - ), - input_schema=GetTracesInput( - brain_region_id="brain_region_id1", etype_id=etype_id - ), - ) - entire_query = tool.create_query( - brain_region_ids=brain_region_ids, etype_id=etype_id - ) - expected_query = { - "size": 2, - "track_total_hits": True, - "query": { - "bool": { - "must": [ - { - "bool": { - "should": [ - { - "term": { - "brainRegion.@id.keyword": ( - "brain_region_id1" - ) - } - }, - ] - } - }, - {"term": {"eType.@id.keyword": ("bAC_id/123")}}, - { - "term": { - "@type.keyword": "https://bbp.epfl.ch/ontologies/core/bmo/ExperimentalTrace" - } - }, - {"term": {"curated": True}}, - {"term": {"deprecated": False}}, - ] - } - }, - } - assert entire_query == expected_query diff --git a/tests/__init__.py b/tests/__init__.py index e69de29..480d94b 100644 --- a/tests/__init__.py +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Sarm copy tests.""" diff --git a/tests/agents/__init__.py b/tests/agents/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/agents/test_simple_agent.py b/tests/agents/test_simple_agent.py deleted file mode 100644 index 9bdd8ba..0000000 --- a/tests/agents/test_simple_agent.py +++ /dev/null @@ -1,57 +0,0 @@ -"""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, _ = 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, _ = 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 deleted file mode 100644 index 86e105c..0000000 --- a/tests/agents/test_simple_chat_agent.py +++ /dev/null @@ -1,120 +0,0 @@ -"""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.httpx_mock(can_send_already_matched_responses=True) -@pytest.mark.asyncio -async def test_arun(fake_llm_with_tools, httpx_mock): - llm, tools, fake_responses = 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.httpx_mock(can_send_already_matched_responses=True) -@pytest.mark.asyncio -async def test_astream(fake_llm_with_tools, httpx_mock): - llm, tools, fake_responses = 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 == "\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 deleted file mode 100644 index e69de29..0000000 diff --git a/tests/app/database/__init__.py b/tests/app/database/__init__.py index e69de29..8ce3e8d 100644 --- a/tests/app/database/__init__.py +++ b/tests/app/database/__init__.py @@ -0,0 +1 @@ +"""Unit tests for database.""" diff --git a/swarm_copy_tests/app/database/test_db_utils.py b/tests/app/database/test_db_utils.py similarity index 86% rename from swarm_copy_tests/app/database/test_db_utils.py rename to tests/app/database/test_db_utils.py index 840e697..39fac42 100644 --- a/swarm_copy_tests/app/database/test_db_utils.py +++ b/tests/app/database/test_db_utils.py @@ -4,11 +4,11 @@ from fastapi import HTTPException from sqlalchemy import select -from swarm_copy.app.app_utils import setup_engine -from swarm_copy.app.config import Settings -from swarm_copy.app.database.db_utils import get_thread, save_history, get_history -from swarm_copy.app.database.sql_schemas import Entity, Messages, Base, Threads -from swarm_copy.app.dependencies import get_session +from neuroagent.app.app_utils import setup_engine +from neuroagent.app.config import Settings +from neuroagent.app.database.db_utils import get_history, get_thread, save_history +from neuroagent.app.database.sql_schemas import Base, Entity, Messages, Threads +from neuroagent.app.dependencies import get_session @pytest.mark.asyncio @@ -155,7 +155,9 @@ async def test_save_history(patch_required_env, db_connection): ] await save_history(history, user_id, thread_id, offset=0, session=session) - result = await session.execute(select(Messages).where(Messages.thread_id == thread_id)) + result = await session.execute( + select(Messages).where(Messages.thread_id == thread_id) + ) messages = result.scalars().all() assert len(messages) == len(history) @@ -164,7 +166,9 @@ async def test_save_history(patch_required_env, db_connection): assert messages[1].entity == Entity.AI_MESSAGE assert messages[1].content == json.dumps(history[1]) - updated_thread = await get_thread(user_id=user_id, thread_id=thread_id, session=session) + updated_thread = await get_thread( + user_id=user_id, thread_id=thread_id, session=session + ) assert updated_thread.update_date is not None finally: @@ -200,7 +204,9 @@ async def test_save_history_with_tool_messages(patch_required_env, db_connection ] await save_history(history, user_id, thread_id, offset=0, session=session) - result = await session.execute(select(Messages).where(Messages.thread_id == thread_id)) + result = await session.execute( + select(Messages).where(Messages.thread_id == thread_id) + ) messages = result.scalars().all() assert len(messages) == len(history) @@ -277,7 +283,9 @@ async def test_save_history_with_offset(patch_required_env, db_connection): ] await save_history(history, user_id, thread_id, offset=5, session=session) - result = await session.execute(select(Messages).where(Messages.thread_id == thread_id)) + result = await session.execute( + select(Messages).where(Messages.thread_id == thread_id) + ) messages = result.scalars().all() assert len(messages) == len(history) @@ -345,10 +353,18 @@ async def test_get_history_with_messages(patch_required_env, db_connection): await session.commit() messages_to_add = [ - Messages(order=1, thread_id=thread_id, entity=Entity.USER, - content=json.dumps({"role": "user", "content": "User message"})), - Messages(order=2, thread_id=thread_id, entity=Entity.AI_MESSAGE, - content=json.dumps({"role": "assistant", "content": "AI message"})), + Messages( + order=1, + thread_id=thread_id, + entity=Entity.USER, + content=json.dumps({"role": "user", "content": "User message"}), + ), + Messages( + order=2, + thread_id=thread_id, + entity=Entity.AI_MESSAGE, + content=json.dumps({"role": "assistant", "content": "AI message"}), + ), ] session.add_all(messages_to_add) await session.commit() @@ -388,11 +404,21 @@ async def test_get_history_ignore_empty_messages(patch_required_env, db_connecti await session.commit() messages_to_add = [ - Messages(order=1, thread_id=thread_id, entity=Entity.USER, - content=json.dumps({"role": "user", "content": "User message"})), - Messages(order=2, thread_id=thread_id, entity=Entity.TOOL, content=""), # Empty content should be ignored - Messages(order=3, thread_id=thread_id, entity=Entity.AI_MESSAGE, - content=json.dumps({"role": "assistant", "content": "AI message"})), + Messages( + order=1, + thread_id=thread_id, + entity=Entity.USER, + content=json.dumps({"role": "user", "content": "User message"}), + ), + Messages( + order=2, thread_id=thread_id, entity=Entity.TOOL, content="" + ), # Empty content should be ignored + Messages( + order=3, + thread_id=thread_id, + entity=Entity.AI_MESSAGE, + content=json.dumps({"role": "assistant", "content": "AI message"}), + ), ] session.add_all(messages_to_add) await session.commit() @@ -432,9 +458,18 @@ async def test_get_history_with_malformed_json(patch_required_env, db_connection await session.commit() messages_to_add = [ - Messages(order=1, thread_id=thread_id, entity=Entity.USER, - content=json.dumps({"role": "user", "content": "Valid message"})), - Messages(order=2, thread_id=thread_id, entity=Entity.AI_MESSAGE, content="MALFORMED_JSON"), # Malformed JSON + Messages( + order=1, + thread_id=thread_id, + entity=Entity.USER, + content=json.dumps({"role": "user", "content": "Valid message"}), + ), + Messages( + order=2, + thread_id=thread_id, + entity=Entity.AI_MESSAGE, + content="MALFORMED_JSON", + ), # Malformed JSON ] session.add_all(messages_to_add) await session.commit() diff --git a/tests/app/database/test_threads.py b/tests/app/database/test_threads.py deleted file mode 100644 index be37e66..0000000 --- a/tests/app/database/test_threads.py +++ /dev/null @@ -1,209 +0,0 @@ -"""Test of the thread router.""" - -import pytest -from sqlalchemy import MetaData -from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine -from sqlalchemy.sql.expression import Select - -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 - - -def test_create_thread(patch_required_env, httpx_mock, app_client, db_connection): - test_settings = Settings( - db={"prefix": db_connection}, - ) - app.dependency_overrides[get_settings] = lambda: test_settings - httpx_mock.add_response( - url=f"{test_settings.virtual_lab.get_project_url}/test_vlab/projects/test_project" - ) - with app_client as app_client: - # Create a thread - create_output = app_client.post( - "/threads/?virtual_lab_id=test_vlab&project_id=test_project" - ).json() - assert create_output["thread_id"] - assert create_output["user_sub"] == "dev" - assert create_output["title"] == "title" - assert create_output["timestamp"] - assert create_output["vlab_id"] == "test_vlab" - assert create_output["project_id"] == "test_project" - - -@pytest.mark.httpx_mock(can_send_already_matched_responses=True) -def test_get_threads(patch_required_env, httpx_mock, app_client, db_connection): - test_settings = Settings( - db={"prefix": db_connection}, - ) - app.dependency_overrides[get_settings] = lambda: test_settings - httpx_mock.add_response( - url=f"{test_settings.virtual_lab.get_project_url}/test_vlab/projects/test_project" - ) - 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/?virtual_lab_id=test_vlab&project_id=test_project" - ).json() - create_output_2 = app_client.post( - "/threads/?virtual_lab_id=test_vlab&project_id=test_project" - ).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.httpx_mock(can_send_already_matched_responses=True) -@pytest.mark.asyncio -async def test_get_thread( - patch_required_env, fake_llm_with_tools, httpx_mock, app_client, db_connection -): - # Put data in the db - llm, _, _ = 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=f"{test_settings.virtual_lab.get_project_url}/test_vlab/projects/test_project" - ) - httpx_mock.add_response(url="https://fake_url/api/nexus/v1/search/query/") - - 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/?virtual_lab_id=test_vlab&project_id=test_project" - ).json() - thread_id = create_output["thread_id"] - - # Fill the thread - app_client.post( - f"/qa/chat/{thread_id}", - json={"query": "This is my query"}, - headers={"x-virtual-lab-id": "test_vlab", "x-project-id": "test_project"}, - ) - - create_output = app_client.post( - "/threads/?virtual_lab_id=test_vlab&project_id=test_project" - ).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, httpx_mock, app_client, db_connection): - test_settings = Settings( - db={"prefix": db_connection}, - ) - app.dependency_overrides[get_settings] = lambda: test_settings - httpx_mock.add_response( - url=f"{test_settings.virtual_lab.get_project_url}/test_vlab/projects/test_project" - ) - 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/?virtual_lab_id=test_vlab&project_id=test_project" - ).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.httpx_mock(can_send_already_matched_responses=True) -@pytest.mark.asyncio -async def test_delete_thread( - patch_required_env, fake_llm_with_tools, httpx_mock, app_client, db_connection -): - # Put data in the db - llm, _, _ = 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=f"{test_settings.virtual_lab.get_project_url}/test_vlab/projects/test_project" - ) - httpx_mock.add_response(url="https://fake_url/api/nexus/v1/search/query/") - - 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/?virtual_lab_id=test_vlab&project_id=test_project" - ).json() - thread_id = create_output["thread_id"] - # Fill the thread - app_client.post( - f"/qa/chat/{thread_id}", - json={"query": "This is my query"}, - params={"thread_id": thread_id}, - headers={"x-virtual-lab-id": "test_vlab", "x-project-id": "test_project"}, - ) - # 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_async_engine(test_settings.db.prefix) - async with engine.begin() as conn: - await conn.run_sync(metadata.reflect) - - async with AsyncSession(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 - ) - results = await session.execute(query) - row = results.one_or_none() - assert row is None - await engine.dispose() diff --git a/tests/app/database/test_tools.py b/tests/app/database/test_tools.py deleted file mode 100644 index 31aedbd..0000000 --- a/tests/app/database/test_tools.py +++ /dev/null @@ -1,172 +0,0 @@ -"""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.httpx_mock(can_send_already_matched_responses=True) -@pytest.mark.asyncio -async def test_get_tool_calls( - patch_required_env, fake_llm_with_tools, httpx_mock, app_client, db_connection -): - # Put data in the db - llm, _, _ = 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=f"{test_settings.virtual_lab.get_project_url}/test_vlab/projects/test_project" - ) - httpx_mock.add_response(url="https://fake_url/api/nexus/v1/search/query/") - - 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/?virtual_lab_id=test_vlab&project_id=test_project" - ).json() - thread_id = create_output["thread_id"] - - # Fill the thread - app_client.post( - f"/qa/chat/{thread_id}", - json={"query": "This is my query"}, - params={"thread_id": thread_id}, - headers={"x-virtual-lab-id": "test_vlab", "x-project-id": "test_project"}, - ) - - 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.httpx_mock(can_send_already_matched_responses=True) -@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, _, _ = 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=f"{test_settings.virtual_lab.get_project_url}/test_vlab/projects/test_project" - ) - 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/?virtual_lab_id=test_vlab&project_id=test_project" - ).json() - thread_id = create_output["thread_id"] - - # Fill the thread - app_client.post( - f"/qa/chat/{thread_id}", - json={"query": "This is my query"}, - params={"thread_id": thread_id}, - headers={"x-virtual-lab-id": "test_vlab", "x-project-id": "test_project"}, - ) - - 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/swarm_copy_tests/app/routers/__init__.py b/tests/app/routers/__init__.py similarity index 100% rename from swarm_copy_tests/app/routers/__init__.py rename to tests/app/routers/__init__.py diff --git a/tests/app/routers/test_qa.py b/tests/app/routers/test_qa.py new file mode 100644 index 0000000..e9b9209 --- /dev/null +++ b/tests/app/routers/test_qa.py @@ -0,0 +1,168 @@ +from unittest.mock import AsyncMock, Mock + +import pytest + +from neuroagent.app.config import Settings +from neuroagent.app.dependencies import ( + get_agents_routine, + get_settings, + get_starting_agent, +) +from neuroagent.app.main import app +from neuroagent.app.routers import qa +from neuroagent.new_types import Agent, Response + + +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) +def test_run_agent(app_client, patch_required_env, httpx_mock): + agent_output = Response( + messages=[ + {"role": "user", "content": "Hello"}, + {"content": "Hello! How can I assist you today?"}, + ], + agent=Agent( + name="Agent", + model="gpt-4o-mini", + instructions="You are a helpfull assistant", + ), + ) + agent_routine = AsyncMock() + agent_routine.arun.return_value = agent_output + mock_agent = Agent() + + test_settings = Settings() + app.dependency_overrides[get_settings] = lambda: test_settings + app.dependency_overrides[get_agents_routine] = lambda: agent_routine + app.dependency_overrides[get_starting_agent] = lambda: mock_agent + + with app_client as app_client: + response = app_client.post("/qa/run", json={"query": "This is my query"}) + + assert response.status_code == 200 + assert ( + response.json()["message"] + == agent_output.model_dump()["messages"][-1]["content"] + ) + + # Missing query + response = app_client.post("/qa/run", json={}) + assert response.status_code == 422 + + +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) +def test_run_chat_agent(app_client, httpx_mock, patch_required_env, db_connection): + agent_output = Response( + messages=[ + {"role": "user", "content": "Hello"}, + {"content": "Hello! How can I assist you today?", "role": "assistant"}, + ], + agent=Agent( + name="Agent", + model="gpt-4o-mini", + instructions="You are a helpfull assistant", + ), + ) + agent_routine = AsyncMock() + agent_routine.arun.return_value = agent_output + + test_settings = Settings( + db={"prefix": db_connection}, + ) + app.dependency_overrides[get_settings] = lambda: test_settings + app.dependency_overrides[get_agents_routine] = lambda: agent_routine + httpx_mock.add_response( + url=f"{test_settings.virtual_lab.get_project_url}/test_vlab/projects/test_project" + ) + with app_client as app_client: + create_output = app_client.post( + "/threads/?virtual_lab_id=test_vlab&project_id=test_project" + ).json() + response = app_client.post( + f"/qa/chat/{create_output['thread_id']}", + json={"query": "This is my query"}, + headers={"x-virtual-lab-id": "test_vlab", "x-project-id": "test_project"}, + ) + assert response.status_code == 200 + assert ( + response.json()["message"] + == agent_output.model_dump()["messages"][-1]["content"] + ) + + # Missing thread_id query + response = app_client.post( + "/qa/chat", + json={"query": "This is my query"}, + headers={"x-virtual-lab-id": "test_vlab", "x-project-id": "test_project"}, + ) + assert response.status_code == 404 + + +async def streamed_response(): + response = [ + "Calling ", + "tool ", + ": ", + "resolve_entities_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 + + +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) +def test_chat_streamed(app_client, httpx_mock, patch_required_env, db_connection): + """Test the generative QA endpoint with a fake LLM.""" + qa.stream_agent_response = Mock() + qa.stream_agent_response.return_value = streamed_response() + + test_settings = Settings( + db={"prefix": db_connection}, + ) + app.dependency_overrides[get_settings] = lambda: test_settings + agent_routine = Mock() + app.dependency_overrides[get_agents_routine] = lambda: agent_routine + + expected_tokens = ( + b"Calling tool : resolve_entities_tool with arguments : {brain_region:" + b" thalamus}\n This is an amazingly well streamed response. I can't believe how" + b" good it is!" + ) + httpx_mock.add_response( + url=f"{test_settings.virtual_lab.get_project_url}/test_vlab/projects/test_project" + ) + with app_client as app_client: + create_output = app_client.post( + "/threads/?virtual_lab_id=test_vlab&project_id=test_project" + ).json() + response = app_client.post( + f"/qa/chat_streamed/{create_output['thread_id']}", + json={"query": "This is my query"}, + headers={"x-virtual-lab-id": "test_vlab", "x-project-id": "test_project"}, + ) + assert response.status_code == 200 + assert response.content == expected_tokens diff --git a/swarm_copy_tests/app/routers/test_threads.py b/tests/app/routers/test_threads.py similarity index 95% rename from swarm_copy_tests/app/routers/test_threads.py rename to tests/app/routers/test_threads.py index 5fd2221..1f4a6c9 100644 --- a/swarm_copy_tests/app/routers/test_threads.py +++ b/tests/app/routers/test_threads.py @@ -1,17 +1,16 @@ import pytest -from swarm_copy.agent_routine import Agent, AgentsRoutine -from swarm_copy.app.config import Settings -from swarm_copy.app.dependencies import ( +from neuroagent.agent_routine import Agent, AgentsRoutine +from neuroagent.app.config import Settings +from neuroagent.app.dependencies import ( get_agents_routine, get_settings, get_starting_agent, ) -from swarm_copy.app.main import app -from swarm_copy_tests.mock_client import create_mock_response +from neuroagent.app.main import app +from tests.mock_client import create_mock_response -@pytest.mark.httpx_mock(can_send_already_matched_responses=True) def test_create_thread(patch_required_env, httpx_mock, app_client, db_connection): test_settings = Settings( db={"prefix": db_connection}, diff --git a/swarm_copy_tests/app/routers/test_tools.py b/tests/app/routers/test_tools.py similarity index 94% rename from swarm_copy_tests/app/routers/test_tools.py rename to tests/app/routers/test_tools.py index 46a7ae8..b5ed083 100644 --- a/swarm_copy_tests/app/routers/test_tools.py +++ b/tests/app/routers/test_tools.py @@ -4,17 +4,17 @@ import pytest -from swarm_copy.agent_routine import Agent, AgentsRoutine -from swarm_copy.app.config import Settings -from swarm_copy.app.database.schemas import ToolCallSchema -from swarm_copy.app.dependencies import ( +from neuroagent.agent_routine import Agent, AgentsRoutine +from neuroagent.app.config import Settings +from neuroagent.app.database.schemas import ToolCallSchema +from neuroagent.app.dependencies import ( get_agents_routine, get_context_variables, get_settings, get_starting_agent, ) -from swarm_copy.app.main import app -from swarm_copy_tests.mock_client import create_mock_response +from neuroagent.app.main import app +from tests.mock_client import create_mock_response @pytest.mark.httpx_mock(can_send_already_matched_responses=True) diff --git a/tests/app/test_app_utils.py b/tests/app/test_app_utils.py index 7b67400..9300f91 100644 --- a/tests/app/test_app_utils.py +++ b/tests/app/test_app_utils.py @@ -56,7 +56,7 @@ def test_setup_engine(create_engine_mock, monkeypatch, patch_required_env): settings = Settings() - connection_string = "https://localhost" + connection_string = "postgresql+asyncpg://user:password@localhost/dbname" retval = setup_engine(settings=settings, connection_string=connection_string) assert retval is not None diff --git a/tests/app/test_dependencies.py b/tests/app/test_dependencies.py index b79a0e9..8f8a093 100644 --- a/tests/app/test_dependencies.py +++ b/tests/app/test_dependencies.py @@ -7,52 +7,25 @@ from unittest.mock import Mock, patch import pytest -from fastapi import Request -from fastapi.exceptions import HTTPException +from fastapi import HTTPException, Request from httpx import AsyncClient -from langchain_openai import ChatOpenAI -from langgraph.checkpoint.postgres.aio import AsyncPostgresSaver -from langgraph.checkpoint.sqlite.aio import AsyncSqliteSaver -from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine -from neuroagent.agents import SimpleAgent, SimpleChatAgent from neuroagent.app.app_utils import setup_engine +from neuroagent.app.database.sql_schemas import Base, Threads from neuroagent.app.dependencies import ( Settings, - get_agent, - get_agent_memory, - get_bluenaas_tool, get_cell_types_kg_hierarchy, - get_chat_agent, get_connection_string, - get_electrophys_feature_tool, - get_entities_resolver_tool, get_httpx_client, - get_kg_morpho_feature_tool, get_kg_token, - get_language_model, - get_literature_tool, - get_me_model_tool, - get_morpho_tool, - get_morphology_feature_tool, get_session, get_settings, - get_traces_tool, + get_starting_agent, get_update_kg_hierarchy, get_user_id, get_vlab_and_project, - validate_project, -) -from neuroagent.app.routers.database.schemas import Base, Threads -from neuroagent.tools import ( - ElectrophysFeatureTool, - GetMEModelTool, - GetMorphoTool, - GetTracesTool, - KGMorphoFeatureTool, - LiteratureSearchTool, - MorphologyFeatureTool, ) +from neuroagent.new_types import Agent def test_get_settings(patch_required_env): @@ -102,101 +75,108 @@ async def test_get_user(httpx_mock, monkeypatch, patch_required_env): assert user_id == fake_response["sub"] -def test_get_literature_tool(monkeypatch, patch_required_env): - url = "https://fake_url" - - httpx_client = AsyncClient() - settings = Settings() +@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() - 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"] == 500 - assert literature_tool.metadata["reranker_k"] == 8 - 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 + file_url = "https://fake_file_url" + monkeypatch.setenv( + "NEUROAGENT_KNOWLEDGE_GRAPH__HIERARCHY_URL", "http://fake_hierarchy_url.com" + ) -@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], - [get_me_model_tool, True, "ME_MODEL", GetMEModelTool], - ), -) -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" + settings = Settings( + knowledge_graph={"br_saving_path": tmp_path / "test_brain_region.json"} + ) - httpx_client = AsyncClient() - settings = Settings() + 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) - 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" + 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) - if has_search_size: - monkeypatch.setenv(f"NEUROAGENT_TOOLS__{tool_env_name}__SEARCH_SIZE", "100") - settings = Settings() + await get_update_kg_hierarchy( + token, + client, + settings, + file_name, + ) - 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 + assert os.path.exists(settings.knowledge_graph.br_saving_path) @pytest.mark.asyncio -async def test_get_memory(patch_required_env, db_connection): - conn_string = await anext(get_agent_memory(None)) +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() - assert conn_string is None + 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"} + ) - conn_string = await anext(get_agent_memory(db_connection)) + 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) - 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. + 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, + ) -def test_language_model(monkeypatch, patch_required_env): - monkeypatch.setenv("NEUROAGENT_OPENAI__MODEL", "dummy") - monkeypatch.setenv("NEUROAGENT_OPENAI__TEMPERATURE", "99") - monkeypatch.setenv("NEUROAGENT_OPENAI__MAX_TOKENS", "99") + assert os.path.exists(settings.knowledge_graph.ct_saving_path) - settings = Settings() - language_model = get_language_model(settings) +def test_get_connection_string_full(monkeypatch, patch_required_env): + monkeypatch.setenv("NEUROAGENT_DB__PREFIX", "http://") + monkeypatch.setenv("NEUROAGENT_DB__USER", "John") + monkeypatch.setenv("NEUROAGENT_DB__PASSWORD", "Doe") + monkeypatch.setenv("NEUROAGENT_DB__HOST", "localhost") + monkeypatch.setenv("NEUROAGENT_DB__PORT", "5000") + monkeypatch.setenv("NEUROAGENT_DB__NAME", "test") - assert isinstance(language_model, ChatOpenAI) - assert language_model.model_name == "dummy" - assert language_model.temperature == 99 - assert language_model.max_tokens == 99 + settings = Settings() + result = get_connection_string(settings) + assert ( + result == "http://John:Doe@localhost:5000/test" + ), "must return fully formed connection string" @pytest.mark.asyncio @@ -218,24 +198,19 @@ async def test_get_vlab_and_project( url=f"{test_settings.virtual_lab.get_project_url}/test_vlab/projects/test_project", json="test_project_ID", ) - httpx_mock.add_response( - url=f"{test_settings.virtual_lab.get_project_url}/test_vlab_DB/projects/project_id_DB", - json="test_project_ID", - ) # create test thread table async with engine.begin() as conn: await conn.run_sync(Base.metadata.create_all) new_thread = Threads( - user_sub=user_id, + user_id=user_id, vlab_id="test_vlab_DB", project_id="project_id_DB", title="test_title", ) session.add(new_thread) await session.commit() - await session.refresh(new_thread) try: # Test with info in headers. @@ -259,7 +234,41 @@ async def test_get_vlab_and_project( httpx_client=httpx_client, ) assert ids == {"vlab_id": "test_vlab", "project_id": "test_project"} + finally: + # don't forget to close the session, otherwise the tests hangs. + await session.close() + await engine.dispose() + +@pytest.mark.asyncio +async def test_get_vlab_and_project_no_info_in_headers( + patch_required_env, db_connection, monkeypatch +): + # Setup DB with one thread to do the tests + monkeypatch.setenv("NEUROAGENT_KEYCLOAK__VALIDATE_TOKEN", "true") + test_settings = Settings( + db={"prefix": db_connection}, + ) + engine = setup_engine(test_settings, db_connection) + session = await anext(get_session(engine)) + user_id = "Super_user" + token = "fake_token" + httpx_client = AsyncClient() + + # create test thread table + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + new_thread = Threads( + user_id=user_id, + vlab_id="test_vlab_DB", + project_id="project_id_DB", + title="test_title", + ) + session.add(new_thread) + await session.commit() + + try: # Test with no infos in headers. bad_request = Request( scope={ @@ -282,10 +291,48 @@ async def test_get_vlab_and_project( token=token, httpx_client=httpx_client, ) - assert ( - error.value.detail == "thread not found when trying to validate project ID." - ) + assert error.value.detail == "Thread not found." + finally: + # don't forget to close the session, otherwise the tests hangs. + await session.close() + await engine.dispose() + + +@pytest.mark.asyncio +@pytest.mark.httpx_mock(can_send_already_matched_responses=True) +async def test_get_vlab_and_project_valid_thread_id( + patch_required_env, httpx_mock, db_connection, monkeypatch +): + # Setup DB with one thread to do the tests + monkeypatch.setenv("NEUROAGENT_KEYCLOAK__VALIDATE_TOKEN", "true") + test_settings = Settings( + db={"prefix": db_connection}, + ) + engine = setup_engine(test_settings, db_connection) + session = await anext(get_session(engine)) + user_id = "Super_user" + token = "fake_token" + httpx_client = AsyncClient() + httpx_mock.add_response( + url=f"{test_settings.virtual_lab.get_project_url}/test_vlab_DB/projects/project_id_DB", + json="test_project_ID", + ) + # create test thread table + async with engine.begin() as conn: + await conn.run_sync(Base.metadata.create_all) + + new_thread = Threads( + user_id=user_id, + vlab_id="test_vlab_DB", + project_id="project_id_DB", + title="test_title", + ) + session.add(new_thread) + await session.commit() + await session.refresh(new_thread) + + try: # Test with no infos in headers, but valid thread_ID. good_request_DB = Request( scope={ @@ -315,284 +362,26 @@ async def test_get_vlab_and_project( await engine.dispose() -@pytest.mark.asyncio -async def test_get_agent(monkeypatch, httpx_mock, patch_required_env): - monkeypatch.setenv("NEUROAGENT_AGENT__MODEL", "simple") - monkeypatch.setenv("NEUROAGENT_KEYCLOAK__VALIDATE_TOKEN", "true") - token = "fake_token" - httpx_client = AsyncClient() +def test_get_starting_agent(patch_required_env): settings = Settings() + agent = get_starting_agent(None, settings) - vlab_and_project = {"vlab_id": "test_vlab", "project_id": "test_project"} - httpx_mock.add_response( - url=f'{settings.virtual_lab.get_project_url}/{vlab_and_project["vlab_id"]}/projects/{vlab_and_project["project_id"]}', - json="test_project_ID", - ) - valid_project = await validate_project( - httpx_client=httpx_client, - vlab_id=vlab_and_project["vlab_id"], - project_id=vlab_and_project["project_id"], - token=token, - vlab_project_url=settings.virtual_lab.get_project_url, - ) - - 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 - ) - entities_resolver_tool = get_entities_resolver_tool( - token=token, - httpx_client=httpx_client, - settings=settings, - ) - me_model_tool = get_me_model_tool( - settings=settings, token=token, httpx_client=httpx_client - ) - bluenaas_tool = get_bluenaas_tool( - settings=settings, token=token, httpx_client=httpx_client - ) - - agent = get_agent( - valid_project, - llm=language_model, - bluenaas_tool=bluenaas_tool, - literature_tool=literature_tool, - entities_resolver_tool=entities_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, - settings=settings, - me_model_tool=me_model_tool, - ) + assert isinstance(agent, Agent) - assert isinstance(agent, SimpleAgent) - - -@pytest.mark.asyncio -async def test_get_chat_agent( - monkeypatch, db_connection, httpx_mock, patch_required_env -): - monkeypatch.setenv("NEUROAGENT_DB__PREFIX", "sqlite://") - monkeypatch.setenv("NEUROAGENT_KEYCLOAK__VALIDATE_TOKEN", "true") - - token = "fake_token" - httpx_client = AsyncClient() - settings = Settings() - - vlab_and_project = {"vlab_id": "test_vlab", "project_id": "test_project"} - httpx_mock.add_response( - url=f'{settings.virtual_lab.get_project_url}/{vlab_and_project["vlab_id"]}/projects/{vlab_and_project["project_id"]}', - json="test_project_ID", - ) - valid_project = await validate_project( - httpx_client=httpx_client, - vlab_id=vlab_and_project["vlab_id"], - project_id=vlab_and_project["project_id"], - token=token, - vlab_project_url=settings.virtual_lab.get_project_url, - ) - - language_model = get_language_model(settings) - bluenaas_tool = get_bluenaas_tool( - settings=settings, token=token, httpx_client=httpx_client - ) - literature_tool = get_literature_tool( - token=token, settings=settings, httpx_client=httpx_client - ) - me_model_tool = get_me_model_tool( - settings=settings, token=token, 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 - ) - entities_resolver_tool = get_entities_resolver_tool( - token=token, - httpx_client=httpx_client, - settings=settings, - ) - - memory = await anext(get_agent_memory(db_connection)) - - agent = get_chat_agent( - valid_project, - llm=language_model, - bluenaas_tool=bluenaas_tool, - literature_tool=literature_tool, - entities_resolver_tool=entities_resolver_tool, - morpho_tool=morpho_tool, - morphology_feature_tool=morphology_feature_tool, - me_model_tool=me_model_tool, - kg_morpho_feature_tool=kg_morpho_feature_tool, - electrophys_feature_tool=electrophys_feature_tool, - traces_tool=traces_tool, - memory=memory, - ) - - 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) - - -def test_get_connection_string_full(monkeypatch, patch_required_env): - monkeypatch.setenv("NEUROAGENT_DB__PREFIX", "http://") - monkeypatch.setenv("NEUROAGENT_DB__USER", "John") - monkeypatch.setenv("NEUROAGENT_DB__PASSWORD", "Doe") - monkeypatch.setenv("NEUROAGENT_DB__HOST", "localhost") - monkeypatch.setenv("NEUROAGENT_DB__PORT", "5000") - monkeypatch.setenv("NEUROAGENT_DB__NAME", "test") - - settings = Settings() - result = get_connection_string(settings) - assert ( - result == "http://John:Doe@localhost:5000/test" - ), "must return fully formed connection string" - - -def test_get_connection_string_no_prefix(monkeypatch, patch_required_env): - monkeypatch.setenv("NEUROAGENT_DB__PREFIX", "") - - settings = Settings() - - result = get_connection_string(settings) - assert result is None, "should return None when prefix is not set" - - -@patch("sqlalchemy.orm.Session") -@pytest.mark.asyncio -async def test_get_session_success(_): - database_url = "sqlite+aiosqlite:///:memory:" - engine = create_async_engine(database_url) - result = await anext(get_session(engine)) - assert isinstance(result, AsyncSession) - await engine.dispose() - - -@pytest.mark.asyncio -async def test_get_session_no_engine(): - with pytest.raises(HTTPException): - await anext(get_session(None)) - - -def test_get_kg_token_with_token(patch_required_env): +@pytest.mark.parametrize( + "input_token, expected_token", + [ + ("existing_token", "existing_token"), + (None, "new_token"), + ], +) +def test_get_kg_token(patch_required_env, input_token, expected_token): settings = Settings() - - token = "Test_Token" - result = get_kg_token(settings, token) - assert result == "Test_Token" + mock = Mock() + mock.token.return_value = {"access_token": expected_token} + with ( + patch("neuroagent.app.dependencies.KeycloakOpenID", return_value=mock), + ): + result = get_kg_token(settings, input_token) + assert result == expected_token diff --git a/tests/app/test_main.py b/tests/app/test_main.py index acc3f6f..f573757 100644 --- a/tests/app/test_main.py +++ b/tests/app/test_main.py @@ -7,8 +7,7 @@ from neuroagent.app.main import app -def test_settings_endpoint(app_client, dont_look_at_env_file): - settings = app.dependency_overrides[get_settings]() +def test_settings_endpoint(app_client, dont_look_at_env_file, settings): response = app_client.get("/settings") replace_secretstr = settings.model_dump() @@ -17,7 +16,17 @@ def test_settings_endpoint(app_client, dont_look_at_env_file): assert response.json() == replace_secretstr -def test_startup(caplog, monkeypatch, tmp_path, patch_required_env, db_connection): +def test_readyz(app_client): + response = app_client.get( + "/", + ) + + body = response.json() + assert isinstance(body, dict) + assert body["status"] == "ok" + + +def test_lifespan(caplog, monkeypatch, tmp_path, patch_required_env, db_connection): get_settings.cache_clear() caplog.set_level(logging.INFO) @@ -64,13 +73,3 @@ async def save_dummy(*args, **kwargs): 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 deleted file mode 100644 index 5a9bdcf..0000000 --- a/tests/app/test_middleware.py +++ /dev/null @@ -1,44 +0,0 @@ -"""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 deleted file mode 100644 index afdea12..0000000 --- a/tests/app/test_qa.py +++ /dev/null @@ -1,134 +0,0 @@ -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={"query": "This is my query"}) - assert response.status_code == 200 - assert response.json() == agent_output.model_dump() - - # Missing query - response = app_client.post("/qa/run", json={}) - assert response.status_code == 422 - - -def test_run_chat_agent(app_client, httpx_mock, patch_required_env, db_connection): - 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"}, - ), - ], - ) - test_settings = Settings( - db={"prefix": db_connection}, - ) - 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 - httpx_mock.add_response( - url=f"{test_settings.virtual_lab.get_project_url}/test_vlab/projects/test_project" - ) - with app_client as app_client: - create_output = app_client.post( - "/threads/?virtual_lab_id=test_vlab&project_id=test_project" - ).json() - response = app_client.post( - f"/qa/chat/{create_output['thread_id']}", - json={"query": "This is my query"}, - ) - assert response.status_code == 200 - assert response.json() == agent_output.model_dump() - - # Missing thread_id query - response = app_client.post("/qa/chat", json={"query": "This is my query"}) - assert response.status_code == 404 - - -async def streamed_response(): - response = [ - "Calling ", - "tool ", - ": ", - "resolve_entities_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, httpx_mock, patch_required_env, db_connection): - """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 - - test_settings = Settings( - db={"prefix": db_connection}, - ) - app.dependency_overrides[get_settings] = lambda: test_settings - expected_tokens = ( - b"Calling tool : resolve_entities_tool with arguments : {brain_region:" - b" thalamus}\n This is an amazingly well streamed response. I can't believe how" - b" good it is!" - ) - httpx_mock.add_response( - url=f"{test_settings.virtual_lab.get_project_url}/test_vlab/projects/test_project" - ) - with app_client as app_client: - create_output = app_client.post( - "/threads/?virtual_lab_id=test_vlab&project_id=test_project" - ).json() - response = app_client.post( - f"/qa/chat_streamed/{create_output['thread_id']}", - json={"query": "This is my query"}, - ) - assert response.status_code == 200 - assert response.content == expected_tokens diff --git a/tests/conftest.py b/tests/conftest.py index 3f26988..7a8d751 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -2,20 +2,20 @@ import json from pathlib import Path +from typing import ClassVar import pytest import pytest_asyncio 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 pydantic import BaseModel, ConfigDict from sqlalchemy import MetaData from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine from neuroagent.app.config import Settings -from neuroagent.app.dependencies import get_kg_token, get_settings +from neuroagent.app.dependencies import Agent, get_kg_token, get_settings from neuroagent.app.main import app -from neuroagent.tools import GetMorphoTool +from neuroagent.tools.base_tool import BaseTool +from tests.mock_client import MockOpenAIClient, create_mock_response @pytest.fixture(name="app_client") @@ -46,6 +46,70 @@ def client_fixture(): app.dependency_overrides.clear() +@pytest.fixture +def mock_openai_client(): + """Fake openai client.""" + m = MockOpenAIClient() + m.set_response( + create_mock_response( + {"role": "assistant", "content": "sample response content"} + ) + ) + return m + + +@pytest.fixture(name="get_weather_tool") +def fake_tool(): + """Fake get weather tool.""" + + class FakeToolInput(BaseModel): + location: str + + class FakeToolMetadata( + BaseModel + ): # Should be a BaseMetadata but we don't want httpx client here + model_config = ConfigDict(extra="ignore", arbitrary_types_allowed=True) + planet: str | None = None + + class FakeTool(BaseTool): + name: ClassVar[str] = "get_weather" + description: ClassVar[str] = "Great description" + metadata: FakeToolMetadata + input_schema: FakeToolInput + + async def arun(self): + if self.metadata.planet: + return f"It's sunny today in {self.input_schema.location} from planet {self.metadata.planet}." + return "It's sunny today." + + return FakeTool + + +@pytest.fixture +def agent_handoff_tool(): + """Fake agent handoff tool.""" + + class HandoffToolInput(BaseModel): + pass + + class HandoffToolMetadata( + BaseModel + ): # Should be a BaseMetadata but we don't want httpx client here + to_agent: Agent + model_config = ConfigDict(extra="ignore", arbitrary_types_allowed=True) + + class HandoffTool(BaseTool): + name: ClassVar[str] = "agent_handoff_tool" + description: ClassVar[str] = "Handoff to another agent." + metadata: HandoffToolMetadata + input_schema: HandoffToolInput + + async def arun(self): + return self.metadata.to_agent + + return HandoffTool + + @pytest.fixture(autouse=True, scope="session") def dont_look_at_env_file(): """Never look inside of the .env when running unit tests.""" @@ -107,83 +171,22 @@ def brain_region_json_path(): return br_path -@pytest_asyncio.fixture -async def fake_llm_with_tools(brain_region_json_path): - class FakeFuntionChatModel(GenericFakeChatModel): - def bind_tools(self, functions: list): - return self - - def bind_functions(self, **kwargs): - return self - - # If you need another fake response to use different tools, - # you can do in your test - # ```python - # llm, _ = 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", - } - ] +@pytest.fixture(name="settings") +def settings(): + return Settings( + tools={ + "literature": { + "url": "fake_literature_url", }, - 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, + }, + knowledge_graph={ + "base_url": "https://fake_url/api/nexus/v1", + }, + openai={ "token": "fake_token", - "brainregion_path": brain_region_json_path, - } + }, + keycloak={ + "username": "fake_username", + "password": "fake_password", + }, ) - - yield FakeFuntionChatModel(messages=iter(fake_responses)), [tool], fake_responses - await async_client.aclose() diff --git a/tests/data/kg_cell_types_hierarchy_test.json b/tests/data/kg_cell_types_hierarchy_test.json index e7cb024..294e509 100644 --- a/tests/data/kg_cell_types_hierarchy_test.json +++ b/tests/data/kg_cell_types_hierarchy_test.json @@ -10,4 +10,4 @@ {"@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/tool_calls.json b/tests/data/tool_calls.json index 98b6aa3..313a3f5 100644 --- a/tests/data/tool_calls.json +++ b/tests/data/tool_calls.json @@ -1,129 +1,102 @@ [ - { - "prompt": "What are the morphological features of neurons in the thalamus?", - "expected_tools": [ - { - "tool_name": "resolve-entities-tool", - "arguments": {"brain_region": "thalamus"} - }, - { - "tool_name": "get-morpho-tool", - "arguments": {"brain_region_id":"http://api.brain-map.org/api/v2/data/Structure/549"} - } - ], - "optional_tools": ["literature-search-tool"], - "forbidden_tools": ["get-traces-tool", "electrophys-features-tool", "get-me-model-tool", "bluenaas-tool"] - }, - { - "prompt": "Find me articles about the role of the hippocampus in memory formation.", - "expected_tools": [ - { - "tool_name": "literature-search-tool", - "arguments": { - "query": "hippocampus memory formation" - } - } - ], - "optional_tools": ["resolve-entities-tool"], - "forbidden_tools": ["get-morpho-tool", "get-traces-tool", "electrophys-features-tool", "get-me-model-tool", "bluenaas-tool"] - }, - { - "prompt": "Retrieve electrophysiological features of cortical neurons.", - "expected_tools": [ - { - "tool_name": "resolve-entities-tool", - "arguments": { - "brain_region": "cortex" - } - }, - { - "tool_name": "get-traces-tool", - "arguments": { - "brain_region_id": "http://api.brain-map.org/api/v2/data/Structure/134" + { + "prompt": "What are the morphological features of neurons in the thalamus?", + "expected_tool_calls": [ + { + "tool_name": "resolve-entities-tool", + "arguments": {"brain_region": "thalamus"} + }, + { + "tool_name": "get-morpho-tool", + "arguments": {"brain_region_id":"http://api.brain-map.org/api/v2/data/Structure/549"} } - }, - { - "tool_name": "electrophys-features-tool", + ] + }, + { + "prompt": "Find me articles about the role of the hippocampus in memory formation.", + "expected_tool_calls": [ + { + "tool_name": "literature-search-tool", "arguments": { - "brain_region": "cortex" + "query": "hippocampus memory formation" } - } - ], - "optional_tools": ["literature-search-tool"], - "forbidden_tools": ["get-morpho-tool", "get-me-model-tool", "bluenaas-tool"] - }, + } + ] + }, { - "prompt": "Get traces for neurons in the hippocampus.", - "expected_tools": [ - { + "prompt": "Retrieve electrophysiological features of cortical neurons.", + "expected_tool_calls": [ + { "tool_name": "resolve-entities-tool", "arguments": { - "brain_region": "hippocampus"} - }, - { + "brain_region": "cortex" + } + }, + { "tool_name": "get-traces-tool", "arguments": { "brain_region_id": "http://api.brain-map.org/api/v2/data/Structure/134" } - }, - { - "tool_name": "electrophys-features-tool" - } - ], - "optional_tools": ["literature-search-tool"], - "forbidden_tools": ["get-morpho-tool", "get-me-model-tool", "bluenaas-tool"] + }, + { + "tool_name": "electrophys-feature-tool", + "arguments": { + "brain_region": "cortex" + } + } + ] }, { - "prompt": "Get traces for neurons in the primary somatosensory area.", - "expected_tools": [ - { - "tool_name": "resolve-entities-tool", - "arguments": { - "brain_region": "primary somatosensory area"} - }, - { - "tool_name": "get-traces-tool" - } - ], - "optional_tools": ["literature-search-tool","electrophys-features-tool"], - "forbidden_tools": ["get-morpho-tool", "get-me-model-tool", "bluenaas-tool"] + "prompt": "Get traces for neurons in the hippocampus.", + "expected_tool_calls": [ + { + "tool_name": "resolve-entities-tool", + "arguments": { + "brain_region": "hippocampus"} + }, + { + "tool_name": "get-traces-tool", + "arguments": { + "brain_region_id": "http://api.brain-map.org/api/v2/data/Structure/134" + } + }, + { + "tool_name": "electrophys-features-tool" + } + ] }, { - "prompt": "Search for literature on synaptic plasticity.", - "expected_tools": [ - { - "tool_name": "literature-search-tool", - "arguments": { - "query": "synaptic plasticity" - } - } - ], - "optional_tools": ["resolve-entities-tool"], - "forbidden_tools": ["get-morpho-tool", "get-traces-tool", "electrophys-features-tool", "get-me-model-tool", "bluenaas-tool"] + "prompt": "Search for literature on synaptic plasticity.", + "expected_tool_calls": [ + { + "tool_name": "literature-search-tool", + "arguments": { + "query": "synaptic plasticity" + } + } + ] }, { "prompt": "Run 1000 ms of simulation of a me model from somatosensory cortex with 34 degree temperature, current clamp stimulation mode with step current for fire pattern detection. use 1 number of step and 0.05 nA current stimulation. Record from soma.", - "expected_tools": [ - { - "tool_name": "resolve-entities-tool", + "expected_tool_calls": [ + { + "tool_name": "resolve-entities-tool", + "arguments": { + "brain_region": "somatosensory area" + } + }, + { + "tool_name": "literature-search-tool" + }, + { + "tool_name": "get-me-model-tool", "arguments": { - "brain_region": "somatosensory area" + "brain_region_id" : "http://api.brain-map.org/api/v2/data/Structure/322" } - }, - { - "tool_name": "literature-search-tool" - }, - { - "tool_name": "get-me-model-tool", - "arguments": { - "brain_region_id" : "http://api.brain-map.org/api/v2/data/Structure/322" + }, + { + "tool_name": "bluenaas-tool" } - }, - { - "tool_name": "bluenaas-tool" - } - ], - "optional_tools": [], - "forbidden_tools": ["get-morpho-tool", "get-traces-tool", "electrophys-features-tool"] - } -] \ No newline at end of file + ] +} +] diff --git a/swarm_copy_tests/mock_client.py b/tests/mock_client.py similarity index 100% rename from swarm_copy_tests/mock_client.py rename to tests/mock_client.py diff --git a/tests/multi_agents/__init__.py b/tests/multi_agents/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/multi_agents/test_supervisor_multi_agent.py b/tests/multi_agents/test_supervisor_multi_agent.py deleted file mode 100644 index 630fa4f..0000000 --- a/tests/multi_agents/test_supervisor_multi_agent.py +++ /dev/null @@ -1,82 +0,0 @@ -from unittest.mock import AsyncMock, MagicMock, Mock - -import pytest -from langchain_core.messages import HumanMessage, SystemMessage - -from neuroagent.multi_agents.supervisor_multi_agent import AgentState -from src.neuroagent.multi_agents import SupervisorMultiAgent - - -def test_create_main_agent_initialization(): - mock_llm = Mock() - bind_function_result = MagicMock() - bind_function_result.__ror__.return_value = {} - mock_llm.bind_functions.return_value = bind_function_result - data = {"llm": mock_llm, "agents": [("agent1",)]} - - result = SupervisorMultiAgent.create_main_agent(data) - assert "main_agent" in result - assert "summarizer" in result - - -@pytest.mark.asyncio -async def test_agent_node(): - mock_message = HumanMessage( - content="hello", - name="test_agent", - ) - - async def mock_ainvoke(_): - return {"messages": [mock_message]} - - agent_state = Mock() - agent = Mock() - agent.ainvoke = mock_ainvoke - - agent_node_test = await SupervisorMultiAgent.agent_node( - agent_state, agent, "test_agent" - ) - - assert isinstance(agent_node_test, dict) - assert "messages" in agent_node_test - assert len(agent_node_test["messages"]) == 1 - assert agent_node_test["messages"][0].content == "hello" - assert agent_node_test["messages"][0].name == "test_agent" - - -@pytest.mark.asyncio -async def test_summarizer_node(fake_llm_with_tools): - fake_state = AgentState( - messages=[ - HumanMessage( - content="What is the airspeed velocity of an unladen swallow?" - ), - SystemMessage(content="11 m/s"), - ] - ) - - mock_llm, _, _ = fake_llm_with_tools - agent = SupervisorMultiAgent(agents=[("agent1", [])], llm=mock_llm) - - mock_message = SystemMessage( - content="hello", - name="test_agent", - ) - - mock_summarizer = Mock() - mock_summarizer.ainvoke = AsyncMock() - mock_summarizer.ainvoke.return_value = mock_message - agent.summarizer = mock_summarizer - result = await agent.summarizer_node(fake_state) - assert result["messages"][0].content == "hello" - - -@pytest.mark.asyncio -async def test_create_graph(fake_llm_with_tools): - mock_llm, _, _ = fake_llm_with_tools - agent = SupervisorMultiAgent(agents=[("agent1", [])], llm=mock_llm) - result = agent.create_graph() - nodes = result.nodes - assert "agent1" in nodes - assert "Supervisor" in nodes - assert "Summarizer" in nodes diff --git a/tests/tools/test_validate_tool_call.py b/tests/scripts/test_avalidate_tool_calls.py similarity index 100% rename from tests/tools/test_validate_tool_call.py rename to tests/scripts/test_avalidate_tool_calls.py diff --git a/swarm_copy_tests/test_agent_routine.py b/tests/test_agent_routine.py similarity index 98% rename from swarm_copy_tests/test_agent_routine.py rename to tests/test_agent_routine.py index 37eb119..548cd85 100644 --- a/swarm_copy_tests/test_agent_routine.py +++ b/tests/test_agent_routine.py @@ -11,9 +11,9 @@ ChoiceDeltaToolCallFunction, ) -from swarm_copy.agent_routine import AgentsRoutine -from swarm_copy.new_types import Agent, Response, Result -from swarm_copy_tests.mock_client import create_mock_response +from neuroagent.agent_routine import AgentsRoutine +from neuroagent.new_types import Agent, Response, Result +from tests.mock_client import create_mock_response class TestAgentsRoutine: @@ -555,7 +555,7 @@ async def mock_openai_streaming_response( tokens = [] with patch( - "swarm_copy.agent_routine.AgentsRoutine.get_chat_completion", + "neuroagent.agent_routine.AgentsRoutine.get_chat_completion", new=return_iterator, ): async for token in routine.astream( diff --git a/tests/test_cell_types.py b/tests/test_cell_types.py index 930ec71..b9259fe 100644 --- a/tests/test_cell_types.py +++ b/tests/test_cell_types.py @@ -71,6 +71,44 @@ def test_from_json(self): "http://bbp.epfl.ch/neurosciencegraph/ontologies/mtypes/GCL_GC", } + def test_from_dict(self): + test_dict = { + "defines": [ + {"@id": "id1", "label": "cell1", "subClassOf": []}, + {"@id": "id2", "label": "cell2", "subClassOf": ["id1"]}, + {"@id": "id3", "subClassOf": ["id2"]}, + ] + } + cell_meta = CellTypesMeta.from_dict(test_dict) + assert isinstance(cell_meta, CellTypesMeta) + assert cell_meta.name_ == {"id1": "cell1", "id2": "cell2", "id3": None} + assert cell_meta.descendants_ids == {"id1": {"id2", "id3"}, "id2": {"id3"}} + + def test_from_dict_missing_label(self): + test_dict = { + "defines": [ + {"@id": "id1", "subClassOf": []}, + {"@id": "id2", "subClassOf": ["id1"]}, + ] + } + cell_meta = CellTypesMeta.from_dict(test_dict) + assert cell_meta.name_ == {"id1": None, "id2": None} + assert cell_meta.descendants_ids == {"id1": {"id2"}} + + def test_from_dict_missing_subClassOf(self): + test_dict = { + "defines": [ + { + "@id": "id1", + "label": "cell1", + }, + {"@id": "id2", "label": "cell2", "subClassOf": ["id1"]}, + ] + } + cell_meta = CellTypesMeta.from_dict(test_dict) + assert cell_meta.name_ == {"id1": "cell1", "id2": "cell2"} + assert cell_meta.descendants_ids == {"id1": {"id2"}} + @pytest.mark.parametrize( "cell_type_id,expected_descendants", [ diff --git a/tests/test_dummy.py b/tests/test_dummy.py deleted file mode 100644 index f450c55..0000000 --- a/tests/test_dummy.py +++ /dev/null @@ -1,5 +0,0 @@ -"""Dummy test""" - - -def test_dummy(): - assert True diff --git a/tests/test_utils.py b/tests/test_utils.py index 403dddc..2874a2d 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -13,9 +13,77 @@ get_file_from_KG, get_kg_data, is_lnmc, + merge_chunk, + merge_fields, ) +def test_merge_fields_str(): + target = {"key_1": "abc", "key_2": ""} + source = {"key_1": "def"} + merge_fields(target, source) + assert target == {"key_1": "abcdef", "key_2": ""} + + source = {"key_1": "", "key_2": ""} + target = {"key_1": "value_1"} + with pytest.raises(KeyError): + merge_fields(target, source) + + +def test_merge_fields_dict(): + target = {"key_1": "abc", "key_2": {"sub_key_1": "", "sub_key_2": "abc"}} + source = {"key_1": "def", "key_2": {"sub_key_1": "hello", "sub_key_2": "cba"}} + merge_fields(target, source) + assert target == { + "key_1": "abcdef", + "key_2": {"sub_key_1": "hello", "sub_key_2": "abccba"}, + } + + +def test_merge_chunk(): + message = { + "content": "", + "sender": "test agent", + "role": "assistant", + "function_call": None, + "tool_calls": [ + { + "function": {"arguments": "", "name": ""}, + "id": "", + "type": "", + } + ], + } + delta = { + "content": "Great content", + "function_call": None, + "refusal": None, + "role": "assistant", + "tool_calls": [ + { + "index": 0, + "id": "call_NDiPAjDW4oLef44xIptVSAZC", + "function": {"arguments": "Thalamus", "name": "resolve-entities-tool"}, + "type": "function", + } + ], + } + merge_chunk(message, delta) + assert message == { + "content": "Great content", + "sender": "test agent", + "role": "assistant", + "function_call": None, + "tool_calls": [ + { + "function": {"arguments": "Thalamus", "name": "resolve-entities-tool"}, + "id": "call_NDiPAjDW4oLef44xIptVSAZC", + "type": "function", + } + ], + } + + @pytest.mark.parametrize( "brain_region_id,expected_descendants", [ diff --git a/tests/tools/test_base_tool.py b/tests/tools/test_base_tool.py index c29bc6c..e69de29 100644 --- a/tests/tools/test_base_tool.py +++ b/tests/tools/test_base_tool.py @@ -1,106 +0,0 @@ -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 pydantic import BaseModel - -from neuroagent.tools.base_tool import BasicTool - - -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_bluenaas_tool.py b/tests/tools/test_bluenaas_tool.py deleted file mode 100644 index e03c59a..0000000 --- a/tests/tools/test_bluenaas_tool.py +++ /dev/null @@ -1,285 +0,0 @@ -"""Tests BlueNaaS tool.""" - -from typing import Literal - -import httpx -import pytest -from langchain_core.messages import AIMessage, HumanMessage, ToolMessage - -from neuroagent.tools import BlueNaaSTool -from neuroagent.tools.bluenaas_tool import ( - BlueNaaSInvalidatedOutput, - BlueNaaSValidatedOutput, - RecordingLocation, -) - - -def hil_usecases( - tool_name, - use_case: Literal["first_encounter", "approve/modify", "refuse", "change_topic"], -): - generic_human = HumanMessage( - content="run a simulation on a me model", - additional_kwargs={}, - response_metadata={}, - id="02cf3adf-df10-45f3-af0d-05e7155a520c", - ) - generic_ai_toolcall = AIMessage( - content="", - additional_kwargs={ - "tool_calls": [ - { - "id": "call_TxjrqoPaRNnFmiZwmb9iMkgM", - "function": { - "arguments": '{"me_model_id":"https://great_memodel.com"}', - "name": tool_name, - }, - "type": "function", - } - ], - "refusal": None, - }, - response_metadata={ - "token_usage": { - "completion_tokens": 166, - "prompt_tokens": 4484, - "total_tokens": 4650, - "completion_tokens_details": {"reasoning_tokens": 0}, - "prompt_tokens_details": {"cached_tokens": 3456}, - }, - "model_name": "gpt-4o-mini-2024-07-18", - "system_fingerprint": "fp_e2bde53e6e", - "finish_reason": "tool_calls", - "logprobs": None, - }, - id="run-5c0c50f3-668f-41df-b69d-8497bb7dc92e-0", - tool_calls=[ - { - "name": "bluenaas-tool", - "args": {"me_model_id": "https://great_memodel"}, - "id": "call_TxjrqoPaRNnFmiZwmb9iMkgM", - "type": "tool_call", - } - ], - usage_metadata={ - "input_tokens": 4484, - "output_tokens": 166, - "total_tokens": 4650, - }, - ) - messages = [generic_human, generic_ai_toolcall] - if use_case == "first_encounter": - return messages - - generic_ai_content = AIMessage( - content="The simulation parameters for the selected ME model...", - additional_kwargs={"refusal": None}, - response_metadata={ - "token_usage": { - "completion_tokens": 177, - "prompt_tokens": 4819, - "total_tokens": 4996, - "completion_tokens_details": {"reasoning_tokens": 0}, - "prompt_tokens_details": {"cached_tokens": 4736}, - }, - "model_name": "gpt-4o-mini-2024-07-18", - "system_fingerprint": "fp_e2bde53e6e", - "finish_reason": "stop", - "logprobs": None, - }, - id="run-6e963cac-2392-483a-8a4c-0ac385130aba-0", - usage_metadata={ - "input_tokens": 4819, - "output_tokens": 177, - "total_tokens": 4996, - }, - ) - messages.extend( - [ - ToolMessage( - content="A simulation will be ran with the following inputs {'currentInjection': {'injectTo': 'soma[0]', 'stimulus': {'stimulusType': 'current_clamp', 'stimulusProtocol': 'ap_waveform', 'amplitudes': [0.1]}}, 'recordFrom': [{'section': 'soma[0]', 'offset': 0.5}], 'conditions': {'celsius': 34, 'vinit': -73, 'hypamp': 0, 'max_time': 100, 'time_step': 0.05, 'seed': 100}, 'type': 'single-neuron-simulation', 'simulationDuration': 100}. \n Please confirm that you are satisfied by the simulation parameters, or correct them accordingly.", - name="bluenaas-tool", - id="95a70cb3-8afc-4e51-8868-29ea9cf5c8bd", - tool_call_id="call_IScHmOF8TsJEjqmZ9yL5ehRY", - artifact={"is_validated": True}, - ), - generic_ai_content, - ] - ) - if use_case == "approve/modify": - # To modify, the tool call invocation needs to have a different signature - messages.extend([generic_human, generic_ai_toolcall]) - return messages - if use_case == "refuse": - messages.extend( - [generic_human, generic_ai_content, generic_human, generic_ai_toolcall] - ) - return messages - if use_case == "change_topic": - messages.extend( - [ - generic_human, - AIMessage( - content="", - additional_kwargs={ - "tool_calls": [ - { - "id": "call_TxjrqoPaRNnFmiZwmb9iMkgM", - "function": { - "arguments": '{"me_model_id":"https://great_memodel.com"}', - "name": "other-tool", - }, - "type": "function", - } - ], - "refusal": None, - }, - ), - ToolMessage( - content="great content", - name="other-tool", - id="95a70cb3-8afc-4e51-8868-29ea9cf5c8bd", - tool_call_id="call_IScHmOF8TsJEjqmZ9yL5ehRY", - artifact={"is_validated": True}, - ), - generic_ai_content, - generic_human, - generic_ai_toolcall, - ] - ) - return messages - - -@pytest.mark.asyncio -async def test_arun(httpx_mock): - me_model_id = "great_id" - url = "http://fake_url" - - httpx_mock.add_response( - url=url + f"?model_id={me_model_id}", - json={"t": [0.05, 0.1, 0.15, 0.2], "v": [-1.14, -0.67, -1.78]}, - ) - - tool = BlueNaaSTool( - metadata={ - "url": url, - "httpx_client": httpx.AsyncClient(), - "token": "fake_token", - } - ) - - messages = hil_usecases("bluenaas-tool", "first_encounter") - # First call to bluenaas. Need to validate - response = await tool._arun( - me_model_id=me_model_id, - messages=messages, - ) - assert isinstance(response[0], BlueNaaSInvalidatedOutput) - assert response[0].inputs == tool.create_json_api() - assert response[1] == {"is_validated": True} - - messages_approve = hil_usecases("bluenaas-tool", "approve/modify") - # Case where we call bluenaas after validating. Run simu - response = await tool._arun( - me_model_id=me_model_id, - messages=messages_approve, - ) - assert isinstance(response[0], BlueNaaSValidatedOutput) - assert response[0] == BlueNaaSValidatedOutput(status="success") - assert response[1] == {"is_validated": False} - - messages_change_topic = hil_usecases("bluenaas-tool", "change_topic") - - # Don't validate but completely change topic - response = await tool._arun( - me_model_id=me_model_id, - messages=messages_change_topic, - ) - assert isinstance(response[0], BlueNaaSInvalidatedOutput) - assert response[1] == {"is_validated": True} - - messages_refusal = hil_usecases("bluenaas-tool", "refuse") - - # Don't validate but ask again to run - response = await tool._arun( - me_model_id=me_model_id, - messages=messages_refusal, - ) - - assert isinstance(response[0], BlueNaaSInvalidatedOutput) - assert response[1] == {"is_validated": True} - - # Modify the input. Should ask for validation again - response = await tool._arun( - me_model_id=me_model_id, messages=messages_approve, conditions__celsius=40 - ) - - assert isinstance(response[0], BlueNaaSInvalidatedOutput) - assert response[1] == {"is_validated": True} - - -@pytest.mark.parametrize( - "use_case,output", - [ - ("first_encounter", False), - ("approve/modify", True), - ("refuse", False), - ("change_topic", False), - ], -) -def test_is_validated(use_case, output): - messages = hil_usecases("bluenaas-tool", use_case) - - # Same json as in the messages - json_api = BlueNaaSTool.create_json_api() - assert BlueNaaSTool.is_validated(messages, json_api) == output - - # Different json as in the messages - json_api = BlueNaaSTool.create_json_api(conditions__celsius=25) - assert not BlueNaaSTool.is_validated(messages, json_api) - - -def test_create_json_api(): - url = "http://fake_url" - - tool = BlueNaaSTool( - metadata={ - "url": url, - "httpx_client": httpx.AsyncClient(), - "token": "fake_token", - } - ) - - json_api = tool.create_json_api( - conditions__vinit=-3, - current_injection__stimulus__stimulus_type="conductance", - record_from=[ - RecordingLocation(), - RecordingLocation(section="axon[78]", offset=0.1), - ], - current_injection__stimulus__amplitudes=[0.1, 0.5], - ) - assert json_api == { - "currentInjection": { - "injectTo": "soma[0]", - "stimulus": { - "stimulusType": "conductance", - "stimulusProtocol": "ap_waveform", - "amplitudes": [0.1, 0.5], - }, - }, - "recordFrom": [ - {"section": "soma[0]", "offset": 0.5}, - {"section": "axon[78]", "offset": 0.1}, - ], - "conditions": { - "celsius": 34, - "vinit": -3, - "hypamp": 0, - "max_time": 100, - "time_step": 0.05, - "seed": 100, - }, - "type": "single-neuron-simulation", - "simulationDuration": 100, - } diff --git a/tests/tools/test_electrophys_tool.py b/tests/tools/test_electrophys_tool.py index 70a85ae..ae46272 100644 --- a/tests/tools/test_electrophys_tool.py +++ b/tests/tools/test_electrophys_tool.py @@ -5,13 +5,13 @@ import httpx import pytest -from langchain_core.tools import ToolException from neuroagent.tools import ElectrophysFeatureTool from neuroagent.tools.electrophys_tool import ( CALCULATED_FEATURES, AmplitudeInput, - FeatureOutput, + ElectrophysInput, + ElectrophysMetadata, ) @@ -40,66 +40,135 @@ async def test_arun(self, httpx_mock): content=trace_content, ) + trace_id = "https://bbp.epfl.ch/data/demo/morpho-demo/1761e604-03fc-452b-9bf2-2214782bb751" + tool = ElectrophysFeatureTool( - metadata={ - "url": url, - "search_size": 2, - "httpx_client": httpx.AsyncClient(), - "token": "fake_token", - } + input_schema=ElectrophysInput( + trace_id=trace_id, + stimuli_types=[ + "step", + ], + calculated_feature=[ + "mean_frequency", + ], + amplitude=None, + ), + metadata=ElectrophysMetadata( + knowledge_graph_url=url, + search_size=2, + httpx_client=httpx.AsyncClient(), + token="fake_token", + ), + ) + response = await tool.arun() + assert isinstance(response, 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 + ) + + @pytest.mark.httpx_mock(can_send_already_matched_responses=True) + @pytest.mark.asyncio + async def test_arun_with_amplitude(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, ) 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, FeatureOutput) - assert isinstance(response.feature_dict, dict) - assert len(response.feature_dict.keys()) == 1 + tool = ElectrophysFeatureTool( + input_schema=ElectrophysInput( + trace_id=trace_id, + stimuli_types=[ + "step", + ], + calculated_feature=[ + "mean_frequency", + ], + amplitude=AmplitudeInput(min_value=-0.5, max_value=1), + ), + metadata=ElectrophysMetadata( + knowledge_graph_url=url, + search_size=2, + httpx_client=httpx.AsyncClient(), + token="fake_token", + ), + ) + response = await tool.arun() + assert isinstance(response, dict) + assert len(response["feature_dict"].keys()) == 1 assert ( - len(response.feature_dict["step_0"].keys()) + len(response["feature_dict"]["step_0.25"].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, FeatureOutput) - 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 + @pytest.mark.httpx_mock(can_send_already_matched_responses=True) + @pytest.mark.asyncio + async def test_arun_without_stimuli_types(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, + ) + + trace_id = "https://bbp.epfl.ch/data/demo/morpho-demo/1761e604-03fc-452b-9bf2-2214782bb751" + + tool = ElectrophysFeatureTool( + input_schema=ElectrophysInput( + trace_id=trace_id, + stimuli_types=[ + "step", + ], + calculated_feature=[], + amplitude=None, + ), + metadata=ElectrophysMetadata( + knowledge_graph_url=url, + search_size=2, + httpx_client=httpx.AsyncClient(), + token="fake_token", + ), ) # Without stimuli types and calculated features - response = await tool._arun( - trace_id=trace_id, - stimuli_types=[ - "step", - ], - calculated_feature=[], - ) - assert isinstance(response, FeatureOutput) - assert isinstance(response.feature_dict, dict) - assert len(response.feature_dict.keys()) == 1 + response = await tool.arun() + assert isinstance(response, dict) + assert len(response["feature_dict"].keys()) == 1 assert ( - len(response.feature_dict["step_0"].keys()) + len(response["feature_dict"]["step_0"].keys()) == len(list(CALCULATED_FEATURES.__args__[0].__args__)) + 1 # 1 for stimulus current added manually ) @@ -110,16 +179,7 @@ async def test_arun_errors(self): 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( + input_schema=ElectrophysInput( trace_id="wrong-trace-id", stimuli_types=[ "idrest", @@ -127,7 +187,18 @@ async def test_arun_errors(self): calculated_feature=[ "mean_frequency", ], - ) + amplitude=None, + ), + metadata=ElectrophysMetadata( + knowledge_graph_url=url, + search_size=2, + httpx_client=httpx.AsyncClient(), + token="fake_token", + ), + ) + + with pytest.raises(ValueError) as tool_exception: + await tool.arun() assert ( tool_exception.value.args[0] diff --git a/tests/tools/test_get_me_model_tool.py b/tests/tools/test_get_me_model_tool.py deleted file mode 100644 index 25c4c34..0000000 --- a/tests/tools/test_get_me_model_tool.py +++ /dev/null @@ -1,161 +0,0 @@ -"""Tests Get Morpho tool.""" - -import json -from pathlib import Path - -import httpx -import pytest -from langchain_core.tools import ToolException - -from neuroagent.tools.get_me_model_tool import GetMEModelTool, MEModelOutput - - -class TestGetMEModelTool: - @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" / "kg_me_model_output.json" - ) - with open(json_path) as f: - knowledge_graph_response = json.load(f) - - httpx_mock.add_response( - url=url, - json=knowledge_graph_response, - ) - tool = GetMEModelTool( - 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="mtype_id_link/549", - etype_id="etype_id_link/549", - ) - assert isinstance(response, list) - assert len(response) == 1 - assert isinstance(response[0], MEModelOutput) - - @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 = GetMEModelTool( - 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="mtype_id_link/superbad", - etype_id="etype_id_link/superbad", - ) - - assert tool_exception.value.args[0] == "'hits'" - - -def test_create_query(): - url = "http://fake_url" - - tool = GetMEModelTool( - 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" - etype_id = "etype-id/1234" - - entire_query = tool.create_query( - brain_regions_ids=brain_regions_ids, mtype_ids={mtype_id}, etype_id=etype_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" - } - }, - ] - } - }, - {"term": {"@type.keyword": ("https://neuroshapes.org/MEModel")}}, - {"term": {"deprecated": False}}, - { - "bool": { - "should": [{"term": {"mType.@id.keyword": "mtype-id/1234"}}] - } - }, - {"term": {"eType.@id.keyword": "etype-id/1234"}}, - ] - } - }, - } - 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/MEModel")}}, - {"term": {"deprecated": False}}, - ] - } - }, - } - assert entire_query1 == expected_query1 diff --git a/tests/tools/test_get_morpho_tool.py b/tests/tools/test_get_morpho_tool.py index 87fdcbb..1ccdaaa 100644 --- a/tests/tools/test_get_morpho_tool.py +++ b/tests/tools/test_get_morpho_tool.py @@ -5,10 +5,9 @@ import httpx import pytest -from langchain_core.tools import ToolException from neuroagent.tools import GetMorphoTool -from neuroagent.tools.get_morpho_tool import KnowledgeGraphOutput +from neuroagent.tools.get_morpho_tool import GetMorphoInput, GetMorphoMetadata class TestGetMorphoTool: @@ -26,22 +25,23 @@ async def test_arun(self, httpx_mock, brain_region_json_path, tmp_path): 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", + input_schema=GetMorphoInput( + brain_region_id="brain_region_id_link/549", + mtype_id="brain_region_id_link/549", + ), + metadata=GetMorphoMetadata( + knowledge_graph_url=url, + morpho_search_size=2, + httpx_client=httpx.AsyncClient(), + token="fake_token", + brainregion_path=brain_region_json_path, + celltypes_path=tmp_path, + ), ) + response = await tool.arun() assert isinstance(response, list) assert len(response) == 2 - assert isinstance(response[0], KnowledgeGraphOutput) + assert isinstance(response[0], dict) @pytest.mark.asyncio async def test_arun_errors(self, httpx_mock, brain_region_json_path, tmp_path): @@ -52,34 +52,41 @@ async def test_arun_errors(self, httpx_mock, brain_region_json_path, tmp_path): ) 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( + input_schema=GetMorphoInput( brain_region_id="brain_region_id_link/bad", mtype_id="brain_region_id_link/superbad", - ) + ), + metadata=GetMorphoMetadata( + knowledge_graph_url=url, + morpho_search_size=2, + httpx_client=httpx.AsyncClient(), + token="fake_token", + brainregion_path=brain_region_json_path, + celltypes_path=tmp_path, + ), + ) + with pytest.raises(KeyError) as tool_exception: + await tool.arun() - assert tool_exception.value.args[0] == "'hits'" + assert tool_exception.value.args[0] == "hits" -def test_create_query(): +def test_create_query(brain_region_json_path, tmp_path): url = "http://fake_url" tool = GetMorphoTool( - metadata={ - "url": url, - "search_size": 2, - "httpx_client": httpx.AsyncClient(), - "token": "fake_token", - } + input_schema=GetMorphoInput( + brain_region_id="not_needed", + mtype_id="not_needed", + ), + metadata=GetMorphoMetadata( + knowledge_graph_url=url, + morpho_search_size=2, + httpx_client=httpx.AsyncClient(), + token="fake_token", + brainregion_path=brain_region_json_path, + celltypes_path=tmp_path, + ), ) # This should be a set, but passing a list here ensures that the test doesn;t rely on order. diff --git a/tests/tools/test_kg_morpho_features_tool.py b/tests/tools/test_kg_morpho_features_tool.py index b346e26..ef68d6b 100644 --- a/tests/tools/test_kg_morpho_features_tool.py +++ b/tests/tools/test_kg_morpho_features_tool.py @@ -5,13 +5,13 @@ 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, + KGFeatRangeInput, + KGFeatureInput, + KGMorphoFeatureInput, + KGMorphoFeatureMetadata, ) @@ -32,26 +32,26 @@ async def test_arun(self, httpx_mock, brain_region_json_path): 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( + feature_input = KGFeatureInput( label="Section Tortuosity", ) - response = await tool._arun( - brain_region_id="brain_region_id_link/549", features=feature_input + tool = KGMorphoFeatureTool( + input_schema=KGMorphoFeatureInput( + brain_region_id="brain_region_id_link/549", features=feature_input + ), + metadata=KGMorphoFeatureMetadata( + knowledge_graph_url=url, + kg_morpho_feature_search_size=2, + token="fake_token", + brainregion_path=brain_region_json_path, + httpx_client=httpx.AsyncClient(), + ), ) + response = await tool.arun() assert isinstance(response, list) assert len(response) == 2 - assert isinstance(response[0], KGMorphoFeatureOutput) + assert isinstance(response[0], dict) @pytest.mark.asyncio async def test_arun_errors(self, httpx_mock, brain_region_json_path): @@ -63,45 +63,49 @@ async def test_arun_errors(self, httpx_mock, brain_region_json_path): 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( + feature_input = KGFeatureInput( label="Section Tortuosity", ) - with pytest.raises(ToolException) as tool_exception: - _ = await tool._arun( + tool = KGMorphoFeatureTool( + input_schema=KGMorphoFeatureInput( brain_region_id="brain_region_id_link/549", features=feature_input - ) - assert tool_exception.value.args[0] == "'hits'" + ), + metadata=KGMorphoFeatureMetadata( + knowledge_graph_url=url, + kg_morpho_feature_search_size=2, + token="fake_token", + brainregion_path=brain_region_json_path, + httpx_client=httpx.AsyncClient(), + ), + ) + + with pytest.raises(KeyError) as tool_exception: + await tool.arun() + 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( + feature_input = KGFeatureInput( label="Soma Radius", compartment="NeuronMorphology", ) brain_regions_ids = {"brain-region-id/68"} + tool = KGMorphoFeatureTool( + input_schema=KGMorphoFeatureInput( + brain_region_id="", features=feature_input + ), + metadata=KGMorphoFeatureMetadata( + knowledge_graph_url=url, + kg_morpho_feature_search_size=2, + token="fake_token", + brainregion_path=brain_region_json_path, + httpx_client=httpx.AsyncClient(), + ), + ) + entire_query = tool.create_query( brain_regions_ids=brain_regions_ids, features=feature_input ) @@ -162,16 +166,34 @@ def test_create_query(self, brain_region_json_path): assert isinstance(entire_query, dict) assert entire_query == expected_query - # Case 2 with max value - feature_input1 = FeatureInput( + def test_create_query_with_max_value(self, brain_region_json_path): + url = "http://fake_url" + + feature_input = KGFeatureInput( label="Soma Radius", compartment="NeuronMorphology", - feat_range=FeatRangeInput(max_value=5), + feat_range=KGFeatRangeInput(max_value=5), ) - entire_query1 = tool.create_query( - brain_regions_ids=brain_regions_ids, features=feature_input1 + + brain_regions_ids = {"brain-region-id/68"} + + tool = KGMorphoFeatureTool( + input_schema=KGMorphoFeatureInput( + brain_region_id="", features=feature_input + ), + metadata=KGMorphoFeatureMetadata( + knowledge_graph_url=url, + kg_morpho_feature_search_size=2, + token="fake_token", + brainregion_path=brain_region_json_path, + httpx_client=httpx.AsyncClient(), + ), ) - expected_query1 = { + + entire_query = tool.create_query( + brain_regions_ids=brain_regions_ids, features=feature_input + ) + expected_query = { "size": 2, "track_total_hits": True, "query": { @@ -237,18 +259,35 @@ def test_create_query(self, brain_region_json_path): } }, } - assert entire_query1 == expected_query1 + assert entire_query == expected_query - # Case 3 with min value - feature_input2 = FeatureInput( + def test_create_query_with_min_value(self, brain_region_json_path): + url = "http://fake_url" + + feature_input = KGFeatureInput( label="Soma Radius", compartment="NeuronMorphology", - feat_range=FeatRangeInput(min_value=2), + feat_range=KGFeatRangeInput(min_value=2), ) - entire_query2 = tool.create_query( - brain_regions_ids=brain_regions_ids, features=feature_input2 + + tool = KGMorphoFeatureTool( + input_schema=KGMorphoFeatureInput( + brain_region_id="", features=feature_input + ), + metadata=KGMorphoFeatureMetadata( + knowledge_graph_url=url, + kg_morpho_feature_search_size=2, + token="fake_token", + brainregion_path=brain_region_json_path, + httpx_client=httpx.AsyncClient(), + ), ) - expected_query2 = { + + 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": { @@ -314,18 +353,35 @@ def test_create_query(self, brain_region_json_path): } }, } - assert entire_query2 == expected_query2 + assert entire_query == expected_query + + def test_create_query_with_min_max_value(self, brain_region_json_path): + url = "http://fake_url" - # Case 4 with min and max value - feature_input3 = FeatureInput( + feature_input = KGFeatureInput( label="Soma Radius", compartment="NeuronMorphology", - feat_range=FeatRangeInput(min_value=2, max_value=5), + feat_range=KGFeatRangeInput(min_value=2, max_value=5), + ) + + tool = KGMorphoFeatureTool( + input_schema=KGMorphoFeatureInput( + brain_region_id="", features=feature_input + ), + metadata=KGMorphoFeatureMetadata( + knowledge_graph_url=url, + kg_morpho_feature_search_size=2, + token="fake_token", + brainregion_path=brain_region_json_path, + httpx_client=httpx.AsyncClient(), + ), ) - entire_query3 = tool.create_query( - brain_regions_ids=brain_regions_ids, features=feature_input3 + + brain_regions_ids = {"brain-region-id/68"} + entire_query = tool.create_query( + brain_regions_ids=brain_regions_ids, features=feature_input ) - expected_query3 = { + expected_query = { "size": 2, "track_total_hits": True, "query": { @@ -394,4 +450,4 @@ def test_create_query(self, brain_region_json_path): } }, } - assert entire_query3 == expected_query3 + assert entire_query == expected_query diff --git a/tests/tools/test_literature_search_tool.py b/tests/tools/test_literature_search_tool.py index 93adf4f..31cb9a3 100644 --- a/tests/tools/test_literature_search_tool.py +++ b/tests/tools/test_literature_search_tool.py @@ -1,26 +1,22 @@ """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 +from neuroagent.tools.literature_search_tool import ( + LiteratureSearchInput, + LiteratureSearchMetadata, +) class TestLiteratureSearchTool: @pytest.mark.asyncio - async def test_arun(self): - url = "http://fake_url" + async def test_arun(self, httpx_mock): + url = "http://fake_url?query=covid+19&retriever_k=100&use_reranker=true&reranker_k=5" reranker_k = 5 - client = httpx.AsyncClient() - client.get = AsyncMock() - response = Mock() - response.status_code = 200 - client.get.return_value = response - response.json.return_value = [ + fake_response = [ { "article_title": "Article title", "article_authors": ["Author1", "Author2"], @@ -32,54 +28,23 @@ async def test_arun(self): 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, - } + httpx_mock.add_response( + url=url, + json=fake_response, ) - 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, - } + input_schema=LiteratureSearchInput(query="covid 19"), + metadata=LiteratureSearchMetadata( + literature_search_url=url, + httpx_client=httpx.AsyncClient(), + token="fake_token", + retriever_k=100, + use_reranker=True, + reranker_k=reranker_k, + ), ) - - response = tool._run(query="covid 19") + response = await tool.arun() assert isinstance(response, list) assert len(response) == reranker_k - assert isinstance(response[0], ParagraphMetadata) + assert isinstance(response[0], dict) diff --git a/tests/tools/test_morphology_features_tool.py b/tests/tools/test_morphology_features_tool.py index 92a311f..3f10ee2 100644 --- a/tests/tools/test_morphology_features_tool.py +++ b/tests/tools/test_morphology_features_tool.py @@ -5,10 +5,12 @@ import httpx import pytest -from langchain_core.tools import ToolException from neuroagent.tools import MorphologyFeatureTool -from neuroagent.tools.morphology_features_tool import MorphologyFeatureOutput +from neuroagent.tools.morphology_features_tool import ( + MorphologyFeatureInput, + MorphologyFeatureMetadata, +) class TestMorphologyFeatureTool: @@ -41,27 +43,30 @@ async def test_arun(self, httpx_mock): ) tool = MorphologyFeatureTool( - metadata={ - "url": url, - "httpx_client": httpx.AsyncClient(), - "token": "fake_token", - } + metadata=MorphologyFeatureMetadata( + knowledge_graph_url=url, + httpx_client=httpx.AsyncClient(), + token="fake_token", + ), + input_schema=MorphologyFeatureInput(morphology_id=morphology_id), ) - response = await tool._arun(morphology_id=morphology_id) - assert isinstance(response[0], MorphologyFeatureOutput) - assert len(response[0].feature_dict) == 23 + response = await tool.arun() + assert isinstance(response[0], dict) + assert len(response[0]["feature_dict"]) == 23 @pytest.mark.asyncio - async def test_arun_errors(self, httpx_mock): + async def test_arun_errors_404(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", - } + metadata=MorphologyFeatureMetadata( + knowledge_graph_url=url, + httpx_client=httpx.AsyncClient(), + token="fake_token", + ), + input_schema=MorphologyFeatureInput(morphology_id=morphology_id), ) # test different failures @@ -70,8 +75,8 @@ async def test_arun_errors(self, httpx_mock): url=url, status_code=404, ) - with pytest.raises(ToolException) as tool_exception: - _ = await tool._arun(morphology_id=morphology_id) + with pytest.raises(ValueError) as tool_exception: + await tool.arun() assert ( tool_exception.value.args[0] == "We did not find the object" @@ -79,14 +84,28 @@ async def test_arun_errors(self, httpx_mock): " you are asking" ) + @pytest.mark.asyncio + async def test_arun_wrong_id(self, httpx_mock): + url = "http://fake_url" + morphology_id = "https://bbp.epfl.ch/neurosciencegraph/data/neuronmorphologies/046fb11c-8de8-42e8-9303-9d5a65ac04b9" + + tool = MorphologyFeatureTool( + metadata=MorphologyFeatureMetadata( + knowledge_graph_url=url, + httpx_client=httpx.AsyncClient(), + token="fake_token", + ), + input_schema=MorphologyFeatureInput(morphology_id=morphology_id), + ) + # 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) + with pytest.raises(ValueError) as tool_exception: + await tool.arun() assert ( tool_exception.value.args[0] == "We did not find the object" diff --git a/tests/tools/test_resolve_entities_tool.py b/tests/tools/test_resolve_entities_tool.py index c0ab774..b2ac893 100644 --- a/tests/tools/test_resolve_entities_tool.py +++ b/tests/tools/test_resolve_entities_tool.py @@ -8,21 +8,13 @@ BRResolveOutput, EtypeResolveOutput, MTypeResolveOutput, + ResolveBRInput, + ResolveBRMetadata, ) @pytest.mark.asyncio async def test_arun(httpx_mock, get_resolve_query_output): - tool = ResolveEntitiesTool( - 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", @@ -62,32 +54,45 @@ async def test_arun(httpx_mock, get_resolve_query_output): 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", etype="bAC") + + tool = ResolveEntitiesTool( + metadata=ResolveBRMetadata( + 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", + ), + input_schema=ResolveBRInput( + brain_region="Field", mtype="Interneu", etype="bAC" + ), + ) + + response = await tool.arun() assert response == [ BRResolveOutput( brain_region_name="Field CA1", brain_region_id="http://api.brain-map.org/api/v2/data/Structure/382", - ), + ).model_dump(), BRResolveOutput( brain_region_name="Field CA2", brain_region_id="http://api.brain-map.org/api/v2/data/Structure/423", - ), + ).model_dump(), BRResolveOutput( brain_region_name="Field CA3", brain_region_id="http://api.brain-map.org/api/v2/data/Structure/463", - ), + ).model_dump(), MTypeResolveOutput( mtype_name="Interneuron", mtype_id="https://neuroshapes.org/Interneuron" - ), + ).model_dump(), MTypeResolveOutput( mtype_name="Hippocampus CA3 Oriens Interneuron", mtype_id="http://uri.interlex.org/base/ilx_0105044", - ), + ).model_dump(), MTypeResolveOutput( mtype_name="Spinal Cord Ventral Horn Interneuron IA", mtype_id="http://uri.interlex.org/base/ilx_0110929", - ), + ).model_dump(), EtypeResolveOutput( etype_name="bAC", etype_id="http://uri.interlex.org/base/ilx_0738199" - ), + ).model_dump(), ] diff --git a/tests/tools/test_traces_tool.py b/tests/tools/test_traces_tool.py index 4ac1639..4cfe039 100644 --- a/tests/tools/test_traces_tool.py +++ b/tests/tools/test_traces_tool.py @@ -5,10 +5,9 @@ import httpx import pytest -from langchain_core.tools import ToolException from neuroagent.tools import GetTracesTool -from neuroagent.tools.traces_tool import TracesOutput +from neuroagent.tools.traces_tool import GetTracesInput, GetTracesMetadata class TestTracesTool: @@ -26,33 +25,94 @@ async def test_arun(self, httpx_mock, brain_region_json_path): ) tool = GetTracesTool( - metadata={ - "url": url, - "search_size": 2, - "httpx_client": httpx.AsyncClient(), - "token": "fake_token", - "brainregion_path": brain_region_json_path, - } + metadata=GetTracesMetadata( + knowledge_graph_url=url, + trace_search_size=2, + httpx_client=httpx.AsyncClient(), + token="fake_token", + brainregion_path=brain_region_json_path, + ), + input_schema=GetTracesInput(brain_region_id="brain_region_id_link/549"), ) - response = await tool._arun(brain_region_id="brain_region_id_link/549") + response = await tool.arun() assert isinstance(response, list) assert len(response) == 2 - assert isinstance(response[0], TracesOutput) + assert isinstance(response[0], dict) + assert isinstance(response[0], dict) - # With specific etype - response = await tool._arun( - brain_region_id="brain_region_id_link/549", etype_id="bAC_id/123" + @pytest.mark.httpx_mock(can_send_already_matched_responses=True) + @pytest.mark.asyncio + async def test_arun_with_etype(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=GetTracesMetadata( + knowledge_graph_url=url, + trace_search_size=2, + httpx_client=httpx.AsyncClient(), + token="fake_token", + brainregion_path=brain_region_json_path, + ), + input_schema=GetTracesInput( + brain_region_id="brain_region_id_link/549", etype_id="bAC_id/123" + ), + ) + response = await tool.arun() assert isinstance(response, list) assert len(response) == 2 - assert isinstance(response[0], TracesOutput) + assert isinstance(response[0], dict) + + @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={}, + ) - def test_create_query(self): + tool = GetTracesTool( + metadata=GetTracesMetadata( + knowledge_graph_url=url, + trace_search_size=2, + httpx_client=httpx.AsyncClient(), + token="fake_token", + brainregion_path=brain_region_json_path, + ), + input_schema=GetTracesInput(brain_region_id="brain_region_id_link/549"), + ) + with pytest.raises(KeyError) as tool_exception: + await tool.arun() + + assert tool_exception.value.args[0] == "hits" + + def test_create_query(self, brain_region_json_path): brain_region_ids = {"brain_region_id1"} etype_id = "bAC_id/123" + url = "http://fake_url" - tool = GetTracesTool(metadata={"search_size": 2}) + tool = GetTracesTool( + metadata=GetTracesMetadata( + knowledge_graph_url=url, + trace_search_size=2, + httpx_client=httpx.AsyncClient(), + token="fake_token", + brainregion_path=brain_region_json_path, + ), + input_schema=GetTracesInput( + brain_region_id="brain_region_id1", etype_id=etype_id + ), + ) entire_query = tool.create_query( brain_region_ids=brain_region_ids, etype_id=etype_id ) @@ -88,28 +148,3 @@ def test_create_query(self): }, } 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'"