Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion backend/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ where = ["src"]
namespaces = false

[tool.setuptools.package-data]
neuroagent = ["rules/*.mdc", "mcp.json"]
neuroagent = ["rules/*.mdc", "mcp.json", "tools/autogenerated_types/*.graphql"]

[tool.bandit]
exclude_dirs = ["tests"]
Expand Down
2 changes: 2 additions & 0 deletions backend/src/neuroagent/app/dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
MtypeGetAllTool,
MtypeGetOneTool,
OBIExpertTool,
ObiGraphQLTool,
OrganizationGetAllTool,
OrganizationGetOneTool,
PersonGetAllTool,
Expand Down Expand Up @@ -412,6 +413,7 @@ def get_tool_list(
MtypeGetAllTool,
MtypeGetOneTool,
OBIExpertTool,
ObiGraphQLTool,
OrganizationGetAllTool,
OrganizationGetOneTool,
PersonGetAllTool,
Expand Down
2 changes: 2 additions & 0 deletions backend/src/neuroagent/tools/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@
from neuroagent.tools.entitycore_subject_getone import SubjectGetOneTool
from neuroagent.tools.generate_plot import PlotGeneratorTool
from neuroagent.tools.obi_expert import OBIExpertTool
from neuroagent.tools.obi_graphql import ObiGraphQLTool
from neuroagent.tools.obione_circuitmetrics_getone import CircuitMetricGetOneTool
from neuroagent.tools.obione_circuitnodesets_getone import CircuitNodesetsGetOneTool
from neuroagent.tools.obione_circuitpopulations_getone import (
Expand Down Expand Up @@ -195,6 +196,7 @@
"PlotElectricalCellRecordingGetOneTool",
"PlotGeneratorTool",
"PlotMorphologyGetOneTool",
"ObiGraphQLTool",
"SimulationCampaignGetAllTool",
"SimulationCampaignGetOneTool",
"SimulationExecutionGetAllTool",
Expand Down
91 changes: 91 additions & 0 deletions backend/src/neuroagent/tools/autogenerated_types/obione.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
type MorphologyMetrics {
"""ID of the morphology in the database."""
id: String!

"""Aspect ratio of the morphology."""
aspectRatio: Float!

"""Circularity of the morphology points along the plane."""
circularity: Float!

"""Length fraction of segments with midpoints higher than soma."""
lengthFractionAboveSoma: Float!

"""Maximum radial distance from the soma in micrometers."""
maxRadialDistance: Float!

"""Number of neurites in the morphology."""
numberOfNeurites: Int!

"""Radius of the soma in micrometers."""
somaRadius: Float!

"""Surface area of the soma in square micrometers."""
somaSurfaceArea: Float!

"""Total length of the morphology neurites in micrometers."""
totalLength: Float!

"""Total height (Y-range) of the morphology in micrometers."""
totalHeight: Float!

"""Total width (X-range) of the morphology in micrometers."""
totalWidth: Float!

"""Total depth (Z-range) of the morphology in micrometers."""
totalDepth: Float!

"""Total surface area of all sections in square micrometers."""
totalArea: Float!

"""Total volume of all sections in cubic micrometers."""
totalVolume: Float!

"""Distribution of lengths per section in micrometers."""
sectionLengths: MultipleValuesContainer!

"""Distribution of radii of the morphology in micrometers."""
segmentRadii: MultipleValuesContainer!

"""Number of sections in the morphology."""
numberOfSections: Int!

"""Angles between sections computed at bifurcation (local) in radians."""
localBifurcationAngles: MultipleValuesContainer!

"""Angles between sections computed at section ends (remote) in radians."""
remoteBifurcationAngles: MultipleValuesContainer!

"""Path distances from soma to section endpoints in micrometers."""
sectionPathDistances: MultipleValuesContainer!

"""Radial distance from soma to section endpoints in micrometers."""
sectionRadialDistances: MultipleValuesContainer!

"""Distribution of branch orders of sections, computed from soma."""
sectionBranchOrders: MultipleValuesContainer!

"""
Distribution of strahler branch orders of sections, computed from terminals.
"""
sectionStrahlerOrders: MultipleValuesContainer!
}

type MultipleValuesContainer {
"""The list of individual values."""
values: [Float!]!

"""The number of values in the list."""
length: Int!

"""The arithmetic mean of all values."""
mean: Float!

"""The standard deviation of all values."""
std: Float!
}

type Query {
"""Get morphology metrics for one or more cell morphology IDs."""
morphologyMetrics(cellMorphologyIds: [String!]!): [MorphologyMetrics!]!
}
122 changes: 122 additions & 0 deletions backend/src/neuroagent/tools/obi_graphql.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
"""OBI GraphQL Query Executor tool."""

import logging
from pathlib import Path
from typing import ClassVar
from uuid import UUID

from httpx import AsyncClient
from pydantic import BaseModel, ConfigDict, Field

from neuroagent.tools.base_tool import BaseMetadata, BaseTool

logger = logging.getLogger(__name__)


def _load_graphql_schema() -> str:
"""Load the GraphQL schema from the file."""
schema_path = Path(__file__).parent / "autogenerated_types" / "obione.graphql"
try:
with open(schema_path, "r") as f:
return f.read()
except FileNotFoundError:
return "GraphQL schema file not found."


# Load the schema at module level
GRAPHQL_SCHEMA = _load_graphql_schema()


class GraphQLResponse(BaseModel):
"""Response model for GraphQL queries."""

model_config = ConfigDict(extra="allow")


class ObiGraphQLInputs(BaseModel):
"""Input class of the OBI GraphQL tool."""

graphql_query: str = Field(
description="The GraphQL query string to execute against the ObiOne API."
)
variables: dict | None = Field(
description="Optional variables for the GraphQL query.",
default=None,
)


class ObiGraphQLMetadata(BaseMetadata):
"""Metadata of the OBI GraphQL tool."""

httpx_client: AsyncClient
obi_one_url: str
vlab_id: UUID | None
project_id: UUID | None


class ObiGraphQLTool(BaseTool):
"""OBI GraphQL Query Executor tool."""

name: ClassVar[str] = "obi_graphql"
name_frontend: ClassVar[str] = "Execute GraphQL Query"
utterances: ClassVar[list[str]] = [
"Execute a GraphQL query",
"Run GraphQL query against ObiOne API",
"Query morphology metrics",
"Get data using GraphQL",
"Execute GraphQL mutation",
]
description: ClassVar[
str
] = f"""Execute GraphQL queries against the ObiOne API. This tool allows you to run GraphQL queries to fetch data from the ObiOne service. The schema includes detailed information about morphology metrics and other available data structures. Attention, the `values` fiels will contain large arrays so only query it when the user specifically requests it.

GraphQL Schema:
{GRAPHQL_SCHEMA}"""
description_frontend: ClassVar[
str
] = """Execute GraphQL queries against the ObiOne API. This tool allows you to:
• Run custom GraphQL queries
• Fetch morphology metrics data
• Query any available data types
• Execute mutations if supported

Provide a GraphQL query string to execute against the ObiOne service."""
metadata: ObiGraphQLMetadata
input_schema: ObiGraphQLInputs

async def arun(self) -> GraphQLResponse:
"""Run the GraphQL query execution logic."""
headers: dict[str, str] = {}
if self.metadata.vlab_id is not None:
headers["virtual-lab-id"] = str(self.metadata.vlab_id)
if self.metadata.project_id is not None:
headers["project-id"] = str(self.metadata.project_id)

# Prepare GraphQL request payload
payload = {
"query": self.input_schema.graphql_query,
"variables": self.input_schema.variables or {},
}

graphql_response = await self.metadata.httpx_client.post(
url=f"{self.metadata.obi_one_url.rstrip('/')}/graphql",
headers=headers,
json=payload,
)

if graphql_response.status_code != 200:
raise ValueError(
f"The GraphQL endpoint returned a non 200 response code. Error: {graphql_response.text}"
)

response_data = graphql_response.json()

return GraphQLResponse(**response_data)

@classmethod
async def is_online(cls, *, httpx_client: AsyncClient, obi_one_url: str) -> bool:
"""Check if the tool is online."""
response = await httpx_client.get(
f"{obi_one_url.rstrip('/')}/health",
)
return response.status_code == 200
Loading