diff --git a/docs/source/route_layer/sync.rst b/docs/source/route_layer/sync.rst index efb1bebe91f13f22c518a2dbb96ae274394e7d78..aa04ea2e36d3272961791cdbe8f86748649672fe 100644 --- a/docs/source/route_layer/sync.rst +++ b/docs/source/route_layer/sync.rst @@ -72,5 +72,35 @@ When initializing the `PineconeIndex` object, we can specify the `sync` paramete Checking for Synchronization ---------------------------- -To verify whether the local and remote instances are synchronized, you can use the `is_synced` method. This method checks if the routes, utterances, and associated metadata in the local instance match those stored in the remote index. -Consider that if the `sync` flag is not set (e.g. for indexes different from Pinecone), it raises an error. If the index supports sync feature and everything aligns, it returns `True`, indicating that the local and remote instances are synchronized, otherwise it returns `False`. \ No newline at end of file +To verify whether the local and remote instances are synchronized, you can use +the `RouteLayer.is_synced` method. This method checks if the routes, utterances, +and associated metadata in the local instance match those stored in the remote +index. + +The `is_synced` method works in two steps. The first is our *fast* sync check. +The fast check creates a hash of our local route layer which is constructed +from: + +- `encoder_type` and `encoder_name` +- `route` names +- `route` utterances +- `route` description +- `route` function schemas (if any) +- `route` llm (if any) +- `route` score threshold +- `route` metadata (if any) + +The fast check then compares this hash to the hash of the remote index. If +the hashes match, we know that the local and remote instances are synchronized +and we can return `True`. If the hashes do not match, we need to perform a +*slow* sync check. + +The slow sync check works by creating a `LayerConfig` object from the remote +index and then comparing this to our local `LayerConfig` object. If the two +objects match, we know that the local and remote instances are synchronized and +we can return `True`. If the two objects do not match, we need to perform a +diff. + +The diff works by creating a list of all the routes in the remote index and +then comparing these to the routes in our local instance. Any differences +between the remote and local routes are shown in the diff. \ No newline at end of file diff --git a/semantic_router/index/base.py b/semantic_router/index/base.py index 271120219ccf3898a3f8e3491d8cf44582926017..bd5c7c943312eead08d313b9415690b252baacb4 100644 --- a/semantic_router/index/base.py +++ b/semantic_router/index/base.py @@ -4,6 +4,7 @@ import numpy as np from pydantic.v1 import BaseModel from semantic_router.schema import ConfigParameter +from semantic_router.route import Route class BaseIndex(BaseModel): @@ -37,16 +38,33 @@ class BaseIndex(BaseModel): """ raise NotImplementedError("This method should be implemented by subclasses.") - def get_routes(self): - """ - Retrieves a list of routes and their associated utterances from the index. - This method should be implemented by subclasses. - - :returns: A list of tuples, each containing a route name and an associated utterance. - :rtype: list[tuple] - :raises NotImplementedError: If the method is not implemented by the subclass. - """ - raise NotImplementedError("This method should be implemented by subclasses.") + def get_routes(self) -> List[Route]: + """Gets a list of route objects currently stored in the index. + + :return: A list of Route objects. + :rtype: List[Route] + """ + route_tuples = self.get_utterances() + routes_dict: Dict[str, List[str]] = {} + # first create a dictionary of routes mapping to all their utterances, + # function_schema, and metadata + for route_name, utterance, function_schema, metadata in route_tuples: + routes_dict.setdefault( + route_name, + { + "function_schemas": None, + "metadata": {}, + }, + ) + routes_dict[route_name]["utterances"] = routes_dict[route_name].get( + "utterances", [] + ) + routes_dict[route_name]["utterances"].append(utterance) + # then create a list of routes from the dictionary + routes: List[Route] = [] + for route_name, route_data in routes_dict.items(): + routes.append(Route(name=route_name, **route_data)) + return routes def _remove_and_sync(self, routes_to_delete: dict): """ diff --git a/semantic_router/index/local.py b/semantic_router/index/local.py index 09e23ffc1b6fb7f152cd4e6aad12ca5a8df89391..52e0c0b693bf20c035a59105a0989c2801e0f058 100644 --- a/semantic_router/index/local.py +++ b/semantic_router/index/local.py @@ -60,7 +60,7 @@ class LocalIndex(BaseIndex): if self.sync is not None: logger.error("Sync remove is not implemented for LocalIndex.") - def get_routes(self) -> List[Tuple]: + def get_utterances(self) -> List[Tuple]: """ Gets a list of route and utterance objects currently stored in the index. diff --git a/semantic_router/index/pinecone.py b/semantic_router/index/pinecone.py index 2aae42b873d76d60ecdfd1da14ebf63bd8b9d8d3..25d2f5d912976d9b20d517d6c724ad950b2c3310 100644 --- a/semantic_router/index/pinecone.py +++ b/semantic_router/index/pinecone.py @@ -218,6 +218,7 @@ class PineconeIndex(BaseIndex): logger.warning("Index could not be initialized.") self.host = index_stats["host"] if index_stats else None + # TODO: deprecate? def _format_routes_dict_for_sync( self, local_route_names: List[str], @@ -256,48 +257,6 @@ class PineconeIndex(BaseIndex): return local_dict, remote_dict - def is_synced( - self, - local_route_names: List[str], - local_utterances_list: List[str], - local_function_schemas_list: List[Dict[str, Any]], - local_metadata_list: List[Dict[str, Any]], - ) -> bool: - remote_routes = self.get_routes() - - local_dict, remote_dict = self._format_routes_dict_for_sync( - local_route_names, - local_utterances_list, - local_function_schemas_list, - local_metadata_list, - remote_routes, - ) - logger.info(f"LOCAL: {local_dict}") - logger.info(f"REMOTE: {remote_dict}") - - all_routes = set(remote_dict.keys()).union(local_dict.keys()) - - for route in all_routes: - local_utterances = local_dict.get(route, {}).get("utterances", set()) - remote_utterances = remote_dict.get(route, {}).get("utterances", set()) - local_function_schemas = ( - local_dict.get(route, {}).get("function_schemas", {}) or {} - ) - remote_function_schemas = ( - remote_dict.get(route, {}).get("function_schemas", {}) or {} - ) - local_metadata = local_dict.get(route, {}).get("metadata", {}) - remote_metadata = remote_dict.get(route, {}).get("metadata", {}) - - if ( - local_utterances != remote_utterances - or local_function_schemas != remote_function_schemas - or local_metadata != remote_metadata - ): - return False - - return True - def _sync_index( self, local_route_names: List[str], @@ -310,7 +269,7 @@ class PineconeIndex(BaseIndex): self.dimensions = self.dimensions or dimensions self.index = self._init_index(force_create=True) - remote_routes = self.get_routes() + remote_routes = self.get_utterances() local_dict, remote_dict = self._format_routes_dict_for_sync( local_route_names, @@ -602,7 +561,7 @@ class PineconeIndex(BaseIndex): return all_vector_ids, metadata - def get_routes(self) -> List[Tuple]: + def get_utterances(self) -> List[Tuple]: """Gets a list of route and utterance objects currently stored in the index, including additional metadata. @@ -680,15 +639,16 @@ class PineconeIndex(BaseIndex): def _read_hash(self) -> ConfigParameter: if self.index is None: raise ValueError("Index has not been initialized.") + hash_id = f"sr_hash#{self.namespace}" hash_record = self.index.fetch( - ids=[f"sr_hash#{self.namespace}"], + ids=[hash_id], namespace="sr_config", ) if hash_record["vectors"]: return ConfigParameter( field="sr_hash", - value=hash_record["vectors"]["sr_hash"]["metadata"]["value"], - created_at=hash_record["vectors"]["sr_hash"]["metadata"]["created_at"], + value=hash_record["vectors"][hash_id]["metadata"]["value"], + created_at=hash_record["vectors"][hash_id]["metadata"]["created_at"], namespace=self.namespace, ) else: diff --git a/semantic_router/index/postgres.py b/semantic_router/index/postgres.py index ff63ec09c419a787033a8412a3517fa1bcb49d58..e0a8b872253095d0763a6ca77d43d906ef70a447 100644 --- a/semantic_router/index/postgres.py +++ b/semantic_router/index/postgres.py @@ -423,17 +423,17 @@ class PostgresIndex(BaseIndex): return all_vector_ids, metadata - def get_routes(self) -> List[Tuple]: + def get_utterances(self) -> List[Tuple]: """ Gets a list of route and utterance objects currently stored in the index. - :return: A list of (route_name, utterance) tuples. + :return: A list of (route_name, utterance, function_schema, metadata) tuples. :rtype: List[Tuple] """ # Get all records with metadata _, metadata = self._get_all(include_metadata=True) - # Create a list of (route_name, utterance) tuples - route_tuples = [(x["sr_route"], x["sr_utterance"]) for x in metadata] + # Create a list of (route_name, utterance, function_schema, metadata) tuples + route_tuples = [(x["sr_route"], x["sr_utterance"], None, {}) for x in metadata] return route_tuples def delete_all(self): @@ -463,7 +463,7 @@ class PostgresIndex(BaseIndex): self.conn.commit() def aget_routes(self): - logger.error("Sync remove is not implemented for PostgresIndex.") + raise NotImplementedError("Sync remove is not implemented for PostgresIndex.") def __len__(self): """ diff --git a/semantic_router/index/qdrant.py b/semantic_router/index/qdrant.py index 49e0d0d4f66e9d3fbb5e3cc3bfe0eb0c94402726..1026868849ffbe83eb4c05f847315fdafde97ad9 100644 --- a/semantic_router/index/qdrant.py +++ b/semantic_router/index/qdrant.py @@ -199,12 +199,12 @@ class QdrantIndex(BaseIndex): batch_size=batch_size, ) - def get_routes(self) -> List[Tuple]: + def get_utterances(self) -> List[Tuple]: """ Gets a list of route and utterance objects currently stored in the index. Returns: - List[Tuple]: A list of (route_name, utterance) objects. + List[Tuple]: A list of (route_name, utterance, function_schema, metadata) objects. """ from qdrant_client import grpc @@ -228,7 +228,12 @@ class QdrantIndex(BaseIndex): results.extend(records) route_tuples = [ - (x.payload[SR_ROUTE_PAYLOAD_KEY], x.payload[SR_UTTERANCE_PAYLOAD_KEY]) + ( + x.payload[SR_ROUTE_PAYLOAD_KEY], + x.payload[SR_UTTERANCE_PAYLOAD_KEY], + None, + {}, + ) for x in results ] return route_tuples diff --git a/semantic_router/layer.py b/semantic_router/layer.py index e2f7f2667a890957222d325d14d3a8d9fa3aa41a..8637dfdf54843e50c5b37498e8efa1c7e31969dc 100644 --- a/semantic_router/layer.py +++ b/semantic_router/layer.py @@ -1,3 +1,4 @@ +from difflib import Differ import importlib import json import os @@ -123,6 +124,68 @@ class LayerConfig: encoder_type=encoder_type, encoder_name=encoder_name, routes=routes ) + @classmethod + def from_tuples( + cls, + route_tuples: List[Tuple[str, str]], + 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: List[Route] = [] + routes_dict: Dict[str, List[str]] = {} + # first create a dictionary of routes mapping to all their utterances, + # function_schema, and metadata + for route_name, utterance, function_schema, metadata in route_tuples: + routes_dict.setdefault( + route_name, + { + "function_schemas": None, + "metadata": {}, + }, + ) + routes_dict[route_name]["utterances"] = routes_dict[route_name].get( + "utterances", [] + ) + routes_dict[route_name]["utterances"].append(utterance) + # then create a list of routes from the dictionary + for route_name, route_data in routes_dict.items(): + routes.append(Route(name=route_name, **route_data)) + 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=remote_routes, + encoder_type=encoder_type, + encoder_name=encoder_name, + ) + def to_dict(self) -> Dict[str, Any]: return { "encoder_type": self.encoder_type, @@ -153,6 +216,30 @@ class LayerConfig: elif ext in [".yaml", ".yml"]: yaml.safe_dump(self.to_dict(), f) + def _get_diff(self, other: "LayerConfig") -> List[Dict[str, Any]]: + """Get the difference between two LayerConfigs. + + :param other: The LayerConfig to compare to. + :type other: LayerConfig + :return: A list of differences between the two LayerConfigs. + :rtype: List[Dict[str, Any]] + """ + self_yaml = yaml.dump(self.to_dict()) + other_yaml = yaml.dump(other.to_dict()) + differ = Differ() + return list(differ.compare(self_yaml.splitlines(), other_yaml.splitlines())) + + def show_diff(self, other: "LayerConfig") -> str: + """Show the difference between two LayerConfigs. + + :param other: The LayerConfig to compare to. + :type other: LayerConfig + :return: A string showing the difference between the two LayerConfigs. + :rtype: str + """ + diff = self._get_diff(other) + return "\n".join(diff) + def add(self, route: Route): self.routes.append(route) logger.info(f"Added route `{route.name}`") @@ -499,7 +586,7 @@ class RouteLayer: """Pulls out the latest routes from the index.""" raise NotImplementedError("This method has not yet been implemented.") route_mapping = {route.name: route for route in self.routes} - index_routes = self.index.get_routes() + index_routes = self.index.get_utterances() new_routes_names = [] new_routes = [] for route_name, utterance in index_routes: @@ -548,18 +635,45 @@ class RouteLayer: remote_hash = self.index._read_hash() if local_hash.value == remote_hash.value: return True - # TODO: we may be able to remove the below logic - # if hashes are different, double check - local_route_names, local_utterances, local_function_schemas, local_metadata = ( - self._extract_routes_details(self.routes, include_metadata=True) - ) - # return result of double check - return self.index.is_synced( - local_route_names=local_route_names, - local_utterances_list=local_utterances, - local_function_schemas_list=local_function_schemas, - local_metadata_list=local_metadata, + else: + return False + + def get_utterance_diff(self) -> List[str]: + """Get the difference between the local and remote utterances. Returns + a list of strings showing what is different in the remote when compared + to the local. For example: + + [" route1: utterance1", + " route1: utterance2", + "- route2: utterance3", + "- route2: utterance4"] + + Tells us that the remote is missing "route2: utterance3" and "route2: + utterance4", which do exist locally. If we see: + + [" route1: utterance1", + " route1: utterance2", + "+ route2: utterance3", + "+ route2: utterance4"] + + This diff tells us that the remote has "route2: utterance3" and + "route2: utterance4", which do not exist locally. + """ + # first we get remote and local utterances + remote_utterances = [f"{x[0]}: {x[1]}" for x in self.index.get_utterances()] + local_routes, local_utterance_arr, _ = self._extract_routes_details( + self.routes, include_metadata=False ) + local_utterances = [ + f"{x[0]}: {x[1]}" for x in zip(local_routes, local_utterance_arr) + ] + # sort local and remote utterances + local_utterances.sort() + remote_utterances.sort() + # now get diff + differ = Differ() + diff = list(differ.compare(local_utterances, remote_utterances)) + return diff def _add_and_sync_routes(self, routes: List[Route]): # create embeddings for all routes and sync at startup with remote ones based on sync setting @@ -829,7 +943,7 @@ class RouteLayer: # Switch to a local index for fitting from semantic_router.index.local import LocalIndex - remote_routes = self.index.get_routes() + remote_routes = self.index.get_utterances() # TODO Enhance by retrieving directly the vectors instead of embedding all utterances again routes, utterances, function_schemas, metadata = map( list, zip(*remote_routes) diff --git a/semantic_router/route.py b/semantic_router/route.py index 41fd0bf2c4e2b496b92f22f841c8b565dda8bdad..50d516fa5bbe31d7eff25b8ece8372801649abbd 100644 --- a/semantic_router/route.py +++ b/semantic_router/route.py @@ -99,9 +99,6 @@ class Route(BaseModel): func_call = None return RouteChoice(name=self.name, function_call=func_call) - # def to_dict(self) -> Dict[str, Any]: - # return self.dict() - def to_dict(self) -> Dict[str, Any]: data = self.dict() if self.llm is not None: diff --git a/semantic_router/schema.py b/semantic_router/schema.py index bbd5dc1bd02b40d6c68294c1441a4dfd89cf1c5a..8d87f01733f0e7803d5b38c6359744a66790ba54 100644 --- a/semantic_router/schema.py +++ b/semantic_router/schema.py @@ -80,7 +80,7 @@ class ConfigParameter(BaseModel): "metadata": { "value": self.value, "created_at": self.created_at, - "namespace": self.namespace, + "namespace": namespace, "field": self.field, }, } diff --git a/tests/unit/test_layer.py b/tests/unit/test_layer.py index 3bfcd485cc34389905ab161bcfa2c79cb12b4119..f39639451448e166f58339464022797802551e84 100644 --- a/tests/unit/test_layer.py +++ b/tests/unit/test_layer.py @@ -252,14 +252,14 @@ class TestRouteLayer: time.sleep(PINECONE_SLEEP) # allow for index to be populated assert route_layer.routes == [routes[0]] assert route_layer.index is not None - assert len(route_layer.index.get_routes()) == 2 + assert len(route_layer.index.get_utterances()) == 2 # Add route2 and check route_layer.add(route=routes[1]) if index_cls is PineconeIndex: time.sleep(PINECONE_SLEEP) # allow for index to be populated assert route_layer.routes == [routes[0], routes[1]] - assert len(route_layer.index.get_routes()) == 5 + assert len(route_layer.index.get_utterances()) == 5 def test_list_route_names(self, openai_encoder, routes, index_cls): index = init_index(index_cls) @@ -306,7 +306,7 @@ class TestRouteLayer: if index_cls is PineconeIndex: time.sleep(PINECONE_SLEEP) # allow for index to be populated assert route_layer.index is not None - assert len(route_layer.index.get_routes()) == 5 + assert len(route_layer.index.get_utterances()) == 5 def test_query_and_classification(self, openai_encoder, routes, index_cls): index = init_index(index_cls, dimensions=3) @@ -381,7 +381,7 @@ class TestRouteLayer: encoder=openai_encoder, routes=routes_2, index=pinecone_index ) time.sleep(PINECONE_SLEEP) # allow for index to be populated - assert route_layer.index.get_routes() == [ + assert route_layer.index.get_utterances() == [ ("Route 1", "Hello", None, {}), ("Route 2", "Hi", None, {}), ], "The routes in the index should match the local routes" @@ -393,7 +393,7 @@ class TestRouteLayer: ) time.sleep(PINECONE_SLEEP) # allow for index to be populated - assert route_layer.index.get_routes() == [ + assert route_layer.index.get_utterances() == [ ("Route 1", "Hello", None, {}), ("Route 2", "Hi", None, {}), ], "The routes in the index should match the local routes" @@ -405,7 +405,7 @@ class TestRouteLayer: ) time.sleep(PINECONE_SLEEP) # allow for index to be populated - assert route_layer.index.get_routes() == [ + assert route_layer.index.get_utterances() == [ ("Route 1", "Hello", None, {}), ("Route 2", "Hi", None, {}), ], "The routes in the index should match the local routes" @@ -417,7 +417,7 @@ class TestRouteLayer: ) time.sleep(PINECONE_SLEEP) # allow for index to be populated - assert route_layer.index.get_routes() == [ + assert route_layer.index.get_utterances() == [ ("Route 1", "Hello", None, {"type": "default"}), ("Route 1", "Hi", None, {"type": "default"}), ("Route 2", "Bye", None, {}), @@ -432,7 +432,7 @@ class TestRouteLayer: ) time.sleep(PINECONE_SLEEP) # allow for index to be populated - assert route_layer.index.get_routes() == [ + assert route_layer.index.get_utterances() == [ ("Route 1", "Hello", None, {"type": "default"}), ("Route 1", "Hi", None, {"type": "default"}), ("Route 1", "Goodbye", None, {"type": "default"}),