diff --git a/docs/indexes/pinecone-sync-routes.ipynb b/docs/indexes/pinecone-sync-routes.ipynb index 6cb9b4eb7df99602b6adb60b73d1284dea0e36f4..d2ba412f552199f0a2b4178e962d5861f3a4a8f3 100644 --- a/docs/indexes/pinecone-sync-routes.ipynb +++ b/docs/indexes/pinecone-sync-routes.ipynb @@ -135,15 +135,6 @@ "The `RouteLayer` class supports both sync and async operations by default, so we initialize as usual:" ] }, - { - "cell_type": "code", - "execution_count": 5, - "metadata": {}, - "outputs": [], - "source": [ - "encoder.score_threshold = None" - ] - }, { "cell_type": "code", "execution_count": 5, @@ -153,18 +144,18 @@ "name": "stderr", "output_type": "stream", "text": [ - "\u001b[33m2024-11-23 23:10:13 WARNING semantic_router.utils.logger TEMP | add:\n", + "\u001b[33m2024-11-23 23:46:42 WARNING semantic_router.utils.logger TEMP | add:\n", + "chitchat: how are things going?\n", + "chitchat: how's the weather today?\n", + "chitchat: let's go to the chippy\n", + "chitchat: lovely weather today\n", + "chitchat: the weather is horrendous\u001b[0m\n", + "\u001b[33m2024-11-23 23:46:50 WARNING semantic_router.utils.logger TEMP | add:\n", "chitchat: how are things going?\n", "chitchat: how's the weather today?\n", "chitchat: let's go to the chippy\n", "chitchat: lovely weather today\n", - "chitchat: the weather is horrendous\n", - "politics: don't you just hate the president\n", - "politics: don't you just love the president\n", - "politics: isn't politics the best thing ever\n", - "politics: they will save the country!\n", - "politics: they're going to destroy this country!\n", - "politics: why don't you tell me about your political opinions\u001b[0m\n" + "chitchat: the weather is horrendous\u001b[0m\n" ] } ], @@ -172,10 +163,7 @@ "from semantic_router.routers import RouteLayer\n", "import time\n", "\n", - "rl = RouteLayer(\n", - " encoder=encoder, routes=routes, index=pc_index,\n", - " auto_sync=\"local\"\n", - ")\n", + "rl = RouteLayer(encoder=encoder, routes=routes, index=pc_index, auto_sync=\"local\")\n", "# due to pinecone indexing latency we wait 3 seconds\n", "time.sleep(3)" ] @@ -216,7 +204,7 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": 7, "metadata": {}, "outputs": [], "source": [ @@ -235,7 +223,7 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": 8, "metadata": {}, "outputs": [ { @@ -244,7 +232,7 @@ "False" ] }, - "execution_count": 9, + "execution_count": 8, "metadata": {}, "output_type": "execute_result" } @@ -262,7 +250,7 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": 9, "metadata": {}, "outputs": [ { @@ -281,7 +269,7 @@ " \" politics: why don't you tell me about your political opinions\"]" ] }, - "execution_count": 10, + "execution_count": 9, "metadata": {}, "output_type": "execute_result" } @@ -306,7 +294,7 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": 10, "metadata": {}, "outputs": [ { @@ -325,7 +313,7 @@ " Utterance(route='politics', utterance=\"why don't you tell me about your political opinions\", function_schemas=None, metadata={}, diff_tag=' ')]" ] }, - "execution_count": 11, + "execution_count": 10, "metadata": {}, "output_type": "execute_result" } @@ -337,7 +325,7 @@ }, { "cell_type": "code", - "execution_count": 12, + "execution_count": 11, "metadata": {}, "outputs": [ { @@ -351,7 +339,7 @@ " Utterance(route='politics', utterance='they will save the country!', function_schemas=None, metadata={}, diff_tag=' ')]" ] }, - "execution_count": 12, + "execution_count": 11, "metadata": {}, "output_type": "execute_result" } @@ -370,7 +358,7 @@ }, { "cell_type": "code", - "execution_count": 13, + "execution_count": 12, "metadata": {}, "outputs": [], "source": [ @@ -390,7 +378,7 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": 13, "metadata": {}, "outputs": [ { @@ -409,7 +397,7 @@ " Utterance(route='politics', utterance=\"why don't you tell me about your political opinions\", function_schemas=None, metadata={}, diff_tag=' ')]" ] }, - "execution_count": 14, + "execution_count": 13, "metadata": {}, "output_type": "execute_result" } @@ -435,7 +423,7 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": 14, "metadata": {}, "outputs": [ { @@ -448,7 +436,7 @@ " Utterance(route='chitchat', utterance='the weather is horrendous', function_schemas=None, metadata={}, diff_tag='+')]" ] }, - "execution_count": 15, + "execution_count": 14, "metadata": {}, "output_type": "execute_result" } @@ -466,7 +454,7 @@ }, { "cell_type": "code", - "execution_count": 16, + "execution_count": 15, "metadata": {}, "outputs": [ { @@ -475,7 +463,7 @@ "[]" ] }, - "execution_count": 16, + "execution_count": 15, "metadata": {}, "output_type": "execute_result" } @@ -493,7 +481,7 @@ }, { "cell_type": "code", - "execution_count": 17, + "execution_count": 16, "metadata": {}, "outputs": [ { @@ -507,7 +495,7 @@ " Utterance(route='politics', utterance=\"why don't you tell me about your political opinions\", function_schemas=None, metadata={}, diff_tag=' ')]" ] }, - "execution_count": 17, + "execution_count": 16, "metadata": {}, "output_type": "execute_result" } @@ -546,7 +534,7 @@ }, { "cell_type": "code", - "execution_count": 18, + "execution_count": 17, "metadata": {}, "outputs": [ { @@ -561,7 +549,7 @@ " 'local': {'upsert': [], 'delete': []}}" ] }, - "execution_count": 18, + "execution_count": 17, "metadata": {}, "output_type": "execute_result" } @@ -572,7 +560,7 @@ }, { "cell_type": "code", - "execution_count": 19, + "execution_count": 18, "metadata": {}, "outputs": [ { @@ -587,7 +575,7 @@ " 'delete': []}}" ] }, - "execution_count": 19, + "execution_count": 18, "metadata": {}, "output_type": "execute_result" } @@ -598,7 +586,7 @@ }, { "cell_type": "code", - "execution_count": 20, + "execution_count": 19, "metadata": {}, "outputs": [ { @@ -613,7 +601,7 @@ " 'delete': []}}" ] }, - "execution_count": 20, + "execution_count": 19, "metadata": {}, "output_type": "execute_result" } @@ -624,14 +612,14 @@ }, { "cell_type": "code", - "execution_count": 21, + "execution_count": 20, "metadata": {}, "outputs": [ { "name": "stderr", "output_type": "stream", "text": [ - "\u001b[32m2024-11-23 23:14:16 INFO semantic_router.utils.logger local_only_mapper: {}\u001b[0m\n" + "\u001b[32m2024-11-23 23:47:11 INFO semantic_router.utils.logger local_only_mapper: {}\u001b[0m\n" ] }, { @@ -646,7 +634,7 @@ " 'local': {'upsert': [], 'delete': []}}" ] }, - "execution_count": 21, + "execution_count": 20, "metadata": {}, "output_type": "execute_result" } @@ -657,7 +645,7 @@ }, { "cell_type": "code", - "execution_count": 22, + "execution_count": 21, "metadata": {}, "outputs": [ { @@ -677,7 +665,7 @@ " 'delete': []}}" ] }, - "execution_count": 22, + "execution_count": 21, "metadata": {}, "output_type": "execute_result" } @@ -695,14 +683,14 @@ }, { "cell_type": "code", - "execution_count": 23, + "execution_count": 22, "metadata": {}, "outputs": [ { "name": "stderr", "output_type": "stream", "text": [ - "\u001b[33m2024-11-23 23:14:25 WARNING semantic_router.utils.logger TEMP | _remove_and_sync:\n", + "\u001b[33m2024-11-23 23:47:15 WARNING semantic_router.utils.logger TEMP | _remove_and_sync:\n", "chitchat: ['how are things going?', \"how's the weather today?\", \"let's go to the chippy\", 'lovely weather today', 'the weather is horrendous']\u001b[0m\n" ] } @@ -714,16 +702,16 @@ }, { "cell_type": "code", - "execution_count": 24, + "execution_count": 23, "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "False" + "True" ] }, - "execution_count": 24, + "execution_count": 23, "metadata": {}, "output_type": "execute_result" } @@ -742,7 +730,7 @@ }, { "cell_type": "code", - "execution_count": 25, + "execution_count": 24, "metadata": {}, "outputs": [ { @@ -756,7 +744,7 @@ " \" politics: why don't you tell me about your political opinions\"]" ] }, - "execution_count": 25, + "execution_count": 24, "metadata": {}, "output_type": "execute_result" } @@ -776,9 +764,16 @@ }, { "cell_type": "code", - "execution_count": 26, + "execution_count": 25, "metadata": {}, "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "\u001b[33m2024-11-23 23:47:23 WARNING semantic_router.utils.logger Local and remote route layers are already synchronized.\u001b[0m\n" + ] + }, { "data": { "text/plain": [ @@ -790,7 +785,7 @@ " \" politics: why don't you tell me about your political opinions\"]" ] }, - "execution_count": 26, + "execution_count": 25, "metadata": {}, "output_type": "execute_result" } @@ -798,6 +793,13 @@ "source": [ "rl.sync(sync_mode=\"local\")" ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "---" + ] } ], "metadata": { diff --git a/semantic_router/__init__.py b/semantic_router/__init__.py index 80f33f231ccb296fa8c9b81f1340d9bd37903a59..b2c0b88527d68d3a0d1fc54ceea003ee44a1d88a 100644 --- a/semantic_router/__init__.py +++ b/semantic_router/__init__.py @@ -1,6 +1,6 @@ -from semantic_router.routers import LayerConfig, RouteLayer, HybridRouteLayer +from semantic_router.routers import RouterConfig, RouteLayer, HybridRouter from semantic_router.route import Route -__all__ = ["RouteLayer", "HybridRouteLayer", "Route", "LayerConfig"] +__all__ = ["RouteLayer", "HybridRouter", "Route", "RouterConfig"] __version__ = "0.1.0.dev2" diff --git a/semantic_router/index/hybrid_local.py b/semantic_router/index/hybrid_local.py index 9316487e33d193494fec0cfd7b1664951413d8be..a56463b30a56262ec61944ed9af86d50faf3ce59 100644 --- a/semantic_router/index/hybrid_local.py +++ b/semantic_router/index/hybrid_local.py @@ -5,7 +5,6 @@ from numpy.linalg import norm from semantic_router.schema import ConfigParameter, Utterance from semantic_router.index.local import LocalIndex -from semantic_router.linear import similarity_matrix, top_scores from semantic_router.utils.logger import logger from typing import Any @@ -104,7 +103,9 @@ class HybridLocalIndex(LocalIndex): # calculate sparse vec similarity sparse_norm = norm(self.sparse_index, axis=1) xq_s_norm = norm(xq_s) # TODO: this used to be xq_s.T, should work without - sim_s = np.squeeze(np.dot(self.sparse_index, xq_s.T)) / (sparse_norm * xq_s_norm) + sim_s = np.squeeze(np.dot(self.sparse_index, xq_s.T)) / ( + sparse_norm * xq_s_norm + ) total_sim = sim_d + sim_s # get indices of top_k records top_k = min(top_k, total_sim.shape[0]) diff --git a/semantic_router/index/local.py b/semantic_router/index/local.py index 420ad30f6ec29c0f1bfffbc432582b85e2f6d6fa..9d33163e010fdc116733830c06faad6236567486 100644 --- a/semantic_router/index/local.py +++ b/semantic_router/index/local.py @@ -43,9 +43,7 @@ class LocalIndex(BaseIndex): self.utterances = np.concatenate([self.utterances, utterances_arr]) def _remove_and_sync(self, routes_to_delete: dict): - logger.warning( - f"Sync remove is not implemented for {self.__class__.__name__}." - ) + logger.warning(f"Sync remove is not implemented for {self.__class__.__name__}.") def get_utterances(self) -> List[Utterance]: """ diff --git a/semantic_router/routers/__init__.py b/semantic_router/routers/__init__.py index 4c8031ab50bd7512f23225c91deed5c1e0908c15..4ba619d5e023b420f4d96881e82866e65887fb4a 100644 --- a/semantic_router/routers/__init__.py +++ b/semantic_router/routers/__init__.py @@ -1,10 +1,10 @@ -from semantic_router.routers.base import BaseRouteLayer, LayerConfig +from semantic_router.routers.base import BaseRouter, RouterConfig from semantic_router.routers.semantic import RouteLayer -from semantic_router.routers.hybrid import HybridRouteLayer +from semantic_router.routers.hybrid import HybridRouter __all__ = [ - "BaseRouteLayer", - "LayerConfig", + "BaseRouter", + "RouterConfig", "RouteLayer", - "HybridRouteLayer", + "HybridRouter", ] diff --git a/semantic_router/routers/base.py b/semantic_router/routers/base.py index 4fd7b58b67889f33c46a3d2a09d87d3880d9eed3..69753e035846f9707dbc0ecfb809efa6cc254d0f 100644 --- a/semantic_router/routers/base.py +++ b/semantic_router/routers/base.py @@ -57,10 +57,10 @@ def is_valid(layer_config: str) -> bool: return False -class LayerConfig: +class RouterConfig: """ - Generates a LayerConfig object that can be used for initializing a - RouteLayer. + Generates a RouterConfig object that can be used for initializing a + Routers. """ routes: List[Route] = [] @@ -80,7 +80,7 @@ class LayerConfig: if encode_type.value == self.encoder_type: if self.encoder_type == EncoderType.HUGGINGFACE.value: raise NotImplementedError( - "HuggingFace encoder not supported by LayerConfig yet." + "HuggingFace encoder not supported by RouterConfig yet." ) encoder_name = EncoderDefault[encode_type.name].value[ "embedding_model" @@ -91,7 +91,7 @@ class LayerConfig: self.routes = routes @classmethod - def from_file(cls, path: str) -> "LayerConfig": + def from_file(cls, path: str) -> "RouterConfig": logger.info(f"Loading route config from {path}") _, ext = os.path.splitext(path) with open(path, "r") as f: @@ -143,7 +143,7 @@ class LayerConfig: encoder_type: str = "openai", encoder_name: Optional[str] = None, ): - """Initialize a LayerConfig from a list of tuples of routes and + """Initialize a RouterConfig from a list of tuples of routes and utterances. :param route_tuples: A list of tuples, each containing a route name and an @@ -182,9 +182,9 @@ class LayerConfig: encoder_type: str = "openai", encoder_name: Optional[str] = None, ): - """Initialize a LayerConfig from a BaseIndex object. + """Initialize a RouterConfig from a BaseIndex object. - :param index: The index to initialize the LayerConfig from. + :param index: The index to initialize the RouterConfig from. :type index: BaseIndex :param encoder_type: The type of encoder to use, defaults to "openai". :type encoder_type: str, optional @@ -275,7 +275,7 @@ class LayerConfig: ) -class BaseRouteLayer(BaseModel): +class BaseRouter(BaseModel): encoder: BaseEncoder index: BaseIndex = Field(default_factory=BaseIndex) score_threshold: Optional[float] = Field(default=None) @@ -365,7 +365,7 @@ class BaseRouteLayer(BaseModel): def _set_score_threshold(self): """Set the score threshold for the layer based on the encoder score threshold. - + When no score threshold is used a default `None` value is used, which means that a route will always be returned when the layer is called.""" @@ -688,18 +688,18 @@ class BaseRouteLayer(BaseModel): @classmethod def from_json(cls, file_path: str): - config = LayerConfig.from_file(file_path) + config = RouterConfig.from_file(file_path) encoder = AutoEncoder(type=config.encoder_type, name=config.encoder_name).model return cls(encoder=encoder, routes=config.routes) @classmethod def from_yaml(cls, file_path: str): - config = LayerConfig.from_file(file_path) + config = RouterConfig.from_file(file_path) encoder = AutoEncoder(type=config.encoder_type, name=config.encoder_name).model return cls(encoder=encoder, routes=config.routes) @classmethod - def from_config(cls, config: LayerConfig, index: Optional[BaseIndex] = None): + def from_config(cls, config: RouterConfig, index: Optional[BaseIndex] = None): encoder = AutoEncoder(type=config.encoder_type, name=config.encoder_name).model return cls(encoder=encoder, routes=config.routes, index=index) @@ -1115,8 +1115,8 @@ class BaseRouteLayer(BaseModel): route.name, self.score_threshold ) - def to_config(self) -> LayerConfig: - return LayerConfig( + def to_config(self) -> RouterConfig: + return RouterConfig( encoder_type=self.encoder.type, encoder_name=self.encoder.name, routes=self.routes, @@ -1226,7 +1226,7 @@ class BaseRouteLayer(BaseModel): def threshold_random_search( - route_layer: BaseRouteLayer, + route_layer: BaseRouter, search_range: Union[int, float], ) -> Dict[str, float]: """Performs a random search iteration given a route layer and a search range.""" diff --git a/semantic_router/routers/hybrid.py b/semantic_router/routers/hybrid.py index 7ea3eddb4f518d5a5071f3b130a3d70879deea88..6e66142afd51691b9abdb3b3751c0f01938d184d 100644 --- a/semantic_router/routers/hybrid.py +++ b/semantic_router/routers/hybrid.py @@ -13,13 +13,13 @@ from semantic_router.route import Route from semantic_router.index.hybrid_local import HybridLocalIndex from semantic_router.schema import RouteChoice from semantic_router.utils.logger import logger -from semantic_router.routers.base import BaseRouteLayer +from semantic_router.routers.base import BaseRouter from semantic_router.llms import BaseLLM -class HybridRouteLayer(BaseRouteLayer): - """A hybrid layer that uses both dense and sparse embeddings to classify routes. - """ +class HybridRouter(BaseRouter): + """A hybrid layer that uses both dense and sparse embeddings to classify routes.""" + # there are a few additional attributes for hybrid sparse_encoder: BM25Encoder = Field(default_factory=BM25Encoder) alpha: float = 0.3 @@ -74,7 +74,7 @@ class HybridRouteLayer(BaseRouteLayer): @validator("sparse_encoder", pre=True, always=True) def set_sparse_encoder(cls, v): return v if v is not None else BM25Encoder() - + @validator("index", pre=True, always=True) def set_index(cls, v): return v if v is not None else HybridLocalIndex() @@ -87,10 +87,10 @@ class HybridRouteLayer(BaseRouteLayer): # TODO: add alpha as a parameter # create dense query vector xq_d = np.array(self.encoder(text)) - #xq_d = np.squeeze(xq_d) # Reduce to 1d array. + # xq_d = np.squeeze(xq_d) # Reduce to 1d array. # create sparse query vector xq_s = np.array(self.sparse_encoder(text)) - #xq_s = np.squeeze(xq_s) + # xq_s = np.squeeze(xq_s) # convex scaling xq_d, xq_s = self._convex_scaling(xq_d, xq_s) return xq_d, xq_s @@ -107,10 +107,10 @@ class HybridRouteLayer(BaseRouteLayer): dense_vec, sparse_vec = await asyncio.gather(dense_coro, sparse_coro) # create dense query vector xq_d = np.array(dense_vec) - #xq_d = np.squeeze(xq_d) # reduce to 1d array + # xq_d = np.squeeze(xq_d) # reduce to 1d array # create sparse query vector xq_s = np.array(sparse_vec) - #xq_s = np.squeeze(xq_s) + # xq_s = np.squeeze(xq_s) # convex scaling xq_d, xq_s = self._convex_scaling(xq_d, xq_s) return xq_d, xq_s @@ -137,15 +137,18 @@ class HybridRouteLayer(BaseRouteLayer): vector=np.array(vector) if isinstance(vector, list) else vector, top_k=self.top_k, route_filter=route_filter, - sparse_vector=np.array(sparse_vector) if isinstance(sparse_vector, list) else sparse_vector, + sparse_vector=( + np.array(sparse_vector) + if isinstance(sparse_vector, list) + else sparse_vector + ), + ) + top_class, top_class_scores = self._semantic_classify( + list(zip(scores, route_names)) ) - top_class, top_class_scores = self._semantic_classify(list(zip(scores, route_names))) passed = self._pass_threshold(top_class_scores, self.score_threshold) if passed: - return RouteChoice( - name=top_class, - similarity_score=max(top_class_scores) - ) + return RouteChoice(name=top_class, similarity_score=max(top_class_scores)) else: return RouteChoice() diff --git a/semantic_router/routers/semantic.py b/semantic_router/routers/semantic.py index 2104d431bffab7ed7240042ab14fa2ece0b8048c..951ef6f76f0c17b502844716ae967acd019c753c 100644 --- a/semantic_router/routers/semantic.py +++ b/semantic_router/routers/semantic.py @@ -1,13 +1,9 @@ -import importlib import json -import os import random -import hashlib from typing import Any, Dict, List, Optional, Tuple, Union -from pydantic.v1 import validator, BaseModel, Field +from pydantic.v1 import validator, Field import numpy as np -import yaml # type: ignore from tqdm.auto import tqdm from semantic_router.encoders import AutoEncoder, BaseEncoder, OpenAIEncoder @@ -16,15 +12,13 @@ from semantic_router.index.local import LocalIndex from semantic_router.index.pinecone import PineconeIndex from semantic_router.llms import BaseLLM, OpenAILLM from semantic_router.route import Route -from semantic_router.routers.base import BaseRouteLayer +from semantic_router.routers.base import BaseRouter, RouterConfig from semantic_router.schema import ( ConfigParameter, - EncoderType, RouteChoice, Utterance, UtteranceDiff, ) -from semantic_router.utils.defaults import EncoderDefault from semantic_router.utils.logger import logger @@ -58,222 +52,7 @@ def is_valid(layer_config: str) -> bool: return False -class LayerConfig: - """ - Generates a LayerConfig object that can be used for initializing a - RouteLayer. - """ - - routes: List[Route] = [] - - def __init__( - self, - routes: List[Route] = [], - encoder_type: str = "openai", - encoder_name: Optional[str] = None, - ): - self.encoder_type = encoder_type - if encoder_name is None: - for encode_type in EncoderType: - if encode_type.value == self.encoder_type: - if self.encoder_type == EncoderType.HUGGINGFACE.value: - raise NotImplementedError( - "HuggingFace encoder not supported by LayerConfig yet." - ) - encoder_name = EncoderDefault[encode_type.name].value[ - "embedding_model" - ] - break - logger.info(f"Using default {encoder_type} encoder: {encoder_name}") - self.encoder_name = encoder_name - self.routes = routes - - @classmethod - def from_file(cls, path: str) -> "LayerConfig": - logger.info(f"Loading route config from {path}") - _, ext = os.path.splitext(path) - with open(path, "r") as f: - if ext == ".json": - layer = json.load(f) - elif ext in [".yaml", ".yml"]: - layer = yaml.safe_load(f) - else: - raise ValueError( - "Unsupported file type. Only .json and .yaml are supported" - ) - - if not is_valid(json.dumps(layer)): - raise Exception("Invalid config JSON or YAML") - - encoder_type = layer["encoder_type"] - encoder_name = layer["encoder_name"] - routes = [] - for route_data in layer["routes"]: - # Handle the 'llm' field specially if it exists - if "llm" in route_data and route_data["llm"] is not None: - llm_data = route_data.pop( - "llm" - ) # Remove 'llm' from route_data and handle it separately - # Use the module path directly from llm_data without modification - llm_module_path = llm_data["module"] - # Dynamically import the module and then the class from that module - llm_module = importlib.import_module(llm_module_path) - llm_class = getattr(llm_module, llm_data["class"]) - # Instantiate the LLM class with the provided model name - llm = llm_class(name=llm_data["model"]) - # Reassign the instantiated llm object back to route_data - route_data["llm"] = llm - - # Dynamically create the Route object using the remaining route_data - route = Route(**route_data) - routes.append(route) - - return cls( - encoder_type=encoder_type, encoder_name=encoder_name, routes=routes - ) - - @classmethod - def from_tuples( - cls, - route_tuples: List[ - Tuple[str, str, Optional[List[Dict[str, Any]]], Dict[str, Any]] - ], - encoder_type: str = "openai", - encoder_name: Optional[str] = None, - ): - """Initialize a LayerConfig from a list of tuples of routes and - utterances. - - :param route_tuples: A list of tuples, each containing a route name and an - associated utterance. - :type route_tuples: List[Tuple[str, str]] - :param encoder_type: The type of encoder to use, defaults to "openai". - :type encoder_type: str, optional - :param encoder_name: The name of the encoder to use, defaults to None. - :type encoder_name: Optional[str], optional - """ - routes_dict: Dict[str, Route] = {} - # first create a dictionary of route names to Route objects - # TODO: duplicated code with BaseIndex.get_routes() - for route_name, utterance, function_schema, metadata in route_tuples: - # if the route is not in the dictionary, add it - if route_name not in routes_dict: - routes_dict[route_name] = Route( - name=route_name, - utterances=[utterance], - function_schemas=function_schema, - metadata=metadata, - ) - else: - # otherwise, add the utterance to the route - routes_dict[route_name].utterances.append(utterance) - # then create a list of routes from the dictionary - routes: List[Route] = [] - for route_name, route in routes_dict.items(): - routes.append(route) - return cls(routes=routes, encoder_type=encoder_type, encoder_name=encoder_name) - - @classmethod - def from_index( - cls, - index: BaseIndex, - encoder_type: str = "openai", - encoder_name: Optional[str] = None, - ): - """Initialize a LayerConfig from a BaseIndex object. - - :param index: The index to initialize the LayerConfig from. - :type index: BaseIndex - :param encoder_type: The type of encoder to use, defaults to "openai". - :type encoder_type: str, optional - :param encoder_name: The name of the encoder to use, defaults to None. - :type encoder_name: Optional[str], optional - """ - remote_routes = index.get_utterances() - return cls.from_tuples( - route_tuples=[utt.to_tuple() for utt in remote_routes], - encoder_type=encoder_type, - encoder_name=encoder_name, - ) - - def to_dict(self) -> Dict[str, Any]: - return { - "encoder_type": self.encoder_type, - "encoder_name": self.encoder_name, - "routes": [route.to_dict() for route in self.routes], - } - - def to_file(self, path: str): - """Save the routes to a file in JSON or YAML format""" - logger.info(f"Saving route config to {path}") - _, ext = os.path.splitext(path) - - # Check file extension before creating directories or files - if ext not in [".json", ".yaml", ".yml"]: - raise ValueError( - "Unsupported file type. Only .json and .yaml are supported" - ) - - dir_name = os.path.dirname(path) - - # Create the directory if it doesn't exist and dir_name is not an empty string - if dir_name and not os.path.exists(dir_name): - os.makedirs(dir_name) - - with open(path, "w") as f: - if ext == ".json": - json.dump(self.to_dict(), f, indent=4) - elif ext in [".yaml", ".yml"]: - yaml.safe_dump(self.to_dict(), f) - - def to_utterances(self) -> List[Utterance]: - """Convert the routes to a list of Utterance objects. - - :return: A list of Utterance objects. - :rtype: List[Utterance] - """ - utterances = [] - for route in self.routes: - utterances.extend( - [ - Utterance( - route=route.name, - utterance=x, - function_schemas=route.function_schemas, - metadata=route.metadata or {}, - ) - for x in route.utterances - ] - ) - return utterances - - def add(self, route: Route): - self.routes.append(route) - logger.info(f"Added route `{route.name}`") - - def get(self, name: str) -> Optional[Route]: - for route in self.routes: - if route.name == name: - return route - logger.error(f"Route `{name}` not found") - return None - - def remove(self, name: str): - if name not in [route.name for route in self.routes]: - logger.error(f"Route `{name}` not found") - else: - self.routes = [route for route in self.routes if route.name != name] - logger.info(f"Removed route `{name}`") - - def get_hash(self) -> ConfigParameter: - layer = self.to_dict() - return ConfigParameter( - field="sr_hash", - value=hashlib.sha256(json.dumps(layer).encode()).hexdigest(), - ) - - -class RouteLayer(BaseRouteLayer): +class RouteLayer(BaseRouter): index: BaseIndex = Field(default_factory=LocalIndex) @validator("index", pre=True, always=True) @@ -655,18 +434,18 @@ class RouteLayer(BaseRouteLayer): @classmethod def from_json(cls, file_path: str): - config = LayerConfig.from_file(file_path) + config = RouterConfig.from_file(file_path) encoder = AutoEncoder(type=config.encoder_type, name=config.encoder_name).model return cls(encoder=encoder, routes=config.routes) @classmethod def from_yaml(cls, file_path: str): - config = LayerConfig.from_file(file_path) + config = RouterConfig.from_file(file_path) encoder = AutoEncoder(type=config.encoder_type, name=config.encoder_name).model return cls(encoder=encoder, routes=config.routes) @classmethod - def from_config(cls, config: LayerConfig, index: Optional[BaseIndex] = None): + def from_config(cls, config: RouterConfig, index: Optional[BaseIndex] = None): encoder = AutoEncoder(type=config.encoder_type, name=config.encoder_name).model return cls(encoder=encoder, routes=config.routes, index=index) @@ -1069,8 +848,8 @@ class RouteLayer(BaseRouteLayer): route.name, self.score_threshold ) - def to_config(self) -> LayerConfig: - return LayerConfig( + def to_config(self) -> RouterConfig: + return RouterConfig( encoder_type=self.encoder.type, encoder_name=self.encoder.name, routes=self.routes, diff --git a/tests/unit/test_hybrid_layer.py b/tests/unit/test_hybrid_layer.py index 0859fc8394fd71c0b23071c95e08b66d1049c945..3cba34caa8ca28f4c421117857b163047b0be4ff 100644 --- a/tests/unit/test_hybrid_layer.py +++ b/tests/unit/test_hybrid_layer.py @@ -8,7 +8,7 @@ from semantic_router.encoders import ( OpenAIEncoder, TfidfEncoder, ) -from semantic_router.OLD_hybrid_layer import HybridRouteLayer +from semantic_router.OLD_hybrid_layer import HybridRouter from semantic_router.route import Route @@ -78,9 +78,9 @@ sparse_encoder = BM25Encoder(use_default_params=False) sparse_encoder.fit(["The quick brown fox", "jumps over the lazy dog", "Hello, world!"]) -class TestHybridRouteLayer: +class TestHybridRouter: def test_initialization(self, openai_encoder, routes): - route_layer = HybridRouteLayer( + route_layer = HybridRouter( encoder=openai_encoder, sparse_encoder=sparse_encoder, routes=routes, @@ -96,18 +96,18 @@ class TestHybridRouteLayer: assert len(set(route_layer.categories)) == 2 def test_initialization_different_encoders(self, cohere_encoder, openai_encoder): - route_layer_cohere = HybridRouteLayer( + route_layer_cohere = HybridRouter( encoder=cohere_encoder, sparse_encoder=sparse_encoder ) assert route_layer_cohere.score_threshold == 0.3 - route_layer_openai = HybridRouteLayer( + route_layer_openai = HybridRouter( encoder=openai_encoder, sparse_encoder=sparse_encoder ) assert route_layer_openai.score_threshold == 0.3 def test_add_route(self, openai_encoder): - route_layer = HybridRouteLayer( + route_layer = HybridRouter( encoder=openai_encoder, sparse_encoder=sparse_encoder ) route = Route(name="Route 3", utterances=["Yes", "No"]) @@ -117,7 +117,7 @@ class TestHybridRouteLayer: assert len(set(route_layer.categories)) == 1 def test_add_multiple_routes(self, openai_encoder, routes): - route_layer = HybridRouteLayer( + route_layer = HybridRouter( encoder=openai_encoder, sparse_encoder=sparse_encoder ) for route in routes: @@ -127,20 +127,20 @@ class TestHybridRouteLayer: assert len(set(route_layer.categories)) == 2 def test_query_and_classification(self, openai_encoder, routes): - route_layer = HybridRouteLayer( + route_layer = HybridRouter( encoder=openai_encoder, sparse_encoder=sparse_encoder, routes=routes ) query_result = route_layer("Hello") assert query_result in ["Route 1", "Route 2"] def test_query_with_no_index(self, openai_encoder): - route_layer = HybridRouteLayer( + route_layer = HybridRouter( encoder=openai_encoder, sparse_encoder=sparse_encoder ) assert route_layer("Anything") is None def test_semantic_classify(self, openai_encoder, routes): - route_layer = HybridRouteLayer( + route_layer = HybridRouter( encoder=openai_encoder, sparse_encoder=sparse_encoder, routes=routes ) classification, score = route_layer._semantic_classify( @@ -153,7 +153,7 @@ class TestHybridRouteLayer: assert score == [0.9] def test_semantic_classify_multiple_routes(self, openai_encoder, routes): - route_layer = HybridRouteLayer( + route_layer = HybridRouter( encoder=openai_encoder, sparse_encoder=sparse_encoder, routes=routes ) classification, score = route_layer._semantic_classify( @@ -167,21 +167,19 @@ class TestHybridRouteLayer: assert score == [0.9, 0.8] def test_pass_threshold(self, openai_encoder): - route_layer = HybridRouteLayer( + route_layer = HybridRouter( encoder=openai_encoder, sparse_encoder=sparse_encoder ) assert not route_layer._pass_threshold([], 0.5) assert route_layer._pass_threshold([0.6, 0.7], 0.5) def test_failover_score_threshold(self, base_encoder): - route_layer = HybridRouteLayer( - encoder=base_encoder, sparse_encoder=sparse_encoder - ) + route_layer = HybridRouter(encoder=base_encoder, sparse_encoder=sparse_encoder) assert base_encoder.score_threshold == 0.50 assert route_layer.score_threshold == 0.50 def test_add_route_tfidf(self, cohere_encoder, tfidf_encoder, routes): - hybrid_route_layer = HybridRouteLayer( + hybrid_route_layer = HybridRouter( encoder=cohere_encoder, sparse_encoder=tfidf_encoder, routes=routes[:-1], @@ -195,7 +193,7 @@ class TestHybridRouteLayer: def test_setting_aggregation_methods(self, openai_encoder, routes): for agg in ["sum", "mean", "max"]: - route_layer = HybridRouteLayer( + route_layer = HybridRouter( encoder=openai_encoder, sparse_encoder=sparse_encoder, routes=routes, @@ -218,7 +216,7 @@ class TestHybridRouteLayer: {"route": "Route 3", "score": 1.0}, ] for agg in ["sum", "mean", "max"]: - route_layer = HybridRouteLayer( + route_layer = HybridRouter( encoder=openai_encoder, sparse_encoder=sparse_encoder, routes=routes, diff --git a/tests/unit/test_router.py b/tests/unit/test_router.py index 2b39410d2f400017865308b02f1766ac1b618b21..61731b29a88c042bf031c8a43c5885e52724c0c4 100644 --- a/tests/unit/test_router.py +++ b/tests/unit/test_router.py @@ -10,7 +10,7 @@ from semantic_router.encoders import BaseEncoder, CohereEncoder, OpenAIEncoder from semantic_router.index.local import LocalIndex from semantic_router.index.pinecone import PineconeIndex from semantic_router.index.qdrant import QdrantIndex -from semantic_router.routers import LayerConfig, RouteLayer +from semantic_router.routers import RouterConfig, RouteLayer from semantic_router.llms.base import BaseLLM from semantic_router.route import Route from platform import python_version @@ -588,8 +588,8 @@ class TestRouteLayer: layer_json() ) # Assuming layer_json() returns a valid JSON string - # Load the LayerConfig from the temporary file - layer_config = LayerConfig.from_file(str(config_path)) + # Load the RouterConfig from the temporary file + layer_config = RouterConfig.from_file(str(config_path)) # Assertions to verify the loaded configuration assert layer_config.encoder_type == "cohere" @@ -604,8 +604,8 @@ class TestRouteLayer: layer_yaml() ) # Assuming layer_yaml() returns a valid YAML string - # Load the LayerConfig from the temporary file - layer_config = LayerConfig.from_file(str(config_path)) + # Load the RouterConfig from the temporary file + layer_config = RouterConfig.from_file(str(config_path)) # Assertions to verify the loaded configuration assert layer_config.encoder_type == "cohere" @@ -615,7 +615,7 @@ class TestRouteLayer: def test_from_file_invalid_path(self, index_cls): with pytest.raises(FileNotFoundError) as excinfo: - LayerConfig.from_file("nonexistent_path.json") + RouterConfig.from_file("nonexistent_path.json") assert "[Errno 2] No such file or directory: 'nonexistent_path.json'" in str( excinfo.value ) @@ -626,7 +626,7 @@ class TestRouteLayer: config_path.write_text(layer_json()) with pytest.raises(ValueError) as excinfo: - LayerConfig.from_file(str(config_path)) + RouterConfig.from_file(str(config_path)) assert "Unsupported file type" in str(excinfo.value) def test_from_file_invalid_config(self, tmp_path, index_cls): @@ -645,10 +645,10 @@ class TestRouteLayer: # Patch the is_valid function to return False for this test with patch("semantic_router.layer.is_valid", return_value=False): - # Attempt to load the LayerConfig from the temporary file + # Attempt to load the RouterConfig from the temporary file # and assert that it raises an exception due to invalid configuration with pytest.raises(Exception) as excinfo: - LayerConfig.from_file(str(config_path)) + RouterConfig.from_file(str(config_path)) assert "Invalid config JSON or YAML" in str( excinfo.value ), "Loading an invalid configuration should raise an exception." @@ -675,8 +675,8 @@ class TestRouteLayer: with open(config_path, "w") as file: file.write(llm_config_json) - # Load the LayerConfig from the temporary file - layer_config = LayerConfig.from_file(str(config_path)) + # Load the RouterConfig from the temporary file + layer_config = RouterConfig.from_file(str(config_path)) # Using BaseLLM because trying to create a usable Mock LLM is a nightmare. assert isinstance( @@ -939,61 +939,61 @@ class TestLayerFit: # Add more tests for edge cases and error handling as needed. -class TestLayerConfig: +class TestRouterConfig: def test_init(self): - layer_config = LayerConfig() + layer_config = RouterConfig() assert layer_config.routes == [] def test_to_file_json(self): route = Route(name="test", utterances=["utterance"]) - layer_config = LayerConfig(routes=[route]) + layer_config = RouterConfig(routes=[route]) with patch("builtins.open", mock_open()) as mocked_open: layer_config.to_file("data/test_output.json") mocked_open.assert_called_once_with("data/test_output.json", "w") def test_to_file_yaml(self): route = Route(name="test", utterances=["utterance"]) - layer_config = LayerConfig(routes=[route]) + layer_config = RouterConfig(routes=[route]) with patch("builtins.open", mock_open()) as mocked_open: layer_config.to_file("data/test_output.yaml") mocked_open.assert_called_once_with("data/test_output.yaml", "w") def test_to_file_invalid(self): route = Route(name="test", utterances=["utterance"]) - layer_config = LayerConfig(routes=[route]) + layer_config = RouterConfig(routes=[route]) with pytest.raises(ValueError): layer_config.to_file("test_output.txt") def test_from_file_json(self): mock_json_data = layer_json() with patch("builtins.open", mock_open(read_data=mock_json_data)) as mocked_open: - layer_config = LayerConfig.from_file("data/test.json") + layer_config = RouterConfig.from_file("data/test.json") mocked_open.assert_called_once_with("data/test.json", "r") - assert isinstance(layer_config, LayerConfig) + assert isinstance(layer_config, RouterConfig) def test_from_file_yaml(self): mock_yaml_data = layer_yaml() with patch("builtins.open", mock_open(read_data=mock_yaml_data)) as mocked_open: - layer_config = LayerConfig.from_file("data/test.yaml") + layer_config = RouterConfig.from_file("data/test.yaml") mocked_open.assert_called_once_with("data/test.yaml", "r") - assert isinstance(layer_config, LayerConfig) + assert isinstance(layer_config, RouterConfig) def test_from_file_invalid(self): with open("test.txt", "w") as f: f.write("dummy content") with pytest.raises(ValueError): - LayerConfig.from_file("test.txt") + RouterConfig.from_file("test.txt") os.remove("test.txt") def test_to_dict(self): route = Route(name="test", utterances=["utterance"]) - layer_config = LayerConfig(routes=[route]) + layer_config = RouterConfig(routes=[route]) assert layer_config.to_dict()["routes"] == [route.to_dict()] def test_add(self): route = Route(name="test", utterances=["utterance"]) route2 = Route(name="test2", utterances=["utterance2"]) - layer_config = LayerConfig() + layer_config = RouterConfig() layer_config.add(route) # confirm route added assert layer_config.routes == [route] @@ -1003,17 +1003,17 @@ class TestLayerConfig: def test_get(self): route = Route(name="test", utterances=["utterance"]) - layer_config = LayerConfig(routes=[route]) + layer_config = RouterConfig(routes=[route]) assert layer_config.get("test") == route def test_get_not_found(self): route = Route(name="test", utterances=["utterance"]) - layer_config = LayerConfig(routes=[route]) + layer_config = RouterConfig(routes=[route]) assert layer_config.get("not_found") is None def test_remove(self): route = Route(name="test", utterances=["utterance"]) - layer_config = LayerConfig(routes=[route]) + layer_config = RouterConfig(routes=[route]) layer_config.remove("test") assert layer_config.routes == []